diff --git a/.codex-handoff/document-navigation-h6.json b/.codex-handoff/document-navigation-h6.json new file mode 100644 index 0000000000..b06bf9f9f5 --- /dev/null +++ b/.codex-handoff/document-navigation-h6.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "workKey": "phase-0/document-navigation/1587615c/none/producer/r1", + "exactBase": "1587615c751c3cb12b5078ab4b7264b6e9fd42ad", + "status": "ready_for_review", + "changedPaths": [ + ".codex-handoff/document-navigation-h6.json", + "AGENTS.md", + "docs/hosted-web-phases/EXECUTION_INDEX.json", + "docs/hosted-web-phases/README.md", + "docs/hosted-web-phases/START_HERE.md", + "docs/hosted-web-phases/worker-start-contract.schema.json", + "scripts/hosted-web/orchestration/validate-worker-start.mjs", + "test/architecture/hosted-web/orchestration/fixtures/valid-worker-start.template.json", + "test/architecture/hosted-web/orchestration/worker-start-contract.test.mjs" + ], + "focusedChecks": [ + { + "command": "/usr/local/bin/node --test test/architecture/hosted-web/orchestration/worker-start-contract.test.mjs", + "exitCode": 0, + "result": "pass" + }, + { + "command": "/usr/bin/git diff --check", + "exitCode": 0, + "result": "pass" + } + ], + "risks": [ + "Each mandatory read list is capped at 64 exact files; a future larger lane must be split or deliberately revise the contract.", + "Controllers must run the bounded worker-start validator before the registry admission gate, as documented in START_HERE.md." + ] +} diff --git a/.codex-handoff/estimate-semantics-h6.json b/.codex-handoff/estimate-semantics-h6.json new file mode 100644 index 0000000000..92a43d6ab8 --- /dev/null +++ b/.codex-handoff/estimate-semantics-h6.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": 1, + "workKey": "phase-0/estimate-semantics/1587615c/11f9cb48/producer/r2", + "taskId": "agent-teams-hosted-web-refactor-estimate-semantics-h6", + "phaseId": "phase-00", + "laneId": "estimate-reconciliation", + "revision": 2, + "retryLimit": 2, + "status": "ready_for_controller_review", + "authorityStatus": "current_candidate_not_canonical", + "exactBaseSha": "1587615c751c3cb12b5078ab4b7264b6e9fd42ad", + "sourceSnapshotSha": "42ec333848e29e97c41699b9fed73ed199740e3f", + "targetCandidateSha": "1587615c751c3cb12b5078ab4b7264b6e9fd42ad", + "supersedesChain": [ + { + "artifactId": "estimate-candidate-reconcile-h4", + "disposition": "rejected", + "relationship": "directly_superseded_by_this_current_candidate", + "preservedPatchReused": true, + "reason": "Its interval wording incorrectly treated 38,300 as outside the inclusive 28,000-45,000 interval and did not state current-candidate authority clearly enough." + }, + { + "artifactId": "estimate-adoption-h3", + "disposition": "rejected", + "relationship": "previously_superseded_by_estimate-candidate-reconcile-h4", + "preservedPatchReused": false, + "reason": "The preserved h4 handoff records that h3 coupled asOfCommit to HEAD and retained stale target-candidate hashes." + } + ], + "authority": { + "canonicalEntrypoint": "docs/hosted-web-phases/START_HERE.md", + "evidenceLifecycle": "docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md", + "candidateReviewDisposition": "pending", + "rejectedH4RemainsCanonical": false, + "statement": "Source and target snapshot classifications identify reproducible bytes, not evidence authority. This artifact remains a current candidate unless separately reviewed and adopted under the evidence lifecycle." + }, + "result": { + "targetReleaseScope": "non_terminal_v1", + "unit": "gross_integrated_changed_lines", + "baselineInterval": { + "low": 28000, + "high": 45000 + }, + "reconciledInterval": { + "low": 38300, + "high": 62100 + }, + "intervalRelationship": { + "lowEndpointInsideBaselineInclusive": true, + "highEndpointAboveBaseline": true, + "fullyOutsideBaseline": false, + "partiallyOutsideBaseline": true + }, + "countedComponents": [ + "implementationAdditions", + "testsAndEvidenceAdditions", + "deletedLegacyLines", + "unallocatedMixedScope" + ], + "generatedAndExcludedLinesContribution": 0, + "deferredTerminalV1Contribution": { + "low": 0, + "high": 0 + }, + "requiresScopeDesignReview": true + }, + "changedPaths": [ + ".codex-handoff/estimate-semantics-h6.json", + "docs/research/hosted-web/phase-0/estimate-reconciliation/README.md", + "docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json", + "docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json", + "docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs" + ], + "artifactHashes": { + "docs/research/hosted-web/phase-0/estimate-reconciliation/README.md": "119ce9f76e6c10bcff7d6ceab0eba3dbc0b5bca4f574b7987657dc7ffb8b7b0e", + "docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json": "4a499adb3dceab512f9011aef355ded611045b25b72fc0e0c2142a54bb6563f5", + "docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json": "06e5b5658cac598f793b0da736c3e287ba3e631d9e579932fa7929f77b4a1543", + "docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs": "1320a3e53b99cce5e634662d3c6902b682a26a33ab34f1071deadbcb4bb7f9ac" + }, + "checks": [ + { + "id": "ledger-verifier", + "command": "node docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs", + "result": "PASS", + "evidence": "Verified 9 buckets, 34 bucket allocations, 11 W3/W4/W5 allocations, 23 classified hashes, 38,300-62,100 arithmetic, confidence, terminal deferral, current-candidate authority, and partial interval semantics." + }, + { + "id": "schema-validation", + "command": "python3 -m jsonschema -i docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json", + "result": "PASS", + "evidence": "The ledger validates against the strict Draft 2020-12 schema." + }, + { + "id": "format", + "command": "pnpm exec prettier --check docs/research/hosted-web/phase-0/estimate-reconciliation/README.md docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs .codex-handoff/estimate-semantics-h6.json", + "result": "PASS", + "evidence": "All five changed paths use Prettier code style." + }, + { + "id": "git-diff-check", + "command": "git diff --check", + "result": "PASS", + "evidence": "No whitespace errors were reported." + }, + { + "id": "targeted-fast-lint", + "command": "pnpm lint:fast:files -- docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs", + "result": "PASS", + "evidence": "The modified verifier passes the repository fast ESLint configuration." + } + ], + "mandatoryInputNotes": [ + "docs/hosted-web-phases/phase-00/README.md does not exist at exact base 1587615c751c3cb12b5078ab4b7264b6e9fd42ad; its absence was confirmed in the worktree and with git show.", + "No substitute phase packet was inferred. Canonical authority was reconciled directly from START_HERE.md and EVIDENCE_LIFECYCLE.md." + ], + "remainingUncertainty": [ + "W4 executable allocations remain controller estimates over characterized scope.", + "The W3/W5 shared storage and transaction fixture bound remains a controller estimate.", + "Migration and rollout lines remain identified but not numerically separable from production and release-E2E mixed scope.", + "Identity/workspace and release-E2E retain explicit unallocated mixed scope.", + "The separately estimated hosted terminal slice remains deferred post-v1 at 6,500-11,500 lines and contributes zero to non-terminal v1." + ], + "commit": { + "created": false, + "reason": "The linked-worktree worker policy reserves add, commit, and push for Project Integration." + } +} diff --git a/.codex-handoff/final-gate-candidate-reconcile-h4.json b/.codex-handoff/final-gate-candidate-reconcile-h4.json new file mode 100644 index 0000000000..7022d49651 --- /dev/null +++ b/.codex-handoff/final-gate-candidate-reconcile-h4.json @@ -0,0 +1,106 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "taskId": "agent-teams-hosted-web-refactor-final-gate-candidate-reconcile-h4", + "workKey": "phase0/final-gate/3bc0dfa7/6be51a/candidate-reconcile/r3", + "supersedes": "agent-teams-hosted-web-refactor-final-gate-remediation-h3", + "baseSha": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "status": "passed", + "result": { + "diagnosticBaselineClassification": "inherited_source_observed", + "diagnosticBaselineCount": 7, + "diagnosticDistribution": { + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts": 5, + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts": 1, + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts": 1 + }, + "targetedGatePassed": true, + "workspaceMilestonePassed": true, + "workspaceTypecheckMilestoneInvocationCount": 1, + "workspaceTypecheckMilestoneRetryCount": 0, + "workspaceStagesExecuted": ["root", "mcp-source", "mcp-tests"], + "newDiagnostics": 0, + "effectiveDiagnostics": 0 + }, + "milestoneEvidence": { + "command": "PHASE0_TYPECHECK_TIMEOUT_MS=300000 bash scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh milestone", + "attemptCount": 1, + "durationMs": 218250, + "wallSeconds": 219, + "timeoutMsPerStage": 300000, + "normalizedReportSha256": "53769bbc62166f27840a74b5495ea14d20fad588dcd456638b952530ad1a97d5", + "normalizedReportPath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-report.json", + "stages": [ + { + "id": "root", + "durationMs": 207899, + "rawExitCode": 2, + "processDisposition": "exited", + "signal": null, + "compilerOutputBytes": 1815, + "compilerOutputSha256": "46821e45d10298369490fd11ddc4046f16e38a15b0cf4e1c0532e36de26c43aa", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck.raw.txt" + }, + { + "id": "mcp-source", + "durationMs": 4362, + "rawExitCode": 0, + "processDisposition": "exited", + "signal": null, + "compilerOutputBytes": 0, + "compilerOutputSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-source.raw.txt" + }, + { + "id": "mcp-tests", + "durationMs": 5989, + "rawExitCode": 0, + "processDisposition": "exited", + "signal": null, + "compilerOutputBytes": 0, + "compilerOutputSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-tests.raw.txt" + } + ] + }, + "checks": [ + { + "id": "candidate-source-reconciliation", + "status": "passed", + "result": "fresh candidate reproduction preserved the exact seven-diagnostic normalization" + }, + { + "id": "production-runner-dispositions", + "status": "passed", + "result": "shell-level timeout, signal, and runner-error fixtures all failed closed" + }, + { + "id": "workspace-normalized-typecheck", + "status": "passed", + "result": "the single authorized milestone completed root and both MCP stages with precise exited dispositions" + }, + { + "id": "lint-format-shell-json-scope-secret-path-patch", + "status": "passed", + "result": "all final hygiene and ownership checks passed" + } + ], + "normalizationSafety": { + "exactFields": ["file", "line", "column", "code", "complete message"], + "repositoryRootCanonicalized": true, + "knownDiagnosticConsumedAtMostOnce": true, + "removedKnownDiagnosticAcceptedAndReported": true, + "newMovedChangedOrDuplicatedDiagnosticFails": true, + "compilerCrashSignalTimeoutRunnerErrorUnparsedOutputOrUnsupportedExitFails": true, + "abnormalTerminationDispositions": ["timeout", "signal", "runner-error"] + }, + "scopeAttestation": { + "phase1Implementation": false, + "onlyAuthorizedPathsUsed": true, + "realUserProjectsUsed": false, + "hostSpecificPathsCommitted": false + }, + "requestedCommitMessage": "fix(hosted-web): regenerate final gate for candidate", + "integrationAction": "project controller must apply and commit this worktree diff", + "blockers": [] +} diff --git a/.codex-handoff/orchestration-authority-remediation-h5.json b/.codex-handoff/orchestration-authority-remediation-h5.json new file mode 100644 index 0000000000..9d21561a6a --- /dev/null +++ b/.codex-handoff/orchestration-authority-remediation-h5.json @@ -0,0 +1,200 @@ +{ + "schemaVersion": 1, + "workKey": "phase0/orchestration-authority/63ff349e/5e8eafac6d535af776b9f016bfd3488676785bdb2e6131c1ad1bcbe8a9ba21ec/remediation/r2", + "phaseId": "phase-00", + "laneId": "cross-cutting-support", + "packetRevision": "phase-00-r2", + "status": "authority_remediation_ready_for_independent_review", + "authority": "phase_0_supporting_evidence_only", + "producerBaseSha": "63ff349e14e44a83d363ccbcdd756af935555aa9", + "canonicalProvenanceSha": "42ec333848e29e97c41699b9fed73ed199740e3f", + "phaseStartSha": "63ff349e14e44a83d363ccbcdd756af935555aa9", + "inputPatchSha256": "5e8eafac6d535af776b9f016bfd3488676785bdb2e6131c1ad1bcbe8a9ba21ec", + "supersedes": { + "producer": "agent-teams-hosted-web-refactor-orchestration-reconciliation-h4-f1", + "review": "agent-teams-hosted-web-refactor-orchestration-candidate-review-h4-r1", + "reviewDisposition": "REJECTED", + "blockingFinding": "A superseded authority could point to a raw/not-required or rejected/rejected successor without schema or semantic rejection." + }, + "currentBaseProof": { + "expectedHead": "63ff349e14e44a83d363ccbcdd756af935555aa9", + "observedHeadBeforeMutation": "63ff349e14e44a83d363ccbcdd756af935555aa9", + "branch": "fix/hosted-web-orchestration-authority-h5", + "phase1PlanAncestor": "ae53850741796f1b49809320689b84704402661b", + "finalGateAncestor": "63ff349e14e44a83d363ccbcdd756af935555aa9" + }, + "requirements": [ + { + "id": "authoritative_successor_schema", + "result": "pass", + "evidence": "Draft 2020-12 requires every row with a non-empty supersedes list to be canonical with approved or approved-with-conditions disposition." + }, + { + "id": "authoritative_successor_semantics", + "result": "pass", + "evidence": "Whole-catalog validation rejects both a non-authoritative reverse-link owner and a superseded row whose reciprocal successor lacks canonical accepted authority." + }, + { + "id": "authority_laundering_regressions", + "result": "pass", + "evidence": "Focused schema and semantic negatives cover raw/not-required, rejected/rejected and generated/approved successor targets; approved-with-conditions canonical authority remains accepted." + }, + { + "id": "bounded_supersession_integrity", + "result": "pass", + "evidence": "Existing reciprocal, missing-target and finite-entry traversal checks remain intact, with an explicit deterministic two-row cycle regression." + }, + { + "id": "preserved_orchestration_contracts", + "result": "pass", + "evidence": "Focused worker-start and orchestration-state tests preserve prompt-in-jobRoot, deterministic workKey and candidate identity, exact-path, capacity, refill and supersession rules." + }, + { + "id": "phase_authority", + "result": "pass", + "evidence": "Only Phase 0 supporting evidence is introduced; Phase 1 implementation and freeze/final-gate paths are unchanged." + } + ], + "evidence": [ + { + "id": "P0.SUPPORT.ORCHESTRATION.START_HERE", + "path": "docs/hosted-web-phases/START_HERE.md", + "sha256": "db59465ac97f0c835c5f55ebb8170fcbf15951d0dec7a3ad36b33d1d5804b8dd" + }, + { + "id": "P0.SUPPORT.EVIDENCE.LIFECYCLE", + "path": "docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md", + "sha256": "278815270952fe19fffbd053d49fc948f0c9d4db4c0f12330bf6e462d6fb97b6" + }, + { + "id": "P0.SUPPORT.ORCHESTRATION.GUARDS", + "path": "docs/hosted-web-phases/ORCHESTRATION_GUARDS.md", + "sha256": "9f7427d90617572e47b25d7f3a18f450309e1432c76bcf42e3443fa6c752af1c" + }, + { + "id": "P0.SUPPORT.EVIDENCE.CATALOG_SCHEMA", + "path": "docs/hosted-web-phases/evidence-catalog.schema.json", + "sha256": "5250378d766960b7215e9b99aff78612350f3fe437a86b6d6fb81ebc258eb3ea" + }, + { + "id": "P0.SUPPORT.ORCHESTRATION.WORKER_START_SCHEMA", + "path": "docs/hosted-web-phases/worker-start-contract.schema.json", + "sha256": "12934ed700b9dbdc04e512bc6223a8eb290166b6a01c167a56ad8419d6dd6c70" + }, + { + "id": "P0.SUPPORT.ORCHESTRATION.CONTRACT_LIB", + "path": "scripts/hosted-web/orchestration/contract-lib.mjs", + "sha256": "da1e4a1d81784bc3134a851c7c7f5a5662def9296dcff884952259d091d77edb" + }, + { + "id": "P0.SUPPORT.EVIDENCE.CATALOG_VALIDATOR", + "path": "scripts/hosted-web/orchestration/evidence-catalog.mjs", + "sha256": "96bc6907bc8afd82eb269ddfe8024251340fc39855cd3c7ce013716935fa3ab8" + }, + { + "id": "P0.SUPPORT.ORCHESTRATION.WORK_REGISTRY", + "path": "scripts/hosted-web/orchestration/orchestration-state.mjs", + "sha256": "05c11e0493a412014c0499714cbcbe9c041021d97c1f35585b8e5465f6d1cd03" + }, + { + "id": "P0.SUPPORT.ORCHESTRATION.COMBINED_ADMISSION", + "path": "scripts/hosted-web/orchestration/validate-worker-admission.mjs", + "sha256": "967596f48b69cc79bc333157ff717039196b796add539d992425676d69ab21fd" + }, + { + "id": "P0.SUPPORT.EVIDENCE.CATALOG_REGRESSIONS", + "path": "test/architecture/hosted-web/orchestration/evidence-catalog.test.mjs", + "sha256": "af414cb715c120e5c556011a5d54591c43a8608900d1936e22bda64f4c425ba6" + }, + { + "id": "P0.SUPPORT.ORCHESTRATION.SCHEMA_REGRESSIONS", + "path": "test/architecture/hosted-web/orchestration/schema-contracts.test.mjs", + "sha256": "ec621259892c51a35f9944ccacbb0befabb739a6d6d138884624a787cf617a86" + }, + { + "id": "P0.SUPPORT.ORCHESTRATION.SCHEMA_DRIFT_CASES", + "path": "test/architecture/hosted-web/orchestration/fixtures/schema-drift-cases.json", + "sha256": "ab3ffa42fa7818fa5af8ee6fd1a03655fa1b66607ca0ffe527c34ad49d243eee" + } + ], + "changedPaths": [ + ".codex-handoff/orchestration-authority-remediation-h5.json", + "docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md", + "docs/hosted-web-phases/evidence-catalog.schema.json", + "docs/hosted-web-phases/ORCHESTRATION_GUARDS.md", + "docs/hosted-web-phases/README.md", + "docs/hosted-web-phases/START_HERE.md", + "docs/hosted-web-phases/worker-start-contract.schema.json", + "docs/research/hosted-web/README.md", + "scripts/hosted-web/orchestration/contract-lib.mjs", + "scripts/hosted-web/orchestration/evidence-catalog.mjs", + "scripts/hosted-web/orchestration/generate-evidence-catalog.mjs", + "scripts/hosted-web/orchestration/orchestration-state.mjs", + "scripts/hosted-web/orchestration/README.md", + "scripts/hosted-web/orchestration/validate-evidence-catalog.mjs", + "scripts/hosted-web/orchestration/validate-orchestration-state.mjs", + "scripts/hosted-web/orchestration/validate-worker-admission.mjs", + "scripts/hosted-web/orchestration/validate-worker-start.mjs", + "scripts/hosted-web/README.md", + "test/architecture/hosted-web/orchestration/evidence-catalog.test.mjs", + "test/architecture/hosted-web/orchestration/fixtures/catalog-current.txt", + "test/architecture/hosted-web/orchestration/fixtures/catalog-old.txt", + "test/architecture/hosted-web/orchestration/fixtures/catalog-source.json", + "test/architecture/hosted-web/orchestration/fixtures/input-fixture.json", + "test/architecture/hosted-web/orchestration/fixtures/required-doc.md", + "test/architecture/hosted-web/orchestration/fixtures/required-script.mjs", + "test/architecture/hosted-web/orchestration/fixtures/schema-drift-cases.json", + "test/architecture/hosted-web/orchestration/fixtures/valid-worker-start.template.json", + "test/architecture/hosted-web/orchestration/fixtures/worker-prompt.md", + "test/architecture/hosted-web/orchestration/orchestration-state.test.mjs", + "test/architecture/hosted-web/orchestration/schema-contracts.test.mjs", + "test/architecture/hosted-web/orchestration/worker-start-contract.test.mjs" + ], + "checks": [ + { + "command": "for file in test/architecture/hosted-web/orchestration/*.test.mjs; do node $file || exit $?; done", + "exitCode": 0, + "result": "32 focused tests passed across four files" + }, + { + "command": "generate-evidence-catalog.mjs then validate-evidence-catalog.mjs against catalog-source.json", + "exitCode": 0, + "result": "deterministic fixture catalog generated and validated" + }, + { + "command": "pnpm lint:fast:files -- ", + "exitCode": 0, + "result": "pass" + }, + { + "command": "pnpm exec prettier --check --ignore-unknown", + "exitCode": 0, + "result": "pass" + }, + { + "command": "git diff --check plus no-index checks for untracked paths", + "exitCode": 0, + "result": "pass" + }, + { + "command": "bounded secret and private-path scan over exact changed paths", + "exitCode": 0, + "result": "no findings" + } + ], + "scope": { + "supportingEvidenceOnly": true, + "phase1ImplementationChanged": false, + "estimateChanged": false, + "freezeChanged": false, + "finalGateChanged": false, + "realProjectsUsed": false, + "commitCreated": false, + "pushPerformed": false + }, + "unverifiedClaims": [ + "Durable multi-host admission, uniqueness, compare-and-swap refill, and launch-after-commit enforcement remain a separate shared-runtime hardening requirement." + ], + "blockers": [], + "nextAction": "independent_review_then_controller_policy_integration" +} diff --git a/.codex-handoff/phase-0-freeze-h6-remediation-r2.json b/.codex-handoff/phase-0-freeze-h6-remediation-r2.json new file mode 100644 index 0000000000..64d8bbb310 --- /dev/null +++ b/.codex-handoff/phase-0-freeze-h6-remediation-r2.json @@ -0,0 +1,179 @@ +{ + "schemaVersion": 1, + "workKey": "phase-0/final-freeze/f4fa24aa/494b9d03566ed26cdaed0053d2c6dbcc7bf0b6ed7ab7ff5f894a7313df590e3b/remediation/r2", + "status": "ready_for_controller_review", + "exactBase": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "supersedesChain": [ + { + "order": 1, + "taskId": "agent-teams-hosted-web-refactor-phase-0-freeze-h6", + "role": "rejected_producer", + "disposition": "rejected_navigation_conflict" + }, + { + "order": 2, + "taskId": "agent-teams-hosted-web-refactor-phase-0-freeze-h6-review-r1", + "role": "rejecting_reviewer", + "verdict": "REJECTED" + }, + { + "order": 3, + "taskId": "agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r1", + "role": "remediation_producer", + "supersedes": "agent-teams-hosted-web-refactor-phase-0-freeze-h6" + }, + { + "order": 4, + "taskId": "agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r1-review-r2", + "role": "rejecting_reviewer", + "verdict": "REJECTED", + "disposition": "missing_two_current_lanes_regression" + }, + { + "order": 5, + "taskId": "agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r2", + "role": "remediation_producer", + "supersedes": "agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r1", + "remediatesReviewer": "agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r1-review-r2" + } + ], + "digests": { + "preservedApprovedR1Patch": { + "path": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/archives/agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r1-rejected-reject-phase-0-freeze-h6-remediation-r1-missing-two-lanes-test/tracked.diff", + "sha256": "494b9d03566ed26cdaed0053d2c6dbcc7bf0b6ed7ab7ff5f894a7313df590e3b", + "verifiedBeforeImport": true + }, + "r2ReviewerResult": { + "path": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r1-review-r2/agent-teams-hosted-web-refactor-phase-0-freeze-h6-remediation-r1-review-r2.latest-result.json", + "sha256": "1b76907f8ac122439baea387edef4816c7cd700d6c57eafded1550faf9b4159f" + }, + "remediationContentPatchExcludingThisHandoff": { + "sha256": "663ae850d8c9074a4f423216716812c25321f971fe5fedaff30a4a21ef0909ac", + "algorithm": "sha256 of git diff --binary excluding .codex-handoff, followed by git diff --binary --no-index /dev/null for the untracked P1.S0 lane packet" + } + }, + "importEvidence": { + "method": "after SHA-256 verification, omit only the prohibited r1 handoff section and apply the remaining archive with git apply --recount", + "preservation": "18 reviewed non-test paths are byte-identical to r1; the test differs by 11 additive lines only" + }, + "paths": { + "changed": [ + ".codex-handoff/phase-0-freeze-h6-remediation-r2.json", + "docs/hosted-web-phases/EXECUTION_INDEX.json", + "docs/hosted-web-phases/README.md", + "docs/hosted-web-phases/START_HERE.md", + "docs/hosted-web-phases/phase-01/README.md", + "docs/hosted-web-phases/phase-01/controller-packet.md", + "docs/hosted-web-phases/phase-01/execution-dag.md", + "docs/hosted-web-phases/phase-01/lanes/p1-s0-serial-bootstrap.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/README.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/acceptance-and-completion.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/canonical-index.schema.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/handoff-census.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs", + "test/architecture/hosted-web/orchestration/worker-start-contract.test.mjs" + ], + "preservedNonHandoffPaths": 19, + "rejectedR1HandoffCopied": false, + "productSourceChanged": false + }, + "remediation": { + "finding": "missing multiple-current-lanes navigation regression", + "changedBehavior": false, + "testMutation": "adds a distinct P1.S1 entry to the existing P1.S0 currentRoute lanePackets array", + "mutatedCurrentLaneCount": 2, + "exactDiagnostic": "currentRoute:expected_one_lane:2", + "helperAlreadyEnforcedDiagnostic": true + }, + "checks": [ + { + "id": "base", + "command": "git rev-parse HEAD", + "exitCode": 0, + "result": "f4fa24aac9615a4ce10632965a2244a2e11a273e" + }, + { + "id": "preserved-patch-sha256", + "command": "sha256sum ", + "exitCode": 0, + "result": "494b9d03566ed26cdaed0053d2c6dbcc7bf0b6ed7ab7ff5f894a7313df590e3b" + }, + { + "id": "freeze-index-verifier", + "command": "node docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs", + "exitCode": 0, + "result": "5 schemas, 40 evidence IDs, 5 accepted authorities, and 3 focused negatives passed" + }, + { + "id": "estimate-verifier", + "command": "node docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs", + "exitCode": 0, + "result": "9 unique buckets, 38,300-62,100 gross integrated changed lines, and 23 classified source hashes passed" + }, + { + "id": "full-navigation-regression", + "command": "node --test test/architecture/hosted-web/orchestration/worker-start-contract.test.mjs", + "exitCode": 0, + "result": "pass, including zero and two current Phase 1 lane mutations" + }, + { + "id": "targeted-fast-lint", + "command": "pnpm lint:fast:files --cache-location /tmp/phase-0-freeze-h6-remediation-r2-eslint-cache ", + "exitCode": 0, + "result": "pass" + }, + { + "id": "prettier-exact-paths", + "command": "pnpm exec prettier --check <20 exact changed paths>", + "exitCode": 0, + "result": "pass" + }, + { + "id": "changed-json-parse", + "command": "JSON.parse every changed .json path", + "exitCode": 0, + "result": "9 changed JSON files parsed" + }, + { + "id": "tracked-and-untracked-whitespace", + "command": "git diff --check plus git diff --no-index --check for both untracked files", + "exitCode": 0, + "result": "pass" + }, + { + "id": "owned-path-scope", + "command": "compare tracked and untracked paths with the exact owned-path allowlist", + "exitCode": 0, + "result": "20 paths, all owned" + }, + { + "id": "secret-private-path-scan", + "command": "bounded added-line and untracked-file secret/private-user-path scans", + "exitCode": 0, + "result": "no findings" + }, + { + "id": "two-lane-negative-control-self-review", + "command": "run an isolated transformed copy with only the count-2 helper diagnostic suppressed", + "exitCode": 1, + "result": "exactly the navigation test failed at the new assertion, proving the count-2 regression depends on helper enforcement" + } + ], + "risks": [ + "P1.S0 cannot start until the controller validates its exact worker-start contract and exactly one matching queued admission record at the integration phaseStartSha.", + "P1.S0 output requires review and explicit router advancement; it cannot self-authorize P1.S1 or product implementation.", + "Exact target-image/profile construction, provider canaries, production composition, and terminal-negative admission remain fail closed in their owning later phases.", + "The accepted estimate high endpoint exceeds the former baseline; W4 allocation, W3/W5 overlap, and migration split remain implementation-planning risks." + ], + "actions": { + "commitCreated": false, + "pushPerformed": false, + "realProjectUsed": false + } +} diff --git a/.codex-handoff/phase-00-audit-completion.json b/.codex-handoff/phase-00-audit-completion.json new file mode 100644 index 0000000000..0a88cd5d12 --- /dev/null +++ b/.codex-handoff/phase-00-audit-completion.json @@ -0,0 +1,136 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "audit-completion-v3", + "packetRevision": "phase-00-r2", + "status": "completed_audit_rejects_phase_completion", + "rootSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "integrationEvidenceSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "evidence": [ + { + "id": "P0.AUDIT.COMPLETION.V3", + "path": "docs/research/hosted-web/phase-0/audits/completion/requirement-matrix.json", + "proofLevel": "cross_worktree_registry_audited" + } + ], + "overallClassification": "contradicted", + "overallDecision": "remediate", + "adopt": [ + "W1 selection-reconciliation-invariants.md only; corrected W1 review holds the other 12 files", + "W2 execution topology, runtime-ingress inventory, credential-exposure matrix and canonical estimate, with their four schemas", + "W3 state catalog, writer classes, unknown-field catalog, TeamBackupService fault characterization and current-ABI Online Backup spike", + "W4 six corrected-review-approved target/lease/workspace/native-proposal and source files only", + "W5 event inventory, snapshot-handoff scheduler, fingerprint goldens and estimate only", + "W6 observed current-artifact scan as contradiction evidence only" + ], + "reject": [ + "Any Phase 0 completion/freeze claim", + "W1 claim that four reviewed composition files exhaust every visible team control", + "W4 process-anchor adoption while numeric process-group reuse and deterministic PID reuse remain unresolved", + "Current W6 standalone artifact as the v1 hosted artifact", + "Current W6 terminal-absence claim", + "The pre-freeze blocked Phase 1 draft as a valid JIT handoff" + ], + "remediate": [ + "Complete W1 visible-control discovery independently of the manual four-file list and repeat its focused review", + "Refresh W2, W5 and W6 standard handoffs/results; add W4 handoff/result; ensure no-index checks cover every untracked file", + "Fix W4 native-probes cleanup assertion and rerun the owned suite", + "Align W4/W6 artifact paths, manifest identity fields and drain/reset generation protocol", + "Make every explicit W2 environment key removal-sensitive and add the three missing provider fixture pairs", + "Make W5 mutation census omission-detecting against the full mutating source interfaces", + "Make W5 effect recovery reload through a fresh runner at every boundary and fix two whitespace errors", + "Fix the W6 import-sort lint error and repeat the focused W4/W6 review", + "Repair the integrated lane ledger and consume corrected review results", + "Repeat the omitted 0A.4 commands and post-prerequisite baseline evidence before any completion claim" + ], + "furtherProof": [ + "Final non-root image, shared-volume two-container/manual contender, seccomp/init/UID/filesystem and native artifact hashes/probes", + "Final Node worker Online Backup/recovery topology", + "Final HTTPS edge/private listener/trusted CIDR auth/proxy schedules and real durable keyring/SQLite behavior", + "Official Fastify-5 @fastify/cookie compatibility and exact pinned dependency", + "Controller estimate variance/deduplication, all freeze decisions, post-adoption narrow/required repository gates and completion report" + ], + "registryDecisions": { + "originalW1ThroughW6": "reject", + "originalReciprocalReviews": "adopt_as_historical_rejection_evidence", + "remediationW1": "remediate; corrected review approves one file and rejects lane adoption", + "remediationW2": "remediate; corrected review approves eight evidence/schema files and rejects lane adoption", + "remediationW3": "adopt all 16 files per completed corrected review", + "remediationW4": "remediate; corrected review approves six files and rejects lane/pair adoption", + "remediationW5": "remediate; corrected review approves four evidence files and holds 13", + "remediationW6": "remediate; corrected review approves observed artifact scan only and rejects lane/pair adoption", + "phase0Freeze": "reject" + }, + "checks": [ + { + "command": "W1 focused Vitest via read-only-safe temp Vite cache", + "exitCode": 0, + "result": "7/7" + }, + { "command": "W2 scanner and focused Vitest", "exitCode": 0, "result": "scanner pass; 8/8" }, + { + "command": "W3 verifier and focused Node tests", + "exitCode": 0, + "result": "verifier pass; 1/1 plus 7/7" + }, + { + "command": "W4 current-host native probes and evidence scanner", + "exitCode": 0, + "result": "current host only; finalShapeContainer=false" + }, + { + "command": "W4 focused Vitest", + "exitCode": 1, + "result": "2/3; stale cleanup exact-object assertion" + }, + { + "command": "W5 generator check and focused Node tests", + "exitCode": 0, + "result": "9 evidence files; 7/7" + }, + { + "command": "W6 verifier and focused Vitest", + "exitCode": 0, + "result": "fixture/contract verifier pass; 25/25" + }, + { + "command": "scope-corrected W1/W2 remediation review", + "exitCode": 0, + "result": "completed; remediate/reject both; 9 exact files approved; 6 findings" + }, + { + "command": "scope-corrected W3/W5 remediation review", + "exitCode": 0, + "result": "completed; adopt all W3, remediate W5; 4 W5 files approved; 4 findings" + }, + { + "command": "scope-corrected W4/W6 remediation review", + "exitCode": 0, + "result": "completed; remediate both/reject pair; 7 exact files approved; W4 test and W6 lint red" + }, + { + "command": "git status --short --branch in integration worktree", + "exitCode": 0, + "result": "clean at 0e8431b" + }, + { + "command": "git diff --no-index --check /dev/null over each untracked audit artifact", + "exitCode": 1, + "result": "no whitespace diagnostics; no-index returns 1 because each file differs from /dev/null" + }, + { + "command": "jq validation and matrix vocabulary/count checks", + "exitCode": 0, + "result": "valid JSON; 119 classified rows; satisfied=48, contradicted=28, weak=31, missing=12; adopt=48, reject=6, remediate=37, further-proof=28" + } + ], + "scope": { + "integrationPerformed": false, + "pushed": false, + "broadOrFinalGateRun": false, + "phase1WorkPerformed": false, + "terminalImplementationPerformed": false, + "productionSourceChanged": false + }, + "nextAction": "Controller should consume this audit as a remediation/further-proof gate, not as Phase 0 completion." +} diff --git a/.codex-handoff/phase-00-audit-cross-lane-v2.json b/.codex-handoff/phase-00-audit-cross-lane-v2.json new file mode 100644 index 0000000000..821f058224 --- /dev/null +++ b/.codex-handoff/phase-00-audit-cross-lane-v2.json @@ -0,0 +1,110 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "taskId": "agent-teams-hosted-web-refactor-phase-00-audit-cross-lane-v2", + "auditId": "P0.AUDIT.CROSS_LANE.V2", + "packetRevision": "phase-00-r2", + "auditHeadSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "remediationBaseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "status": "characterized_w3_pair_accepted_no_integration_performed", + "artifacts": [ + { + "id": "P0.AUDIT.CROSS_LANE.V2.MANIFEST", + "path": "docs/research/hosted-web/phase-0/audits/cross-lane-v2/approved-files-conflict-risk-manifest.json" + }, + { + "id": "P0.AUDIT.CROSS_LANE.V2.REPORT", + "path": "docs/research/hosted-web/phase-0/audits/cross-lane-v2/README.md" + } + ], + "disposition": { + "immediatelyAdoptableFiles": 0, + "pairAcceptedBundleFiles": 16, + "selectiveEvidenceApprovedFiles": 20, + "evidenceApprovedButHeldFiles": 36, + "laneResults": { + "w1": "reject_remediation_as_a_set", + "w2": "partial_evidence_approval_hold_lane", + "w3": "pair_accepted_all_16_controller_policy_eligible", + "w4": "reject_incomplete_remediation", + "w5": "reject_remediation_as_a_set", + "w6": "reject_remediation_as_a_set" + } + }, + "remediationPairReviews": [ + { + "pair": "w1-w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-review-w1-w2-v3", + "status": "completed_remediate_both", + "approvedFiles": 9 + }, + { + "pair": "w3-w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-review-w3-w5-v2", + "status": "completed_adopt_w3_remediate_w5", + "approvedFiles": 20 + }, + { + "pair": "w4-w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-review-w4-w6-v2", + "status": "completed_remediate_both", + "approvedFiles": 7 + } + ], + "criticalConflicts": [ + "P0.V2.CONFLICT.W4_W6.DRAIN_SCHEMA", + "P0.V2.CONFLICT.W4_W6.ARTIFACT_CONTRACT", + "P0.V2.CONFLICT.W1_W2_W5.ACTION_OWNERSHIP" + ], + "checks": [ + { + "scope": "w1", + "result": "failed exhaustive coverage: 649 additional team-component interaction sites outside claimed composition set" + }, + { + "scope": "w2", + "exitCode": 0, + "result": "scanner passed locally; corrected review found eight classified-key omissions and three missing provider proofs" + }, + { + "scope": "w3", + "exitCode": 0, + "result": "7 TeamBackupService tests, evidence verifier and state-writers test passed" + }, + { + "scope": "w4", + "exitCode": 0, + "result": "current-host probe passed as characterization; corrected review found stale W4 Vitest failure and reusable PGID risk" + }, + { + "scope": "w5", + "exitCode": 0, + "result": "evidence freshness and 7 tests passed at scope; fresh recovery-machine, ownership/direction, census, handoff and whitespace findings remain" + }, + { + "scope": "w6", + "exitCode": 0, + "result": "revocation restart and local verifier passed; W4/W6 contracts incompatible and corrected-review targeted lint fails" + }, + { + "scope": "audit_artifacts", + "exitCode": 0, + "result": "JSON parsed; 36 approved producer hashes, prior-audit hashes and corrected pair-report hashes verified; unique orders/paths and manifest counts asserted; Prettier and no-index whitespace checks passed" + } + ], + "producerPreservation": { + "allProducerWorktreesPreserved": true, + "allReviewWorktreesPreserved": true, + "dirtyOutputsInspectedDirectly": true + }, + "scopeExclusions": { + "integrationPerformed": false, + "pushPerformed": false, + "broadOrFinalGateRun": false, + "phase1Implemented": false, + "terminalImplemented": false, + "realProjectOrProviderUsed": false + }, + "nextAction": "controller_may_offer_exact_16_file_w3_bundle_to_policy_lifecycle; resume_other_incomplete_producers_in_place, resolve_cross_lane_contracts, then repeat_exact_hash_pair_reviews" +} diff --git a/.codex-handoff/phase-00-audit-cross-lane.json b/.codex-handoff/phase-00-audit-cross-lane.json new file mode 100644 index 0000000000..e56df702ce --- /dev/null +++ b/.codex-handoff/phase-00-audit-cross-lane.json @@ -0,0 +1,117 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "audit-cross-lane", + "packetRevision": "phase-00-r2", + "baseSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "status": "characterized", + "evidence": [ + { + "id": "P0.AUDIT.CROSS_LANE_MANIFEST", + "path": "docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.AUDIT.CROSS_LANE_REPORT", + "path": "docs/research/hosted-web/phase-0/audits/cross-lane/README.md", + "proofLevel": "source_observed" + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-audit-cross-lane.json", + "docs/research/hosted-web/phase-0/audits/cross-lane/README.md", + "docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json" + ], + "checks": [ + { + "command": "/usr/bin/jq empty docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json .codex-handoff/phase-00-audit-cross-lane.json", + "exitCode": 0 + }, + { + "command": "/usr/bin/jq -s '[.[].findings[]] | severity rollup' ", + "exitCode": 0, + "result": "24 findings: 3 critical, 14 high, 5 medium, 2 low" + }, + { + "command": "/usr/bin/jq '' docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json", + "exitCode": 0, + "result": "true" + }, + { + "command": "pnpm exec prettier --check docs/research/hosted-web/phase-0/audits/cross-lane .codex-handoff/phase-00-audit-cross-lane.json", + "exitCode": 0 + }, + { + "command": "git diff --check plus git diff --no-index --check /dev/null ", + "exitCode": 0, + "result": "zero whitespace diagnostics across all three audit-owned files" + } + ], + "unverifiedClaims": [ + "No corrected producer handoff or repeated reciprocal review exists yet.", + "No admitted final-shape W4/W6 target topology exists.", + "The canonical 28k-45k estimate cannot be frozen from the rejected estimate inputs." + ], + "blockers": [ + { + "class": "missing_evidence", + "affectedEvidenceIds": [ + "P0.W1.API_PARITY_LEDGER", + "P0.W1.RENDERER_ACTIONS", + "P0.W2.RUNTIME_INGRESS_INVENTORY", + "P0.W3.BACKUP_BEHAVIOR", + "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER", + "P0.W5.COMMAND_CATALOG", + "P0.W5.EFFECT_RECOVERY_MATRIX", + "P0.W5.FINGERPRINT_GOLDENS", + "P0.W4.PROCESS_ANCHOR_SPIKE", + "P0.W6.AUTH_TRANSITIONS", + "P0.W6.ARTIFACT_INVENTORY" + ], + "unrelatedLaneWorkCanContinue": false, + "controllerAction": "Return reciprocal-review findings to W1-W6 owners, reconcile the controller ledger, and repeat all three focused reviews before adoption." + } + ], + "adrRecommendations": [ + { + "adr": "ADR-7", + "recommendation": "reopened", + "reason": "The W6 restart model restores revoked mutation authority and reset is not fenced by typed W4 drain evidence." + }, + { + "adr": "ADR-14", + "recommendation": "narrowed", + "reason": "The browser/runtime authority direction is useful, but W1 action ownership and W2 schemas/environment completeness remain rejected." + }, + { + "adr": "ADR-16/28/31", + "recommendation": "blocked", + "reason": "W4 process/cleanup evidence is falsified and no admitted final-shape topology exists." + }, + { + "adr": "ADR-17", + "recommendation": "blocked", + "reason": "The current artifact includes terminal surfaces and omits required W4 native artifacts and reproducible ABI probes." + }, + { + "adr": "ADR-19/20", + "recommendation": "reopened", + "reason": "The W1 action inventory is heuristic, source-line-dependent and cross-lane inconsistent." + }, + { + "adr": "ADR-32/33/34", + "recommendation": "reopened", + "reason": "Backup behavior lacks fault characterization and recovery schedules/classes contradict writer authority." + } + ], + "estimateBuckets": [], + "summary": { + "producerJobsDone": 6, + "reviewJobsDone": 3, + "producerPairsApproved": 0, + "reviewApprovedFilesHeld": 9, + "findings": 24, + "immediatelyAdoptableFiles": 0 + }, + "nextAction": "controller_reconcile_ledger_then_owner_corrections_and_repeat_reciprocal_reviews" +} diff --git a/.codex-handoff/phase-00-audit-oom-adoption-v2.json b/.codex-handoff/phase-00-audit-oom-adoption-v2.json new file mode 100644 index 0000000000..2852b604e3 --- /dev/null +++ b/.codex-handoff/phase-00-audit-oom-adoption-v2.json @@ -0,0 +1,140 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "taskId": "agent-teams-hosted-web-refactor-phase-00-audit-oom-adoption-v2", + "auditId": "P0.AUDIT.OOM_ADOPTION.V2", + "packetRevision": "phase-00-r2", + "status": "complete", + "classificationSummary": { + "original": { + "w1": "reject", + "w2": "reject", + "w3": "reject", + "w4": "reject", + "w5": "reject", + "w6": "reject" + }, + "remediation": { + "w1": "remediate", + "w2": "remediate", + "w3": "adopt", + "w4": "remediate", + "w5": "remediate", + "w6": "remediate" + } + }, + "approvedCandidate": { + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w3-v1", + "worktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w3-v1", + "treeDigest": "0f0aeca56e4c2b9363ae557b72dcab2d27c794e7f2dc99306a9ceb3704ac5ce2", + "fileCount": 16, + "lineCount": 2454, + "adoptionMode": "controller_policy_lifecycle_three_splits", + "integrationPerformed": false + }, + "preservationRequired": [ + { + "laneId": "w1", + "treeDigest": "e48be0aa2d5751ddafc50f546ec038ebe2137be17064c84589a34f7036d29498", + "fileCount": 13 + }, + { + "laneId": "w2", + "treeDigest": "697ac0097c0e78317b0a6fec68b4ae4c633d03b8ce3b936479554e30d0437795", + "fileCount": 18 + }, + { + "laneId": "w4", + "treeDigest": "a82271568e79de8e053c7333f2d4b8017a9ab6eb4b4e2b408af1f4026f356fe6", + "fileCount": 21 + }, + { + "laneId": "w5", + "treeDigest": "67a8f2f3302433d06413f91caf1be24e0bd28197c0e103abc80e21f91daad459", + "fileCount": 17 + }, + { + "laneId": "w6", + "treeDigest": "00c385f81894e7fc2b424de55f06432f95de7f6dafdb46fe15015fc15a20cdbd", + "fileCount": 11 + } + ], + "auditEvidence": [ + "docs/research/hosted-web/phase-0/audits/oom-adoption-v2/README.md", + "docs/research/hosted-web/phase-0/audits/oom-adoption-v2/candidate-manifest.json" + ], + "criticalFindings": [ + "Stopped dirty jobs are not terminal outputs and must be continued from preserved state.", + "W1's four-file semantic catalog omits reachable child controls and W2's environment gate misses eight explicit-key removals plus three unproved provider cases.", + "W5 effect recovery is not a fresh durable runner, its mutation census is not independently complete, and its launch/stop/cancel plus permission ownership contradicts W1/W2.", + "W4 still signals a reusable numeric process group after it can become empty, so the process-anchor resolution is unsafe.", + "W4 and W6 disagree on native artifact paths and required manifest fields.", + "W4 raw drain evidence is not schema/hash-mapped to W6 adapter drain evidence.", + "W2 and W4 estimate contributions must replace one canonical range rather than be added.", + "W3 and W5 estimate contributions overlap and must be deduplicated." + ], + "checks": [ + { + "name": "w1_vitest", + "result": "pass_with_audit_finding", + "detail": "7 tests; omitted-child coverage probe fails" + }, + { + "name": "w1_regeneration", + "result": "pass", + "detail": "API/action/estimate equal and raw SHA-256 reproduced" + }, + { + "name": "w2_scanner", + "result": "pass_with_audit_findings", + "detail": "4 providers, 2 backend families, 5 operations, 13 rows; eight-key removal probe fails" + }, + { + "name": "w2_vitest", + "result": "pass_with_coverage_gap", + "detail": "8 tests; three provider cases remain unproved" + }, + { + "name": "w3_fault_suite", + "result": "pass", + "detail": "7 tests, 12 TeamBackupService cases" + }, + { "name": "w3_verifier_and_architecture_test", "result": "pass" }, + { + "name": "w4_current_host_native_probes", + "result": "direct_runner_pass_owned_test_fail", + "detail": "numeric PGID reuse remains; one stale cleanup test fails; finalShapeContainer=false" + }, + { + "name": "w5_generator_and_tests", + "result": "pass_with_audit_findings", + "detail": "7 tests" + }, + { + "name": "w5_no_index_whitespace", + "result": "fail", + "detail": "two named EOF blank-line defects" + }, + { + "name": "w6_verifier", + "result": "false_green_contract", + "detail": "exit 0; Node ABI 137; Electron ABI 143; incomplete W4 contract accepted" + }, + { + "name": "w6_vitest", + "result": "pass_with_lint_failure", + "detail": "25 tests; targeted lint import-order failure" + } + ], + "scopeAttestation": { + "producerWorktreesModified": false, + "integrationModified": false, + "integrationAttemptOpened": false, + "commitCreated": false, + "pushPerformed": false, + "broadOrFinalGateRun": false, + "phase1Work": false, + "terminalImplementation": false + }, + "nextAction": "Controller captures immutable preservation backups, then opens one policy lifecycle attempt for exact W3 digest and follows the ordered lifecycle in candidate-manifest.json." +} diff --git a/.codex-handoff/phase-00-audit-requirements.json b/.codex-handoff/phase-00-audit-requirements.json new file mode 100644 index 0000000000..cacf2e4465 --- /dev/null +++ b/.codex-handoff/phase-00-audit-requirements.json @@ -0,0 +1,84 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "audit-requirements", + "auditId": "P0.AUDIT.REQUIREMENTS.V1", + "packetRevision": "phase-00-r2", + "baseSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "status": "characterized", + "acceptanceDisposition": "reject_pending_correction", + "evidence": [ + { + "id": "P0.AUDIT.REQUIREMENTS.MATRIX", + "path": "docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.AUDIT.REQUIREMENTS.REGISTRY_SNAPSHOT", + "path": "docs/research/hosted-web/phase-0/audits/requirements/registry-snapshot.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.AUDIT.REQUIREMENTS.REPORT", + "path": "docs/research/hosted-web/phase-0/audits/requirements/README.md", + "proofLevel": "source_observed" + } + ], + "summary": { + "requirements": 70, + "satisfied": 17, + "partial": 14, + "failed": 28, + "missing": 5, + "pending": 6, + "completedProducerJobs": 6, + "completedReviewJobs": 3, + "approvedProducerLanes": 0, + "rejectedProducerLanes": 6 + }, + "changedPaths": [ + ".codex-handoff/phase-00-audit-requirements.json", + "docs/research/hosted-web/phase-0/audits/requirements/README.md", + "docs/research/hosted-web/phase-0/audits/requirements/registry-snapshot.json", + "docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json" + ], + "checks": [ + { + "command": "jq empty docs/research/hosted-web/phase-0/audits/requirements/registry-snapshot.json docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json .codex-handoff/phase-00-audit-requirements.json", + "exitCode": 0 + }, + { + "command": "jq semantic assertions for 70 unique requirement IDs, status enum, 6 completed producers, 3 completed rejected reviews and handoff summary counts", + "exitCode": 0 + }, + { + "command": "sha256 verification of every sources, producer handoff/latest-result and review handoff/report/latest-result entry in registry-snapshot.json", + "exitCode": 0 + }, + { + "command": "pnpm exec prettier --check docs/research/hosted-web/phase-0/audits/requirements/README.md docs/research/hosted-web/phase-0/audits/requirements/registry-snapshot.json docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json .codex-handoff/phase-00-audit-requirements.json", + "exitCode": 0 + }, + { + "command": "git diff --check plus git diff --no-index --check /dev/null for each of the four untracked audit files", + "exitCode": 0 + }, + { + "command": "bounded private-key, provider-token and Bearer credential scan over the audit directory and handoff", + "exitCode": 0, + "result": "no findings" + }, + { + "command": "git status --short", + "exitCode": 0, + "result": "only .codex-handoff/ and docs/research/hosted-web/phase-0/audits/ are untracked" + } + ], + "unverifiedClaims": [ + "No final-shape container, HTTPS edge, final ABI or terminal-negative artifact proof exists in the audited outputs.", + "No producer correction or focused reciprocal re-review exists after the three rejection records.", + "No controller estimate reconciliation, decision freeze, combined gate, completion report or Phase 1 JIT packet exists." + ], + "blockers": [], + "nextAction": "return_all_six_lanes_for_correction_then_repeat_reciprocal_reviews" +} diff --git a/.codex-handoff/phase-00-freeze-fix-drain-d1.json b/.codex-handoff/phase-00-freeze-fix-drain-d1.json new file mode 100644 index 0000000000..a4903bed11 --- /dev/null +++ b/.codex-handoff/phase-00-freeze-fix-drain-d1.json @@ -0,0 +1,91 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "taskId": "agent-teams-hosted-web-refactor-phase-00-freeze-fix-drain-d1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-freeze-fix-drain-d1", + "findingId": "P0.C1.DRAIN.001", + "baseSha": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e", + "status": "resolved_controller_contract_w4_projection", + "result": { + "envelopeId": "P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1", + "controllerOwner": "phase-00-controller", + "schemaPath": "docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json", + "schemaSha256": "764e707edc1148e45f18f1ac70a30b681e2a794b55d6f8946fb5b50601bc099f", + "w4Reference": "process-anchor.protocol.json references native-protocol.schema.json; x-processAnchorDrainEvidence pins the controller envelope ID, path, hash, exact ready/drained projection, and controller authority", + "successRule": "accept only exact envelopeId/ready/drained fields with equal protocol, purpose, reset, deployment, and process-anchor generations; both ready facts true; drained outcome; zero residuals", + "failClosed": true, + "w6WrapperOwnsW4Authority": false + }, + "scopeAttestation": { + "authArtifactsEdited": false, + "w6LaneEdited": false, + "otherLanesEdited": false, + "processAnchorWireFieldsChanged": false, + "reasonProtocolHashUnchanged": "The already-exact W4 wire fields are projected through the W4 native schema reference, avoiding unrelated artifact-contract and W6 manifest hash churn." + }, + "changedPaths": [ + ".codex-handoff/phase-00-freeze-fix-drain-d1.json", + "docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json", + "docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json", + "scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.d.mts", + "scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.d.mts", + "scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.mjs", + "test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts" + ], + "checks": [ + { + "command": "node scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "exitCode": 0, + "result": "W4 evidence scan consumed the controller schema projection and passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts --maxWorkers=1 --minWorkers=1", + "exitCode": 0, + "result": "2 files and 33 tests passed, including schema, exact ready/drained/unclassified projection, generation, residual, authority-wrapper, omission, and drift negatives" + }, + { + "command": "pnpm lint:fast:files -- ", + "exitCode": 0, + "result": "focused fast lint passed" + }, + { + "command": "pnpm exec tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --strict --skipLibCheck --types node,vitest/globals test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts", + "exitCode": 0, + "result": "focused contract test and generator declaration typecheck passed" + }, + { + "command": "pnpm typecheck", + "exitCode": 2, + "result": "repository-wide gate remains red only in pre-existing out-of-scope auth-artifacts, host-primitives evidence-scanner declaration, and provider-runtime fixture typing; the focused W4/W6 contract file passes the strict standalone typecheck" + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "all matched files use Prettier style" + } + ], + "followUps": [ + { + "id": "P0.C1.DRAIN.001.W6.CONSUME", + "owner": "w6", + "required": true, + "implementedHere": false, + "action": "Replace the W6-local kind/source/controlChannelRef drain wrapper with direct consumption of P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1 and its exact ready/drained objects. Pin the controller schema path/hash, run the shared fail-closed validator before reset authority can reopen, and do not label any W6-owned object as W4 authority.", + "requiredNegatives": [ + "missing or extra envelope/ready/drained field", + "controller envelope ID/path/hash drift", + "purpose or protocol mismatch", + "stale reset/deployment/process-anchor generation", + "unclassified outcome or non-empty residuals", + "W6-owned wrapper claiming W4 source or authority" + ] + } + ], + "knownOutOfScopeGateFailures": [ + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts" + ], + "blockers": [] +} diff --git a/.codex-handoff/phase-00-freeze-fix-index-d1.json b/.codex-handoff/phase-00-freeze-fix-index-d1.json new file mode 100644 index 0000000000..7d40d87f6b --- /dev/null +++ b/.codex-handoff/phase-00-freeze-fix-index-d1.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "taskId": "agent-teams-hosted-web-refactor-phase-00-freeze-fix-index-d1", + "controllerOwner": "agent-teams-hosted-web-refactor-controller-v2", + "status": "done", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "currentIntegrationCommit": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e", + "scope": { + "ownedDirectory": "docs/research/hosted-web/phase-0/freeze/current-canonical", + "ownedHandoff": ".codex-handoff/phase-00-freeze-fix-index-d1.json", + "immutableHistoricalPathsEdited": false + }, + "resolutions": [ + { + "findingId": "P0.C1.IDENTITY.001", + "status": "resolved", + "evidence": "A schema-validated controller index maps exactly one current identity for every lane W1-W6, with one phaseStartSha and exact integration/handoff provenance." + }, + { + "findingId": "P0.C1.STALE.001", + "status": "resolved", + "evidence": "Controller-owned current review, decision, evidence and supersession indexes replace stale current-state interpretations while preserving all historical files byte-for-byte." + } + ], + "w2PhaseStartCorrection": { + "historicalIncorrectClaim": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "authoritativePhaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "historicalClaimDisposition": "explicitly-superseded-not-rewritten", + "authorityPath": "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json" + }, + "changedPaths": [ + ".codex-handoff/phase-00-freeze-fix-index-d1.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/README.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/canonical-index.schema.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/duplicate-id.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/omission.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/stale-hash.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/negative-fixture.schema.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs" + ], + "checks": [ + { + "command": "node docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs --include-controller-external", + "exitCode": 0, + "result": "5 indexes schema-valid; 40 unique evidence IDs and repository/controller-external provenance hashes valid; omission, stale-hash and duplicate-ID negatives failed with expected diagnostics" + }, + { + "command": "pnpm exec prettier --check docs/research/hosted-web/phase-0/freeze/current-canonical", + "exitCode": 0, + "result": "all matched files use Prettier style" + }, + { + "command": "pnpm lint:fast:files -- docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs", + "exitCode": 0, + "result": "focused fast lint passed" + }, + { + "command": "git diff --check", + "exitCode": 0, + "result": "no tracked whitespace diagnostics; all new current-canonical files also passed Prettier" + } + ], + "blockers": [], + "nextAction": "controller integration" +} diff --git a/.codex-handoff/phase-00-freeze-fix-w1-f4.json b/.codex-handoff/phase-00-freeze-fix-w1-f4.json new file mode 100644 index 0000000000..b6c1903e64 --- /dev/null +++ b/.codex-handoff/phase-00-freeze-fix-w1-f4.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": 3, + "phaseId": "phase-00", + "laneId": "w1-freeze-fix-f4", + "packetRevision": "phase-00-r2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-freeze-fix-w1-f4", + "worktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-freeze-fix-w1-f4", + "baseSha": "6cf53a3d71e1bd34ff71f99968b705a0e1aa939c", + "workspaceHeadSha": "6cf53a3d71e1bd34ff71f99968b705a0e1aa939c", + "integrationCommitSha": null, + "commitStatus": "uncommitted_by_linked_worktree_worker_policy", + "status": "characterized", + "sourceRecreation": { + "sourceJobId": "agent-teams-hosted-web-refactor-phase-00-freeze-fix-w1-d1", + "sourceBaseSha": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e", + "preservedPatchSha256": "sha256:bb297e5bc0cda6a2ee1b05bfc867a87058ed1b7299b6adab163d46f6116b790c", + "recreatedAgainstCurrentCanonical": true + }, + "findingResolutions": [ + { + "id": "P0.FREEZE.W1_W2.C1.001", + "status": "resolved", + "resolution": "The approved d1 React event-family, mixed-handler, subject-save, catalog, and evidence work is recreated on the current canonical base. Mounted interaction discovery now additionally finds every on* callback on directly imported external package components and maps all 817 reachable sites exactly once." + }, + { + "id": "P0.FREEZE.W1_W2.C1.001.E1", + "status": "resolved", + "resolution": "Both repository DndContext.onDragCancel sites and the mounted Sheet.onClose and Sheet.onSnap sites are discovered and dispositioned. Repository-backed removal negatives independently prove omission of each callback family makes the checked-in catalog fail closed." + } + ], + "evidence": [ + { + "id": "P0.W1.RENDERER_ACTIONS", + "path": "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W1.RENDERER_CHILD_CONTROLS", + "path": "docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W1.SCANNER", + "path": "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "proofLevel": "fixture_characterized" + } + ], + "counts": { + "apiMembers": 109, + "reachableControlFiles": 171, + "reachableInteractionSites": 817, + "semanticActions": 491, + "deliberateAbsenceClasses": 6, + "childCatalogMappings": 666, + "excludedUnreachableTeamFiles": 7, + "excludedUnreachableInteractionSites": 11 + }, + "changedPaths": [ + ".codex-handoff/phase-00-freeze-fix-w1-f4.json", + "docs/research/hosted-web/phase-0/parity-renderer/README.md", + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts" + ], + "checks": [ + { + "command": "W1_RAW_EVIDENCE_ROOT=/tmp/agent-teams-hosted-web-refactor-phase-00-freeze-fix-w1-f4-artifacts node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "exitCode": 0, + "result": "109 API members and 817 mapped sites; schemas valid; raw bypass hash reproduced" + }, + { + "command": "node node_modules/vitest/vitest.mjs run test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts", + "exitCode": 0, + "result": "1 file and 21 tests passed, including three repository-backed external-callback omission negatives" + }, + { + "command": "pnpm lint:fast:files -- scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts", + "exitCode": 0, + "result": "focused fast lint passed" + }, + { + "command": "pnpm typecheck", + "exitCode": 2, + "result": "changed W1 scanner/test files are clean; the repository-wide check remains blocked by seven pre-existing errors in untouched auth-artifacts, host-primitives, and provider-runtime tests" + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "all changed W1-owned files match Prettier style" + }, + { + "command": "git diff --check; exact W1 path allowlist; bounded secret scan", + "exitCode": 0, + "result": "no whitespace, out-of-scope path, or secret findings" + } + ], + "externalRawEvidence": { + "path": "/tmp/agent-teams-hosted-web-refactor-phase-00-freeze-fix-w1-f4-artifacts/legacy-bypass-raw.json", + "sha256": "sha256:2ea065639675df126935790d86578cfdae9cf9feb60371826b03f1f877c5f497", + "recordCount": 610, + "ephemeral": true + }, + "scopeExclusions": [ + "No W2, W3, W4, W5, W6, production source, controller index, shared architecture, package, lockfile, or terminal implementation was edited." + ], + "risks": [ + "External callback discovery is intentionally anchored to direct external package imports; internal component callback props are counted at their mounted DOM or external-package interaction boundary to avoid double-counting.", + "Repository-wide pnpm typecheck still exits 2 on seven errors outside this lane; none points to either changed TypeScript file." + ], + "blockers": [], + "nextAction": "integration controller commits the verified workspace diff and requests independent review of P0.FREEZE.W1_W2.C1.001.E1" +} diff --git a/.codex-handoff/phase-00-freeze-fix-w2-d1.json b/.codex-handoff/phase-00-freeze-fix-w2-d1.json new file mode 100644 index 0000000000..83a6fa723f --- /dev/null +++ b/.codex-handoff/phase-00-freeze-fix-w2-d1.json @@ -0,0 +1,93 @@ +{ + "schemaVersion": 1, + "taskId": "agent-teams-hosted-web-refactor-phase-00-freeze-fix-w2-d1", + "phaseId": "phase-00", + "findingId": "P0.FREEZE.W1_W2.C1.002", + "owner": "W2", + "status": "resolved", + "title": "Workspace-trust provider-child environment sanitizer is included in the independent census", + "sourceReview": { + "reviewTaskId": "agent-teams-hosted-web-refactor-phase-00-freeze-review-w1-w2-c1", + "reviewedCommit": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e", + "historicalFindingIds": ["R12-W2-001", "R12R-W2-001"] + }, + "corrections": [ + { + "id": "P0.FREEZE.W2.CENSUS.WORKSPACE_TRUST", + "status": "satisfied", + "evidence": "The production-source census now declares src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts and scans file roots as well as directory roots. Removing this surface from discovered occurrences produces a classified-without-source failure for every workspace-trust prohibition." + }, + { + "id": "P0.FREEZE.W2.CENSUS.EXACT_PREFIX", + "status": "satisfied", + "evidence": "Extraction version 3 recognizes exact environment-key Set declarations and prefix arrays as explicit wildcard policies. Provenance, semantic fixture, and credential-exposure membership cover CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER, AGENT_TEAMS_RUNTIME_TURN_SETTLED_*, AGENT_TEAMS_MCP_*, and CLAUDE_TEAM_BOOTSTRAP_*." + }, + { + "id": "P0.FREEZE.W2.CENSUS.OMISSION_NEGATIVES", + "status": "satisfied", + "evidence": "Focused tests independently remove the workspace-trust sanitizer surface and each of the four exact/prefix policies. Every mutation fails the completeness validator with the omitted policy named." + }, + { + "id": "P0.FREEZE.W2.PRIOR_PROOFS", + "status": "satisfied", + "evidence": "The prior 40 proofs remain green: all 26 canonical fake-runtime seam proofs and the prior 14 scanner tests pass. Two new omission-sensitive scanner tests increase the focused result to 42 tests." + } + ], + "checks": [ + { + "id": "P0.FREEZE.W2.CHECK.SCANNER", + "command": "node --experimental-strip-types scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "exitCode": 0, + "result": "Scanner passed with 4 providers, 2 backend families, 5 operations, 13 independently executed provider cases, 7 provider/mode dispositions, per-key provenance/exposure, strict schemas, and the omission-sensitive census." + }, + { + "id": "P0.FREEZE.W2.CHECK.TESTS", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts --reporter=verbose", + "exitCode": 0, + "result": "2 files and 42 tests passed: 16 scanner tests and 26 canonical fake-runtime seam proofs." + }, + { + "id": "P0.FREEZE.W2.CHECK.SANITIZER", + "command": "pnpm exec vitest run test/features/workspace-trust/main/workspaceTrustPreflightEnv.test.ts --reporter=verbose", + "exitCode": 0, + "result": "The existing focused sanitizer test passed, directly proving removal of the named exact key and all three prefix examples while preserving user authentication environment values." + }, + { + "id": "P0.FREEZE.W2.CHECK.LINT", + "command": "pnpm lint:fast:files -- scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "exitCode": 0, + "result": "Focused fast lint passed." + }, + { + "id": "P0.FREEZE.W2.CHECK.FORMAT", + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "All owned changed paths use Prettier style." + }, + { + "id": "P0.FREEZE.W2.CHECK.DIFF", + "command": "git diff --check", + "exitCode": 0, + "result": "No whitespace errors." + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-freeze-fix-w2-d1.json", + "docs/research/hosted-web/phase-0/provider-runtime/README.md", + "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json", + "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/environment-semantics.json", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts" + ], + "scope": { + "otherLanesEdited": false, + "controllerIndexesEdited": false, + "productionWorkspaceTrustSourceEdited": false + }, + "remainingNarrowings": [ + "Evidence remains source-observed at the pinned Phase 0 base; final-image provider execution and credential canaries remain target-unverified as declared by the canonical W2 packet." + ], + "nextAction": "Controller reruns the focused W1/W2 freeze review against this W2 correction." +} diff --git a/.codex-handoff/phase-00-freeze-fix-w5-format-f4.json b/.codex-handoff/phase-00-freeze-fix-w5-format-f4.json new file mode 100644 index 0000000000..9bdea5ce33 --- /dev/null +++ b/.codex-handoff/phase-00-freeze-fix-w5-format-f4.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": 2, + "phaseId": "phase-00", + "laneId": "w5", + "taskId": "phase-00-freeze-fix-w5-format-f4", + "status": "ready_for_controller_commit", + "canonicalBaseSha": "6cf53a3d71e1bd34ff71f99968b705a0e1aa939c", + "approvedProducerTaskId": "phase-00-freeze-fix-w5-owner-d1", + "approvedReview": { + "reviewId": "phase-00-freeze-review-fix-w5-owner-e1", + "decision": "APPROVE", + "findings": ["F35-C1-001", "P0.C1.OWNER.001"] + }, + "scope": { + "mode": "approved_w5_owner_gate_recreation_plus_integration_formatting", + "semanticWidening": false, + "terminalImplementation": false, + "w1FilesEdited": false, + "otherLaneFilesEdited": false + }, + "ownerGate": { + "authorityArtifact": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "authorityEvidenceId": "P0.W1.API_PARITY_LEDGER", + "comparedRequiredW1W5Members": 49, + "missingW1Rows": 0, + "ownerMismatches": 0, + "primaryOwnerCorrections": { + "git.initialize_repository": "workspace-registry", + "git.create_initial_commit": "workspace-registry", + "member.restart": "team-lifecycle", + "member.skip_for_launch": "team-lifecycle" + }, + "runtimeSecondaryEffectOwnersPreserved": ["member.restart", "member.skip_for_launch"] + }, + "formattingIntegration": { + "generatorChange": "Emit the three changed generated JSON artifacts in Prettier-stable form so generation freshness and formatting checks hold simultaneously.", + "formattedGeneratedArtifacts": [ + "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json" + ], + "approvedD1JsonSemanticsPreserved": true + }, + "changedPaths": [ + ".codex-handoff/phase-00-freeze-fix-w5-format-f4.json", + "docs/research/hosted-web/phase-0/recovery-events/README.md", + "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json", + "scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs", + "scripts/hosted-web/phase-0/recovery-events/model.mjs", + "scripts/hosted-web/phase-0/recovery-events/mutation-census.mjs", + "test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs" + ], + "checks": [ + { + "command": "node scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs --check", + "exitCode": 0, + "result": "verified 9 W5 evidence files" + }, + { + "command": "node test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs", + "exitCode": 0, + "result": "8 tests passed" + }, + { + "command": "pnpm lint:fast:files -- ", + "exitCode": 0, + "result": "targeted fast lint passed" + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "all changed files use Prettier code style" + }, + { + "command": "git diff --check", + "exitCode": 0, + "result": "workspace diff is whitespace-clean" + } + ], + "commit": { + "created": false, + "reason": "The isolated linked-worktree worker policy reserves add, commit, and push for Project Integration." + }, + "blockers": [], + "risks": [ + "This lane supplies Phase 0 evidence and gates only; it does not implement the hosted command coordinator, secondary-effect ports, or terminal behavior.", + "The drift gate intentionally reads the checked-in W1 ledger without changing it; future W1 owner changes require explicit W5 reconciliation." + ], + "nextAction": "Project Integration should inspect the intact diff, commit it, and rerun the controller-level cross-lane and freeze sequence." +} diff --git a/.codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json b/.codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json new file mode 100644 index 0000000000..f275df632d --- /dev/null +++ b/.codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json @@ -0,0 +1,167 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "w6-artifact-authority", + "taskId": "phase-00-freeze-fix-w6-artifact-f16", + "status": "remediation_complete_pending_review", + "canonicalSourceCommit": "0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3", + "recreationProvenance": { + "sourceTaskId": "agent-teams-hosted-web-refactor-phase-00-freeze-fix-w6-artifact-f4", + "sourceBaseCommit": "6cf53a3d71e1bd34ff71f99968b705a0e1aa939c", + "preservedPatch": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/archives/agent-teams-hosted-web-refactor-phase-00-freeze-fix-w6-artifact-f4-rejected-20260712T031354Z/worker-output.patch", + "preservedPatchSha256": "530d108aabdda19e72f64194a95feee55200ff618cf0d735aee11502a1aeb811", + "artifactAuthoritySourceCommit": "6cf53a3d71e1bd34ff71f99968b705a0e1aa939c", + "recreatedAgainstCanonical": true + }, + "findings": [ + { + "id": "P0-FREEZE-W46-C1-001", + "status": "resolved", + "resolution": "Split historical rejected-candidate provenance from an exact current-commit authority and made the verifier compare a fresh targeted build." + }, + { + "id": "P0.C1.W6.001", + "status": "resolved", + "resolution": "Aligned the artifact-evolution assumption and proof levels without admitting the current artifact or claiming terminal exclusion is achieved." + }, + { + "id": "P0.C1.DRAIN.001.W6.CONSUME", + "status": "resolved", + "resolution": "Directly consumed the controller-owned drain envelope and pinned its ID, schema path, hash, exact ready/drained shapes and no-wrapper authority boundary." + }, + { + "id": "P0-FREEZE-W6-E1-001", + "status": "resolved", + "resolution": "Made the schema and verifier derive estimate, inventory, terminal-rule and handoff projections from one authority and added a negative mutation for every projection." + }, + { + "id": "P0-FREEZE-W6-F4-E1-001", + "status": "resolved", + "resolution": "Typed the two projection-mutation evidence lookups as { id: string }; focused and repository comparisons both retain the canonical diagnostic sets with a zero-diagnostic delta." + } + ], + "currentCommitAuthority": { + "path": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "recordType": "w6-current-commit-artifact-scan", + "semanticSha256": "606046d7d5b461e7293c5ee366c5145b94886e6c4ff2bd316fb428f9d41a06f3", + "proofLevel": "targeted_current_commit_build_observed", + "buildCommand": "pnpm exec vite build --config docker/vite.standalone.config.ts --outDir --emptyOutDir", + "comparison": "complete semantic record with exact relative paths, byte counts and SHA-256 hashes", + "targetedBuildCompared": true + }, + "historicalProvenance": { + "path": "docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json", + "recordType": "w6-historical-rejected-candidate-artifact-scan", + "integrationAttemptId": "a8405fd56102c02a0319e197c5b1b892d612616e39e5e871167cdb42798d5767", + "relationship": "historical_only_not_current_commit_authority" + }, + "drainEnvelopeConsumer": { + "envelopeId": "P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1", + "schemaPath": "docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json", + "schemaSha256": "764e707edc1148e45f18f1ac70a30b681e2a794b55d6f8946fb5b50601bc099f", + "authority": "phase-00-controller", + "projection": "exact_required_fields_no_lane_owned_wrapper", + "consumedDirectly": true + }, + "artifactEvolution": { + "assumption": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet.", + "reviewGate": "Any evolved candidate requires a separately reviewed packet." + }, + "proofLevels": { + "artifactInventory": "targeted_current_commit_build_observed", + "currentTerminalRuleEvaluation": "targeted_current_commit_build_observed", + "authAndProxyModels": "fixture_characterized", + "abiProbe": "fixture_characterized", + "historicalRejectedCandidate": "historical_rejected_candidate_build_observed" + }, + "scope": { + "disposition": "standalone_artifact_rejected_for_hosted_v1", + "remoteAuthReady": false, + "remoteMutationReady": false, + "productionCompositionReady": false, + "terminalAbsenceAchieved": false, + "terminalImplemented": false + }, + "changedPaths": [ + ".codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json", + "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json", + "docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json", + "docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json", + "docs/research/hosted-web/phase-0/auth-artifacts/report.md", + "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts" + ], + "checks": [ + { + "command": "node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "exitCode": 0, + "result": "Controller drain envelope, targeted current build, exact authority projections, hosted-v1 rejection, terminal rule, split historical provenance, schema and handoff passed." + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts --maxWorkers=1 --minWorkers=1", + "exitCode": 0, + "result": "57 W4/W6 contract and W6 artifact-authority tests passed." + }, + { + "command": "pnpm lint:fast:files -- scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "exitCode": 0, + "result": "Focused fast lint passed for the two W6 scripts and focused W6 test." + }, + { + "command": "pnpm exec tsc --noEmit --pretty false --target ES2023 --module ESNext --lib ES2023,DOM,DOM.Iterable --jsx react-jsx --strict --esModuleInterop --skipLibCheck --moduleResolution bundler --resolveJsonModule --isolatedModules --baseUrl . --types node,vitest/globals test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "exitCode": 2, + "result": "The independently reproducible narrow compile reported the canonical five known diagnostics (TS7016, TS7031, TS18046, TS7031, TS7031); the two rejected-f4 projection-lookup TS7031 diagnostics remain absent." + }, + { + "command": "pnpm exec prettier --check .codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json docs/research/hosted-web/phase-0/auth-artifacts/evidence.json docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json docs/research/hosted-web/phase-0/auth-artifacts/report.md scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "exitCode": 0, + "result": "All exact 12 changed paths use Prettier style." + }, + { + "command": "git diff --check && for path in .codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json; do git diff --no-index --check /dev/null \"$path\" >/dev/null; test $? -eq 1 || exit 1; done", + "exitCode": 0, + "result": "No whitespace errors exist in tracked or untracked output." + }, + { + "command": "actual=\"$(git status --porcelain=v1 | cut -c4- | sort)\"; expected=\"$(printf '%s\\n' '.codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json' 'docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json' 'docs/research/hosted-web/phase-0/auth-artifacts/evidence.json' 'docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json' 'docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json' 'docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json' 'docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json' 'docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json' 'docs/research/hosted-web/phase-0/auth-artifacts/report.md' 'scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs' 'scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs' 'test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts' | sort)\"; test \"$actual\" = \"$expected\"", + "exitCode": 0, + "result": "The worktree contains exactly the approved 12-path W6 scope and no terminal, production, Docker, package, lockfile or Phase 1 change." + }, + { + "command": "node -e 'const fs=require(\"node:fs\");for(const path of [\".codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/evidence.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json\"])JSON.parse(fs.readFileSync(path,\"utf8\"));'", + "exitCode": 0, + "result": "Every changed JSON artifact and the structured handoff parse successfully." + }, + { + "command": "node -e 'const fs=require(\"node:fs\");const paths=[\".codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/evidence.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json\",\"docs/research/hosted-web/phase-0/auth-artifacts/report.md\",\"scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs\",\"scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs\",\"test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts\"];const patterns=[/-----BEGIN [A-Z ]+PRIVATE KEY-----/,/\\bBearer\\s+[A-Za-z0-9._~-]+/,/\\b(?:sk|ghp)_[A-Za-z0-9]{12,}/,/\\/(?:Users|home)\\/[A-Za-z0-9._-]+\\//];for(const path of paths){const text=fs.readFileSync(path,\"utf8\");for(const pattern of patterns)if(pattern.test(text))throw new Error(path+\": \"+pattern);}'", + "exitCode": 0, + "result": "No private-key, bearer-token, API-token or user-home pattern matched the exact 12 changed paths." + } + ], + "blockers": [], + "risks": [ + "No final hosted image/profile, pinned builder/compiler identity, startup/seccomp/load probe or production composition exists.", + "The exact current standalone build still includes terminal surfaces and omits the internal-storage worker, so hosted-v1 admission remains closed." + ], + "diagnosticComparison": { + "scope": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "command": "pnpm exec tsc --noEmit --pretty false --target ES2023 --module ESNext --lib ES2023,DOM,DOM.Iterable --jsx react-jsx --strict --esModuleInterop --skipLibCheck --moduleResolution bundler --resolveJsonModule --isolatedModules --baseUrl . --types node,vitest/globals test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "exitCode": 2, + "diagnosticCount": 5, + "diagnosticCodes": ["TS7016", "TS7031", "TS18046", "TS7031", "TS7031"], + "rejectedProjectionLookupDiagnosticsPresent": false + }, + "nextAction": "independent_review_of_f16_artifact_authority_remediation", + "reviewRemediation": { + "rejectedCandidateTaskId": "phase-00-freeze-fix-w6-artifact-f10", + "rejectedCheck": "pnpm exec tsc -p tsconfig.w6-f5-review.json --pretty false", + "rejectionReason": "tsconfig.w6-f5-review.json was absent from the candidate and repository, so the recorded command failed with TS5058.", + "status": "resolved", + "resolution": "Replaced the absent-config command with a self-contained TypeScript CLI invocation using the repository-owned focused test path and the root compiler settings spelled out on the command line." + } +} diff --git a/.codex-handoff/phase-00-freeze-remediation-provenance-f1.json b/.codex-handoff/phase-00-freeze-remediation-provenance-f1.json new file mode 100644 index 0000000000..5f80a375ab --- /dev/null +++ b/.codex-handoff/phase-00-freeze-remediation-provenance-f1.json @@ -0,0 +1,119 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "taskId": "phase-00-freeze-remediation-provenance-f1", + "status": "freeze_candidate_ready_for_independent_review", + "canonicalBaseSha": "c958c872fa22edf9b2d6a0741d7781b00957903c", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "freezeCandidate": { + "integrationCommit": null, + "evidenceIndexSha256": "d5c8725dfb22f7e0228e0dd51f53d978d117ed7253fdb279c8ddba7000ff8758", + "authorization": "not_authorized" + }, + "findingResolutions": [ + { + "id": "F12-E1-001", + "status": "resolved_in_candidate", + "resolution": "Lane identity, review disposition, decisions, evidence rows and the canonical verifier now record explicit narrowed adoption for later W1 commit 0d1a82fe, W2 commit 6d54e7c6 and W5 commit 5d723407. Integrated rows are re-hashed from git-show bytes at their true commits. The two portability-normalized W1 bytes are explicitly pending integration and are not attributed to an existing commit." + }, + { + "id": "F12-E1-002", + "status": "resolved_in_candidate", + "resolution": "P0.W1.LEGACY_BYPASSES now records only artifact-pack-relative legacy-bypass-raw.json, the raw digest/count and a deterministic repository command. The scanner regenerates the same envelope regardless of its task-local output directory, and the verifier rejects non-portable provenance." + }, + { + "id": "F12-E1-003", + "status": "resolved_in_candidate", + "resolution": "Phase 1 projections use the current W1/W2, W3/W5 and W4/W6 dispositions. R12, RW35, R46, hold-all-adoption and failed-freeze conclusions are retained only as explicitly historical evidence. Current blockers are limited to candidate integration, target-image proof, estimate reconciliation, final gate/typecheck normalization, serial bootstrap and explicit authorization." + }, + { + "id": "F12-E1-004", + "status": "resolved_in_candidate", + "resolution": "Phase 1 is a blocked, non-authoritative proposal. Exact paths, shared writers, contract IDs, review pairs and ownership remain unassigned until readiness and serial bootstrap; producer target is zero and no implementation is authorized." + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-freeze-remediation-provenance-f1.json", + "docs/hosted-web-phases/phase-01/README.md", + "docs/hosted-web-phases/phase-01/controller-packet.md", + "docs/hosted-web-phases/phase-01/packet-inputs.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/README.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/acceptance-and-completion.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/canonical-index.schema.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/handoff-census.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs", + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/schemas/legacy-bypass-inventory.schema.json", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts" + ], + "checks": [ + { + "command": "node docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs --include-controller-external", + "exitCode": 0, + "result": "Five schemas, 40 evidence IDs, three negative fixtures, repository/worktree hashes, controller-external hashes, true git-commit byte provenance, explicit later-byte dispositions and candidate/integration separation passed." + }, + { + "command": "W1_RAW_EVIDENCE_ROOT=/tmp/phase-00-freeze-remediation-provenance-f1-artifacts node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "exitCode": 0, + "result": "109 API members, 817 mapped sites and raw SHA-256 2ea065639675df126935790d86578cfdae9cf9feb60371826b03f1f877c5f497 reproduced; the checked-in envelope remained pack-relative and byte-stable." + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts --reporter=verbose", + "exitCode": 0, + "result": "W1: 1 file and 21 tests passed." + }, + { + "command": "node --experimental-strip-types scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts; pnpm exec vitest run test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts --reporter=verbose", + "exitCode": 0, + "result": "W2 scanner passed and 2 files/42 tests passed." + }, + { + "command": "node scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs --check; node --test test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs", + "exitCode": 0, + "result": "W5 verified nine evidence files and the focused Node test passed." + }, + { + "command": "pnpm lint:fast:files -- docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "exitCode": 0, + "result": "Focused fast lint passed." + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "All changed paths match Prettier formatting." + }, + { + "command": "JSON parse; git diff --check plus untracked checks; exact scope allowlist; bounded secret and task-local-path scans", + "exitCode": 0, + "result": "All changed JSON parsed; no whitespace, scope, secret or task-local provenance finding." + } + ], + "notRun": [ + "Repo-wide typecheck, per user instruction. The inherited seven-diagnostic normalization remains an explicit final-gate requirement.", + "Final full Phase 0 gate, per user instruction.", + "Target-image/container/provider/terminal probes; these remain explicit readiness blockers." + ], + "scope": { + "changedPathCount": 18, + "productionSourceModified": false, + "terminalModified": false, + "dockerModified": false, + "packagesOrLockfilesModified": false, + "phase1Started": false, + "phase1Authorized": false, + "committed": false, + "pushed": false, + "integrated": false + }, + "risks": [ + "The freeze candidate has no integration commit. After independent approval and controller integration, current indexes must be regenerated or finalized with the actual freeze commit without attributing candidate bytes to c958c872.", + "Final target-image proof, estimate reconciliation, the final gate with inherited typecheck normalization, serial bootstrap and explicit authorization remain open." + ], + "nextAction": "Independent review of the intact 18-path candidate. If approved, the project controller may integrate it, record the actual freeze commit and integrated evidence digest, and keep Phase 1 blocked until every readiness item and explicit authorization are satisfied." +} diff --git a/.codex-handoff/phase-00-prep-evidence-phase1.json b/.codex-handoff/phase-00-prep-evidence-phase1.json new file mode 100644 index 0000000000..f8323d716f --- /dev/null +++ b/.codex-handoff/phase-00-prep-evidence-phase1.json @@ -0,0 +1,86 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "prep-evidence-phase1", + "packetRevision": "phase-00-r2", + "baseSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "status": "characterized", + "evidence": [ + { + "id": "P0.ASSEMBLY.REGISTRY_SNAPSHOT", + "path": "docs/research/hosted-web/phase-0/evidence/registry-snapshot.json", + "proofLevel": "source_observed", + "sha256": "690ddfae1104d301b6aa613a2127d14fa561cafe315e18b6b8f5410b6220511d" + }, + { + "id": "P0.ASSEMBLY.ACCEPTANCE_GAPS", + "path": "docs/research/hosted-web/phase-0/evidence/acceptance-gap-register.md", + "proofLevel": "source_observed", + "sha256": "105eba7ab3d1503dbc7caae46f20c5f67576c4fae82e2f1224f359861de9dbcf" + }, + { + "id": "P1.PACKET.BLOCKED_DRAFT", + "path": "docs/hosted-web-phases/phase-01/controller-packet.md", + "proofLevel": "unverified", + "sha256": "c6bc3dfb23e4a6c5b67335bbdabb1271d12730b0ea881b1933af318ab644f854" + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-prep-evidence-phase1.json", + "docs/hosted-web-phases/phase-01/README.md", + "docs/hosted-web-phases/phase-01/controller-packet.md", + "docs/hosted-web-phases/phase-01/packet-inputs.md", + "docs/research/hosted-web/phase-0/evidence/acceptance-gap-register.md", + "docs/research/hosted-web/phase-0/evidence/index.md", + "docs/research/hosted-web/phase-0/evidence/registry-snapshot.json" + ], + "checks": [ + { + "command": "jq empty docs/research/hosted-web/phase-0/evidence/registry-snapshot.json .codex-handoff/phase-00-prep-evidence-phase1.json", + "exitCode": 0 + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0 + }, + { + "command": "git diff --check plus per-file no-index checks for untracked assembly files", + "exitCode": 0 + } + ], + "unverifiedClaims": [ + "No Phase 0 producer/review output has been adopted into the integration worktree by this task.", + "The three reciprocal review records were worktree-local and had no registry latest-result at capture time.", + "Both audits were worktree-local without registry latest-results; cross-lane holds all adoption and requirements rejects Phase 0 acceptance/freeze at capture time.", + "No predecessor completion SHA, frozen evidence-index hash, final Phase 0 gate, or accepted estimate exists for a ready Phase 1 packet.", + "No Phase 1 owned paths, contract IDs, worker lane packets, or implementation are frozen or authorized." + ], + "blockers": [ + { + "class": "missing_evidence", + "affectedEvidence": ["P0.ASSEMBLY.ACCEPTANCE_GAPS", "P1.PACKET.BLOCKED_DRAFT"], + "detail": "All three reciprocal review pairs reject producer evidence pending correction; the cross-lane audit holds all adoption, the requirements audit rejects acceptance/freeze, and reviewed adoption, final target-topology proof, final gates, and Phase 0 freeze are absent.", + "recommendedAction": "Correct and re-review W1-W6, complete and refresh both audits, adopt approved evidence, run final topology/gates, freeze Phase 0, then replace the blocked draft with a revisioned ready Phase 1 packet." + } + ], + "adrRecommendations": [ + { + "adr": "ADR-15/19/20", + "recommendation": "do_not_freeze_from_current_evidence", + "reason": "The W1/W2 review rejects the semantic action catalog, stable IDs, cross-lane ownership, schemas, and estimate inputs required to materialize exact Phase 1 contracts and gates." + }, + { + "adr": "ADR-7/16/17/28/31/32/33/34", + "recommendation": "retain_open_or_blocked_per_review", + "reason": "The W3/W5 and W4/W6 reviews identify substantive falsification or missing target-topology evidence." + } + ], + "estimateBuckets": [ + { + "bucketId": "EST-CONTRACTS", + "status": "not_reconciled", + "reason": "W1 and W2 estimates were rejected and the controller-owned unique-bucket ledger has not been regenerated." + } + ], + "nextAction": "Controller correction and focused reciprocal re-review for all three pairs, followed by both audits and serialized 0D adoption/freeze; do not start Phase 1." +} diff --git a/.codex-handoff/phase-00-remediation-review-w1-w2-v3.json b/.codex-handoff/phase-00-remediation-review-w1-w2-v3.json new file mode 100644 index 0000000000..e92421c047 --- /dev/null +++ b/.codex-handoff/phase-00-remediation-review-w1-w2-v3.json @@ -0,0 +1,230 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "reviewId": "P0.R12R.W1_W2.V3", + "laneId": "remediation-review-w1-w2-v3", + "packetRevision": "phase-00-r2", + "baseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "status": "failed", + "reviewedAt": "2026-07-11T19:25:00Z", + "evidence": [ + { + "id": "P0.R12R.W1_W2.V3", + "path": "docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2-remediation-v3.md", + "proofLevel": "source_observed" + } + ], + "producerSnapshots": [ + { + "laneId": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v1", + "worktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w1-v1", + "registryProgress": "running_stale", + "processAlive": false, + "latestResultPresent": false, + "dirtyFileCount": 13 + }, + { + "laneId": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w2-v1", + "worktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w2-v1", + "registryProgress": "running_stale", + "processAlive": false, + "latestResultPresent": false, + "dirtyFileCount": 18 + } + ], + "producerDispositions": [ + { + "laneId": "w1", + "disposition": "remediate", + "requiredFindingIds": ["R12R-W1-001", "R12R-W1-002"] + }, + { + "laneId": "w2", + "disposition": "remediate", + "requiredFindingIds": ["R12R-W2-001", "R12R-W2-002", "R12R-W2-003", "R12R-W2-004"] + } + ], + "approvedFiles": [ + { + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json" + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-remediation-review-w1-w2-v3.json", + "docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2-remediation-v3.md" + ], + "overlapAudit": { + "exactRelativePathCollisions": [], + "duplicateBasenames": ["README.md", "estimate-input.json", "estimate-input.schema.json"], + "unsafeSemanticOverlaps": [], + "consistentSemanticOverlaps": [ + "team.lifecycle.stop is W1 team-lifecycle operator action and W2 operator-only authority", + "provider.management renderer actions remain separate from W2 runtime ingress", + "W1 and W2 contribute to distinct canonical estimate buckets" + ] + }, + "checks": [ + { + "command": "git status --porcelain=v1 -uall (both producer worktrees)", + "exitCode": 0, + "result": "W1 13 untracked files; W2 18 untracked files; no tracked diffs" + }, + { + "command": "W1 scanner in isolated /tmp producer copy", + "exitCode": 0, + "result": "109 API members; 106 catalogued controls; raw bypass hash reproduced; generated hashes match producer" + }, + { + "command": "pnpm exec vitest --config /tmp/phase-00-remediation-review-w1-w2-v3-vitest.config.mjs run test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts", + "exitCode": 0, + "result": "1 file, 7 tests passed" + }, + { + "command": "node --experimental-strip-types scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "exitCode": 0, + "result": "passes its current rules: 4 providers, 2 backends, 5 operations, 13 matrix rows" + }, + { + "command": "pnpm exec vitest --config /tmp/phase-00-remediation-review-w1-w2-v3-vitest.config.mjs run test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "exitCode": 0, + "result": "1 file, 8 tests passed" + }, + { + "command": "pnpm exec eslint --config eslint.fast.config.js --no-cache ", + "exitCode": 0 + }, + { + "command": "W1 immediate-child coverage probe", + "exitCode": 1, + "result": "TeamListFilterPopover has 5 interaction sites and is absent from the four-file catalog" + }, + { + "command": "W2 exhaustive classified-key removal probe", + "exitCode": 1, + "result": "8 explicit keys can be removed without a completeness diagnostic" + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 1, + "result": "11 files require formatting" + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 1, + "result": "scan-runtime-surfaces.ts requires formatting" + }, + { + "command": "JSON parse plus git diff --check and per-untracked no-index checks", + "exitCode": 0, + "result": "all JSON parses; no whitespace diagnostics" + }, + { + "command": "bounded secret and prohibited real-project-path scan", + "exitCode": 0, + "result": "no findings" + } + ], + "findings": [ + { + "id": "R12R-W1-001", + "owner": "w1", + "severity": "high", + "evidencePath": "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "line": 11, + "requiredCorrection": "Cover the reachable visible team child-control closure and add an omitted-child negative fixture.", + "affected": ["R12-W1-001", "P0.REQ.W1.003", "P0.W1.RENDERER_ACTIONS", "P0.W1.SCANNER"] + }, + { + "id": "R12R-W1-002", + "owner": "w1", + "severity": "medium", + "evidencePath": ".codex-handoff/phase-00-w1.json", + "line": 1, + "requiredCorrection": "Format only W1-owned files and rerun the focused scanner/test/hash checks.", + "affected": ["11 W1 files"] + }, + { + "id": "R12R-W2-001", + "owner": "w2", + "severity": "high", + "evidencePath": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "line": 190, + "requiredCorrection": "Make every explicit classified environment key removal-sensitive and test all non-wildcard keys.", + "affected": [ + "R12-W2-001", + "P0.REQ.W2.003", + "P0.W2.ENVIRONMENT_PROVENANCE", + "P0.W2.RUNTIME_SCANNER" + ] + }, + { + "id": "R12R-W2-002", + "owner": "w2", + "severity": "high", + "evidencePath": "docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json", + "line": 1, + "requiredCorrection": "Add deterministic positive and negative fixtures for malformed capability, restart/adoption, and OpenCode secondary recovery, or retain an explicit failed state.", + "affected": ["P0.REQ.W2.006", "P0.W2.RUNTIME_SCANNER"] + }, + { + "id": "R12R-W2-003", + "owner": "w2", + "severity": "high", + "evidencePath": ".codex-handoff/phase-00-w2.json", + "line": 1, + "requiredCorrection": "Regenerate the handoff from all 18 dirty paths with current checks and finding resolutions.", + "affected": ["W2 structured handoff"] + }, + { + "id": "R12R-W2-004", + "owner": "w2", + "severity": "low", + "evidencePath": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "line": 1, + "requiredCorrection": "Format the W2 scanner only and rerun focused lint/test/scanner checks.", + "affected": ["P0.W2.RUNTIME_SCANNER"] + } + ], + "unverifiedClaims": [ + "No broad CI, typecheck, build, standalone/runtime smoke, final-image/provider execution, Phase 1, or terminal test was run.", + "Neither producer emitted a terminal registry result; decisions are based on frozen dirty worktree snapshots.", + "Approved files were not integrated, committed, or pushed." + ], + "blockers": [], + "nextAction": "selectively_adopt_only_approved_files_or_return_w1_w2_for_listed_corrections_then_rereview" +} diff --git a/.codex-handoff/phase-00-review-w1-w2.json b/.codex-handoff/phase-00-review-w1-w2.json new file mode 100644 index 0000000000..b7c0c00262 --- /dev/null +++ b/.codex-handoff/phase-00-review-w1-w2.json @@ -0,0 +1,204 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "reviewId": "P0.R12.W1_W2", + "laneId": "review-w1-w2", + "packetRevision": "phase-00-r2", + "baseSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "status": "failed", + "evidence": [ + { + "id": "P0.R12.W1_W2", + "path": "docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md", + "proofLevel": "source_observed" + } + ], + "producerDispositions": [ + { + "laneId": "w1", + "disposition": "reject", + "requiredFindingIds": ["R12-W1-001", "R12-W1-002", "R12-X-001", "R12-W1-003", "R12-W1-004"] + }, + { + "laneId": "w2", + "disposition": "reject", + "requiredFindingIds": [ + "R12-X-001", + "R12-W2-001", + "R12-W2-002", + "R12-W2-003", + "R12-W2-004", + "R12-W2-005" + ] + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-review-w1-w2.json", + "docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md" + ], + "checks": [ + { + "command": "git status --porcelain=v1 -uall (both producer worktrees)", + "exitCode": 0, + "result": "only lane-owned paths and worktree-local handoffs" + }, + { + "command": "node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "exitCode": 0, + "result": "86 TeamsAPI, 20 ReviewAPI, 3 CrossTeamAPI; 993 JSX sites; zero reported dynamic sites" + }, + { + "command": "pnpm exec vitest --config /tmp/phase-00-review-w1-w2-vitest.config.mjs run test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts", + "exitCode": 0, + "result": "1 file, 6 tests passed with repository-equivalent 15 second timeout" + }, + { + "command": "node --experimental-strip-types scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "exitCode": 0, + "result": "4 providers, 2 backend families, 5 operations, 6 planner outcomes under current shallow checks" + }, + { + "command": "pnpm exec vitest --config /tmp/phase-00-review-w1-w2-vitest.config.mjs run test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "exitCode": 0, + "result": "1 file, 3 tests passed" + }, + { + "command": "pnpm exec eslint --config eslint.fast.config.js --no-cache ", + "exitCode": 0 + }, + { + "command": "pnpm exec eslint --config eslint.fast.config.js --no-cache ", + "exitCode": 0 + }, + { + "command": "git diff --check plus git diff --no-index --check /dev/null ", + "exitCode": 0, + "result": "no whitespace diagnostics" + }, + { + "command": "bounded secret and real-project-path scan over both owned output sets", + "exitCode": 0, + "result": "no candidate secret values, raw payloads, or real-project paths" + }, + { + "command": "jq empty .codex-handoff/phase-00-review-w1-w2.json", + "exitCode": 0 + }, + { + "command": "pnpm exec prettier --check docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md .codex-handoff/phase-00-review-w1-w2.json", + "exitCode": 0 + }, + { + "command": "git diff --check plus git diff --no-index --check for review-owned untracked files", + "exitCode": 0, + "result": "no whitespace diagnostics" + } + ], + "findings": [ + { + "id": "R12-W1-001", + "owner": "w1", + "severity": "high", + "evidencePath": "scripts/hosted-web/phase-0/parity-renderer/generate-evidence.ts", + "line": 296, + "requiredCorrection": "Replace per-JSX heuristic action manufacture with a reviewed semantic action catalog and semantic missing/duplicate negative fixtures.", + "affected": ["P0.W1.RENDERER_ACTIONS", "P0.W1.SCANNER", "ADR-19", "ADR-20"] + }, + { + "id": "R12-W1-002", + "owner": "w1", + "severity": "high", + "evidencePath": "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "line": 217, + "requiredCorrection": "Make stable action IDs independent of source line/location and add line-shift stability fixtures.", + "affected": ["P0.W1.RENDERER_ACTIONS", "P0.W1.SCANNER", "ADR-19"] + }, + { + "id": "R12-X-001", + "owner": "w1-w2", + "severity": "high", + "evidencePath": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "line": 1957, + "requiredCorrection": "Reconcile team stop and provider-management controls to canonical feature/action ownership and W2 trust direction before adoption.", + "affected": [ + "P0.W1.API_PARITY_LEDGER", + "P0.W1.RENDERER_ACTIONS", + "P0.W2.EXECUTION_TOPOLOGY", + "P0.W2.CREDENTIAL_EXPOSURE_MATRIX", + "ADR-14", + "ADR-19", + "ADR-20" + ] + }, + { + "id": "R12-W1-003", + "owner": "w1", + "severity": "high", + "evidencePath": "docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json", + "line": 121, + "requiredCorrection": "Recompute canonical bucket net ranges and record the greater-than-20-percent scope review.", + "affected": ["P0.W1.ESTIMATE"] + }, + { + "id": "R12-W1-004", + "owner": "w1", + "severity": "high", + "evidencePath": "docs/hosted-web-phase-0-execution-packet.md", + "line": 575, + "requiredCorrection": "Compact/hash the 46,262 raw ledger lines outside Git or obtain a controller-approved adoption split.", + "affected": ["all W1 adoption artifacts"] + }, + { + "id": "R12-W2-001", + "owner": "w2", + "severity": "high", + "evidencePath": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "line": 162, + "requiredCorrection": "Discover child environment keys from source and fail when an explicit key/classification is absent.", + "affected": ["P0.W2.ENVIRONMENT_PROVENANCE", "P0.W2.RUNTIME_SCANNER", "ADR-18"] + }, + { + "id": "R12-W2-002", + "owner": "w2", + "severity": "high", + "evidencePath": "docs/research/hosted-web/phase-0/provider-runtime/schemas/evidence-envelope.schema.json", + "line": 5, + "requiredCorrection": "Use artifact-specific nested schemas and deliberate negative fixtures for all required acceptance fields.", + "affected": ["all W2 machine-readable evidence", "P0.W2.RUNTIME_SCANNER", "ADR-14", "ADR-18"] + }, + { + "id": "R12-W2-003", + "owner": "w2", + "severity": "high", + "evidencePath": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "line": 8, + "requiredCorrection": "Contribute low/high ranges to canonical EST-LIFECYCLE-RUNTIME and state W4 overlap instead of creating top-level bucket IDs.", + "affected": ["P0.W2.ESTIMATE"] + }, + { + "id": "R12-W2-004", + "owner": "w2", + "severity": "medium", + "evidencePath": "docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json", + "line": 42, + "requiredCorrection": "Add one row per required hosted provider matrix case, including unsupported backend and malformed capability response.", + "affected": ["W2 fake-runtime fixture matrix", "ADR-18"] + }, + { + "id": "R12-W2-005", + "owner": "w2", + "severity": "medium", + "evidencePath": ".codex-handoff/phase-00-w2.json", + "line": 76, + "requiredCorrection": "Run diff-check after staging in adoption or record per-file no-index checks for all untracked outputs.", + "affected": ["W2 self-review handoff"] + } + ], + "unverifiedClaims": [ + "No broad CI was run because reciprocal review is limited to targeted checks.", + "No hosted provider, final-image, or Phase 1 behavior was executed.", + "The producer worktrees are read-only to this reviewer; Vitest used a temporary equivalent config and lint used --no-cache to avoid writes." + ], + "blockers": [], + "nextAction": "return-to-w1-w2-owners-for-correction-or-contested-0D-decision" +} diff --git a/.codex-handoff/phase-00-review-w3-w5.json b/.codex-handoff/phase-00-review-w3-w5.json new file mode 100644 index 0000000000..c2390484a9 --- /dev/null +++ b/.codex-handoff/phase-00-review-w3-w5.json @@ -0,0 +1,239 @@ +{ + "schemaVersion": 2, + "phaseId": "phase-00", + "reviewId": "w3-w5-remediation-reciprocal-v2", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "reviewBaseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "status": "adopt_w3_remediate_w5", + "evidencePath": "docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md", + "producerReviews": [ + { + "laneId": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w3-v1", + "producerWorktree": "phase-00-remediation-w3-v1", + "registryState": "done", + "actualDirtyPathCount": 16, + "decision": "adopt", + "findingResolution": { + "RW35-001": "resolved", + "RW35-006": "resolved" + }, + "approvedFiles": [ + ".codex-handoff/phase-00-w3.json", + "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "docs/research/hosted-web/phase-0/state-writers/evidence.schema.json", + "docs/research/hosted-web/phase-0/state-writers/index.md", + "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md", + "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs", + "scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs", + "scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs", + "test/architecture/hosted-web/phase-0/state-writers/fixtures/invalid-state-family-catalog.json", + "test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs", + "test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs" + ], + "heldFiles": [], + "requiredCorrection": null + }, + { + "laneId": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w5-v1", + "producerWorktree": "phase-00-remediation-w5-v1", + "registryState": "missing_latest_result_stale_running_progress_dead_pid", + "actualDirtyPathCount": 17, + "decision": "remediate", + "findingResolution": { + "RW35-002": "partial_snapshot_resolved_effect_recovery_open", + "RW35-003": "resolved_semantically", + "RW35-004": "resolved", + "RW35-005": "partial_descriptor_separation_present_census_completeness_open", + "RW35-006": "open" + }, + "approvedFiles": [ + "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json", + "docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json", + "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json" + ], + "heldFiles": [ + ".codex-handoff/phase-00-w5.json", + "docs/research/hosted-web/phase-0/recovery-events/README.md", + "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/index.json", + "docs/research/hosted-web/phase-0/recovery-events/index.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json", + "scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs", + "scripts/hosted-web/phase-0/recovery-events/model.mjs", + "test/architecture/hosted-web/phase-0/recovery-events/fixtures/invalid-command-catalog.json", + "test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs" + ], + "requiredCorrection": "Make effect recovery terminate and reload through a fresh runner with exact boundary-state assertions; provide an independently complete mutation/member disposition census plus omitted-row and omitted-descriptor negative fixtures; regenerate the 17-path handoff/result; fix the two whitespace errors." + } + ], + "findings": [ + { + "id": "R35-R2-001", + "laneId": "w5", + "severity": "high", + "path": "scripts/hosted-web/phase-0/recovery-events/model.mjs", + "line": 362, + "affectedPriorFindings": ["RW35-002"], + "summary": "Effect recovery snapshots and labels a crash but invokes recover inside the same closure and then continues the pre-crash execution path; tests do not assert exact fresh-run state/effect counts at every boundary." + }, + { + "id": "R35-R2-002", + "laneId": "w5", + "severity": "high", + "path": "scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs", + "line": 93, + "affectedPriorFindings": ["RW35-005"], + "summary": "A generator-local census now catches descriptor deletion while its row remains, but no independent source extraction/full disposition matrix or omitted-row/descriptor negative fixture can detect a missing census obligation." + }, + { + "id": "R35-R2-003", + "laneId": "w5", + "severity": "medium", + "path": ".codex-handoff/phase-00-w5.json", + "line": 6, + "affectedPriorFindings": ["RW35-006"], + "summary": "No latest result exists; progress is stale running with a dead PID; the old handoff lists 15 of 17 current files and contains no remediation resolutions or valid untracked-file check." + }, + { + "id": "R35-R2-004", + "laneId": "w5", + "severity": "low", + "path": ".codex-handoff/phase-00-w5.json", + "line": 109, + "secondaryPath": "test/architecture/hosted-web/phase-0/recovery-events/fixtures/invalid-command-catalog.json", + "secondaryLine": 20, + "affectedPriorFindings": ["RW35-006"], + "summary": "No-index diff checking reports a new blank line at EOF in two W5 files." + } + ], + "changedFileCensus": { + "w3": { + "count": 16, + "allUntracked": true, + "statusEqualsProducerResultList": true, + "outOfScopePaths": [] + }, + "w5": { + "count": 17, + "allUntracked": true, + "handoffListedCount": 15, + "handoffOmissions": [ + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json" + ], + "outOfScopePaths": [] + } + }, + "semanticOverlap": [ + { + "topic": "writer_truth_controls_recovery", + "status": "reconciled_fail_closed", + "w3Evidence": "Current task/config/native-inbox and selected runtime writers remain uncoordinated, quiescent-only, or incompletely observed.", + "w5Evidence": "All 103 individual effects set automaticRecoveryAdmitted=false; task/messaging operation-ID candidates are currently non_reconcilable/operator_required." + }, + { + "topic": "future_sqlite_transaction_seam", + "status": "fixture_characterized_not_target_verified", + "w3Evidence": "Current-host Online Backup and single-worker shape do not prove final ABI, production async wire, keyring preservation, or deployment quiescence.", + "w5Evidence": "transactional_local effects remain operator_required_until_transaction_exists." + }, + { + "topic": "estimate", + "status": "deduplicate", + "w3Evidence": "4500-7250 changed lines", + "w5Evidence": "4500-7500 changed lines", + "controllerAction": "Count shared command/event/storage/backup/keyring work once under EST-RECOVERY-STATE." + }, + { + "topic": "final_topology", + "status": "outside_remediation_proof", + "controllerAction": "Keep production worker wiring, final ABI, drain, recovery publication, and credential/keyring preservation as W6/controller gates." + } + ], + "registryEvidence": [ + { + "laneId": "w3", + "kind": "job", + "sha256": "bd707941a3e7642e622ce766146b618d1425aa30b369677620b67c076054ef11" + }, + { + "laneId": "w3", + "kind": "latest_result", + "state": "done", + "sha256": "22174cfea16eb4d8d9a5bb18c39ebb46ef29e201cd299ccd16632275f03b3824" + }, + { + "laneId": "w5", + "kind": "job", + "sha256": "af9c3140521615089fefd1b40fd921efa87eb54053e84b9a746ebfbac4eabe56" + }, + { + "laneId": "w5", + "kind": "progress", + "state": "stale_running_dead_pid_no_latest_result", + "sha256": "ce004b8ba50ce2cbeb0e9e3457f7df633ccee884847a7b9bec1918b6d474ce2e" + } + ], + "checks": [ + { + "laneId": "w3", + "command": "node --import tsx team-backup-service-faults.test.mjs; sqlite spike; external-writer fixture; evidence verifier; state-writers architecture test", + "exitCode": 0, + "result": "7 production-service tests/12 cases; WAL active; 2000 rows; integrity ok; lostExternalUpdate=true; evidence verified" + }, + { + "laneId": "w5", + "command": "node generate-evidence.mjs --check; node recovery-events.test.mjs", + "exitCode": 0, + "result": "9 evidence/supporting files verified; 7 tests passed; findings identify coverage limitations" + }, + { + "laneId": "w3-w5", + "command": "targeted ESLint fast configuration with --no-cache over all owned MJS files", + "exitCode": 0 + }, + { + "laneId": "w3", + "command": "git diff --no-index --check /dev/null over each of 16 dirty files", + "exitCode": 0 + }, + { + "laneId": "w5", + "command": "git diff --no-index --check /dev/null over each of 17 dirty files", + "exitCode": 1, + "result": "two new blank-line-at-EOF findings" + }, + { + "laneId": "w3-w5", + "command": "bounded private-key/provider-token/Bearer/absolute-home scan over every dirty file", + "exitCode": 0 + } + ], + "scopeAudit": { + "producerEditsByReviewer": false, + "reviewChangedPaths": [ + ".codex-handoff/phase-00-review-w3-w5.json", + "docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md" + ], + "broadOrFinalGateRun": false, + "phase1Implemented": false, + "terminalImplemented": false, + "integrationPerformed": false, + "committed": false, + "pushed": false + }, + "blockers": [], + "nextAction": "Adopt all 16 W3 files; return W5 for R35-R2-001 through R35-R2-004; optionally adopt only the four explicitly approved W5 evidence records if controller policy permits partial evidence from a remediating lane; then repeat focused reciprocal review." +} diff --git a/.codex-handoff/phase-00-review-w4-w6.json b/.codex-handoff/phase-00-review-w4-w6.json new file mode 100644 index 0000000000..8a08930585 --- /dev/null +++ b/.codex-handoff/phase-00-review-w4-w6.json @@ -0,0 +1,243 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "review-w4-w6", + "reviewId": "review-w4-w6", + "packetRevision": "phase-00-r2", + "baseSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "status": "characterized", + "reviewDisposition": "reject", + "evidence": [ + { + "id": "P0.R46.RECIPROCAL_REVIEW", + "path": "docs/research/hosted-web/phase-0/reciprocal-review-w4-w6.md", + "proofLevel": "source_observed" + } + ], + "producerDispositions": [ + { + "laneId": "w4", + "handoff": "../phase-00-w4-lease-guard-process-v1/.codex-handoff/phase-00-w4.json", + "disposition": "reject", + "requiredFindings": ["R46-03", "R46-04", "R46-05", "R46-08"] + }, + { + "laneId": "w6", + "handoff": "../phase-00-w6-auth-proxy-artifacts-v1/.codex-handoff/phase-00-w6.json", + "disposition": "reject", + "requiredFindings": ["R46-01", "R46-02", "R46-06", "R46-07", "R46-08"] + } + ], + "pairDisposition": { + "pair": ["w4", "w6"], + "disposition": "reject", + "reason": "Auth reset is not bound to W4 typed drain evidence, and W6 does not define or scan the required W4 final-image artifacts and topology." + }, + "findings": [ + { + "id": "R46-01", + "severity": "critical", + "owner": "w6", + "path": "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "line": 71, + "affectedEvidence": ["P0.W6.AUTH_TRANSITIONS"], + "affectedAdrs": ["ADR-7"], + "summary": "Restart restores mutation admission after logout, device-family revocation, and replay-family revocation." + }, + { + "id": "R46-02", + "severity": "high", + "owner": "w6", + "path": "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "line": 128, + "affectedEvidence": ["P0.W6.AUTH_TRANSITIONS", "P0.W4.PROCESS_ANCHOR_SPIKE"], + "affectedAdrs": ["ADR-7", "ADR-31"], + "summary": "Host reset trusts runtimeCount=0 rather than a generation-bound W4 drained outcome." + }, + { + "id": "R46-03", + "severity": "high", + "owner": "w4", + "path": "scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c", + "line": 204, + "affectedEvidence": ["P0.W4.PROCESS_ANCHOR_SPIKE"], + "affectedAdrs": ["ADR-31"], + "summary": "Descendants are signaled via reusable numeric PIDs after /proc inspection." + }, + { + "id": "R46-04", + "severity": "high", + "owner": "w4", + "path": "scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "line": 571, + "affectedEvidence": [ + "P0.W4.INSTANCE_LEASE_SPIKE", + "P0.W4.WORKSPACE_GUARD_SPIKE", + "P0.W4.PROCESS_ANCHOR_SPIKE" + ], + "affectedAdrs": ["ADR-16", "ADR-28", "ADR-31"], + "summary": "Cleanup success and zero residuals are hard-coded and emitted before ignored cleanup." + }, + { + "id": "R46-05", + "severity": "medium", + "owner": "w4", + "path": "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "line": 150, + "affectedEvidence": ["P0.W4.WORKSPACE_GUARD_SPIKE", "P0.W4.PROCESS_ANCHOR_SPIKE"], + "affectedAdrs": ["ADR-28", "ADR-31"], + "summary": "Close-all loops stop at FD 1023 and do not prove high control descriptors are absent." + }, + { + "id": "R46-06", + "severity": "high", + "owner": "w6", + "path": "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "line": 239, + "affectedEvidence": [ + "P0.W6.ARTIFACT_INVENTORY", + "P0.W6.ABI_STUB_REPORT", + "P0.W4.TARGET_HOST_ENVELOPE", + "P0.W4.NATIVE_ARTIFACT_PROPOSAL" + ], + "affectedAdrs": ["ADR-16", "ADR-17", "ADR-28", "ADR-31"], + "summary": "The artifact scanner has no required rows or probes for the three W4 native artifacts and final topology." + }, + { + "id": "R46-07", + "severity": "medium", + "owner": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "line": 177, + "affectedEvidence": ["P0.W6.ABI_STUB_REPORT"], + "affectedAdrs": ["ADR-17"], + "summary": "Recorded ABI and SQLite smoke facts have no owned reproducible command or assertion." + }, + { + "id": "R46-08", + "severity": "low", + "owner": "w4,w6", + "path": ".codex-handoff/phase-00-w4.json", + "line": 76, + "affectedEvidence": [], + "affectedAdrs": [], + "summary": "Producer git diff --check commands inspected none of the all-untracked lane output." + } + ], + "audit": { + "w4ChangedPaths": 15, + "w4ChangedLines": 1916, + "w4ScopeClean": true, + "w6ChangedPaths": 9, + "w6ChangedLines": 1431, + "w6ScopeClean": true, + "secretScanFindings": 0, + "realProjectPathsFound": 0, + "phase1ChangesFound": 0, + "broadCiRun": false, + "producerWorktreesModified": false + }, + "checks": [ + { + "command": "PATH=/usr/local/bin:/usr/bin:/bin /usr/bin/python3 scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "worktree": "phase-00-w4-lease-guard-process-v1", + "exitCode": 0 + }, + { + "command": "vitest run W4 owned tests using the writable review-worktree config", + "worktree": "phase-00-w4-lease-guard-process-v1", + "exitCode": 0, + "result": "2 files, 3 tests passed" + }, + { + "command": "node scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "worktree": "phase-00-w4-lease-guard-process-v1", + "exitCode": 0 + }, + { + "command": "eslint --config eslint.fast.config.js --no-cache ", + "worktree": "phase-00-w4-lease-guard-process-v1", + "exitCode": 0 + }, + { + "command": "node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "worktree": "phase-00-w6-auth-proxy-artifacts-v1", + "exitCode": 0 + }, + { + "command": "vitest run W6 owned test using the writable review-worktree config", + "worktree": "phase-00-w6-auth-proxy-artifacts-v1", + "exitCode": 0, + "result": "1 file, 17 tests passed" + }, + { + "command": "eslint --config eslint.fast.config.js --no-cache ", + "worktree": "phase-00-w6-auth-proxy-artifacts-v1", + "exitCode": 0 + }, + { + "command": "git diff --no-index --check /dev/null ", + "worktree": "both producer worktrees", + "exitCode": 0, + "result": "zero whitespace errors" + }, + { + "command": "reviewer auth restart reproducer after logout, forget-device, and replay-family revocation", + "worktree": "phase-00-w6-auth-proxy-artifacts-v1", + "exitCode": 0, + "expectedFailure": true, + "result": "mutationAdmission became true in all three revoked cases" + }, + { + "command": "reviewer secret/private-path scan over every W4/W6 owned file", + "worktree": "both producer worktrees", + "exitCode": 0, + "result": "no findings" + } + ], + "adrRecommendations": [ + { + "adr": "ADR-7", + "outcome": "reopened", + "reason": "The W6 restart model restores revoked authority and reset is not fenced by typed W4 drain evidence." + }, + { + "adr": "ADR-16", + "outcome": "narrowed", + "reason": "Current-host lease characterization is useful, but the required final two-container/manual-contender topology is unavailable." + }, + { + "adr": "ADR-28", + "outcome": "narrowed", + "reason": "Current-host descriptor-bound races pass, but high-FD closure and final-image seccomp/mount behavior remain unproved." + }, + { + "adr": "ADR-31", + "outcome": "reopened", + "reason": "The W4 spike signals discovered descendants through reusable numeric PIDs and has no PID-reuse negative proof." + }, + { + "adr": "ADR-17", + "outcome": "blocked", + "reason": "The current artifact fails worker, fake-stub, terminal-absence and W4 native-artifact manifest requirements." + } + ], + "estimateBuckets": [], + "unverifiedClaims": [ + "All W4 final-image, non-root, init, seccomp and two-container/manual-contender assumptions remain unverified.", + "W6 auth/proxy schedules have not run through the final HTTPS edge/container or real SQLite/keyring/browser topology.", + "No final hosted artifact exists against which W4 native manifests/probes or W6 terminal/worker/native absence can be target-verified." + ], + "blockers": [ + { + "class": "missing_evidence", + "affectedFindings": ["R46-01", "R46-02", "R46-03", "R46-04", "R46-06"], + "controllerAction": "Return findings to W4/W6 owners, rerun targeted review after correction, then admit the exact final-shape image for target probes." + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-review-w4-w6.json", + "docs/research/hosted-web/phase-0/reciprocal-review-w4-w6.md" + ], + "nextAction": "producer_correction_then_repeat_reciprocal_review" +} diff --git a/.codex-handoff/phase-00-w1.json b/.codex-handoff/phase-00-w1.json new file mode 100644 index 0000000000..311fcf9c5a --- /dev/null +++ b/.codex-handoff/phase-00-w1.json @@ -0,0 +1,275 @@ +{ + "schemaVersion": 3, + "phaseId": "phase-00", + "laneId": "w1-remediation-v9", + "packetRevision": "phase-00-r2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9", + "worktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w1-v9", + "baseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "canonicalBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "status": "characterized", + "provenance": { + "startingHead": "f7d98790eb868714e536f77bd796072ea706911a", + "supersededJob": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v8", + "salvageWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w1-v8", + "salvageArchive": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/archives/agent-teams-hosted-web-refactor-phase-00-remediation-w1-v8-rejected-20260711T213521Z", + "salvagePatchSha256": "sha256:14b1630460e6161b64766d34bd531c1197549603025093ac074986598e3fa774", + "salvageBaseSha": "ffaecae3fc70a42df1ac49c65469f84515ea5ed8", + "reproductionReason": "The reviewed W1 v8 output was rejected only because the canonical target was contaminated by a failed W2 apply; v9 reproduces that W1 output on the clean canonical base.", + "rejectingReviewJob": "agent-teams-hosted-web-refactor-phase-00-w1-rereview-v1", + "approvedInvariantSource": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w1-parity-renderer-v1/docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "approvedInvariantSha256": "sha256:b9f9a3b24720fbac112a1b97e40725ecf484e18c15f1d7b6596d2446b4f4eefe", + "preservedApiLedgerSha256": "sha256:a11f469d9c981c3e05be57fed7a6303772349a79297a0f1887db97d54e84c0a3", + "preservedEstimateInputSha256": "sha256:af3fe8edc17ac5f3ca77533a949625b5b97c61c3ebab111726f72ebdc9adf669" + }, + "remediates": { + "producerJob": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9", + "supersedesProducerJob": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v8", + "reviewJobs": [ + "agent-teams-hosted-web-refactor-phase-00-remediation-review-w1-w2-v3", + "agent-teams-hosted-web-refactor-phase-00-audit-cross-lane-v2", + "agent-teams-hosted-web-refactor-phase-00-w1-rereview-v1" + ], + "findingIds": [ + "R12-W1-001", + "R12-W1-002", + "R12-X-001", + "R12-W1-003", + "R12-W1-004", + "R12R-W1-001", + "R12R-W1-002", + "P0.V2.CONFLICT.W1.EXHAUSTIVENESS", + "P0.V2.CONFLICT.W1.APPROVED_FILE_REGRESSION", + "P0.W1.V7.MOUNTED_CLOSURE", + "P0.W1.V7.CHILD_API_OWNERSHIP" + ] + }, + "evidence": [ + { + "id": "P0.W1.API_PARITY_LEDGER", + "path": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W1.RENDERER_ACTIONS", + "path": "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W1.RENDERER_CHILD_CONTROLS", + "path": "docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W1.LEGACY_BYPASSES", + "path": "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W1.SELECTION_INVARIANTS", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "proofLevel": "source_observed" + }, + { + "id": "P0.W1.SCANNER", + "path": "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W1.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json", + "proofLevel": "source_observed" + } + ], + "findingResolutions": [ + { + "id": "P0.W1.V7.MOUNTED_CLOSURE", + "resolution": "The mounted closure now proves the App -> ToolApprovalSheet and TabbedLayout -> GlobalTaskDetailDialogSlot -> GlobalTaskDetailDialog chains. ToolApprovalSheet, ToolApprovalDiffPreview, ToolApprovalSettingsPanel and GlobalTaskDetailDialog are included with their exact 4/1/11/1 scanner-visible sites, expanding the closure from 167 files/726 sites to 171 files/743 sites. A broken real mount chain is a failing negative mutation." + }, + { + "id": "P0.W1.V7.CHILD_API_OWNERSHIP", + "resolution": "AddMember dialog controls, the member restart/restore controls and the AddMember entry control are team-lifecycle-owned. The scanner explicitly maps the three API-reaching legacy child IDs to team.lifecycle.add-member, team.lifecycle.restart-member and team.lifecycle.restore-member, rejects owner disagreement, and rejects a missing required legacy-child mapping." + }, + { + "id": "R12R-W1-001", + "resolution": "The scanner recursively follows relative and renderer-alias imports from five mounted team/provider roots. The checked-in catalog declares all 171 reachable TSX files and maps all 743 scanner-visible occurrences exactly once. Seven other production team TSX files and their 11 sites are omission-sensitive excludedSourceFiles because no import path exists from these roots. The real TeamListFilterPopover immediate-child omission and one of its mapping omissions are both failing negative mutations." + }, + { + "id": "P0.V2.CONFLICT.W1.EXHAUSTIVENESS", + "resolution": "The source-derived closure now includes 171 files, explicit shell-mount proofs for global controls, an explicit child-control catalog, exact missing/extra closure diagnostics, exact site-count/hash validation, repository-backed omission fixtures, and a regenerated excluded-file manifest." + }, + { + "id": "R12R-W1-002", + "resolution": "All W1-owned files are generated in Prettier-compatible form and the scanner invokes the pinned repository Prettier for generated JSON before schema validation. Targeted Prettier, lint, scanner, fixture, typecheck, diff and bounded scans are recorded below." + }, + { + "id": "P0.V2.CONFLICT.W1.APPROVED_FILE_REGRESSION", + "resolution": "Restored the pair-approved 137-line selection-reconciliation-invariants.md byte-for-byte at sha256:b9f9a3b24720fbac112a1b97e40725ecf484e18c15f1d7b6596d2446b4f4eefe." + }, + { + "id": "R12-W1-001", + "resolution": "The 30 original reviewed semantic actions retain their exact IDs. The explicit child catalog adds reviewed component/handler action IDs without per-run manufacture; file/hash/count metadata is used only to detect stale or omitted mappings." + }, + { + "id": "R12-W1-002", + "resolution": "Stable action IDs remain independent of line numbers and source location. Both original and child-catalog fixtures prove unrelated line insertion preserves source keys and explicit IDs." + }, + { + "id": "R12-X-001", + "resolution": "team.lifecycle.stop, addMember, restartMember and restoreMember remain team-lifecycle-owned; provider credential/auth controls remain runtime-provider-management-owned; repository-wide renderer callers and API-linked legacy child IDs stay bound to their exact TeamsAPI, ReviewAPI, or CrossTeamAPI action and canonical owner." + }, + { + "id": "R12-W1-003", + "resolution": "Canonical net arithmetic remains 2.0k-3.0k, 2.1k-3.2k, and 2.8k-4.2k. Both greater-than-20-percent changes remain explicit controller scope-review inputs." + }, + { + "id": "R12-W1-004", + "resolution": "The deterministic 610-row raw bypass projection stays outside Git under /tmp. Git retains the count, exact SHA-256, generator, summary and disposition rather than the raw rows." + } + ], + "requirementCoverage": [ + { + "id": "P0.REQ.W1.001", + "status": "satisfied", + "proof": "All required W1 evidence IDs plus the child-control catalog and this handoff are present." + }, + { + "id": "P0.REQ.W1.002", + "status": "satisfied", + "proof": "Pinned AST count remains 86/20/3; all 109 API members have exactly one disposition and owner." + }, + { + "id": "P0.REQ.W1.003", + "status": "satisfied", + "proof": "The recursively derived 171-file mounted closure has 743 mapped sites. Real App/TabbedLayout mount chains prove the four formerly excluded files, their 17 sites are fixture-pinned, and every non-reachable production team TSX file is explicitly regenerated as excluded." + }, + { + "id": "P0.REQ.W1.004", + "status": "satisfied", + "proof": "Annotated/unannotated dynamic-dispatch fixtures pass and fail as required." + }, + { + "id": "P0.REQ.W1.005", + "status": "satisfied", + "proof": "610 deterministic bypass observations are summarized and externally hashed without adopting oversized raw output." + }, + { + "id": "P0.REQ.W1.006", + "status": "satisfied", + "proof": "The approved 137-line selection, snapshot, tombstone, pagination, event and poll invariant record is restored byte-for-byte." + }, + { + "id": "P0.REQ.W1.007", + "status": "satisfied", + "proof": "Tests reject missing and duplicate API/semantic mappings, an actual omitted immediate child, an actual omitted child-control mapping, a broken global mount chain, legacy-child/API owner disagreement and a missing required child/API mapping while proving root and child line-shift stability." + }, + { + "id": "P0.REQ.W1.008", + "status": "satisfied", + "proof": "Canonical arithmetic, variance escalation, checked schemas and compact raw-evidence treatment remain explicit." + } + ], + "counts": { + "apiMembers": { + "TeamsAPI": 86, + "ReviewAPI": 20, + "CrossTeamAPI": 3, + "total": 109 + }, + "directRendererApiActionBindings": 95, + "reachableControlFiles": 171, + "reachableInteractionSites": 743, + "semanticActions": 442, + "deliberateAbsenceClasses": 5, + "excludedUnreachableTeamFiles": 7, + "excludedUnreachableInteractionSites": 11, + "legacyBypassRawRows": 610, + "legacyBypassRawSha256": "sha256:2ea065639675df126935790d86578cfdae9cf9feb60371826b03f1f877c5f497" + }, + "estimateBuckets": [ + { + "bucketId": "EST-CONTRACTS", + "netLines": { + "low": 2000, + "high": 3000 + }, + "varianceReview": false + }, + { + "bucketId": "EST-RENDERER-LIFECYCLE", + "netLines": { + "low": 2100, + "high": 3200 + }, + "varianceReview": true + }, + { + "bucketId": "EST-REMAINING-PARITY", + "netLines": { + "low": 2800, + "high": 4200 + }, + "varianceReview": true + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-w1.json", + "docs/research/hosted-web/phase-0/parity-renderer/README.md", + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json", + "docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-action-inventory.schema.json", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts" + ], + "checks": [ + { + "command": "git diff --exit-code -- api-parity-ledger.json estimate-input.json selection-reconciliation-invariants.md", + "exitCode": 0, + "result": "verified API ledger, estimate input and invariant record are byte-preserved at their recorded SHA-256 values" + }, + { + "command": "W1_RAW_EVIDENCE_ROOT=/tmp/agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9-artifacts node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "exitCode": 0, + "result": "109 API members, 171 reachable files, 743 mapped control sites, schemas valid, 4/1/11/1 mounted sites and all three legacy child/API bindings asserted, raw bypass hash reproduced" + }, + { + "command": "pnpm exec vitest run --config /tmp/agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9-artifacts/vitest.config.mjs test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts", + "exitCode": 0, + "result": "1 file, 11 tests passed" + }, + { + "command": "pnpm lint:fast:files -- scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts", + "exitCode": 0, + "result": "0 errors, 0 warnings" + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "all matched files use Prettier code style" + }, + { + "command": "focused v8-to-v9 reproduction comparison, exact 8-path scope, preserved-input diff and git diff --check", + "exitCode": 0, + "result": "five files byte-identical to approved v8, scanner and legacy inventory differ only by v9 artifact provenance, handoff differs only by v9 provenance and verification/adoption metadata, archive and preserved patch both sha256:14b1630460e6161b64766d34bd531c1197549603025093ac074986598e3fa774, no whitespace diagnostics" + }, + { + "command": "bounded secret, credential-pattern and prohibited real-project-path scan over all W1-owned paths", + "exitCode": 0, + "result": "no findings" + } + ], + "externalRawEvidence": { + "path": "/tmp/agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9-artifacts/legacy-bypass-raw.json", + "sha256": "sha256:2ea065639675df126935790d86578cfdae9cf9feb60371826b03f1f877c5f497", + "recordCount": 610, + "ephemeral": true + }, + "unverifiedClaims": [ + "No hosted transport, browser mount, provider, runtime, terminal, Phase 1, broad CI, build or standalone smoke behavior was executed.", + "The two greater-than-20-percent estimate changes require controller scope review before the shared estimate can freeze.", + "No fresh broad reciprocal review was run because v9 is a clean-base reproduction of the reviewed W1 v8 output with provenance-only substitutions." + ], + "blockers": [], + "nextAction": "controller-adoption-then-estimate-scope-review" +} diff --git a/.codex-handoff/phase-00-w2.json b/.codex-handoff/phase-00-w2.json new file mode 100644 index 0000000000..24a6a3c85f --- /dev/null +++ b/.codex-handoff/phase-00-w2.json @@ -0,0 +1,222 @@ +{ + "schemaVersion": 3, + "projectId": "agent-teams-hosted-web-refactor", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w2-targeted-fix-a1", + "phaseId": "phase-00", + "laneId": "w2", + "scope": "phase-00-w2-provider-runtime-evidence-only", + "packetRevision": "phase-00-r2", + "remediationIdentity": "w2-targeted-a1", + "baseSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "phaseStartSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "worktreeBaseSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "sourceWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w2-targeted-fix-a1", + "capturedAt": "2026-07-12T01:12:56Z", + "status": "ready_for_independent_review", + "baselineRecreation": { + "sourceWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w2-final-r1", + "sourceHeadSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "targetHeadSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "pathCount": 22, + "exactPathSetMatched": true, + "preCorrectionContentHashesMatched": true, + "postCorrectionDiffAudit": { + "targetedChangedPathCount": 10, + "byteIdenticalCarryForwardPathCount": 12, + "targetedChangedPaths": [ + ".codex-handoff/phase-00-w2.json", + "docs/research/hosted-web/phase-0/provider-runtime/README.md", + "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json", + "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/environment-semantics.json", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts" + ] + } + }, + "evidence": [ + { + "id": "P0.W2.EXECUTION_TOPOLOGY", + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W2.RUNTIME_INGRESS_INVENTORY", + "path": "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W2.ENVIRONMENT_PROVENANCE", + "path": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "proofLevel": "source_observed_per_key_with_executed_routing_semantics_target_unverified" + }, + { + "id": "P0.W2.CREDENTIAL_EXPOSURE_MATRIX", + "path": "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "proofLevel": "source_observed_per_key_target_unverified" + }, + { + "id": "P0.W2.RUNTIME_SCANNER", + "path": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "proofLevel": "executable_fixture_characterized" + }, + { + "id": "P0.W2.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "proofLevel": "source_observed" + } + ], + "substantivePathCount": 21, + "changedPathCount": 22, + "changedPaths": [ + ".codex-handoff/phase-00-w2.json", + "docs/research/hosted-web/phase-0/provider-runtime/README.md", + "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json", + "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json", + "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/environment-semantics.json", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/provider-mode-ingress-negative.json", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/provider-mode-ingress-positive.json", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/surfaces-negative.json", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/surfaces-positive.json", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts" + ], + "scopeAudit": { + "allowedWritablePaths": [ + "docs/research/hosted-web/phase-0/provider-runtime/**", + "scripts/hosted-web/phase-0/provider-runtime/**", + "test/architecture/hosted-web/phase-0/provider-runtime/**", + ".codex-handoff/phase-00-w2.json" + ], + "outsideScopeChangedPaths": [], + "productionSourceChanged": false, + "terminalImplementationChanged": false, + "integrationPerformed": false, + "commitPerformed": false, + "pushPerformed": false, + "phase1WorkPerformed": false, + "broadCiPerformed": false, + "realProviderOrProjectWorkPerformed": false + }, + "findingResolutions": [ + { + "id": "P0-W2-FINAL-R5-001", + "status": "resolved", + "evidence": "The ten routing keys no longer share kp-05. Seven profiles distinguish custom-config preservation, always-emitted positive pins, always-removed legacy flags, each mutually conditional Anthropic backend flag, and configured backend emissions across seven provider/backend scenarios. The authored environment fixture delegates these keys to w2.environment.provider-routing.source-seam, which executes TeamProvisioningEnvBuilder, applyConfiguredRuntimeBackendsEnv and applyProviderRuntimeEnv before comparing ledger semantics." + }, + { + "id": "P0-W2-FINAL-R5-002", + "status": "resolved", + "evidence": "Provider-assigned profiles must have providerBindings minItems 1. The relay-bearer prohibition is the only empty-binding profile and carries an explicit all_provider_execution_units providerlessProhibition constrained by oneOf. Both an empty assigned binding and a missing providerless declaration fail the scanner and Draft-2020-12 validation." + }, + { + "id": "P0-W2-FINAL-R5-003", + "status": "resolved", + "evidence": "The malformed_capability_response pair now calls detectOpenCodeApiCapabilities. The positive sends truncated OpenAPI JSON and proves parse diagnostics plus requiredForTeamLaunch.ready=false; the valid control sends all required routes and proves ready=true with source=openapi_doc." + } + ], + "requirementStatus": [ + { + "id": "P0.REQ.W2.003", + "status": "satisfied_source_observed_target_unverified", + "evidence": "All 181 exact keys/wildcards remain covered; routing semantics are derived from executed canonical branches and remaining keys use the independent source-authority fixture." + }, + { + "id": "P0.REQ.W2.006", + "status": "satisfied_at_executable_fixture_characterized_level_target_unverified", + "evidence": "All 13 rows have positive/failing-negative proofs; malformed capability parsing now reaches the real response parser with malformed and valid responses." + }, + { + "id": "P0.REQ.W2.007", + "status": "satisfied", + "evidence": "Six strict schemas validate, provider assignment cannot omit bindings, and providerless prohibitions require an explicit target-contract declaration." + } + ], + "checks": [ + { + "command": "pre-correction SHA-256 comparison of source final-r1 untracked paths to recreated target paths", + "exitCode": 0, + "result": "All exact 22 path contents matched before targeted edits." + }, + { + "command": "node --experimental-strip-types scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "exitCode": 0, + "result": "Focused scanner passed and executed all 26 named fake-runtime proofs." + }, + { + "command": "pnpm exec vitest run --reporter=verbose", + "exitCode": 0, + "result": "2 files and 40 tests passed: 14 scanner tests plus 26 fake-runtime seam proofs." + }, + { + "command": "python3 Draft202012Validator.check_schema plus six instance validations and two binding mutations", + "exitCode": 0, + "result": "Six schemas and instances validate; empty assigned binding and implicit providerless prohibition mutations are rejected." + }, + { + "command": "pnpm lint:fast:files -- ", + "exitCode": 0, + "result": "Focused fast lint passed." + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "All exact paths use Prettier style." + }, + { + "command": "git diff --check plus git diff --no-index --check /dev/null for each exact untracked path", + "exitCode": 0, + "result": "No whitespace errors across the exact bundle." + }, + { + "command": "git ls-files --others --exclude-standard compared with changedPaths", + "exitCode": 0, + "result": "Exact 22-path set matched with no missing or extra path." + }, + { + "command": "bounded secret-shaped and prohibited real-project scan over exact 22 paths", + "exitCode": 0, + "result": "No sensitive value, private payload, or prohibited real-project path matched." + } + ], + "dependencyProvenance": { + "dependencyBinariesPresent": true, + "scannerRuntime": "/usr/local/bin/node", + "testLintFormatDependencyTree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w2-targeted-fix-a1/node_modules", + "executionCwd": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w2-targeted-fix-a1" + }, + "unverifiedClaims": [ + "Target-unverified: /api/hosted/v1, /api/runtime/v1, ADR-30 relay, and lane credentials do not exist at the canonical base.", + "Target-unverified: provider final-image conformance and credential isolation were not run; deterministic W2 fixtures do not claim provider execution.", + "Target-unverified: externally owned process adoption, rotated credentials, final-image recovery, and secondary-lane recovery remain target implementation/probe work." + ], + "blockers": [], + "excludedVerification": [ + "integration", + "commit", + "push", + "Phase 1", + "broad CI", + "typecheck", + "build", + "desktop/runtime smoke", + "terminal tests", + "real provider execution", + "real project access" + ], + "nextAction": "controller_independent_rereview_exact_22_path_w2_targeted_a1_bundle_for_P0-W2-FINAL-R5-001_through_003" +} diff --git a/.codex-handoff/phase-00-w3.json b/.codex-handoff/phase-00-w3.json new file mode 100644 index 0000000000..6eba8b01ca --- /dev/null +++ b/.codex-handoff/phase-00-w3.json @@ -0,0 +1,314 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "w3", + "taskId": "agent-teams-hosted-web-refactor-phase-00-remediation-w3-v1", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "baseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "headVerifiedBeforeEdits": true, + "status": "ready_for_focused_re_review", + "originalProducer": { + "jobId": "agent-teams-hosted-web-refactor-phase-00-w3-v1", + "worktree": "phase-00-w3-state-writers-backup-v1", + "status": "characterized" + }, + "review": { + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1", + "reviewId": "w3-w5-reciprocal", + "originalDisposition": "reject", + "w3FindingIds": ["RW35-001", "RW35-006"] + }, + "requirementsAuditDisposition": [ + { + "id": "P0.REQ.W3.001", + "disposition": "retained_and_reverified", + "note": "The accepted 17-family authority/writer/schema/atomicity/corruption/backup catalog is semantically unchanged." + }, + { + "id": "P0.REQ.W3.002", + "disposition": "retained_and_reverified", + "note": "All 12 operations retain honest ADR-29 coordination classes and unresolved writers remain fail-closed." + }, + { + "id": "P0.REQ.W3.003", + "disposition": "retained_and_rerun", + "note": "The marker-owned external-process negative control again reports lostExternalUpdate=true." + }, + { + "id": "P0.REQ.W3.004", + "disposition": "corrected", + "note": "Production TeamBackupService is now constructed and fault-characterized by TB-01 through TB-12." + }, + { + "id": "P0.REQ.W3.005", + "disposition": "retained_and_rerun", + "note": "Supported Online Backup again proves active WAL, independent 2,000-row reopen/integrity, and fail-closed BUSY/corruption/deadline handling." + }, + { + "id": "P0.REQ.W3.006", + "disposition": "external_final_topology_gate_not_claimed", + "note": "Final-image ABI, production async worker wire, and deployment quiescence require W6/controller integration and are deliberately outside this W3-only remediation." + }, + { + "id": "P0.REQ.W3.007", + "disposition": "controller_input_clarified", + "note": "The handoff explicitly maps W3/W5 as overlapping decompositions under EST-RECOVERY-STATE; controller adoption remains outside this lane." + } + ], + "findingResolutions": [ + { + "id": "RW35-001", + "status": "resolved_by_owner_evidence", + "evidenceId": "P0.W3.BACKUP_BEHAVIOR", + "resolution": "A marker-owned 12-case fixture now constructs the production TeamBackupService and asserts async/sync enumeration, config readiness, copy/error swallowing, stale/retention prune, identity mutation, split manifest/registry publication failures, corrupt-registry rebuild, and missing/corrupt/partial/mtime restore behavior.", + "proofPath": "test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs", + "result": "7 tests passed; 12 characterized cases; legacy_unverified classification retained" + }, + { + "id": "RW35-006", + "status": "resolved_by_owner_evidence", + "resolution": "The remediation handoff records an explicit no-index whitespace check over all 16 entirely untracked W3 additions instead of relying on plain git diff --check.", + "proofPath": ".codex-handoff/phase-00-w3.json", + "result": "all handoff-listed additions checked; zero whitespace findings" + } + ], + "evidence": [ + { + "id": "P0.W3.STATE_FAMILY_CATALOG", + "path": "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W3.WRITER_COORDINATION", + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W3.SCHEMA_UNKNOWN_FIELDS", + "path": "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W3.BACKUP_BEHAVIOR", + "path": "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W3.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "proofLevel": "source_observed" + } + ], + "summary": { + "stateFamilyCounts": { + "total": 17, + "currentOrFallback": 16, + "phase1Required": 1, + "catalogRowsWithUnresolvedWriters": 8, + "externalOrPotentialExternalWriter": 6 + }, + "writerOperations": { + "total": 12, + "actionableUnresolvedWriterIdentities": 6 + }, + "teamBackupServiceFixture": { + "tests": 7, + "cases": 12, + "passed": 7, + "classification": "legacy_unverified safety copy" + }, + "unresolvedWriterIdentities": [ + "Claude CLI/provider versions and exact config.json mutation verbs", + "Claude task-tool writer implementation and lock behavior", + "native teammate inbox lock-file adoption", + "OpenCode reply-to-user inbox writer path and acknowledgement boundary", + "provider ownership/flush guarantees for members/** and transcript artifacts", + "legacy config/launch paths that bypass the intended coordinator" + ], + "backupFaultResults": [ + { + "case": "production TeamBackupService fault characterization", + "result": "pass", + "detail": "7 tests assert 12 marker-owned cases; observed split publications and best-effort paths retain legacy_unverified classification" + }, + { + "case": "WAL-active online snapshot", + "result": "pass", + "detail": "better-sqlite3#backup completed with live -wal, independent reopen returned 2,000 rows and integrity_check=ok" + }, + { + "case": "app-only lock versus external process", + "result": "negative-control-pass", + "detail": "child replacement completed but stale app rename erased its update; lostExternalUpdate=true" + }, + { + "case": "SQLITE_BUSY/LOCKED", + "result": "fail-closed-pass", + "detail": "fault-injected backup adapter returned typed backup_busy and partial DB/WAL/SHM outputs were absent" + }, + { + "case": "source corruption", + "result": "fail-closed-pass", + "detail": "invalid SQLite source returned source_corrupt before destination publication" + }, + { + "case": "backup deadline", + "result": "fail-closed-pass", + "detail": "progress deadline returned backup_deadline and removed partial output" + } + ] + }, + "ownershipAudit": { + "changedPathCount": 16, + "ownedRoots": [ + "docs/research/hosted-web/phase-0/state-writers/**", + "scripts/hosted-web/phase-0/state-writers/**", + "test/architecture/hosted-web/phase-0/state-writers/**", + ".codex-handoff/phase-00-w3.json" + ], + "outOfScopePaths": [], + "otherLanesModified": false, + "productionSourceModified": false + }, + "changedPaths": [ + ".codex-handoff/phase-00-w3.json", + "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "docs/research/hosted-web/phase-0/state-writers/evidence.schema.json", + "docs/research/hosted-web/phase-0/state-writers/index.md", + "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md", + "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs", + "scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs", + "scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs", + "test/architecture/hosted-web/phase-0/state-writers/fixtures/invalid-state-family-catalog.json", + "test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs", + "test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs" + ], + "checks": [ + { + "command": "git rev-parse HEAD", + "exitCode": 0, + "result": "0e8431b1935c71a2e77bea1384b134ee25c8aa12 (before edits)" + }, + { + "command": "node --import tsx test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs", + "exitCode": 0, + "result": "7 tests passed; TB-01 through TB-12 asserted" + }, + { + "command": "node scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs", + "exitCode": 0, + "result": "WAL active; 2,000 rows; independent integrity_check=ok" + }, + { + "command": "node scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs", + "exitCode": 0, + "result": "lostExternalUpdate=true" + }, + { + "command": "node scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs", + "exitCode": 0, + "result": "6 evidence files, 17 families, 12 operations, 12 TeamBackupService cases" + }, + { + "command": "node --test test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs", + "exitCode": 0, + "result": "targeted W3 architecture test file passed" + }, + { + "command": "jq -S semantic comparison of the six review-accepted original W3 evidence records against remediation copies", + "exitCode": 0, + "result": "state-family, writer-coordination, unknown-field, SQLite, estimate, and schema records are semantically unchanged" + }, + { + "command": "pnpm lint:fast:files -- ", + "exitCode": 0 + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0 + }, + { + "command": "git diff --no-index --check /dev/null ; require empty diagnostic output", + "exitCode": 0, + "result": "16 entirely untracked W3 additions checked; zero whitespace findings" + }, + { + "command": "bounded secret and real-user-path scan over all handoff-listed W3 files", + "exitCode": 0, + "result": "no private-key, provider-token, Bearer credential, or absolute user-home findings" + } + ], + "scopeExclusions": { + "broadCiRun": false, + "phase1Implemented": false, + "terminalImplemented": false, + "integrationPerformed": false, + "otherLaneEvidenceModified": false, + "pushed": false + }, + "unverifiedClaims": [ + "The SQLite native addon was exercised on the current Linux Node ABI, not the final packaged Electron ABI/container artifact.", + "No real provider was launched; provider/version writer identities require W2 sanitized fixtures.", + "A deterministic real-lock Online Backup SQLITE_BUSY was not claimed; BUSY was injected at the Database#backup adapter boundary.", + "The standalone spike does not prove the production worker async wire operation, which is outside this Phase 0 W3 remediation.", + "No deployment recovery point was built; final-image ABI, writer drain, watcher watermarks, immutable publication, credential exclusion, and restore remain W6/controller gates." + ], + "blockers": [], + "adrRecommendations": [ + { + "adr": "ADR-23", + "recommendation": "accepted-design-implementation-open", + "reason": "future SQLite user_version currently does not close known-table mutation" + }, + { + "adr": "ADR-24", + "recommendation": "accepted", + "reason": "unverified file writes remain team-scoped external actors; run attribution needs provider evidence" + }, + { + "adr": "ADR-29", + "recommendation": "accepted", + "reason": "negative fixture proves app-only locking cannot support a lossless external-writer claim" + }, + { + "adr": "ADR-32", + "recommendation": "accepted-with-two-products", + "reason": "Online Backup API is feasible and production TeamBackupService is fault-characterized as legacy_unverified; only quiesced verified publication can be a deployment recovery point" + } + ], + "estimateBuckets": [ + { + "bucketId": "EST-W3-COMPAT-CATALOG", + "changedLines": { "min": 1100, "max": 1800 } + }, + { + "bucketId": "EST-W3-WRITER-COORDINATION", + "changedLines": { "min": 1550, "max": 2450 } + }, + { + "bucketId": "EST-W3-SQLITE-BACKUP", + "changedLines": { "min": 800, "max": 1300 } + }, + { + "bucketId": "EST-W3-BACKUP-PARTICIPANTS", + "changedLines": { "min": 1050, "max": 1700 } + } + ], + "estimateReconciliation": { + "w3Range": { "min": 4500, "max": 7250 }, + "w5Relationship": "overlapping decomposition; do not add", + "controllerAction": "deduplicate once under the shared EST-RECOVERY-STATE range" + }, + "nextAction": "focused_w3_w5_re_review" +} diff --git a/.codex-handoff/phase-00-w4-w6.json b/.codex-handoff/phase-00-w4-w6.json new file mode 100644 index 0000000000..770566f452 --- /dev/null +++ b/.codex-handoff/phase-00-w4-w6.json @@ -0,0 +1,153 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneIds": ["w4", "w6"], + "taskId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "packetRevision": "phase-00-r3", + "status": "remediation_complete_pending_re_review", + "canonicalBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "sourceWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w4-w6-v7", + "provenance": { + "approvedV6Review": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w4-w6-v6/.codex-handoff/phase-00-review-w4-w6-v6.json", + "approvedV6ReviewSha256": "5c4c0ed2792df575dfd74c3a197ff00af6ed2abcc001dd815c39e70a87f7ed7a", + "supersedingReviewRecord": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6.review.json", + "supersedingReviewRecordSha256": "b68ad9f064e622edc64e96194bd00bea42b5c31467a0503b58b8e826911eaa8b", + "rejectedIntegrationArchive": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/project-integration/integration-attempts/a8405fd56102c02a0319e197c5b1b892d612616e39e5e871167cdb42798d5767/attempt.json", + "rejectedIntegrationArchiveSha256": "1b49a4f0745b5e67fe8d56c97174ae55af4d9c5edb006112440b467bc9cea1dc", + "v6PreservedPatch": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v6/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v6.preserved.patch", + "v6PreservedPatchSha256": "479f78a3a89a7e132899ede39a7606c59ce9b201ebe04d97df281e3a4825f690" + }, + "reproducedCanonicalFailure": { + "focusedTests": "1 failed, 49 passed: ambient canonical integration output used HttpServer-CgGq0m_Y.cjs and index-Ds6Ihcda.cjs while V6 committed isolated-output rows", + "verifyEvidence": "failed with committed standalone characterization is stale", + "archiveCheckIds": ["w4-w6-focused-tests", "w6-evidence-verify"] + }, + "rootCause": "The source characterization implicitly scanned mutable ambient dist-standalone output, while emitted facts were duplicated across standalone records. An isolated V6 artifact snapshot therefore became stale when canonical integration contained the other admitted Phase 0 source changes.", + "fix": { + "authority": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "authoritySemanticSha256": "50b954be77896963e5e32c5fc9e7785349ecff6bfb5455162671c751551fd3b4", + "description": "Regenerated emitted rows from the rejected canonical archive, made the observed scan the sole standalone authority, replaced duplicate manifest/evidence facts with checked semantic-hash projections, and made source scans independent of ambient dist output.", + "regression": "auth-artifacts-spike.test.ts mutates one emitted SHA-256 and proves the stale projection is rejected." + }, + "preservedContracts": { + "readyDrainedDto": "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "controllerArtifactContract": "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "controllerArtifactContractSha256": "4e8f2b43f47f94586692530cba2f5198f6de3740225b51dfba627f36fc82812f", + "artifactProjectionAbi": "artifactId/finalImagePath/binarySha256 arrays remain byte-equivalent across W4 and W6", + "terminalImplemented": false + }, + "changedPaths": [ + ".codex-handoff/phase-00-w4-w6.json", + ".codex-handoff/phase-00-w4.json", + ".codex-handoff/phase-00-w6.json", + "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json", + "docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json", + "docs/research/hosted-web/phase-0/auth-artifacts/report.md", + "docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json", + "docs/research/hosted-web/phase-0/host-primitives/estimate-input.json", + "docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json", + "docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json", + "docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md", + "docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json", + "docs/research/hosted-web/phase-0/host-primitives/probe-results.schema.json", + "docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json", + "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json", + "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c", + "scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "test/architecture/hosted-web/phase-0/host-primitives/native-probes.test.ts", + "test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts" + ], + "checks": [ + { + "command": "pnpm exec vitest run --maxWorkers=1 --minWorkers=1", + "exitCode": 0, + "result": "4 files and 51 tests passed" + }, + { + "command": "node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "exitCode": 0, + "result": "single authority, W4 DTO consumption, reset closure, standalone rejection, terminal rule, ABI and V7 provenance passed" + }, + { + "command": "scanStandalone with rejected canonical candidate dist-standalone as explicit buildRoot", + "exitCode": 0, + "result": "all seven emitted paths, byte counts, SHA-256 hashes and marker facts matched the sole committed authority" + }, + { + "command": "node scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "exitCode": 0, + "result": "host-primitives evidence scan passed" + }, + { + "command": "node scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs", + "exitCode": 0, + "result": "controller artifact contract passed" + }, + { + "command": "/usr/bin/python3 scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "exitCode": 0, + "result": "ready/drained, pidfd, cleanup, high-FD and negative current-host probes passed with zero residuals" + }, + { + "command": "pnpm lint:fast:files -- <8 exact W4/W6 JS/TS files>", + "exitCode": 0, + "result": "focused fast lint passed" + }, + { + "command": "pnpm exec prettier --check <33 supported V7 additions>", + "exitCode": 0, + "result": "all matched files use Prettier code style" + }, + { + "command": "JSON.parse over all 19 V7 JSON additions plus checked-in schema validation", + "exitCode": 0, + "result": "all JSON parsed; W6 and controller schemas passed" + }, + { + "command": "PYTHONPYCACHEPREFIX= /usr/bin/python3 -m py_compile scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "exitCode": 0, + "result": "Python syntax passed without a worktree cache artifact" + }, + { + "command": "git diff --no-index --check /dev/null ", + "exitCode": 0, + "result": "zero whitespace diagnostics" + }, + { + "command": "joint changedPaths versus git status --porcelain=v1 -uall", + "exitCode": 0, + "result": "37 exact individual paths matched" + }, + { + "command": "bounded private-key, Bearer-value, provider-token and absolute-home scan over all 37 additions", + "exitCode": 0, + "result": "no findings" + }, + { + "command": "sha256sum approved V6 review, superseding review record, rejected integration archive and V6 preserved patch", + "exitCode": 0, + "result": "all four exact external provenance hashes matched the recorded values" + } + ], + "blockers": [], + "nextAction": "independent_paired_w4_w6_v7_re_review" +} diff --git a/.codex-handoff/phase-00-w4.json b/.codex-handoff/phase-00-w4.json new file mode 100644 index 0000000000..3b89860502 --- /dev/null +++ b/.codex-handoff/phase-00-w4.json @@ -0,0 +1,182 @@ +{ + "schemaVersion": 2, + "phaseId": "phase-00", + "laneId": "w4", + "taskId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "packetRevision": "phase-00-r3", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "baseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "canonicalBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "sourceWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w4-w6-v7", + "remediationProvenance": { + "approvedV6Review": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w4-w6-v6/.codex-handoff/phase-00-review-w4-w6-v6.json", + "approvedV6ReviewSha256": "5c4c0ed2792df575dfd74c3a197ff00af6ed2abcc001dd815c39e70a87f7ed7a", + "supersedingReviewRecord": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6.review.json", + "supersedingReviewRecordSha256": "b68ad9f064e622edc64e96194bd00bea42b5c31467a0503b58b8e826911eaa8b", + "rejectedIntegrationArchive": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/project-integration/integration-attempts/a8405fd56102c02a0319e197c5b1b892d612616e39e5e871167cdb42798d5767/attempt.json", + "rejectedIntegrationArchiveSha256": "1b49a4f0745b5e67fe8d56c97174ae55af4d9c5edb006112440b467bc9cea1dc", + "v6PreservedPatch": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v6/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v6.preserved.patch", + "v6PreservedPatchSha256": "479f78a3a89a7e132899ede39a7606c59ce9b201ebe04d97df281e3a4825f690" + }, + "salvage": { + "sourceTaskId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v5", + "sourceWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w4-w6-v5", + "preservedPatch": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v5/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v5.preserved.patch", + "preservedPatchSha256": "183069adf05cb254c846cbd37a7c39ac930b2cb5dd6994f6b5b96dc5d4304d79", + "independentlyVerified": true + }, + "headVerifiedBeforeEdits": true, + "status": "characterized", + "scope": { + "disposition": "current_host_characterization_and_read_only_projection_only", + "hostedMutationReady": false, + "workspaceEffectsReady": false, + "providerLaunchReady": false, + "productionCompositionReady": false, + "terminalImplemented": false + }, + "crossLaneContracts": { + "drainDto": "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "controllerArtifactContract": "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "controllerArtifactContractSha256": "4e8f2b43f47f94586692530cba2f5198f6de3740225b51dfba627f36fc82812f", + "projection": "docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json" + }, + "evidence": [ + { + "id": "P0.W4.TARGET_HOST_ENVELOPE", + "path": "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "proofLevel": "source_observed" + }, + { + "id": "P0.W4.INSTANCE_LEASE_SPIKE", + "path": "docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W4.WORKSPACE_GUARD_SPIKE", + "path": "docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W4.PROCESS_ANCHOR_SPIKE", + "path": "docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W4.NATIVE_ARTIFACT_PROPOSAL", + "path": "docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md", + "proofLevel": "source_observed" + }, + { + "id": "P0.W4.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/host-primitives/estimate-input.json", + "proofLevel": "source_observed" + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-w4.json", + "docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json", + "docs/research/hosted-web/phase-0/host-primitives/estimate-input.json", + "docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json", + "docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json", + "docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md", + "docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json", + "docs/research/hosted-web/phase-0/host-primitives/probe-results.schema.json", + "docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json", + "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json", + "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c", + "scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "test/architecture/hosted-web/phase-0/host-primitives/native-probes.test.ts", + "test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts" + ], + "checks": [ + { + "command": "git rev-parse HEAD before edits", + "exitCode": 0, + "result": "f7d98790eb868714e536f77bd796072ea706911a" + }, + { + "command": "sha256sum ", + "exitCode": 0, + "result": "183069adf05cb254c846cbd37a7c39ac930b2cb5dd6994f6b5b96dc5d4304d79; patch mechanically salvaged, then independently reviewed and corrected in V6" + }, + { + "command": "/usr/bin/python3 scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "exitCode": 0, + "result": "current-host-only lease/guard/anchor characterization passed; exact ready/drained DTOs, pidfd-only descendant signaling, cleanup, high-FD and negative controls passed; finalShapeContainer=false" + }, + { + "command": "node scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "exitCode": 0, + "result": "W4 evidence, controller projection, exact V7 provenance and r3 narrowing passed" + }, + { + "command": "pnpm exec vitest run --maxWorkers=1 --minWorkers=1 ", + "exitCode": 0, + "result": "4 files and 51 tests passed, including exact DTO, reset-action census, standalone-authority disagreement regression, projection equality and omission/drift negatives" + }, + { + "command": "pnpm lint:fast:files -- <8 owned JS/TS files>", + "exitCode": 0, + "result": "focused fast lint passed" + }, + { + "command": "pnpm exec prettier --check <33 supported V7 additions>", + "exitCode": 0, + "result": "all matched files use Prettier style" + }, + { + "command": "/usr/bin/python3 -m py_compile scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "exitCode": 0, + "result": "Python syntax passed; generated cache removed" + }, + { + "command": "git diff --no-index --check /dev/null ", + "exitCode": 0, + "result": "all entirely untracked additions checked; zero whitespace diagnostics" + }, + { + "command": "bounded private-key/Bearer/provider-token/absolute-home scan over all 37 V7 additions", + "exitCode": 0, + "result": "no findings" + } + ], + "unverifiedClaims": [ + "No admitted final image or final topology was tested.", + "The C programs remain current-host feasibility spikes, not production artifacts.", + "No hosted mutation/runtime readiness, workspace effect authority, provider launch authority, or production composition was established.", + "Terminal absence remains a rule and is not claimed achieved." + ], + "blockers": [], + "adrRecommendations": [ + { + "adr": "ADR-16", + "outcome": "narrowed", + "reason": "Current-host lease characterization grants no hosted mutation/runtime readiness before Phase 5 final-image proof." + }, + { + "adr": "ADR-28", + "outcome": "narrowed", + "reason": "Current-host guard characterization grants no workspace read/write/Git/provider-spawn effect authority before Phase 5." + }, + { + "adr": "ADR-31", + "outcome": "narrowed", + "reason": "Current-host anchor characterization grants no hosted provider launch authority before Phase 5." + } + ], + "estimateBuckets": ["EST-LIFECYCLE-RUNTIME"], + "nextAction": "focused_paired_w4_w6_re_review" +} diff --git a/.codex-handoff/phase-00-w5.json b/.codex-handoff/phase-00-w5.json new file mode 100644 index 0000000000..8b01c6c526 --- /dev/null +++ b/.codex-handoff/phase-00-w5.json @@ -0,0 +1,143 @@ +{ + "schemaVersion": 2, + "phaseId": "phase-00", + "laneId": "w5", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "remediationBaseSha": "648bebed68f5a64c984e83b441e14dd7c587c403", + "salvagedFrom": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w5-v1", + "status": "remediated_pending_reciprocal_review", + "evidence": [ + { + "id": "P0.W5.EVENT_CURSOR_INVENTORY", + "path": "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER", + "path": "docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W5.COMMAND_CATALOG", + "path": "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "proofLevel": "source_observed_plus_executable_omission_proof" + }, + { + "id": "P0.W5.EFFECT_RECOVERY_MATRIX", + "path": "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "proofLevel": "fresh_process_fixture_characterized" + }, + { + "id": "P0.W5.FINGERPRINT_GOLDENS", + "path": "docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json", + "proofLevel": "immutable_oracle_fixture_characterized" + }, + { + "id": "P0.W5.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W5.SUPPORTING.MUTATION_CENSUS", + "path": "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "proofLevel": "source_observed_plus_executable_omission_proof" + }, + { + "id": "P0.W5.SUPPORTING.MUTATION_SURFACE_MANIFEST", + "path": "docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json", + "proofLevel": "review_input" + } + ], + "findingResolutions": { + "RW35-002": "resolved: every effect boundary terminates an attempt process with exit 86 and recovers in a distinct process from durable files; exact state/effect/compensation/publication counts are asserted", + "RW35-003": "resolved: all task/inbox/provider candidates remain fail-closed under integrated W3 writer evidence", + "RW35-004": "resolved: retained/default/version/ordered-array vectors are checked against immutable expected encodings and HMAC digests outside generator outputs", + "RW35-005": "resolved: TypeScript AST extraction and independent 114-member disposition manifest are compared bidirectionally; omitted-row, stale-row, and omitted-descriptor controls fail", + "RW35-006": "resolved: every untracked addition is covered by no-index whitespace checking", + "R35-R2-001": "resolved by genuine fresh-process recovery and exact boundary assertions", + "R35-R2-002": "resolved by independent source extraction, full mutation/query/ephemeral/deferred dispositions, and omission fixtures", + "R35-R2-003": "resolved by this 23-path remediation handoff with phase-start/base distinction and exact checks", + "R35-R2-004": "resolved by targeted Prettier and all-file no-index whitespace checking", + "P0.V2.CONFLICT.W1_W2_W5.ACTION_OWNERSHIP": "resolved for W5: launch/stop/cancel use team-lifecycle; legacy runtime.permission_answer is deferred and excluded in favor of runtime permission-request ingress plus operator-owned outbound decision" + }, + "integratedW3Authority": { + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "preserved": true, + "effectDisposition": "all 101 generated effects remain automaticRecoveryAdmitted=false; transactional candidates require the future transaction and external candidates remain operator_required without W3-required writer/lookup proof" + }, + "changedPaths": [ + ".codex-handoff/phase-00-w5.json", + "docs/research/hosted-web/phase-0/recovery-events/README.md", + "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json", + "docs/research/hosted-web/phase-0/recovery-events/index.json", + "docs/research/hosted-web/phase-0/recovery-events/index.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json", + "docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json", + "scripts/hosted-web/phase-0/recovery-events/effect-recovery-worker.mjs", + "scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs", + "scripts/hosted-web/phase-0/recovery-events/model.mjs", + "scripts/hosted-web/phase-0/recovery-events/mutation-census.mjs", + "test/architecture/hosted-web/phase-0/recovery-events/fixtures/fingerprint-oracle-vectors.json", + "test/architecture/hosted-web/phase-0/recovery-events/fixtures/invalid-command-catalog.json", + "test/architecture/hosted-web/phase-0/recovery-events/fixtures/omitted-command-descriptor.json", + "test/architecture/hosted-web/phase-0/recovery-events/fixtures/omitted-mutation-row.json", + "test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs" + ], + "checks": [ + { + "command": "node scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs --check", + "exitCode": 0, + "result": "9 generated evidence files fresh" + }, + { + "command": "node test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs", + "exitCode": 0, + "result": "7 tests passed including 52 two-process recovery schedules and omission controls" + }, + { + "command": "pnpm lint:fast:files -- ", + "exitCode": 0, + "result": "targeted fast lint passed" + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "targeted Prettier check passed" + }, + { + "command": "git diff --no-index --check /dev/null ", + "exitCode": 0, + "result": "all additions whitespace-clean" + }, + { + "command": "bounded secret/private-key/Bearer/token/absolute-home scan over 23 additions", + "exitCode": 0, + "result": "no likely secret or prohibited real-user path" + } + ], + "unverifiedClaims": [ + "No Phase 1 hosted SQLite command registry/event journal, retention lease coordinator, snapshot endpoint, or renderer reconciler is implemented by this lane.", + "W3's integrated proof keeps current native task/config/inbox writers uncoordinated or quiescent-only; no W5 catalog row promotes them to automatic recovery.", + "The future app-exclusive SQLite transaction, production worker wiring, final artifact ABI, deployment drain, recovery-point publication, and credential/keyring preservation remain target-unverified.", + "The immutable fingerprint oracle uses public test-only keys and is evidence for deterministic encoding only, not production key custody." + ], + "blockers": [], + "estimateBuckets": [ + { + "bucketId": "EST-RECOVERY-STATE", + "changedLinesLow": 4500, + "changedLinesHigh": 7500, + "confidence": "medium-low", + "overlap": "Deduplicate integrated W3 SQLite transaction, backup, keyring, and external-writer fixtures." + } + ], + "nextAction": "focused W3/W5 reciprocal re-review against this exact 23-path W5 bundle" +} diff --git a/.codex-handoff/phase-00-w6.json b/.codex-handoff/phase-00-w6.json new file mode 100644 index 0000000000..cb6b9e02f0 --- /dev/null +++ b/.codex-handoff/phase-00-w6.json @@ -0,0 +1,181 @@ +{ + "schemaVersion": 2, + "phaseId": "phase-00", + "laneId": "w6", + "taskId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "packetRevision": "phase-00-r3", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "baseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "canonicalBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "sourceWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w4-w6-v7", + "remediationProvenance": { + "approvedV6Review": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w4-w6-v6/.codex-handoff/phase-00-review-w4-w6-v6.json", + "approvedV6ReviewSha256": "5c4c0ed2792df575dfd74c3a197ff00af6ed2abcc001dd815c39e70a87f7ed7a", + "supersedingReviewRecord": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6.review.json", + "supersedingReviewRecordSha256": "b68ad9f064e622edc64e96194bd00bea42b5c31467a0503b58b8e826911eaa8b", + "rejectedIntegrationArchive": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/project-integration/integration-attempts/a8405fd56102c02a0319e197c5b1b892d612616e39e5e871167cdb42798d5767/attempt.json", + "rejectedIntegrationArchiveSha256": "1b49a4f0745b5e67fe8d56c97174ae55af4d9c5edb006112440b467bc9cea1dc", + "v6PreservedPatch": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v6/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v6.preserved.patch", + "v6PreservedPatchSha256": "479f78a3a89a7e132899ede39a7606c59ce9b201ebe04d97df281e3a4825f690" + }, + "salvage": { + "sourceTaskId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v5", + "sourceWorktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w4-w6-v5", + "preservedPatch": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v5/agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v5.preserved.patch", + "preservedPatchSha256": "183069adf05cb254c846cbd37a7c39ac930b2cb5dd6994f6b5b96dc5d4304d79", + "independentlyVerified": true + }, + "headVerifiedBeforeEdits": true, + "status": "characterized", + "scope": { + "disposition": "standalone_artifact_rejected_for_hosted_v1", + "remoteAuthReady": false, + "remoteMutationReady": false, + "productionCompositionReady": false, + "terminalAbsenceAchieved": false, + "terminalImplemented": false + }, + "crossLaneContracts": { + "drainDto": "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "controllerArtifactContract": "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "controllerArtifactContractSha256": "4e8f2b43f47f94586692530cba2f5198f6de3740225b51dfba627f36fc82812f", + "projection": "docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json" + }, + "evidence": [ + { + "id": "P0.W6.AUTH_TRANSITIONS", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W6.PROXY_ORIGIN_THREAT_MATRIX", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W6.COOKIE_VERSION_EVIDENCE", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W6.ARTIFACT_INVENTORY", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W6.ABI_STUB_REPORT", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "proofLevel": "fixture_characterized" + }, + { + "id": "P0.W6.TERMINAL_ABSENCE_REPORT", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "proofLevel": "source_observed" + }, + { + "id": "P0.W6.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json", + "proofLevel": "source_observed" + } + ], + "changedPaths": [ + ".codex-handoff/phase-00-w6.json", + "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json", + "docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json", + "docs/research/hosted-web/phase-0/auth-artifacts/report.md", + "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json", + "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts" + ], + "checks": [ + { + "command": "git rev-parse HEAD before edits", + "exitCode": 0, + "result": "f7d98790eb868714e536f77bd796072ea706911a" + }, + { + "command": "sha256sum ", + "exitCode": 0, + "result": "183069adf05cb254c846cbd37a7c39ac930b2cb5dd6994f6b5b96dc5d4304d79; patch mechanically salvaged, then independently reviewed and corrected in V6" + }, + { + "command": "rejected canonical integration archive emitted-evidence extraction plus semantic authority hash", + "exitCode": 0, + "result": "seven canonical integration candidate CJS paths/bytes/hashes regenerated under one observed-artifact-scan authority; isolated source scanning no longer consults mutable ambient output" + }, + { + "command": "scanStandalone with rejected canonical candidate dist-standalone as explicit buildRoot", + "exitCode": 0, + "result": "all seven emitted paths, byte counts, SHA-256 hashes and marker facts matched the sole committed authority" + }, + { + "command": "node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "exitCode": 0, + "result": "W6 r3 evidence, exact W4 DTO/artifact consumption, all-action reset admission fence, standalone authority/rejection, terminal rule, ABI characterization and V7 provenance passed" + }, + { + "command": "pnpm exec vitest run --maxWorkers=1 --minWorkers=1 ", + "exitCode": 0, + "result": "4 files and 51 tests passed; stale standalone projection plus missing, extra, duplicate, renamed, artifact-path, artifact-hash, controller-path and controller-hash drift are rejected" + }, + { + "command": "pnpm lint:fast:files -- <8 owned JS/TS files>", + "exitCode": 0, + "result": "focused fast lint passed" + }, + { + "command": "pnpm exec prettier --check <33 supported V7 additions>", + "exitCode": 0, + "result": "all matched files use Prettier style" + }, + { + "command": "node JSON.parse over 19 V7 JSON additions", + "exitCode": 0, + "result": "all JSON parsed; W6 verifier also validated evidence and the controller-owned artifact schema" + }, + { + "command": "git diff --no-index --check /dev/null ", + "exitCode": 0, + "result": "all entirely untracked additions checked; zero whitespace diagnostics" + }, + { + "command": "bounded private-key/Bearer/provider-token/absolute-home scan over all 37 V7 additions", + "exitCode": 0, + "result": "no findings" + } + ], + "unverifiedClaims": [ + "No remote authentication, remote mutation, browser production E2E, or production composition was enabled or claimed ready.", + "No admitted final image, live edge, durable keyring crash schedule, Electron native load, or final ABI load was tested.", + "Terminal absence remains a rule; the current standalone artifact violates it." + ], + "blockers": [], + "adrRecommendations": [ + { + "adr": "ADR-7", + "outcome": "narrowed", + "reason": "Reset/auth schedules are fixture characterization only; Phase 6 owns real HTTPS, durable auth, and proxy proof." + }, + { + "adr": "ADR-14", + "outcome": "narrowed", + "reason": "Origin/proxy ordering remains a negative contract fixture and grants no remote authority." + }, + { + "adr": "ADR-17", + "outcome": "blocked", + "reason": "The current standalone artifact is rejected for hosted v1; Phase 5 owns the clean artifact and terminal-negative image gate." + } + ], + "estimateBuckets": ["EST-HOSTED-OPS"], + "nextAction": "focused_paired_w4_w6_re_review" +} diff --git a/.codex-handoff/phase-01-p1-1a.json b/.codex-handoff/phase-01-p1-1a.json new file mode 100644 index 0000000000..3cca175103 --- /dev/null +++ b/.codex-handoff/phase-01-p1-1a.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "laneId": "p1-s1", + "packetRevision": "phase-01-s1-foundations-r1", + "baseSha": "50db3d6537abf5342162cd8f5cc12bbab6d38067", + "phaseStartSha": "50db3d6537abf5342162cd8f5cc12bbab6d38067", + "resultCommitSha": null, + "status": "characterized", + "toolVersions": "pnpm 10.33.4; Vitest 3.2.6; ESLint 9.39.4; TypeScript 7.0.2; Prettier 3.8.1; Git 2.43.0", + "evidence": [ + { + "id": "P1.1A.KERNEL", + "path": "src/shared/contracts/hosted/index.ts", + "proofLevel": "target_verified" + }, + { + "id": "P1.1A.VERSION", + "path": "test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + ".codex-handoff/phase-01-p1-1a.json", + "src/shared/contracts/hosted/app-error.ts", + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/index.ts", + "src/shared/contracts/hosted/query-context.ts", + "src/shared/contracts/hosted/revision.ts", + "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json", + "test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "test/architecture/hosted-web/phase-1/contracts/query-context.test.ts", + "test/architecture/hosted-web/phase-1/contracts/revision.test.ts" + ], + "checks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts", + "exitCode": 0 + }, + { + "command": "pnpm lint:fast:files -- src/shared/contracts/hosted/app-error.ts src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts src/shared/contracts/hosted/query-context.ts src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-1/contracts/query-context.test.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": "inherited Phase 0 test-tree errors; no P1.S1 path reported" + }, + { + "command": "pnpm exec prettier --check .codex-handoff/phase-01-p1-1a.json src/shared/contracts/hosted/app-error.ts src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts src/shared/contracts/hosted/query-context.ts src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-1/contracts/query-context.test.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "exitCode": 0 + }, + { "command": "git diff --check", "exitCode": 0 }, + { "command": "git status --short", "exitCode": 0 } + ], + "deliberateNegativeResult": { + "id": "P1.NEG.SCHEMA_VERSION", + "diagnostic": "phase1-schema-version-invalid-or-unsupported", + "result": "passed" + }, + "unverifiedClaims": ["production hosted behavior", "production identity mapping"], + "blockers": ["base_failure: pnpm typecheck reports only inherited Phase 0 test-tree diagnostics"], + "adrRecommendations": [], + "estimateBuckets": [{ "id": "P1.1A", "actual": 299, "unit": "gross owned lines" }], + "nextAction": "Controller reviews P1.S1, classifies the inherited typecheck baseline, then applies and commits this worktree diff; P1.S2 remains unauthorized." +} diff --git a/.codex-handoff/phase-01-p1-1b.json b/.codex-handoff/phase-01-p1-1b.json new file mode 100644 index 0000000000..4b2dd6c0af --- /dev/null +++ b/.codex-handoff/phase-01-p1-1b.json @@ -0,0 +1,196 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "laneId": "p1-1b", + "packetRevision": "phase-01-s2-routes-r1", + "baseSha": "041b5c7c2d3225b7dc2eca9e9b7b71aa33217060", + "planBundleCommit": "a0dc964e9a71b782b1bbad4769db62a691e50c97", + "phaseStartSha": "a0dc964e9a71b782b1bbad4769db62a691e50c97", + "resultCommitSha": null, + "status": "verified", + "toolVersions": { + "node": "24.16.0", + "pnpm": "10.33.4", + "vitest": "3.2.6", + "eslint": "9.39.4", + "typescript": "7.0.2", + "prettier": "3.8.1", + "git": "2.43.0" + }, + "evidence": [ + { + "id": "P1.1B.ROUTES", + "path": "src/main/composition/hosted/routing/RouteCatalog.ts", + "proofLevel": "target_verified" + }, + { + "id": "P1.1B.CAPABILITIES", + "path": "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + ".codex-handoff/phase-01-p1-1b.json", + "src/main/composition/hosted/routing/RouteCatalog.ts", + "src/main/composition/hosted/routing/index.ts", + "src/main/composition/hosted/routing/route-types.ts", + "test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts", + "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts", + "test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts", + "test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts" + ], + "checks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts", + "exitCode": 0, + "result": "1 file and 12 tests passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "exitCode": 0, + "result": "1 file and 4 tests passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/routes", + "exitCode": 0, + "result": "2 files and 16 tests passed" + }, + { + "command": "pnpm lint:fast:files -- src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": "unchanged inherited Phase 0 test-tree diagnostics; no owned-path diagnostic", + "diagnosticCount": 7, + "diagnosticPaths": [ + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts" + ] + }, + { + "command": "pnpm exec prettier --check .codex-handoff/phase-01-p1-1b.json src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts", + "exitCode": 0 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git status --short", + "exitCode": 0, + "result": "exactly the nine changedPaths, represented as three untracked directory summaries" + } + ], + "scopeChecks": [ + { + "command": "git merge-base --is-ancestor 041b5c7c2d3225b7dc2eca9e9b7b71aa33217060 a0dc964e9a71b782b1bbad4769db62a691e50c97", + "exitCode": 0 + }, + { + "command": "git diff --name-only 041b5c7c2d3225b7dc2eca9e9b7b71aa33217060..a0dc964e9a71b782b1bbad4769db62a691e50c97", + "exitCode": 0, + "result": "only the eight contract-owned router documentation paths" + }, + { + "command": "git diff --exit-code a0dc964e9a71b782b1bbad4769db62a691e50c97 -- . ':(exclude).codex-handoff/phase-01-p1-1b.json' ':(exclude)src/main/composition/hosted/routing/RouteCatalog.ts' ':(exclude)src/main/composition/hosted/routing/index.ts' ':(exclude)src/main/composition/hosted/routing/route-types.ts' ':(exclude)test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts' ':(exclude)test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts' ':(exclude)test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts' ':(exclude)test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts' ':(exclude)test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts'", + "exitCode": 0, + "result": "all tracked non-owned paths match phaseStartSha" + }, + { + "command": "git diff --name-only a0dc964e9a71b782b1bbad4769db62a691e50c97 && git ls-files --others --exclude-standard", + "exitCode": 0, + "result": "exactly the nine changedPaths" + }, + { + "command": "rg -n -i '(api[_-]?key[[:space:]]*[:=]|access[_-]?token[[:space:]]*[:=]|refresh[_-]?token[[:space:]]*[:=]|password[[:space:]]*[:=]|bearer[[:space:]]+[A-Za-z0-9]|-----BEGIN [A-Z ]*PRIVATE KEY-----|/[U]sers/|/[h]ome/|/[r]oot/|claude[-]runtime|auth[_-]?payload|provider[_-]?payload|raw[_-]?(command|runtime)[_-]?body)' ", + "exitCode": 1, + "result": "zero matches after review of every changed and untracked text file" + }, + { + "command": "rg -n -i '(secret|token|cookie|password|bearer|authorization|provider|api.?key|private|/tmp/|/Users/|/home/|/root/|[A-Za-z]:\\\\Users\\\\|claude-runtime|raw.?command|runtime.?body)' ", + "exitCode": 0, + "result": "four lexical matches reviewed as benign: one inherited provider-runtime diagnostic path, the recorded narrow scan expression, the secret-scan result field, and the route trust-kind enum value private" + }, + { + "command": "file --mime-type ", + "exitCode": 0, + "result": "eight application/javascript files and one application/json file; no binary files" + } + ], + "negativeResultMatrix": [ + { + "id": "P1.NEG.ROUTE_DRIFT", + "case": "duplicate route ID and method/path pair", + "result": "rejected", + "diagnostic": "phase1-route-catalog-drift", + "positiveNeighbor": "accepted" + }, + { + "id": "P1.NEG.ROUTE_DRIFT", + "case": "missing required handler reference", + "result": "rejected", + "diagnostic": "phase1-route-catalog-drift", + "positiveNeighbor": "accepted" + }, + { + "id": "P1.NEG.CAPABILITY_MOUNT", + "case": "capability declares production support", + "result": "rejected", + "diagnostic": "phase1-test-capability-production-mount", + "positiveNeighbor": "accepted with production support absent" + }, + { + "id": "P1.NEG.CAPABILITY_MOUNT", + "case": "production catalog mounts testOnly route", + "result": "rejected", + "diagnostic": "phase1-test-capability-production-mount", + "positiveNeighbor": "accepted only in the test catalog" + } + ], + "patchManifest": { + "phaseStartSha": "a0dc964e9a71b782b1bbad4769db62a691e50c97", + "command": "for each of the eight implementation/test paths in changedPaths: git diff --binary --full-index --no-index /dev/null ; concatenate in changedPaths order; sha256sum", + "computedBeforeHandoffCreation": true, + "sha256": "30cf407200af5ea320d268bba4089de7015a294e4e8c7f21cbf41780f7abf24e", + "changedPathSha256": { + "src/main/composition/hosted/routing/RouteCatalog.ts": "2b304771137dbe3b8047158771fdda2d8592d91c0687f4caa8bb88457d86e3fe", + "src/main/composition/hosted/routing/index.ts": "188ef3cbf87a96956ca03779bd74ddc7c46f21217172f057a8a3b0a5df75aa6a", + "src/main/composition/hosted/routing/route-types.ts": "53a8c83e6d42b9ee9d8a6b7a4a64b93ff1fe853b42d396c16dca6ef87dc0d878", + "test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts": "86713d834e4e3474fb13386ab05617aec6600f8c4eba353e7761bfb576579ab6", + "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts": "d0d7a2728ac4478130fad0aadebdc5f6534f7ff83cc88d49ab0732e056afefa1", + "test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts": "3311c2781f3bdde1235b64163d34d3d19da7961d28109482a6c1a2ee7797a40e", + "test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts": "fde1f2b3737bb9fd8d3dbad44be2964249814374f1ef2a9b63a66af39279ffe5", + "test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts": "1c6eac046aa441e90b3a7818800ee7ef23c8925695deb8975dc75b6e035e7011" + } + }, + "scopeProof": { + "ownedPathsMatchedBaseBeforeWork": true, + "changedAndUntrackedSetIsExact": true, + "nonOwnedPathsMatchPhaseStartSha": true, + "phaseStartDiffFromBaseIsRouterDocumentationOnly": true, + "acceptedPredecessorPathsUnchanged": true, + "productionRegistrationUnchanged": true, + "changedAndUntrackedSecretPathScan": "credential/private-path expression had zero matches; four broader lexical matches were reviewed and classified benign", + "changedAndUntrackedBinaryScan": "text only", + "realProjectDataPresent": false + }, + "inheritedFailures": [ + "pnpm typecheck reports only the accepted predecessor's seven existing Phase 0 test-tree diagnostics; it reports no P1.1B owned-path diagnostic" + ], + "unverifiedClaims": [ + "production route or capability support", + "production transport registration", + "P1.1D feature behavior", + "P1.R1 admission or review", + "integration of this producer candidate", + "complete Phase 1" + ], + "blockers": [], + "adrRecommendations": [], + "estimateBuckets": [], + "nextAction": "Wait for the independent P1.1C handoff and a separately authorized P1.R1 router decision; do not integrate or start later work from this lane." +} diff --git a/.codex-handoff/phase-01-p1-1c.json b/.codex-handoff/phase-01-p1-1c.json new file mode 100644 index 0000000000..8d3b348806 --- /dev/null +++ b/.codex-handoff/phase-01-p1-1c.json @@ -0,0 +1,293 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "laneId": "p1-1c", + "packetRevision": "phase-01-s2-conformance-r1", + "parentPlanCommit": "041b5c7c2d3225b7dc2eca9e9b7b71aa33217060", + "baseSha": "041b5c7c2d3225b7dc2eca9e9b7b71aa33217060", + "planBundleCommit": "a0dc964e9a71b782b1bbad4769db62a691e50c97", + "phaseStartSha": "a0dc964e9a71b782b1bbad4769db62a691e50c97", + "resultCommitSha": null, + "status": "characterized", + "toolVersions": { + "node": "24.16.0", + "pnpm": "10.33.4", + "vitest": "3.2.6", + "eslint": "9.39.4", + "typescript": "7.0.2", + "prettier": "3.8.1", + "git": "2.43.0" + }, + "evidence": [ + { + "id": "P1.1C.CONFORMANCE", + "path": "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P1.1C.RATCHETS", + "path": "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + ".codex-handoff/phase-01-p1-1c.json", + "scripts/hosted-web/phase-1/check-feature-dependencies.ts", + "scripts/hosted-web/phase-1/check-parity-references.ts", + "scripts/hosted-web/phase-1/check-renderer-boundaries.ts", + "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts", + "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts", + "test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts", + "test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts", + "test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts", + "test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts", + "test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts", + "test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts", + "test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts", + "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts", + "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json" + ], + "checks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance test/architecture/hosted-web/phase-1/dependencies test/architecture/hosted-web/phase-1/parity test/architecture/hosted-web/phase-1/renderer-boundaries", + "exitCode": 0, + "result": "4 files and 13 tests passed" + }, + { + "command": "pnpm lint:fast:files -- scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": "inherited Phase 0 test-tree diagnostics; no P1.1C-owned diagnostic", + "inheritedDiagnostics": [ + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts: TS7016, TS7031, TS18046, TS7031, TS7031", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts: TS7016", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts: TS2352" + ] + }, + { + "command": "pnpm exec prettier --check .codex-handoff/phase-01-p1-1c.json scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json", + "exitCode": 0 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git status --short", + "exitCode": 0, + "result": "only the 28 exact writable paths are untracked" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "exitCode": 0, + "result": "4 tests passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts", + "exitCode": 0, + "result": "3 tests passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "exitCode": 0, + "result": "3 tests passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "exitCode": 0, + "result": "3 tests passed" + } + ], + "unverifiedChecks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/features/team-lifecycle/contracts/listTeamLifecycleSummaries.test.ts", + "reason": "the P1.1D-owned second path does not exist in P1.S2; the combined check was not run" + } + ], + "scopeChecks": [ + { + "command": "git merge-base --is-ancestor 041b5c7c2d3225b7dc2eca9e9b7b71aa33217060 a0dc964e9a71b782b1bbad4769db62a691e50c97", + "exitCode": 0 + }, + { + "command": "git diff --name-only 041b5c7c2d3225b7dc2eca9e9b7b71aa33217060..a0dc964e9a71b782b1bbad4769db62a691e50c97", + "exitCode": 0, + "result": "exactly the eight contract-owned router documentation paths" + }, + { + "command": "git diff --name-only a0dc964e9a71b782b1bbad4769db62a691e50c97 && git ls-files --others --exclude-standard", + "exitCode": 0, + "result": "exactly the 28 changedPaths" + }, + { + "command": "git diff --exit-code a0dc964e9a71b782b1bbad4769db62a691e50c97 -- .", + "exitCode": 0, + "result": "no tracked path differs; every untracked path is P1.1C-owned" + } + ], + "safetyChecks": [ + { + "command": "rg -n -i '(api[_-]?key[[:space:]]*[:=]|access[_-]?token[[:space:]]*[:=]|refresh[_-]?token[[:space:]]*[:=]|password[[:space:]]*[:=]|bearer[[:space:]]+[A-Za-z0-9]|-----BEGIN [A-Z ]*PRIVATE KEY-----|/[U]sers/|/[h]ome/|/[r]oot/|claude[-]runtime|auth[_-]?payload|provider[_-]?payload|raw[_-]?(command|runtime)[_-]?body)' <28 exact changedPaths>", + "exitCode": 0, + "result": "one reviewed match: this handoff's safety-result wording; no leaked value or private path" + }, + { + "command": "file <28 exact changedPaths>", + "exitCode": 0, + "result": "all paths are ASCII JavaScript source or JSON text data; zero binaries" + } + ], + "negativeResultMatrix": [ + { + "id": "P1.NEG.CORE_SIDE_EFFECT", + "result": "passed", + "diagnostic": "phase1-core-side-effect-forbidden", + "positiveNeighbor": "verified" + }, + { + "id": "P1.NEG.HOSTED_ELECTRON_API", + "result": "passed", + "diagnostic": "phase1-hosted-electron-api-forbidden", + "positiveNeighbor": "verified" + }, + { + "id": "P1.NEG.IMPORT_FORBIDDEN", + "result": "passed", + "diagnostic": "phase1-core-import-forbidden", + "positiveNeighbor": "verified" + }, + { + "id": "P1.NEG.LEGACY_GOD_DTO", + "result": "fixture-scanner-half-passed", + "diagnostic": "phase1-legacy-god-dto-forbidden", + "positiveNeighbor": "unverified-P1.1D-owned" + }, + { + "id": "P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1", + "result": "fixture-scanner-half-passed", + "diagnostic": "phase1-filesystem-adapter-forbidden", + "positiveNeighbor": "unverified-P1.1D-owned" + }, + { + "id": "P1.NEG.PARITY_DRIFT", + "result": "passed", + "diagnostic": "phase1-parity-reference-drift", + "positiveNeighbor": "verified" + }, + { + "id": "P1.NEG.PATH_SECRET_LEAK", + "result": "passed", + "diagnostic": "phase1-path-secret-leak", + "positiveNeighbor": "verified" + }, + { + "id": "P1.NEG.PRODUCTION_ADAPTER_MOUNT", + "result": "passed", + "diagnostic": "phase1-test-adapter-production-import", + "positiveNeighbor": "verified-on-eight-frozen-production-boundaries" + }, + { + "id": "P1.NEG.RATCHET_REGRESSION", + "result": "passed", + "diagnostic": "phase1-ratchet-regression", + "positiveNeighbor": "verified" + } + ], + "fixtureHashes": { + "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json": "1fc62ffb444c8ebb1f3b7d4ad35303d7c501539ab8cac4e220b88972bc6e46d5", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json": "98a99c4f03af0a407b979b3f606bdfe2d55ba2c544e330c95e51312106fcec07", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json": "2c8378bcd7e928e9ab2fb77f46cdda897e6c350bf130bf376123de961458d2b7", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json": "95f032d6a434a4cd4bbda3d701beb4a99a1657f8ad4e4ed5e3d3ddeb70aacb8e", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json": "f6f582303ffae9919abc2e7b30f794de609808fa69b76101e8d89bc879c9baef", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json": "4aec77f80650895ec7e81dc900c00b17b0fc1c38e44c81a347e40f035c88f3db", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json": "272ac470b7b843018a94d700f40336b4536d6f373839cc9fa12261c27d2083d8", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json": "f4534d45b0523ec16d713be97e94c698804092e153de242e093500eaa422255a", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json": "40e57bfb5af87ff6a1dd019f2ffb8058f09c5e54c88546e971f97a5316425d4b", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json": "e37e636ca979fec6e8cb5372da01e9953674d7b30c772be2c707dc0bc5463247", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json": "beaabfd29b6a7447403b3054a1e7a08247410e21f65be4563f543144bc3d9ea4" + }, + "patchManifest": { + "phaseStartSha": "a0dc964e9a71b782b1bbad4769db62a691e50c97", + "algorithm": "concatenate git diff --binary --no-index -- /dev/null for the 27 ordered non-handoff changedPaths, then SHA-256", + "computedBeforeHandoffCreation": true, + "selfHashOmitted": true, + "sha256": "1beb4adc55d879d0089140f57c3ce3f7a92647a1c3021b8f901879f3d6adb1e1", + "changedPathSha256": { + "scripts/hosted-web/phase-1/check-feature-dependencies.ts": "55da0d4482be98b42b3593aecb33a829ddd20107f3c89001ee1fe7d1860a74bb", + "scripts/hosted-web/phase-1/check-parity-references.ts": "b29a2152c9e0cf106d7e575ad0d8e8e2ffe877f11e0899d95b21d75d0105bec0", + "scripts/hosted-web/phase-1/check-renderer-boundaries.ts": "d4960be8879a90d7e1b2634c39bcdb064fb6dbcb4587bd8f8fc1c5e2456404a3", + "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts": "b71bed9df2f99612d0f1aa14ae690eab9976539f0b1c8d5690c7c6bf9c9752a1", + "test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts": "92dff015dccca5f0f9d2b743d701f88c496a99b71e5baf0221b27d4e574435dc", + "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts": "345d9946c526e776ef93dbce9dfabfda2a1c38f413b7a2a1f72180b4a29b186a", + "test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts": "5ecc201ede7104e77e9fba7689ef92f0ef71bda4ab7cf855b72640bed5bc90c9", + "test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts": "de5695f3bd825b2fa169434a0423f6488fbe3d8aa01969acad5b0f83cde3f329", + "test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts": "6276cc61248bc20db71bf928ac0cae142374507db2d218205aa37f7ff0f659e6", + "test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts": "0ca26159a5ea6a2dc4d57190322743c3d83f93de5d1c75ec412aec85080ba419", + "test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts": "754c40dea9b2683aebd9d6e22b147d4f16194642f2b040aa22f3778309e5b271", + "test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts": "6aa2a176173a436f1c8a180cd7d4f01799b5089701d1e6c29151bd7244afc117", + "test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts": "28f7817de7251ef9e6d11a7d6def5625a0efa256ad6b4d81c1d2ae70f62f4b62", + "test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts": "5afcde69d1ad33c6ecf1a7031e4e5a7d5b8e5870253092b20b86f8abced7edbc", + "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts": "5e85a09294bef3c28278f04ea1e4896dacbd14fe438cd0a40b99781570f212c1", + "test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts": "1eabeaae77ba5a6b59d2e6376263726c2b27e5f55e01faed021ef25c3e144e59", + "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json": "1fc62ffb444c8ebb1f3b7d4ad35303d7c501539ab8cac4e220b88972bc6e46d5", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json": "98a99c4f03af0a407b979b3f606bdfe2d55ba2c544e330c95e51312106fcec07", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json": "2c8378bcd7e928e9ab2fb77f46cdda897e6c350bf130bf376123de961458d2b7", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json": "95f032d6a434a4cd4bbda3d701beb4a99a1657f8ad4e4ed5e3d3ddeb70aacb8e", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json": "f6f582303ffae9919abc2e7b30f794de609808fa69b76101e8d89bc879c9baef", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json": "4aec77f80650895ec7e81dc900c00b17b0fc1c38e44c81a347e40f035c88f3db", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json": "272ac470b7b843018a94d700f40336b4536d6f373839cc9fa12261c27d2083d8", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json": "f4534d45b0523ec16d713be97e94c698804092e153de242e093500eaa422255a", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json": "40e57bfb5af87ff6a1dd019f2ffb8058f09c5e54c88546e971f97a5316425d4b", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json": "e37e636ca979fec6e8cb5372da01e9953674d7b30c772be2c707dc0bc5463247", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json": "beaabfd29b6a7447403b3054a1e7a08247410e21f65be4563f543144bc3d9ea4" + } + }, + "scopeProof": { + "ownedPathCount": 28, + "changedPathListSha256": "c171c9d8fe8b44c2cda1eac9f9a2d3b4cefd5535757ce768105cf33d9f51c2a3", + "ownedPathsMatchBaseBeforeWork": true, + "nonOwnedPathsMatchPhaseStartSha": true, + "phaseStartDiffFromBaseIsRouterDocumentationOnly": true, + "controllerAdmission": "worker-start-v1 admission supplied externally; no production registration was performed", + "changedAndUntrackedSafetyScan": "reviewed scanner denylist terms only; no secret, credential, private/home/real-project path, raw command/runtime body, or binary artifact", + "p1s3Status": "blocked" + }, + "unverifiedClaims": [ + "P1.1D list use-case semantics or transport-shaped adapters", + "P1.1D-owned positive neighbor for P1.NEG.LEGACY_GOD_DTO", + "P1.1D-owned positive neighbor for P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1", + "P1.NEG.SEMANTIC_OUTCOME future feature conformance", + "production isolation beyond the eight frozen boundaries asserted by the owned scanner", + "P1.R1 acceptance or admission", + "controller integration of either P1.S2 producer", + "complete Phase 1 or production hosted behavior" + ], + "blockers": [ + { + "class": "base_failure", + "reproducer": "pnpm typecheck", + "affectedEvidenceIds": ["P1.1C.CONFORMANCE", "P1.1C.RATCHETS"], + "unrelatedLaneCanContinue": true, + "recommendedControllerAction": "Preserve the inherited Phase 0 typecheck diagnostics as a separately owned baseline finding; do not widen P1.1C." + } + ], + "adrRecommendations": [], + "estimateBuckets": [], + "nextAction": "Wait for the independent P1.1B handoff and a separately authorized P1.R1 router decision; do not start review, P1.1D, integration, or production work." +} diff --git a/.codex-handoff/phase-01-p1-1d.json b/.codex-handoff/phase-01-p1-1d.json new file mode 100644 index 0000000000..dcc6508cbe --- /dev/null +++ b/.codex-handoff/phase-01-p1-1d.json @@ -0,0 +1,375 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "laneId": "p1-1d-shadowed-map-remediation", + "packetRevision": "phase-01-p1-1d-shadowed-map-remediation-r4", + "mode": "implementation-remediation", + "baseSha": "1b37afb02bec25a1f08432d733595b553101ecab", + "canonicalSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "planBundleCommit": "3405da177b040c65caad10ef2df4d4f4338feed0", + "phaseStartSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "headSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "producerJob": "agent-teams-hosted-web-refactor-p1-1d-shadowed-map-remediation-producer-v17-r4", + "status": "verified", + "admission": { + "operation": "codex_goal_project_refill_worker", + "workerRole": "producer", + "preStartAdmission": { + "mode": "serial-builtin", + "contract": { + "kind": "worker-launch", + "format": 1, + "canonicalSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "baseSha": "1b37afb02bec25a1f08432d733595b553101ecab", + "phaseStartSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "packetRevision": "phase-01-p1-1d-shadowed-map-remediation-r4", + "controllerPacket": "docs/hosted-web-phases/phase-01/controller-packet.md", + "lanePacket": "docs/hosted-web-phases/phase-01/lanes/p1-1d-additive-response-remediation.md", + "phaseId": "phase-01", + "laneId": "p1-1d-shadowed-map-remediation", + "inputPatchHash": "521d8bab2ed7bc4334b38a5786dd5685f5e4f033c3962cab566f9ab3b60d0000", + "reviewKind": "implementation" + } + } + }, + "inputProvenance": { + "reviewedOutputId": "693d79c9314c46b9ac0ae13c8c62cb7951461fb7d335ec426119fc8a86a23c91", + "materializedArtifact": "output.patch", + "inputPatchHash": "521d8bab2ed7bc4334b38a5786dd5685f5e4f033c3962cab566f9ab3b60d0000", + "runtimeNinePathCarrierSha256": "1f9c6a2a28e5540c61d1395bc51a34a7c0db31855bae575abc9582f839118b49", + "inputEightPathSemanticReconstructionSha256": "fa46617652b072e887563f5a751f7bd0260e0e1d4fb96b628badea91ea7ae9d6", + "rolesKeptDistinct": true, + "runtimeMaterializationVerified": true + }, + "bindingRejectedReview": { + "strictResultSha256": "b8dca625e5eedfc457fd9908a7c0f41489db1dc784c98b52598a1e26504dc895", + "auditedAttempt": "p1-1d-review-v17-r2-formal-reject", + "disposition": "REJECT", + "findings": [ + { + "id": "P1-001", + "classification": "product", + "remediation": "replace input-owned array mapping with an indexed copy into a fresh plain array" + }, + { + "id": "P1-002", + "classification": "process-only-fail-closed", + "productChangeRequired": false + } + ], + "preservedAsReject": true + }, + "rejectedBroadR3Evidence": { + "reviewedOutputId": "5e1f1bcb6bfc076d59346b0fddc97db271800af4a9e17e85c604de0f2d046822", + "patchSha256": "8f74ea9cf5b3e187a75a36c0e4e90378752e52d5e1b06893d961811c54ab5dcf", + "auditedAttempt": "p1-1d-shadowed-map-router-v17-r3-scope-reject", + "copiedOrApplied": false, + "integrated": false + }, + "evidence": [ + { + "id": "P1.1D.TEAM_LIFECYCLE_READ_CONTRACT", + "paths": [ + "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "src/features/team-lifecycle/contracts/index.ts", + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts" + ], + "proofLevel": "target_verified" + }, + { + "id": "P1.1D.TEAM_LIFECYCLE_READ_USE_CASE", + "paths": [ + "src/features/team-lifecycle/core/application/ListTeamLifecycle.ts", + "src/features/team-lifecycle/core/application/index.ts", + "src/features/team-lifecycle/index.ts", + "test/features/team-lifecycle/core/ListTeamLifecycle.test.ts" + ], + "proofLevel": "target_verified" + }, + { + "id": "P1.1D.TEAM_LIFECYCLE_SEMANTIC_PROOF", + "paths": [ + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts" + ], + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "src/features/team-lifecycle/contracts/index.ts", + "src/features/team-lifecycle/core/application/ListTeamLifecycle.ts", + "src/features/team-lifecycle/core/application/index.ts", + "src/features/team-lifecycle/index.ts", + "test/features/team-lifecycle/core/ListTeamLifecycle.test.ts", + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + ".codex-handoff/phase-01-p1-1d.json" + ], + "semanticEditPaths": [ + "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + ".codex-handoff/phase-01-p1-1d.json" + ], + "bytePreservationProofs": [ + { + "path": "src/features/team-lifecycle/contracts/index.ts", + "reviewedCarrierSha256": "6ce70ee187f8b8da58e2d1722b5de13efade6da536f5186cb2aaf5f5ed232bf3", + "finalSha256": "6ce70ee187f8b8da58e2d1722b5de13efade6da536f5186cb2aaf5f5ed232bf3", + "bytePreserved": true + }, + { + "path": "src/features/team-lifecycle/core/application/ListTeamLifecycle.ts", + "reviewedCarrierSha256": "1e6e72911615af3207e04f48f9abf16c57f262abf1ffdcd33e72e66662b9c435", + "finalSha256": "1e6e72911615af3207e04f48f9abf16c57f262abf1ffdcd33e72e66662b9c435", + "bytePreserved": true + }, + { + "path": "src/features/team-lifecycle/core/application/index.ts", + "reviewedCarrierSha256": "ed348a14f02aa070ae6437aef5c0b4932fd7bfa2f2cd43004b86d8c2da255bdd", + "finalSha256": "ed348a14f02aa070ae6437aef5c0b4932fd7bfa2f2cd43004b86d8c2da255bdd", + "bytePreserved": true + }, + { + "path": "src/features/team-lifecycle/index.ts", + "reviewedCarrierSha256": "beff7b458a9b9eafe247c35f1a449f083696376cb83e2abda3e73d917212b03c", + "finalSha256": "beff7b458a9b9eafe247c35f1a449f083696376cb83e2abda3e73d917212b03c", + "bytePreserved": true + }, + { + "path": "test/features/team-lifecycle/core/ListTeamLifecycle.test.ts", + "reviewedCarrierSha256": "d17fa39e0dce56c6f80a2f98d36ec368168f7c29df84238ca4811b128b06fa66", + "finalSha256": "d17fa39e0dce56c6f80a2f98d36ec368168f7c29df84238ca4811b128b06fa66", + "bytePreserved": true + }, + { + "path": "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + "reviewedCarrierSha256": "2f7d971aa6743f3980f60c1693fe97513cdb5d07831ebc91a8739153c58ffe29", + "finalSha256": "2f7d971aa6743f3980f60c1693fe97513cdb5d07831ebc91a8739153c58ffe29", + "bytePreserved": true + } + ], + "semanticEditHashes": [ + { + "path": "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "reviewedCarrierSha256": "5e972fd10bed1df161f324076a727ccb4ccb24fb6b950a87d1ec48a917363e69", + "finalSha256": "9407de573f0fdcabf4cf77fd5faf4b2fa229e4cff7d34b25096d6f69cd2a3df8" + }, + { + "path": "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "reviewedCarrierSha256": "32bf371bae10970dea66abc8a45e56c1fc6222d844413912c974d57ef5353b09", + "finalSha256": "1e16e6a0fdd8bb559ae5c6d5d3973b6ca9d85ada51e18d52c8620354994b36a5" + }, + { + "path": ".codex-handoff/phase-01-p1-1d.json", + "reviewedCarrierSha256": "d07a76b4a641dc4a0928e437823edd6b655a48ddd25ba13a8699f854993b2713", + "finalCanonicalSha256": "fd4331dbbabd6b0e11cb943e33ddaa4a7663b5b0dbe931c8867e99c84664b352", + "canonicalization": "sha256 of the final UTF-8 file bytes after replacing only this finalCanonicalSha256 value with 64 ASCII zeroes" + } + ], + "finalSemanticPatchManifest": { + "algorithm": "sha256", + "sha256": "3280cda7b2169d7ca59e6f0441c09b10b752d3c211f64741eaf2222484f9d403", + "pathOrder": [ + "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "src/features/team-lifecycle/contracts/index.ts", + "src/features/team-lifecycle/core/application/ListTeamLifecycle.ts", + "src/features/team-lifecycle/core/application/index.ts", + "src/features/team-lifecycle/index.ts", + "test/features/team-lifecycle/core/ListTeamLifecycle.test.ts", + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts" + ], + "reconstruction": "after proving every path is absent at phaseStartSha, concatenate git diff --binary --full-index --no-index output from /dev/null to each path in exact pathOrder; sha256", + "handoffExcludedFromSelfHash": true + }, + "checks": [ + { + "command": "pnpm exec vitest run test/features/team-lifecycle/core/ListTeamLifecycle.test.ts", + "exitCode": 0, + "testFiles": "1/1 passed", + "tests": "6/6 passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "exitCode": 0, + "testFiles": "1/1 passed", + "tests": "11/11 passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + "exitCode": 0, + "testFiles": "1/1 passed", + "tests": "5/5 passed" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "exitCode": 0, + "testFiles": "2/2 passed", + "tests": "7/7 passed" + }, + { + "command": "pnpm lint:fast:files -- src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/index.ts src/features/team-lifecycle/core/application/ListTeamLifecycle.ts src/features/team-lifecycle/core/application/index.ts src/features/team-lifecycle/index.ts test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + "exitCode": 0, + "matchedFiles": 8 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": "exact accepted inherited Phase 0 diagnostics only", + "diagnosticCount": 7, + "diagnosticFileCount": 3, + "diagnostics": [ + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7016", + "location": "25:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "66:31" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS18046", + "location": "117:68" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "413:48" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "733:10" + }, + { + "path": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "code": "TS7016", + "location": "12:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "code": "TS2352", + "location": "162:44" + } + ], + "inheritedDiagnosticsUnchanged": true, + "ownedPathDiagnosticCount": 0 + }, + { + "command": "pnpm exec prettier --check src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/index.ts src/features/team-lifecycle/core/application/ListTeamLifecycle.ts src/features/team-lifecycle/core/application/index.ts src/features/team-lifecycle/index.ts test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts .codex-handoff/phase-01-p1-1d.json", + "exitCode": 0, + "matchedFiles": 9 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git status --short", + "exitCode": 0, + "statusEntryCount": 4, + "resolvedChangedPathCount": 9, + "stagedPathCount": 0 + } + ], + "negativeResultMatrix": [ + { + "id": "P1.NEG.SEMANTIC_OUTCOME", + "diagnostic": "phase1-semantic-outcome-drift", + "negativeResult": "rejected", + "positiveNeighborResult": "all ten immutable fixture outcomes preserved" + }, + { + "id": "P1.NEG.LEGACY_GOD_DTO", + "diagnostic": "phase1-legacy-god-dto-forbidden", + "negativeResult": "rejected", + "positiveNeighborResult": "narrow feature-owned contract accepted" + }, + { + "id": "P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1", + "diagnostic": "phase1-filesystem-adapter-forbidden", + "negativeResult": "rejected", + "positiveNeighborResult": "pure injected source port and test-owned in-memory value accepted" + } + ], + "shadowedMapProof": { + "validatedLengthCapturedOnce": true, + "maximumAcceptedLength": 1000, + "freshPlainArrayAllocated": true, + "everyOutputIndexOwned": true, + "sparseInputsRejectedAtEveryTestedPosition": true, + "everyDenseInputIndexReadExactlyOnce": true, + "parseItemCalledForEveryCopiedElement": true, + "inputOwnedMapDispatched": false, + "inputOwnedIteratorDispatched": false, + "inputOwnedConstructorDispatched": false, + "inputOwnedSpeciesDispatched": false, + "deterministicOrderingRunsOnlyOnTrustedArray": true, + "frozenArraysAreTrusted": true + }, + "preservedBehavior": { + "strictTopLevelAndNestedRequests": true, + "sameVersionAdditiveResponsesValidatedAndProjected": true, + "safeKnownFieldOnlyFrozenProjections": true, + "allTenFixtureOutcomes": true, + "transportNeutrality": true, + "sourceCallBehavior": true, + "negativeDiagnostics": true, + "publicBoundaries": true + }, + "ownership": { + "expectedChangedPathCount": 9, + "actualChangedPathCount": 9, + "semanticEditPathCount": 3, + "bytePreservedPathCount": 6, + "onlyOwnedPaths": true, + "stagedPaths": 0 + }, + "provenance": { + "headMatchesCanonical": true, + "baseIsAncestorOfCanonical": true, + "runtimeBindingsMatchPacket": true, + "carrierRolesKeptDistinct": true, + "rejectedR2Preserved": true, + "rejectedR3CopiedOrApplied": false + }, + "safety": { + "highSignalLexicalMatches": 0, + "credentialOrSecretValues": 0, + "authOrProviderPayloadValues": 0, + "privateOrRealProjectPathValues": 0, + "rawCommandOrRuntimePayloadValues": 0, + "binaryFiles": 0, + "textFiles": 9 + }, + "selfReview": { + "authorizedSemanticPathsOnly": true, + "sixCarrierPathsBytePreserved": true, + "shadowedMapFindingRemediated": true, + "scopeExpansion": false, + "independentExactReadReviewCompleted": false + }, + "findings": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "unverifiedClaims": [ + "fresh independent exact-read acceptance", + "integration", + "IPC/HTTP adapters", + "preload/renderer behavior", + "filesystem/runtime integration", + "production mount", + "Phase 1 completion", + "Phase 2+" + ], + "blockers": [], + "reviewRequirement": "fresh independent exact-read reviewer", + "nextAction": "review", + "terminalState": "HOLD" +} diff --git a/.codex-handoff/phase-01-p1-f.json b/.codex-handoff/phase-01-p1-f.json new file mode 100644 index 0000000000..0d7b2a4ebc --- /dev/null +++ b/.codex-handoff/phase-01-p1-f.json @@ -0,0 +1,766 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.F", + "laneId": "p1-f-freeze", + "packetRevision": "phase-01-p1-f-freeze-r3", + "routerRevision": "phase-01-p1-f-environment-router-r3", + "status": "verified", + "disposition": "ACCEPT", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P1.F.FREEZE", + "path": "docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md", + "proofLevel": "target_verified" + }, + { + "id": "P1.F.PHASE_EXIT", + "path": "docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md", + "proofLevel": "target_verified" + } + ], + "evidenceIds": ["P1.F.FREEZE", "P1.F.PHASE_EXIT"], + "changedPaths": [ + ".codex-handoff/phase-01-p1-f.json", + "docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md" + ], + "changedPathCount": 2, + "integrationPathCount": 2, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "reviewer": { + "role": "fresh-independent-phase-1-milestone-freeze-reviewer", + "model": "gpt-5.6-sol", + "reasoningEffort": "xhigh", + "serviceTier": "default", + "fastMode": false, + "independentOfRouterAuthorAndReviewer": true, + "independentOfAllP1IActors": true, + "independentOfAllEarlierPhase1ProducersAndReviewers": true, + "subagentsOrAdditionalReviewersUsed": 0 + }, + "authority": { + "postRouterIntegrationAuthoritySha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "expectedSourceCommit": "f13b7f886ccd2379674506eeecf5fb077495601e", + "baseSha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "canonicalSha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "planBundleCommit": "f13b7f886ccd2379674506eeecf5fb077495601e", + "phaseStartSha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "headSha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "routerAuthoringBaseSha": "69c4219b7ce3c7ad99e469ecd537a42e4bb4d2b5", + "acceptedTrueMergeSha": "20706bd067ce5ccbf13697700411904faa2a00c8", + "acceptedP1IIntegrationSha": "134f64f0c5c7bbbab0552eddf08df1508118f4bb", + "canonicalMergeSecondParentSha": "6bf43f140878f8b79f7ee17349bd21b177df901d", + "localHeadEquality": true, + "routerBaseAncestor": true, + "initialWorktreeClean": true, + "remoteAttestation": { + "schemaVersion": 1, + "kind": "root-broker-remote-attestation", + "sha256": "d547dd6ba6419ba0559ce5ed8c337681f436af48f2a8c3dd182ebb3372df5016", + "command": "git ls-remote origin refs/heads/refactor/hosted-web-feature-boundaries", + "exitCode": 0, + "exactOutput": "f13b7f886ccd2379674506eeecf5fb077495601e\trefs/heads/refactor/hosted-web-feature-boundaries", + "remote": "origin", + "ref": "refs/heads/refactor/hosted-web-feature-boundaries", + "observedSha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "localHeadSha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "brokerReturnedSha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "observedAt": "2026-07-16T06:03:04Z", + "rootActorProvenance": "root hosted-web throughput watchdog", + "brokerCaptureProvenance": "read-only SSH diagnostic via codex-workers-eu-01", + "worktreeStatusBytes": 0, + "contentHashRecomputed": true, + "schemaAndAuthorityBindingValid": true, + "freshAtAdmission": true + } + }, + "mergeProof": { + "p1iIntegrationRange": "134f64f0c5c7bbbab0552eddf08df1508118f4bb^..134f64f0c5c7bbbab0552eddf08df1508118f4bb", + "exactP1IIntegrationPaths": [ + ".codex-handoff/phase-01-p1-i.json", + "docs/research/hosted-web/phase-1/decision-register.json", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "docs/research/hosted-web/phase-1/evidence-index.json", + "docs/research/hosted-web/phase-1/integration-report.json" + ], + "acceptedTrueMergeParentCount": 2, + "acceptedTrueMergeParents": [ + "134f64f0c5c7bbbab0552eddf08df1508118f4bb", + "6bf43f140878f8b79f7ee17349bd21b177df901d" + ], + "firstParentEqualsAcceptedP1IIntegrationSha": true, + "bothParentsAreAncestors": true, + "notSquashOneParentOctopusOrReversed": true, + "secondParentDiffRole": "accumulated-current-base-history-never-exact-p1i-proof", + "secondParentDiffPathCount": 327, + "secondParentDiffPathListSha256": "33464213cc50bc3d53dd33b340ac64c417cbde320c98f85acb47c2145ce0cd3b", + "routerDiffPathCount": 7, + "routerDiffPaths": [ + "docs/hosted-web-phases/EXECUTION_INDEX.json", + "docs/hosted-web-phases/README.md", + "docs/hosted-web-phases/START_HERE.md", + "docs/hosted-web-phases/phase-01/README.md", + "docs/hosted-web-phases/phase-01/controller-packet.md", + "docs/hosted-web-phases/phase-01/execution-dag.md", + "docs/hosted-web-phases/phase-01/lanes/p1-f-freeze.md" + ], + "routerDiffMatchesExclusiveOwnership": true, + "p1iOrProductPathInRouterDiff": false + }, + "phase1FreezeManifest": { + "pathCount": 74, + "distinctPathCount": 74, + "regularFileCount": 74, + "utf8PathCount": 74, + "symlinkPathCount": 0, + "nulBytePathCount": 0, + "orderedPathArraySha256": "0e8e2b82125eb3b8e559f9fa439e8942e0eea89d75da4cccc35d75099e868223", + "orderedContentManifestSha256": "3eb215b1ded06d8cb43c267c51272f88b41eb34e2bed3046dc277f6081020d8f", + "first69PathAndHashMatches": 69, + "lastFivePathAndHashMatches": 5, + "contentHashes": [ + { + "path": "docs/research/hosted-web/phase-1/bootstrap/phase-start.json", + "sha256": "69b5424c27c61cddb013c32618117adbc8a5298a8bf0501ea784cd75a3326f40" + }, + { + "path": "docs/research/hosted-web/phase-1/bootstrap/packet-revision.json", + "sha256": "47044ef4dfc18fc245eaf8051e7fa29a9e82c2d7f9057ac55304f633827faa1f" + }, + { + "path": "docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json", + "sha256": "5ae7d32c2ca7c0e1d1c6f62ed47bc1d4fd960ea22f68bacfd9b2c5a1748d8ac0" + }, + { + "path": "docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json", + "sha256": "03e166cc87c1ca1ae1a8ed41cc7de76e2af02685951661267706f536f8fb5319" + }, + { + "path": "docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json", + "sha256": "35208784d336fb276a89be44a90a8e1de1ff6e5d3aa14587a01ebae05568605a" + }, + { + "path": "docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md", + "sha256": "31b179e9e50ac0bfa2bbb9b64aefcbdec26cff132655628ecab70122ff8f14f4" + }, + { + "path": ".codex-handoff/phase-01-p1-1a.json", + "sha256": "b8c3d99eed6b09a66ad7b2dff79f2b916af570b3674cae82971b048b70671a0d" + }, + { + "path": "src/shared/contracts/hosted/app-error.ts", + "sha256": "838c76670512e4178b9b7507e3ce34356a39e2f57a9761fd92583ccd78253820" + }, + { + "path": "src/shared/contracts/hosted/identifiers.ts", + "sha256": "87ddee58f597734adaaac645d1c3b9fb2ebcf05c2f0fd1ba39a1fe49b98d05ea" + }, + { + "path": "src/shared/contracts/hosted/index.ts", + "sha256": "4a8e23dfa61dd6c3aeaea811846d7154ca6217a8d93be931a5f1478691659857" + }, + { + "path": "src/shared/contracts/hosted/query-context.ts", + "sha256": "431bf6d87b54da2f7fbe2181a3a9e64bdfbc1d8c9c5a32a7973f72d4a9360277" + }, + { + "path": "src/shared/contracts/hosted/revision.ts", + "sha256": "3ae55b8d8ae9e92789313cd6c611ffbdaee4554a195e989de8d2e51bd6f958e6" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + "sha256": "52ee29acbca32a864b600ef3c833785fc11af382e515ec941fe0437ab3ccdf83" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json", + "sha256": "f5799f7437434d51346647f8e1c7525f79d154b7e84d1b2e27124de1d6c1169f" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "sha256": "b48ecb344888c0c85d3d35c33ec5bffe94922405a61323bf49f4515e658fc29f" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "sha256": "6441c8d6075ef568f4cf67355edb2aedf867bf073e3ce340c59127b715da046c" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/query-context.test.ts", + "sha256": "1dcf7503abf545ebb7f77c508698b8cf13f55dec7ac84191cdf64c9c6529b1ab" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "sha256": "608fd717e7eece76c6ea0f3fcc37af26d6047ff94da7e60a34eb4c5deb061e42" + }, + { + "path": ".codex-handoff/phase-01-p1-s1-schema-version-remediation.json", + "sha256": "13670cdbe3dc796e856d3fb7106f594397382fff132db2faa4fd53b24deaa788" + }, + { + "path": ".codex-handoff/phase-01-p1-1b.json", + "sha256": "d439023efd35695dcd338810841158ab4be4863ab5782ebbfbbe3ba4e45c3824" + }, + { + "path": "src/main/composition/hosted/routing/RouteCatalog.ts", + "sha256": "2b304771137dbe3b8047158771fdda2d8592d91c0687f4caa8bb88457d86e3fe" + }, + { + "path": "src/main/composition/hosted/routing/index.ts", + "sha256": "188ef3cbf87a96956ca03779bd74ddc7c46f21217172f057a8a3b0a5df75aa6a" + }, + { + "path": "src/main/composition/hosted/routing/route-types.ts", + "sha256": "53a8c83e6d42b9ee9d8a6b7a4a64b93ff1fe853b42d396c16dca6ef87dc0d878" + }, + { + "path": "test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts", + "sha256": "86713d834e4e3474fb13386ab05617aec6600f8c4eba353e7761bfb576579ab6" + }, + { + "path": "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "sha256": "d0d7a2728ac4478130fad0aadebdc5f6534f7ff83cc88d49ab0732e056afefa1" + }, + { + "path": "test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts", + "sha256": "3311c2781f3bdde1235b64163d34d3d19da7961d28109482a6c1a2ee7797a40e" + }, + { + "path": "test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts", + "sha256": "fde1f2b3737bb9fd8d3dbad44be2964249814374f1ef2a9b63a66af39279ffe5" + }, + { + "path": "test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts", + "sha256": "1c6eac046aa441e90b3a7818800ee7ef23c8925695deb8975dc75b6e035e7011" + }, + { + "path": ".codex-handoff/phase-01-p1-1c.json", + "sha256": "573db0ffff7b45edcce834d34da23e5f8f24aa6dab96dc06b7b0641eb9d991a2" + }, + { + "path": "scripts/hosted-web/phase-1/check-feature-dependencies.ts", + "sha256": "55da0d4482be98b42b3593aecb33a829ddd20107f3c89001ee1fe7d1860a74bb" + }, + { + "path": "scripts/hosted-web/phase-1/check-parity-references.ts", + "sha256": "b29a2152c9e0cf106d7e575ad0d8e8e2ffe877f11e0899d95b21d75d0105bec0" + }, + { + "path": "scripts/hosted-web/phase-1/check-renderer-boundaries.ts", + "sha256": "d4960be8879a90d7e1b2634c39bcdb064fb6dbcb4587bd8f8fc1c5e2456404a3" + }, + { + "path": "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "sha256": "b71bed9df2f99612d0f1aa14ae690eab9976539f0b1c8d5690c7c6bf9c9752a1" + }, + { + "path": "test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts", + "sha256": "92dff015dccca5f0f9d2b743d701f88c496a99b71e5baf0221b27d4e574435dc" + }, + { + "path": "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "sha256": "345d9946c526e776ef93dbce9dfabfda2a1c38f413b7a2a1f72180b4a29b186a" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts", + "sha256": "5ecc201ede7104e77e9fba7689ef92f0ef71bda4ab7cf855b72640bed5bc90c9" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts", + "sha256": "de5695f3bd825b2fa169434a0423f6488fbe3d8aa01969acad5b0f83cde3f329" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts", + "sha256": "6276cc61248bc20db71bf928ac0cae142374507db2d218205aa37f7ff0f659e6" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts", + "sha256": "0ca26159a5ea6a2dc4d57190322743c3d83f93de5d1c75ec412aec85080ba419" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts", + "sha256": "754c40dea9b2683aebd9d6e22b147d4f16194642f2b040aa22f3778309e5b271" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts", + "sha256": "6aa2a176173a436f1c8a180cd7d4f01799b5089701d1e6c29151bd7244afc117" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts", + "sha256": "28f7817de7251ef9e6d11a7d6def5625a0efa256ad6b4d81c1d2ae70f62f4b62" + }, + { + "path": "test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts", + "sha256": "5afcde69d1ad33c6ecf1a7031e4e5a7d5b8e5870253092b20b86f8abced7edbc" + }, + { + "path": "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "sha256": "5e85a09294bef3c28278f04ea1e4896dacbd14fe438cd0a40b99781570f212c1" + }, + { + "path": "test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts", + "sha256": "1eabeaae77ba5a6b59d2e6376263726c2b27e5f55e01faed021ef25c3e144e59" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json", + "sha256": "1fc62ffb444c8ebb1f3b7d4ad35303d7c501539ab8cac4e220b88972bc6e46d5" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json", + "sha256": "98a99c4f03af0a407b979b3f606bdfe2d55ba2c544e330c95e51312106fcec07" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json", + "sha256": "2c8378bcd7e928e9ab2fb77f46cdda897e6c350bf130bf376123de961458d2b7" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json", + "sha256": "95f032d6a434a4cd4bbda3d701beb4a99a1657f8ad4e4ed5e3d3ddeb70aacb8e" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json", + "sha256": "f6f582303ffae9919abc2e7b30f794de609808fa69b76101e8d89bc879c9baef" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json", + "sha256": "4aec77f80650895ec7e81dc900c00b17b0fc1c38e44c81a347e40f035c88f3db" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json", + "sha256": "272ac470b7b843018a94d700f40336b4536d6f373839cc9fa12261c27d2083d8" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json", + "sha256": "f4534d45b0523ec16d713be97e94c698804092e153de242e093500eaa422255a" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json", + "sha256": "40e57bfb5af87ff6a1dd019f2ffb8058f09c5e54c88546e971f97a5316425d4b" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json", + "sha256": "e37e636ca979fec6e8cb5372da01e9953674d7b30c772be2c707dc0bc5463247" + }, + { + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json", + "sha256": "beaabfd29b6a7447403b3054a1e7a08247410e21f65be4563f543144bc3d9ea4" + }, + { + "path": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "sha256": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71" + }, + { + "path": ".codex-handoff/phase-01-p1-1d.json", + "sha256": "023444ae516dc2f0c6d37c0c57a21c6203cfa8f80f775dbe44b90ed7eb758ce7" + }, + { + "path": "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "sha256": "9407de573f0fdcabf4cf77fd5faf4b2fa229e4cff7d34b25096d6f69cd2a3df8" + }, + { + "path": "src/features/team-lifecycle/contracts/index.ts", + "sha256": "6ce70ee187f8b8da58e2d1722b5de13efade6da536f5186cb2aaf5f5ed232bf3" + }, + { + "path": "src/features/team-lifecycle/core/application/ListTeamLifecycle.ts", + "sha256": "1e6e72911615af3207e04f48f9abf16c57f262abf1ffdcd33e72e66662b9c435" + }, + { + "path": "src/features/team-lifecycle/core/application/index.ts", + "sha256": "ed348a14f02aa070ae6437aef5c0b4932fd7bfa2f2cd43004b86d8c2da255bdd" + }, + { + "path": "src/features/team-lifecycle/index.ts", + "sha256": "beff7b458a9b9eafe247c35f1a449f083696376cb83e2abda3e73d917212b03c" + }, + { + "path": "test/features/team-lifecycle/core/ListTeamLifecycle.test.ts", + "sha256": "d17fa39e0dce56c6f80a2f98d36ec368168f7c29df84238ca4811b128b06fa66" + }, + { + "path": "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "sha256": "1e16e6a0fdd8bb559ae5c6d5d3973b6ca9d85ada51e18d52c8620354994b36a5" + }, + { + "path": "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + "sha256": "2f7d971aa6743f3980f60c1693fe97513cdb5d07831ebc91a8739153c58ffe29" + }, + { + "path": ".codex-handoff/phase-01-p1-r2.json", + "sha256": "bbf61b20dad577af7536f78f108116531acd17925ca9a17140c4d9fadb7d038c" + }, + { + "path": "docs/research/hosted-web/phase-1/reviews/list-semantics.md", + "sha256": "de4b1e4fc0f633a40703e6af106bcdfcd2ab3a7d524b28f19df376a4052cdc70" + }, + { + "path": ".codex-handoff/phase-01-p1-i-lint-remediation.json", + "sha256": "df8d0a64711941e23d59e96d90518dc0b8660eb8242c50a118a1b42edf259182" + }, + { + "path": ".codex-handoff/phase-01-p1-i.json", + "sha256": "be6ca8a01fba06871b9246ae2baaf230e7b95222bb0da3eec8548016c5639903" + }, + { + "path": "docs/research/hosted-web/phase-1/decision-register.json", + "sha256": "1d275a95a189d7840a6d75591d90c138b0ec5399747db41794697de0cde32ba9" + }, + { + "path": "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "sha256": "941c58195b9955b9807b896aedf7f46ea1a4ed455dc6713241ffefb074405328" + }, + { + "path": "docs/research/hosted-web/phase-1/evidence-index.json", + "sha256": "07a17cb6674916f65713e337f15deeb3f5405d36fbcccbbcdada3b5895724590" + }, + { + "path": "docs/research/hosted-web/phase-1/integration-report.json", + "sha256": "a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f" + } + ], + "frozenP1IOutputHashes": { + ".codex-handoff/phase-01-p1-i.json": "be6ca8a01fba06871b9246ae2baaf230e7b95222bb0da3eec8548016c5639903", + "docs/research/hosted-web/phase-1/decision-register.json": "1d275a95a189d7840a6d75591d90c138b0ec5399747db41794697de0cde32ba9", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json": "941c58195b9955b9807b896aedf7f46ea1a4ed455dc6713241ffefb074405328", + "docs/research/hosted-web/phase-1/evidence-index.json": "07a17cb6674916f65713e337f15deeb3f5405d36fbcccbbcdada3b5895724590", + "docs/research/hosted-web/phase-1/integration-report.json": "a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f" + }, + "frozenP1IBytesIdenticalAtIntegrationMergeAndHead": true + }, + "evidenceLifecycle": { + "requiredPhase1EvidenceIds": [ + "P1.S0.BASELINE", + "P1.S0.BOOTSTRAP", + "P1.1A.KERNEL", + "P1.1A.VERSION", + "P1.1B.ROUTES", + "P1.1B.CAPABILITIES", + "P1.1C.CONFORMANCE", + "P1.1C.RATCHETS", + "P1.R1.ARCH_REVIEW", + "P1.1D.TEAM_LIFECYCLE_READ_CONTRACT", + "P1.1D.TEAM_LIFECYCLE_READ_USE_CASE", + "P1.1D.TEAM_LIFECYCLE_SEMANTIC_PROOF", + "P1.R2.SEMANTIC_REVIEW", + "P1.NEG.RATCHET_REGRESSION" + ], + "additionalFrozenCatalogIds": ["P1.I.LINT.REMEDIATION", "P1.I.INTEGRATION", "P1.I.ROLLBACK"], + "catalogRowCount": 17, + "distinctCatalogRowCount": 17, + "unknownEvidenceIdCount": 0, + "allContentHashesCurrent": true, + "lifecycleRulesValid": true, + "frozenPendingFieldsMutated": false, + "p1fEvidenceIdsAddedToFrozenIndex": false + }, + "checks": [ + { + "command": "pnpm exec vitest run test/features/team-lifecycle test/architecture/hosted-web/phase-1", + "exitCode": 0, + "durationMs": 3863.25, + "toolVersion": "vitest 3.2.6; node v24.16.0; pnpm 10.33.4", + "testFiles": { + "passed": 13, + "total": 13 + }, + "tests": { + "passed": 60, + "total": 60 + } + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "exitCode": 0, + "durationMs": 2166.98, + "toolVersion": "vitest 3.2.6; node v24.16.0; pnpm 10.33.4", + "testFiles": { + "passed": 1, + "total": 1 + }, + "tests": { + "passed": 3, + "total": 3 + } + }, + { + "command": "node scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.mjs --mode milestone", + "exitCode": 1, + "durationMs": 115090.64, + "toolVersion": "node v24.16.0; TypeScript 7.0.2", + "processDisposition": "exited-after-child-spawn-failure", + "runnerErrorCode": "EPERM", + "runnerErrorMessage": "spawnSync /usr/local/bin/node EPERM", + "validLocalReport": false, + "evidenceSource": "root-attested-normalizer-after-recorded-sandbox-eperm" + }, + { + "command": "pnpm lint", + "exitCode": 0, + "durationMs": 610110.28, + "toolVersion": "ESLint 9.39.4; node v24.16.0; pnpm 10.33.4", + "errorCount": 0, + "warningCount": 3043 + }, + { + "command": "pnpm exec prettier --check \u0024{freeze_paths[@]}", + "exitCode": 0, + "durationMs": 3594.92, + "toolVersion": "Prettier 3.8.1; pnpm 10.33.4", + "matchedPathCount": 74 + }, + { + "command": "pnpm exec prettier --check .codex-handoff/phase-01-p1-f.json docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md", + "exitCode": 0, + "durationMs": 1271.38, + "toolVersion": "Prettier 3.8.1; pnpm 10.33.4", + "matchedPathCount": 2, + "completedAfterFinalBytes": true + } + ], + "normalizerEvidence": { + "boundedRootSubstitutionRuleApplied": true, + "localAttempt": { + "command": "node scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.mjs --mode milestone", + "attemptedAt": "2026-07-16T06:14:02.176133358Z", + "completedAt": "2026-07-16T06:15:57.266775779Z", + "processExitCode": 1, + "processDisposition": "exited-after-child-spawn-failure", + "errorCode": "EPERM", + "errorMessage": "spawnSync /usr/local/bin/node EPERM", + "validLocalReport": false + }, + "rootAttestedRecord": { + "source": "fresh immutable root admission input", + "exactCommandAttested": true, + "normalizerProcessDisposition": "exited", + "normalizerExitCode": 0, + "authoritySha": "f13b7f886ccd2379674506eeecf5fb077495601e", + "cleanWorktree": true, + "reportSha256": "2f0e7547b98f88117e606807750995667dcf8561b65da6c66477c25283ddcc25", + "reportHashRecomputed": true, + "compilerRawExitCode": 2, + "durationMs": 110692.24, + "passed": true, + "observedDiagnosticCount": 7, + "normalizedInheritedCount": 7, + "resolvedInheritedCount": 0, + "effectiveDiagnosticCount": 0, + "unexpectedDiagnosticCount": 0, + "unparsedOutputLineCount": 0, + "exactDiagnosticSetMatched": true, + "rootExecutionTimestampsAndProvenanceInspected": true, + "captureHashesInspected": true + }, + "genericRootSubstitutionUsed": false, + "networkEnabled": false, + "otherLocalChecksSubstituted": false + }, + "rollback": { + "bootstrapSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "acceptedPayloadSha": "20706bd067ce5ccbf13697700411904faa2a00c8", + "payloadPathCount": 54, + "distinctPayloadPathCount": 54, + "bootstrapAbsentPathCount": 54, + "pathManifestSha256": "d67e76fa0b63f51260bc6c3bdd59568a9115a730709f2508231df46da5ca880e", + "patchSha256": "bd0b8cce323c72e9104b620a899add44919c13f913d5fbbe169b6445d79ffb50", + "patchGenerationExitCode": 0, + "forwardApplyCheckExitCode": 0, + "forwardApplyExitCode": 0, + "forwardByteEqualPathCount": 54, + "reverseApplyCheckExitCode": 0, + "reverseApplyExitCode": 0, + "reverseAbsentPathCount": 54, + "scratchOnly": true, + "workspaceApply": false, + "cleanupExitCode": 0 + }, + "gateSummary": { + "required": 14, + "passed": 14, + "failed": 0, + "gates": [ + { + "id": "P1.GATE.PROVENANCE", + "status": "passed", + "currentProof": "f13b7f8 authority, fresh remote attestation, exact ancestry and manifests" + }, + { + "id": "P1.GATE.PREDECESSORS", + "status": "passed", + "currentProof": "accepted P1.I provenance, exact five integration range, true merge and frozen bytes" + }, + { + "id": "P1.GATE.SCOPE", + "status": "passed", + "currentProof": "exact-two P1.F output scope with no staged, tracked, P1.I, product or test change" + }, + { + "id": "P1.GATE.TESTS", + "status": "passed", + "currentProof": "current 13/13 files and 60/60 tests" + }, + { + "id": "P1.GATE.TYPECHECK", + "status": "passed", + "currentProof": "recorded local EPERM and eligible root-attested exact 7/0/0 normalizer result" + }, + { + "id": "P1.GATE.LINT", + "status": "passed", + "currentProof": "current full lint exit 0 with zero errors" + }, + { + "id": "P1.GATE.FORMAT", + "status": "passed", + "currentProof": "current exact-74 and exact-two pinned Prettier checks" + }, + { + "id": "P1.GATE.NEGATIVES", + "status": "passed", + "currentProof": "current 60-test architecture suite and exact frozen negative inventory" + }, + { + "id": "P1.GATE.RATCHET", + "status": "passed", + "currentProof": "current focused 1/1 file and 3/3 tests" + }, + { + "id": "P1.GATE.SECURITY", + "status": "passed", + "currentProof": "exact-76 classified scans, UTF-8, NUL, symlink, MIME and placeholder checks" + }, + { + "id": "P1.GATE.ROLLBACK", + "status": "passed", + "currentProof": "current exact-54 scratch-only forward equality and reverse absence" + }, + { + "id": "P1.GATE.ESTIMATE", + "status": "passed", + "currentProof": "69-input census and unique five-output P1.I actual/variance arithmetic revalidated" + }, + { + "id": "P1.GATE.DECISIONS", + "status": "passed", + "currentProof": "13 distinct decision rows and 14 ordered gate decisions revalidated" + }, + { + "id": "P1.GATE.EVIDENCE_FREEZE", + "status": "passed", + "currentProof": "74 current hashes, 17-row lifecycle, packet hashes and final self-review" + } + ] + }, + "validation": { + "candidatePathCount": 76, + "candidateDistinctPathCount": 76, + "jsonPathCount": 31, + "jsonParsePassed": true, + "duplicateKeyDetection": "parser-does-not-expose-duplicate-keys; required schemas validated structurally", + "p1iSchemaValidatedPathCount": 5, + "p1fSchemaValidatedPathCount": 2, + "markdownPathCount": 4, + "markdownLinksResolved": true, + "historicalP1ILaneSha256": "3f81d6e65f9848b6b3db593dda6eb87e5eeb7276af9e76d2fe79ba3fc6f094fe", + "controllerPacketSha256": "a39fe79dfc483018b6f798d781dadb70139cd27089eb72524b351eaf898118bd", + "lanePacketSha256": "9a8d4e6572a58ca557b0e2d630f4af9f556732daf82c51be27f2c212f0afe748", + "packetHashesMatchExecutionIndex": true, + "gitDiffCheckExitCode": 0, + "stagedPathCount": 0, + "trackedChangedPathCount": 0, + "untrackedPaths": [ + ".codex-handoff/phase-01-p1-f.json", + "docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md" + ], + "statusPathCount": 2, + "onlyDeclaredWriterPathsChanged": true, + "productOrTestPathChanged": false, + "p1iPathChanged": false, + "regularTextPathCount": 76, + "binaryPathCount": 0, + "symlinkPathCount": 0, + "nulBytePathCount": 0, + "validUtf8PathCount": 76, + "outputHashes": { + ".codex-handoff/phase-01-p1-f.json": null, + "docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md": null + }, + "selfHashPolicy": "raw output hashes are recomputed after final bytes and broker-captured; self-referential raw hashes are not embedded" + }, + "classifiedScans": { + "candidatePathCount": 76, + "secretCredential": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' \u0024{candidate_paths[@]}", + "exitCode": 0, + "observedMatchLineCount": 29, + "allMatchesClassified": true, + "classification": "scanner command/control language, governance uses of authorization, and synthetic fixture assertions only; no credential, secret, cookie, bearer, or auth payload" + }, + "provider": { + "command": "rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' \u0024{candidate_paths[@]}", + "exitCode": 0, + "observedMatchLineCount": 54, + "allMatchesClassified": true, + "classification": "reviewer profile, inherited diagnostic paths, scanner/control text, synthetic provider-status negative controls, and prohibited or unverified boundary language only; no provider or auth payload" + }, + "privatePath": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' \u0024{candidate_paths[@]}", + "exitCode": 0, + "observedMatchLineCount": 16, + "allMatchesClassified": true, + "classification": "scanner command/control language and explicit real-project prohibitions only; no private, home, temporary, task-temporary, or real-project path value" + }, + "placeholderAndRawBody": { + "command": "rg -n -i '(TODO|TBD|FIXME|XXX|<[^>]+>|raw[_ -]?(command|runtime)[_ -]?body|auth[_ -]?payload|provider[_ -]?payload)' \u0024{candidate_paths[@]}", + "exitCode": 0, + "observedMatchLineCount": 99, + "allMatchesClassified": true, + "classification": "TypeScript generic/comparison syntax, immutable historical command metavariables, a zero-valued todo baseline field, scanner/control text, and explicit payload/raw-body prohibitions only; no unresolved placeholder or raw sensitive body" + } + }, + "selfReview": { + "independenceConfirmed": true, + "exactAuthorityConfirmed": true, + "remoteAttestationCompletelyInspected": true, + "normalizerAttestationConditionallyInspected": true, + "boundedNormalizerRuleConfirmed": true, + "everySandboxCompatibleCheckIndependentlyExecuted": true, + "all74FrozenBytesConfirmed": true, + "allEvidenceAndGatesConfirmed": true, + "allCheckCountsConfirmed": true, + "rollbackAndCurrentBaseConfirmed": true, + "scanClassificationsConfirmed": true, + "exactTwoOwnershipConfirmed": true, + "noP1IProductOrTestChangeConfirmed": true, + "unsupportedClaimsAbsent": true, + "findingCountsConfirmed": true, + "completeOutputReread": true, + "completeDiffReread": true + }, + "phaseExit": { + "phase1ExitAccepted": true, + "phase2Blocked": true, + "p1fEvidenceIntegrated": false, + "p1fEvidencePushed": false, + "phase2AuthorityClaimed": false, + "successorLaunchClaimed": false + }, + "unverifiedClaims": [ + "broker integration or push of the two P1.F outputs", + "new clean remote equality after P1.F evidence integration", + "Phase 2 router authority", + "Phase 2 product or worker behavior", + "production hosted transport, auth, runtime, filesystem, preload or renderer behavior" + ], + "blockers": [], + "generatedAt": "2026-07-16T06:33:02Z", + "nextAction": "broker-integrate-freeze-evidence" +} diff --git a/.codex-handoff/phase-01-p1-i-format-remediation.json b/.codex-handoff/phase-01-p1-i-format-remediation.json new file mode 100644 index 0000000000..4aa79c2317 --- /dev/null +++ b/.codex-handoff/phase-01-p1-i-format-remediation.json @@ -0,0 +1,271 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.I.FORMAT.REMEDIATION", + "laneId": "p1-i-format-remediation", + "packetRevision": "phase-01-p1-i-integration-r3", + "routerRevision": "phase-01-p1-i-format-remediation-router-r1", + "evidenceId": "P1.I.FORMAT.REMEDIATION", + "baseSha": "d5e311ea24d2a4af96f1ac8deb02ef3c8c8a07ca", + "canonicalSha": "d5e311ea24d2a4af96f1ac8deb02ef3c8c8a07ca", + "planBundleCommit": "d5e311ea24d2a4af96f1ac8deb02ef3c8c8a07ca", + "phaseStartSha": "d5e311ea24d2a4af96f1ac8deb02ef3c8c8a07ca", + "headSha": "d5e311ea24d2a4af96f1ac8deb02ef3c8c8a07ca", + "status": "verified", + "changedPathCount": 2, + "semanticTokenChangeCount": 0, + "producerProfile": { + "model": "gpt-5.6-sol", + "reasoningEffort": "xhigh", + "serviceTier": "default", + "fastMode": false + }, + "evidence": [ + { + "id": "P1.I.FORMAT.REMEDIATION", + "path": ".codex-handoff/phase-01-p1-i-format-remediation.json", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + ".codex-handoff/phase-01-p1-i-format-remediation.json" + ], + "immutableRejectedAttempt": { + "job": "agent-teams-hosted-web-refactor-p1-i-integration-v17-r1", + "status": "BLOCKED", + "terminalState": "HOLD", + "patchSha256": "d94f8dfa6548427e007402e8771c469c8e661cd64de3a8728dec042a509aebbe", + "manifestSha256": "1b88a6e8e53199f0b1905d4f4c194525bcb86db185f0e4748acf60f69bb78f94", + "auditedRejectionLedgerPresent": true, + "requiredGateCount": 14, + "passedGateCount": 13, + "failingGateId": "P1.GATE.FORMAT", + "failingScopePathCount": 74, + "soleUnformattedPath": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "provenanceRole": "never-integrate-provenance-only", + "rejectedBytesReadAppliedCopiedOrMaterialized": false, + "blockedOutputPathCount": 5, + "blockedOutputsIntegratedOrReused": false + }, + "markdownFormatProof": { + "path": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "writerCommand": "pnpm exec prettier --write docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "writerCommandExitCode": 0, + "canonicalRawByteLength": 29700, + "canonicalRawSha256": "80d21ded01b4c040f5a571069aa71a72315e9e727bf33453b8ef5f0adc72aaf5", + "expectedFormattedByteLength": 33124, + "expectedFormattedSha256": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71", + "formattedOutputByteLength": 33124, + "formattedOutputSha256": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71", + "finalMarkdownByteLength": 33124, + "finalMarkdownSha256": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71", + "secondEvaluationSha256": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71", + "postWriteEqualsPrecomputedFormatterOutput": true, + "secondEvaluationByteIdentical": true, + "idempotent": true, + "nonHandoffPathSha256": { + "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71" + } + }, + "prettierProvenance": { + "version": "3.8.1", + "moduleEntry": "node_modules/.pnpm/prettier@3.8.1/node_modules/prettier/index.mjs", + "packageManifestPath": "node_modules/prettier/package.json", + "packageManifestSha256": "b73c6d16f392858c9bad2b4b4d2357c96773676d5769c5e6b700a32be7609f2e", + "configResolutionFilepath": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "configFile": ".prettierrc.json", + "configFileSha256": "189e9124cb5a8f057681a510daf74d6c2c5ea67f351524949263bb1bb532dcae", + "editorConfigResolutionEnabled": true, + "resolvedConfig": { + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "jsxSingleQuote": false, + "bracketSameLine": false, + "plugins": ["prettier-plugin-tailwindcss"], + "proseWrap": "preserve" + }, + "resolvedConfigSha256": "5b12bc7b92ca14e5c4a0f3ec15857bce0b23b6a4d5054c005a1cb6c9b131fd8e" + }, + "semanticEquivalenceProof": { + "algorithm": "phase1-markdown-semantic-token-v1", + "algorithmSteps": [ + "normalize CRLF and CR line endings to LF", + "retain fences and fenced-code content lines byte-for-byte", + "canonicalize only table-delimiter hyphen runs to three hyphens while retaining alignment colons and pipe structure", + "retain inline-code spans and HTML comment tokens byte-for-byte", + "emit all remaining ordered non-whitespace punctuation and text tokens unchanged", + "SHA-256 hash the UTF-8 JSON encoding of each ordered token array" + ], + "remainingTokenBoundary": "each non-whitespace Unicode code point; protected fenced lines, inline-code spans, and HTML comments are single ordered tokens", + "fencedCodeTokenEncoding": "UTF-8 JSON array of ordered opening, content, and closing lines", + "inlineCodeTokenEncoding": "UTF-8 JSON array of ordered exact backtick-span tokens", + "tableAlignmentEncoding": "UTF-8 JSON array of ordered rows of leadingColon/trailingColon boolean cell records", + "semanticTokens": { + "beforeSha256": "82c22568b54829aaebd59e49c167197513e91900a50f5145e306f04c568a20e4", + "afterSha256": "82c22568b54829aaebd59e49c167197513e91900a50f5145e306f04c568a20e4", + "beforeCount": 8628, + "afterCount": 8628, + "equal": true + }, + "fencedCodeTokens": { + "beforeSha256": "e14962fd06a3d97f4bc0a0c0d1d06fcf5d1255abe3f39646e932d8ec9d510322", + "afterSha256": "e14962fd06a3d97f4bc0a0c0d1d06fcf5d1255abe3f39646e932d8ec9d510322", + "beforeCount": 29, + "afterCount": 29, + "equal": true + }, + "inlineCodeTokens": { + "beforeSha256": "fd1b05c3fffe90a200893e93ebe826445f3daa0c70359e39e433deb9eeb00ec3", + "afterSha256": "fd1b05c3fffe90a200893e93ebe826445f3daa0c70359e39e433deb9eeb00ec3", + "beforeCount": 118, + "afterCount": 118, + "equal": true + }, + "tableAlignmentColons": { + "beforeSha256": "94d115ca0105764001fc3b904d75fbaddc9915dc6e5d1e399540759b4ac395b6", + "afterSha256": "94d115ca0105764001fc3b904d75fbaddc9915dc6e5d1e399540759b4ac395b6", + "beforeDelimiterRowCount": 3, + "afterDelimiterRowCount": 3, + "equal": true + }, + "semanticTokenChangeCount": 0, + "contentOrMeaningChanged": false + }, + "checks": [ + { + "command": "pnpm exec prettier --check docs/research/hosted-web/phase-1/reviews/routes-ratchets.md .codex-handoff/phase-01-p1-i-format-remediation.json", + "exitCode": 0, + "matchedPathCount": 2 + }, + { + "command": "node -e JSON.parse handoff validation and recompute recorded formatter/hash/token invariants", + "exitCode": 0 + }, + { + "command": "git rev-parse HEAD", + "exitCode": 0, + "observed": "d5e311ea24d2a4af96f1ac8deb02ef3c8c8a07ca" + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --quiet", + "exitCode": 0 + }, + { + "command": "git diff --name-only d5e311ea24d2a4af96f1ac8deb02ef3c8c8a07ca -- docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "exitCode": 0, + "observed": ["docs/research/hosted-web/phase-1/reviews/routes-ratchets.md"] + }, + { + "command": "git ls-files --others --exclude-standard", + "exitCode": 0, + "observed": [".codex-handoff/phase-01-p1-i-format-remediation.json"] + }, + { + "command": "git status --short", + "exitCode": 0, + "observedPathCount": 2 + }, + { + "command": "git diff -- docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "exitCode": 0, + "result": "complete Markdown diff reread and every hunk classified as exact pinned-Prettier output" + } + ], + "classifiedScans": { + "secretCredentialScan": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' docs/research/hosted-web/phase-1/reviews/routes-ratchets.md .codex-handoff/phase-01-p1-i-format-remediation.json", + "exitCode": 0, + "observedMatchLineCount": 3, + "classification": "three matches: two immutable canonical control/classification lines and this handoff's recorded scan expression; no payload value" + }, + "providerTermScan": { + "command": "rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' docs/research/hosted-web/phase-1/reviews/routes-ratchets.md .codex-handoff/phase-01-p1-i-format-remediation.json", + "exitCode": 0, + "observedMatchLineCount": 6, + "classification": "six matches: required model/profile metadata, one inherited diagnostic path, immutable canonical scan/classification control text, and this handoff's scan key/expression; no payload value" + }, + "privatePathScan": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' docs/research/hosted-web/phase-1/reviews/routes-ratchets.md .codex-handoff/phase-01-p1-i-format-remediation.json", + "exitCode": 0, + "observedMatchLineCount": 3, + "classification": "three matches: immutable canonical no-access/no-location claims and this handoff's recorded scan expression; no unsafe location value" + }, + "mimeScan": { + "command": "file --mime-type docs/research/hosted-web/phase-1/reviews/routes-ratchets.md .codex-handoff/phase-01-p1-i-format-remediation.json", + "exitCode": 0, + "textFileCount": 2, + "binaryFileCount": 0, + "classifications": [ + { + "path": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "mimeType": "text/plain", + "classification": "text" + }, + { + "path": ".codex-handoff/phase-01-p1-i-format-remediation.json", + "mimeType": "application/json", + "classification": "text" + } + ] + }, + "utf8NulScan": { + "command": "node exact-two-path fatal UTF-8 decode and NUL-byte assertion", + "exitCode": 0, + "validUtf8PathCount": 2, + "nulBytePathCount": 0 + } + }, + "scopeProof": { + "authorityHeadMatchedBeforeAndAfterWrite": true, + "trackedDiffPaths": ["docs/research/hosted-web/phase-1/reviews/routes-ratchets.md"], + "untrackedPaths": [".codex-handoff/phase-01-p1-i-format-remediation.json"], + "stagedPathCount": 0, + "statusPathCount": 2, + "onlyDeclaredWriterPathsChanged": true, + "markdownWasOnlyCliWriterTarget": true, + "productOrTestPathChanged": false, + "thirdPathChanged": false + }, + "selfReview": { + "canonicalRawHashCapturedBeforeWrite": true, + "expectedFormatterOutputComputedBeforeWrite": true, + "formatterDerivationRecomputed": true, + "completeMarkdownDiffReread": true, + "semanticTokenProofReread": true, + "fencedCodeProofReread": true, + "inlineCodeProofReread": true, + "tableAlignmentProofReread": true, + "allNonSelfHashesRecomputed": true, + "writerScopeVerified": true, + "completeHandoffReread": true, + "allScanMatchesClassified": true, + "rejectedAttemptQuarantineVerified": true, + "scopeExpansion": false, + "unsupportedClaims": false + }, + "unverifiedClaims": [ + "independent acceptance", + "integration", + "P1.I completion", + "successor authority" + ], + "independentAcceptanceClaimed": false, + "integrationClaimed": false, + "p1iCompletionClaimed": false, + "successorAuthorityClaimed": false, + "failures": [], + "blockers": [], + "nextAction": "independent-verification", + "terminalState": "HOLD" +} diff --git a/.codex-handoff/phase-01-p1-i-lint-remediation.json b/.codex-handoff/phase-01-p1-i-lint-remediation.json new file mode 100644 index 0000000000..720b847766 --- /dev/null +++ b/.codex-handoff/phase-01-p1-i-lint-remediation.json @@ -0,0 +1,231 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.I.LINT.REMEDIATION", + "laneId": "p1-i-lint-remediation", + "packetRevision": "phase-01-p1-i-integration-r2", + "routerRevision": "phase-01-p1-i-lint-remediation-router-r1", + "evidenceId": "P1.I.LINT.REMEDIATION", + "baseSha": "92b8c34722f61d5e65bfbe606c325d13792cef5c", + "canonicalSha": "92b8c34722f61d5e65bfbe606c325d13792cef5c", + "planBundleCommit": "92b8c34722f61d5e65bfbe606c325d13792cef5c", + "phaseStartSha": "92b8c34722f61d5e65bfbe606c325d13792cef5c", + "headSha": "92b8c34722f61d5e65bfbe606c325d13792cef5c", + "status": "verified", + "producerProfile": { + "model": "gpt-5.6-sol", + "reasoningEffort": "xhigh", + "serviceTier": "default", + "fastMode": false + }, + "evidence": [ + { + "id": "P1.I.LINT.REMEDIATION", + "path": ".codex-handoff/phase-01-p1-i-lint-remediation.json", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + "src/shared/contracts/hosted/app-error.ts", + "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + ".codex-handoff/phase-01-p1-i-lint-remediation.json" + ], + "changedPathSha256": { + "src/shared/contracts/hosted/app-error.ts": "838c76670512e4178b9b7507e3ce34356a39e2f57a9761fd92583ccd78253820", + "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts": "52ee29acbca32a864b600ef3c833785fc11af382e515ec941fe0437ab3ccdf83" + }, + "sourceChange": { + "path": "src/shared/contracts/hosted/app-error.ts", + "removedTokenSequence": " as string", + "location": "validated diagnosticId projection", + "onlyRedundantAssertionRemoved": true, + "otherSourceTokensChanged": 0 + }, + "regression": { + "path": "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + "addedTestCount": 1, + "validDiagnosticIdPreservedUnchanged": true, + "safeProjectionFrozen": true, + "safeProjectionKnownFieldsOnly": true, + "unsafeDiagnosticRejectedWithExistingFailure": "hosted-contract-safe-error-invalid", + "inlineSyntheticValuesOnly": true + }, + "semanticPreservation": { + "appErrorSemanticsUnchanged": true, + "requestSemanticsUnchanged": true, + "rawMessageHandlingUnchanged": true, + "transportFieldsUnchanged": true, + "retryRulesUnchanged": true, + "appErrorCodesUnchanged": true, + "validationGrammarUnchanged": true, + "projectionKeysUnchanged": true, + "freezeBehaviorUnchanged": true, + "importsExportsAndReturnTypeUnchanged": true + }, + "baselineLint": { + "command": "pnpm lint", + "exitCode": 1, + "errorCount": 1, + "diagnostics": [ + { + "path": "src/shared/contracts/hosted/app-error.ts", + "line": 29, + "column": 65, + "rule": "@typescript-eslint/no-unnecessary-type-assertion", + "message": "This assertion is unnecessary since it does not change the type of the expression" + } + ] + }, + "checks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + "exitCode": 0, + "testFiles": { + "passed": 1, + "total": 1 + }, + "tests": { + "passed": 2, + "total": 2 + } + }, + { + "command": "pnpm lint", + "exitCode": 0, + "errorCount": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": { + "inherited": 7, + "owned": 0, + "unexpected": 0, + "matchesFrozenBaseline": true + }, + "diagnostics": [ + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7016", + "location": "25:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "66:31" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS18046", + "location": "117:68" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "413:48" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "733:10" + }, + { + "path": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "code": "TS7016", + "location": "12:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "code": "TS2352", + "location": "162:44" + } + ] + }, + { + "command": "pnpm exec prettier --check src/shared/contracts/hosted/app-error.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts .codex-handoff/phase-01-p1-i-lint-remediation.json", + "exitCode": 0, + "checkedFiles": 3 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --quiet", + "exitCode": 0 + } + ], + "scopeProof": { + "headMatchesPostRouterIntegrationAuthoritySha": true, + "trackedDiffPaths": [ + "src/shared/contracts/hosted/app-error.ts", + "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts" + ], + "untrackedPaths": [".codex-handoff/phase-01-p1-i-lint-remediation.json"], + "stagedPathCount": 0, + "statusPathCount": 3, + "onlyDeclaredWriterPathsChanged": true, + "sourceDiffMatchesExactAssertionDeletion": true, + "testDiffIsOneFocusedRegressionOnly": true + }, + "classifiedScans": { + "secretCredentialScan": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' src/shared/contracts/hosted/app-error.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts .codex-handoff/phase-01-p1-i-lint-remediation.json", + "exitCode": 0, + "observedMatchLineCount": 2, + "classification": "recorded scan-command control text only; no secret, credential, cookie, or authorization value" + }, + "providerTermScan": { + "command": "rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' src/shared/contracts/hosted/app-error.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts .codex-handoff/phase-01-p1-i-lint-remediation.json", + "exitCode": 0, + "observedMatchLineCount": 4, + "classification": "required model/profile metadata, inherited typecheck diagnostic path, scan-command control text, and this classification label only; no auth payload or raw runtime value" + }, + "privatePathScan": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' src/shared/contracts/hosted/app-error.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts .codex-handoff/phase-01-p1-i-lint-remediation.json", + "exitCode": 0, + "observedMatchLineCount": 2, + "classification": "recorded scan-command control text only; no private, real-project, home, or task-temporary path value" + }, + "binaryScan": { + "command": "file --mime-type src/shared/contracts/hosted/app-error.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts .codex-handoff/phase-01-p1-i-lint-remediation.json", + "exitCode": 0, + "textFileCount": 3, + "binaryFileCount": 0, + "classifications": [ + { + "path": "src/shared/contracts/hosted/app-error.ts", + "mimeType": "application/javascript", + "classification": "text" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + "mimeType": "application/javascript", + "classification": "text" + }, + { + "path": ".codex-handoff/phase-01-p1-i-lint-remediation.json", + "mimeType": "application/json", + "classification": "text" + } + ] + } + }, + "selfReview": { + "sourceDiffReread": true, + "focusedRegressionDiffReread": true, + "completeHandoffReread": true, + "outputHashesRecomputed": true, + "writerScopeVerified": true, + "frozenTypecheckBaselineVerified": true, + "fullLintZeroErrorsVerified": true, + "appErrorSemanticsPreserved": true, + "scopeExpansion": false, + "unsupportedClaims": false + }, + "unverifiedClaims": ["independent acceptance", "integration", "P1.I completion"], + "failures": [], + "blockers": [], + "nextAction": "independent-verification", + "terminalState": "HOLD" +} diff --git a/.codex-handoff/phase-01-p1-i.json b/.codex-handoff/phase-01-p1-i.json new file mode 100644 index 0000000000..deb27eacc5 --- /dev/null +++ b/.codex-handoff/phase-01-p1-i.json @@ -0,0 +1,326 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.I.INTEGRATION", + "laneId": "p1-i", + "packetRevision": "phase-01-p1-i-integration-r3", + "routerRevision": "phase-01-p1-i-format-remediation-router-r1", + "baseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "canonicalSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "planBundleCommit": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "phaseStartSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "headSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "terminalState": "HOLD", + "status": "verified", + "evidence": [ + { + "id": "P1.I.INTEGRATION", + "path": "docs/research/hosted-web/phase-1/integration-report.json", + "proofLevel": "target_verified" + }, + { + "id": "P1.I.ROLLBACK", + "path": "docs/research/hosted-web/phase-1/integration-report.json", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + ".codex-handoff/phase-01-p1-i.json", + "docs/research/hosted-web/phase-1/decision-register.json", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "docs/research/hosted-web/phase-1/evidence-index.json", + "docs/research/hosted-web/phase-1/integration-report.json" + ], + "changedPathCount": 5, + "canonicalInputs": { + "phase1CanonicalInputCount": 68, + "acceptedLintRemediationHandoffCount": 1, + "totalCount": 69, + "distinctCount": 69, + "manifestSha256": "623c24be71898e7557257beae2693175ce2300be16c387a1051feb0bbf26bc44", + "formatRemediationHandoffInputCount": 0 + }, + "ownedOutputSha256": { + "docs/research/hosted-web/phase-1/decision-register.json": "1d275a95a189d7840a6d75591d90c138b0ec5399747db41794697de0cde32ba9", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json": "941c58195b9955b9807b896aedf7f46ea1a4ed455dc6713241ffefb074405328", + "docs/research/hosted-web/phase-1/evidence-index.json": "07a17cb6674916f65713e337f15deeb3f5405d36fbcccbbcdada3b5895724590", + "docs/research/hosted-web/phase-1/integration-report.json": "a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f" + }, + "checks": [ + { + "command": "pnpm exec vitest run test/features/team-lifecycle test/architecture/hosted-web/phase-1", + "exitCode": 0, + "testFiles": { + "passed": 13, + "total": 13 + }, + "tests": { + "passed": 60, + "total": 60 + } + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "exitCode": 0, + "testFiles": { + "passed": 1, + "total": 1 + }, + "tests": { + "passed": 3, + "total": 3 + }, + "evidenceId": "P1.NEG.RATCHET_REGRESSION", + "diagnostic": "phase1-ratchet-regression" + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": { + "inherited": 7, + "owned": 0, + "unexpected": 0, + "matchesFrozenBaseline": true + }, + "diagnostics": [ + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7016", + "location": "25:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "66:31" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS18046", + "location": "117:68" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "413:48" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "733:10" + }, + { + "path": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "code": "TS7016", + "location": "12:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "code": "TS2352", + "location": "162:44" + } + ] + }, + { + "command": "pnpm lint", + "exitCode": 0, + "errorCount": 0, + "warningCount": 3031 + }, + { + "command": "pnpm exec prettier --check \"${p1_paths[@]}\"", + "exitCode": 0, + "matchedPathCount": 74 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --quiet", + "exitCode": 0 + } + ], + "gateSummary": { + "required": 14, + "passed": 14, + "failed": 0, + "gates": [ + { + "id": "P1.GATE.PROVENANCE", + "status": "passed", + "evidence": "authority equality, 69-input manifest hash, and provenance-only format handoff exclusion" + }, + { + "id": "P1.GATE.PREDECESSORS", + "status": "passed", + "evidence": "accepted P1.R1, P1.R2, lint-remediation, and format-remediation authority present" + }, + { + "id": "P1.GATE.SCOPE", + "status": "passed", + "evidence": "exact five untracked outputs, zero tracked/staged paths" + }, + { + "id": "P1.GATE.TESTS", + "status": "passed", + "evidence": "13/13 files and 60/60 tests" + }, + { + "id": "P1.GATE.TYPECHECK", + "status": "passed", + "evidence": "seven inherited, zero owned, zero unexpected diagnostics" + }, + { + "id": "P1.GATE.LINT", + "status": "passed", + "evidence": "full lint exit 0 with zero errors" + }, + { + "id": "P1.GATE.FORMAT", + "status": "passed", + "evidence": "exact 74-path pinned Prettier exit 0" + }, + { + "id": "P1.GATE.NEGATIVES", + "status": "passed", + "evidence": "complete named negative inventory proved or explicitly deferred behind the passing no-filesystem gate" + }, + { + "id": "P1.GATE.RATCHET", + "status": "passed", + "evidence": "focused 1/1 file and 3/3 tests" + }, + { + "id": "P1.GATE.SECURITY", + "status": "passed", + "evidence": "exact 74-path classified scans plus UTF-8, NUL, and MIME proof" + }, + { + "id": "P1.GATE.ROLLBACK", + "status": "passed", + "evidence": "exact 54-path scratch forward equality and reverse absence" + }, + { + "id": "P1.GATE.ESTIMATE", + "status": "passed", + "evidence": "unique P1.I output allocation and variance reconciliation" + }, + { + "id": "P1.GATE.DECISIONS", + "status": "passed", + "evidence": "complete freeze-candidate decision register" + }, + { + "id": "P1.GATE.EVIDENCE_FREEZE", + "status": "passed", + "evidence": "input hashes, evidence lifecycle rows, owned output hashes, and self-review" + } + ] + }, + "rollback": { + "bootstrapSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "authoritySha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "payloadPathCount": 54, + "distinctPayloadPathCount": 54, + "bootstrapAbsentPathCount": 54, + "forwardApplyCheckExitCode": 0, + "forwardApplyExitCode": 0, + "forwardByteEqualPathCount": 54, + "reverseApplyCheckExitCode": 0, + "reverseApplyExitCode": 0, + "reverseAbsentPathCount": 54, + "patchSha256": "bd0b8cce323c72e9104b620a899add44919c13f913d5fbbe169b6445d79ffb50", + "scratchOnly": true, + "workspaceApply": false, + "evidenceId": "P1.I.ROLLBACK", + "pathManifestSource": "EXECUTION_INDEX.json.rollbackPayload.paths exact declared order", + "pathManifestSha256": "d67e76fa0b63f51260bc6c3bdd59568a9115a730709f2508231df46da5ca880e", + "commands": [ + "git diff --binary --full-index 5f30df49e052d1cc1d0e7efd03aa105673b5b614 5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f -- \"${rollback_paths[@]}\"", + "git apply --no-index --check ", + "git apply --no-index ", + "git apply --no-index -R --check ", + "git apply --no-index -R " + ] + }, + "classifiedScans": { + "scopePathCount": 74, + "inputPathCount": 69, + "outputPathCount": 5, + "secretCredential": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' \"${p1_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 27, + "classification": "canonical control text, synthetic negative-control terms, required scan-command text, and evidence metadata only; no secret or credential value", + "allMatchesClassified": true + }, + "provider": { + "command": "rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' \"${p1_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 46, + "classification": "repository-relative paths, inherited diagnostic paths, required profile/process labels, synthetic negative-control text, and scan-command text only; no auth/provider payload", + "allMatchesClassified": true + }, + "privatePath": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' \"${p1_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 13, + "classification": "canonical provenance/control text and recorded scan-command text only; no private, real-project, home, or task-temporary path value", + "allMatchesClassified": true + }, + "mime": { + "command": "file --mime-type \"${p1_paths[@]}\"", + "exitCode": 0, + "textFileCount": 74, + "binaryFileCount": 0 + }, + "utf8Nul": { + "command": "node exact-74-path fatal UTF-8 decode and NUL-byte assertion", + "exitCode": 0, + "validUtf8PathCount": 74, + "nulBytePathCount": 0 + } + }, + "scopeProof": { + "authorityHeadMatched": true, + "trackedDiffPaths": [], + "untrackedPaths": [ + ".codex-handoff/phase-01-p1-i.json", + "docs/research/hosted-web/phase-1/decision-register.json", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "docs/research/hosted-web/phase-1/evidence-index.json", + "docs/research/hosted-web/phase-1/integration-report.json" + ], + "stagedPathCount": 0, + "statusPathCount": 5, + "onlyDeclaredWriterPathsChanged": true, + "productOrTestPathChanged": false, + "rejectedOutputBytesUsed": false + }, + "selfReview": { + "allMandatoryDocumentsReadInContractOrder": true, + "all69InputsReadInExactManifestOrder": true, + "allFiveOutputsFresh": true, + "completeOutputDiffReread": true, + "all14GatesReread": true, + "allHashesRecomputed": true, + "allScanMatchesClassified": true, + "rollbackProofReread": true, + "rejectedAttemptQuarantineVerified": true, + "scopeExpansion": false, + "unsupportedClaims": false + }, + "unverifiedClaims": ["independent acceptance", "integration", "P1.F authorization", "Phase 2+"], + "blockers": [], + "nextAction": "independent-verification", + "pathResolution": { + "arrayName": "p1_paths", + "inputManifestSource": "EXECUTION_INDEX.json.phase1CanonicalInputs exact group and path order followed by acceptedLintRemediationHandoffPath", + "outputManifestSource": "EXECUTION_INDEX.json.downstreamP1IOutputs.writablePaths writer order", + "inputPathCount": 69, + "outputPathCount": 5, + "totalPathCount": 74, + "distinctPathCount": 74, + "resolvedPathManifestSha256": "0e8e2b82125eb3b8e559f9fa439e8942e0eea89d75da4cccc35d75099e868223" + } +} diff --git a/.codex-handoff/phase-01-p1-r2.json b/.codex-handoff/phase-01-p1-r2.json new file mode 100644 index 0000000000..f927674d97 --- /dev/null +++ b/.codex-handoff/phase-01-p1-r2.json @@ -0,0 +1,297 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "laneId": "p1-r2", + "packetRevision": "phase-01-p1-r2-review-r1", + "baseSha": "f6794b607609c57dc92def696d05946c9c96856a", + "canonicalSha": "f6794b607609c57dc92def696d05946c9c96856a", + "planBundleCommit": "f6794b607609c57dc92def696d05946c9c96856a", + "phaseStartSha": "f6794b607609c57dc92def696d05946c9c96856a", + "headSha": "f6794b607609c57dc92def696d05946c9c96856a", + "reviewedProductSnapshotSha": "666042037a9c91df572b1d8274bf6024f8d00f40", + "status": "verified", + "reviewer": { + "identity": "codex-thread:019f64ac-241c-7ee3-8ec8-7ba99c7360fa", + "job": "agent-teams-hosted-web-refactor-p1-r2-formal-review-v17-r3", + "worktree": "/var/data/agent-teams-hosted-web-refactor/worktrees/p1-r2-formal-review-v17-r3", + "model": "gpt-5.6-sol", + "reasoningEffort": "xhigh", + "serviceTier": "default", + "fastMode": false, + "freshIndependentReviewerCount": 1, + "independentOf": { + "p1R2RouterAuthor": true, + "allP11aAndP11dProducersRemediationWorkersAndReviewers": true, + "allPr252ConflictResolutionProducersAndReviewers": true, + "allPriorPhase1FormalReviewers": true + }, + "proof": "root/controller admission allocated a fresh r3 reviewer identity, isolated job, and isolated worktree under the required profile; no duplicate reviewer was admitted" + }, + "authority": { + "packetBaseSha": "48d79e2b13e258fc82ad55723875f15d6e162872", + "packetBaseRole": "router-authoring-provenance-only", + "postIntegrationAuthoritySha": "f6794b607609c57dc92def696d05946c9c96856a", + "expectedSourceCommit": "f6794b607609c57dc92def696d05946c9c96856a", + "brokerReturnedAndPushedCommitSha": "f6794b607609c57dc92def696d05946c9c96856a", + "rootImmutableAuthorityAttestation": { + "remoteName": "origin", + "remoteRef": "refs/heads/refactor/hosted-web-feature-boundaries", + "command": "git ls-remote origin refs/heads/refactor/hosted-web-feature-boundaries", + "exitCode": 0, + "exactOutput": "f6794b607609c57dc92def696d05946c9c96856a\\trefs/heads/refactor/hosted-web-feature-boundaries", + "remoteEquality": true, + "canonicalWorktreeInitiallyClean": true, + "expectedSourceCommitUsedForAdmission": "f6794b607609c57dc92def696d05946c9c96856a" + }, + "localAuthorityEquality": true, + "allHandoffAuthorityFieldsEqual": true, + "orderedSnapshotParents": [ + "c3135d40c6e70e4b2ddc905dc815407397197634", + "3b48f9391b4bff1d82bc85ef01a2d5e0e5b50e95" + ] + }, + "evidence": [ + { + "id": "P1.R2.SEMANTIC_REVIEW", + "path": "docs/research/hosted-web/phase-1/reviews/list-semantics.md", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + ".codex-handoff/phase-01-p1-r2.json", + "docs/research/hosted-web/phase-1/reviews/list-semantics.md" + ], + "scopeProof": { + "p11aPathCount": 12, + "p11dPathCount": 9, + "semanticCorpusPathCount": 11, + "totalInputPathCount": 32, + "distinctInputPathCount": 32, + "snapshotToHeadByteDiffExitCode": 0, + "worktreeInputDiffExitCode": 0, + "initialOutputPathsAbsent": true, + "initialCanonicalStatusClean": true, + "finalTrackedDiffEmpty": true, + "finalStagedPathCount": 0, + "finalUntrackedPaths": [ + ".codex-handoff/phase-01-p1-r2.json", + "docs/research/hosted-web/phase-1/reviews/list-semantics.md" + ], + "onlyAuthorizedOutputPaths": true + }, + "kernelProof": { + "productFileCount": 5, + "primitiveFamilyCount": 5, + "sourceLineCount": 159, + "sourceByteCount": 7242, + "acceptedP11aGrossOwnedLines": 299, + "unchangedFromReviewedProductSnapshot": true, + "sixthPrimitiveFamilyPresent": false + }, + "checks": [ + { + "command": "test -n \"$postIntegrationAuthoritySha\"", + "exitCode": 0 + }, + { + "command": "test \"$expectedSourceCommit\" = \"$postIntegrationAuthoritySha\"", + "exitCode": 0 + }, + { + "command": "test \"$(git rev-parse HEAD)\" = \"$postIntegrationAuthoritySha\"", + "exitCode": 0 + }, + { + "command": "git rev-list --parents -n 1 666042037a9c91df572b1d8274bf6024f8d00f40", + "exitCode": 0, + "observed": "666042037a9c91df572b1d8274bf6024f8d00f40 c3135d40c6e70e4b2ddc905dc815407397197634 3b48f9391b4bff1d82bc85ef01a2d5e0e5b50e95" + }, + { + "command": "node exact reviewCanonicalInputs manifest assertion from p1-r2-review.md", + "exitCode": 0, + "observed": "32 disjoint paths" + }, + { + "command": "git diff --exit-code 666042037a9c91df572b1d8274bf6024f8d00f40 HEAD -- \"${review_input_paths[@]}\"", + "exitCode": 0, + "observedPathCount": 32 + }, + { + "command": "git diff --exit-code HEAD -- \"${review_input_paths[@]}\"", + "exitCode": 0, + "observedPathCount": 32 + }, + { + "command": "sha256sum docs/hosted-web-phases/phase-01/controller-packet.md docs/hosted-web-phases/phase-01/lanes/p1-r2-review.md", + "exitCode": 0, + "observed": { + "controllerPacket": "9b4b27f5029df8e21214fe6d4a372cbfc3e6d6c6f6506351954243026c33291d", + "lanePacket": "8b089ab8337467da201be884fc8f52bfff6d6377f63722f6f8d1aa6d5f6778c3" + } + }, + { + "command": "wc -l -c src/shared/contracts/hosted/app-error.ts src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts src/shared/contracts/hosted/query-context.ts src/shared/contracts/hosted/revision.ts", + "exitCode": 0, + "observed": "159 lines, 7242 bytes" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts test/features/team-lifecycle", + "exitCode": 0, + "testFiles": { + "passed": 5, + "total": 5 + }, + "tests": { + "passed": 14, + "total": 14 + }, + "vitestVersion": "3.2.6" + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": { + "inherited": 7, + "owned": 0, + "unexpected": 0, + "matchesFrozenBaseline": true + }, + "diagnostics": [ + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:25:8 TS7016", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:66:31 TS7031", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:117:68 TS18046", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:413:48 TS7031", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:733:10 TS7031", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts:12:8 TS7016", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts:162:44 TS2352" + ] + }, + { + "command": "pnpm exec prettier --check .codex-handoff/phase-01-p1-1a.json src/shared/contracts/hosted/app-error.ts src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts src/shared/contracts/hosted/query-context.ts src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-1/contracts/query-context.test.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts .codex-handoff/phase-01-p1-1d.json src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/index.ts src/features/team-lifecycle/core/application/ListTeamLifecycle.ts src/features/team-lifecycle/core/application/index.ts src/features/team-lifecycle/index.ts test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json .codex-handoff/phase-01-p1-r2.json docs/research/hosted-web/phase-1/reviews/list-semantics.md", + "exitCode": 0, + "observedFileCount": 34 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --quiet", + "exitCode": 0 + }, + { + "command": "git diff --exit-code", + "exitCode": 0 + }, + { + "command": "git status --short", + "exitCode": 0, + "observed": [ + "?? .codex-handoff/phase-01-p1-r2.json", + "?? docs/research/hosted-web/phase-1/reviews/list-semantics.md" + ] + }, + { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer)' \"${review_scan_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 2, + "classification": "the two lexical matches are the required scan-command text recorded in the two reviewer outputs; no credential or secret value" + }, + { + "command": "rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' \"${review_scan_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 17, + "classification": "required model/profile metadata, process provenance and unverified-boundary labels, synthetic negative-control terms, and scan-command text only; no provider payload or raw provider value" + }, + { + "command": "rg -n '(/Users/|/home/|/root/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' \"${review_scan_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 2, + "classification": "the two lexical matches are the required scan-command text recorded in the two reviewer outputs; no private user path or user-project path value" + } + ], + "semanticReview": [ + { + "requirement": 1, + "result": "pass", + "finding": "strict top-level and nested own string/symbol request keys; shared actor, session, deployment, boot, request, scope, deadline, and cancellation validation; no ambient auth lookup" + }, + { + "requirement": 2, + "result": "pass", + "finding": "injected value-only source is invoked once for a valid request and zero times for an invalid request; no adapter, transport, runtime, provider, mount, or filesystem dependency" + }, + { + "requirement": 3, + "result": "pass", + "finding": "same-version success, failure, and inapplicable responses validate known fields and produce fresh frozen known-field-only projections while discarding additive own string/symbol fields" + }, + { + "requirement": 4, + "result": "pass", + "finding": "items length captured once and bounded at 1000; sparse and duplicate IDs rejected; dense indices read once into a fresh plain array without input-owned map, iterator, constructor, or species dispatch" + }, + { + "requirement": 5, + "result": "pass", + "finding": "all ten manifest scenarios retain outcome, order, revision/cursor, safe-field, retryability, and empty/error semantics; deliberate mismatch rejects with phase1-semantic-outcome-drift" + }, + { + "requirement": 6, + "result": "pass", + "finding": "safe errors remain in the accepted bounded application surface; unsupported/malformed/thrown/invalid-source cases fail closed without raw details" + }, + { + "requirement": 7, + "result": "pass", + "finding": "revision and cursor tokens remain opaque and kind-separated; no parse/increment/sort/display/cache use or page-one fallback; production adapter integrity remains unverified" + }, + { + "requirement": 8, + "result": "pass", + "finding": "public entrypoints expose only the narrow contract and use case, with no legacy aggregate, universal envelope, route/capability, provider, path, production identity, or implementation-file export" + }, + { + "requirement": 9, + "result": "pass", + "finding": "shared kernel remains five files/families at 159 lines and 7242 bytes; accepted P1.1A handoff remains 299 gross owned lines; no sixth family or unproved export" + }, + { + "requirement": 10, + "result": "pass", + "finding": "P1.NEG.SCHEMA_VERSION retains phase1-schema-version-invalid-or-unsupported with passing same-version neighbors; no production-completeness claim" + } + ], + "disposition": "ACCEPT", + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "resultFileSha256": "de4b1e4fc0f633a40703e6af106bcdfcd2ab3a7d524b28f19df376a4052cdc70", + "unverifiedClaims": [ + "production authorization", + "production cursor integrity, scope, and snapshot binding", + "IPC/HTTP transport parity", + "production adapters and filesystem/runtime integration", + "preload or renderer behavior", + "production mount", + "full Phase 1 completion", + "Phase 2+ behavior" + ], + "blockedSuccessors": [ + "P1.I", + "P1.F", + "Phase 2+", + "product workers", + "integration", + "successor controllers" + ], + "nextAction": "controller-hold", + "terminalState": "HOLD" +} diff --git a/.codex-handoff/phase-01-p1-s1-schema-version-remediation.json b/.codex-handoff/phase-01-p1-s1-schema-version-remediation.json new file mode 100644 index 0000000000..070578c94d --- /dev/null +++ b/.codex-handoff/phase-01-p1-s1-schema-version-remediation.json @@ -0,0 +1,185 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "laneId": "p1-s1-schema-version-remediation", + "packetRevision": "phase-01-s1-schema-version-remediation-r1", + "supersedesWorkerStartRevision": "phase-01-s1-foundations-r1", + "parentPlanCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "baseSha": "da9625e78c0c96699162793a7ebba0657140d937", + "planBundleCommit": "de1b995ed3ffe0d5884d4a0b0869827abacef53e", + "phaseStartSha": "de1b995ed3ffe0d5884d4a0b0869827abacef53e", + "resultCommitSha": null, + "status": "characterized", + "independentReview": { + "quote": "Independent integration review formally REJECTED P1.S1 commit da9625e78 only for incomplete P1.NEG.SCHEMA_VERSION.", + "provenance": "authoritative operator-provided independent integration review finding", + "rejectedEvidenceId": "P1.NEG.SCHEMA_VERSION" + }, + "toolVersions": { + "node": "24.16.0", + "pnpm": "10.33.4", + "vitest": "3.2.6", + "eslint": "9.39.4", + "typescript": "7.0.2", + "prettier": "3.8.1", + "git": "2.43.0" + }, + "evidence": [ + { + "id": "P1.1A.VERSION.REMEDIATION", + "path": "src/shared/contracts/hosted/revision.ts", + "proofLevel": "target_verified" + }, + { + "id": "P1.NEG.SCHEMA_VERSION", + "path": "test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "proofLevel": "target_verified" + } + ], + "changedPaths": [ + ".codex-handoff/phase-01-p1-s1-schema-version-remediation.json", + "src/shared/contracts/hosted/revision.ts", + "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json", + "test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "test/architecture/hosted-web/phase-1/contracts/revision.test.ts" + ], + "checks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts", + "exitCode": 0 + }, + { + "command": "pnpm lint:fast:files -- src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": "inherited Phase 0 test-tree diagnostics; no owned-path diagnostic", + "unchangedBaselineFingerprint": "sha256:c81e31b39c985bcfc2c528ba6040aca11b37b2272dd211d812f8481f814e70cc" + }, + { + "command": "pnpm exec prettier --check .codex-handoff/phase-01-p1-s1-schema-version-remediation.json src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "exitCode": 0 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git status --short", + "exitCode": 0 + } + ], + "scopeChecks": [ + { + "command": "git merge-base --is-ancestor da9625e78c0c96699162793a7ebba0657140d937 de1b995ed3ffe0d5884d4a0b0869827abacef53e", + "exitCode": 0 + }, + { + "command": "git diff --exit-code da9625e78c0c96699162793a7ebba0657140d937..de1b995ed3ffe0d5884d4a0b0869827abacef53e -- src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "exitCode": 0 + }, + { + "command": "git diff --name-only de1b995ed3ffe0d5884d4a0b0869827abacef53e && git ls-files --others --exclude-standard", + "exitCode": 0, + "result": "exactly the five changedPaths" + }, + { + "command": "git diff --name-only da9625e78c0c96699162793a7ebba0657140d937..de1b995ed3ffe0d5884d4a0b0869827abacef53e", + "exitCode": 0, + "result": "only the six bounded router/controller/lane documentation paths" + }, + { + "command": "git diff --exit-code de1b995ed3ffe0d5884d4a0b0869827abacef53e -- . ':(exclude).codex-handoff/phase-01-p1-s1-schema-version-remediation.json' ':(exclude)src/shared/contracts/hosted/revision.ts' ':(exclude)test/architecture/hosted-web/phase-1/contracts/revision.test.ts' ':(exclude)test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json' ':(exclude)test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json'", + "exitCode": 0, + "result": "all non-owned paths match phaseStartSha" + }, + { + "command": "rg -n -i '(api[_-]?key[[:space:]]*[:=]|access[_-]?token[[:space:]]*[:=]|refresh[_-]?token[[:space:]]*[:=]|password[[:space:]]*[:=]|bearer[[:space:]]+[A-Za-z0-9]|-----BEGIN [A-Z ]*PRIVATE KEY-----|/[U]sers/|/[h]ome/|/[r]oot/|claude[-]runtime|auth[_-]?payload|provider[_-]?payload|raw[_-]?(command|runtime)[_-]?body)' .codex-handoff/phase-01-p1-s1-schema-version-remediation.json src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "exitCode": 1, + "result": "zero matches" + } + ], + "negativeResultMatrix": [ + { + "case": "malformed schema version", + "values": [null, "1", 0, 1.1], + "result": "rejected", + "diagnostic": "phase1-schema-version-invalid-or-unsupported" + }, + { + "case": "missing schema version", + "result": "rejected", + "diagnostic": "phase1-schema-version-invalid-or-unsupported" + }, + { + "case": "non-object revision contract", + "values": [null, "array", "string"], + "result": "rejected", + "diagnostic": "phase1-schema-version-invalid-or-unsupported" + }, + { + "case": "future schema version", + "values": [2], + "result": "rejected", + "diagnostic": "phase1-schema-version-invalid-or-unsupported" + }, + { + "case": "same-version response with missing or invalid revision", + "result": "rejected-before-additive-discard", + "diagnostic": "phase1-schema-version-invalid-or-unsupported" + }, + { + "case": "same-version response with additive fields", + "result": "accepted-as-fresh-known-field-projection-with-additive-fields-discarded" + }, + { + "case": "same-version input with unknown string or symbol own field", + "result": "rejected", + "diagnostic": "phase1-schema-version-invalid-or-unsupported" + } + ], + "deliberateNegativeResult": { + "id": "P1.NEG.SCHEMA_VERSION", + "diagnostic": "phase1-schema-version-invalid-or-unsupported", + "result": "passed" + }, + "patchManifest": { + "phaseStartSha": "de1b995ed3ffe0d5884d4a0b0869827abacef53e", + "command": "git diff --binary --full-index de1b995ed3ffe0d5884d4a0b0869827abacef53e", + "computedBeforeHandoffCreation": true, + "sha256": "ccdc4a1e2c03657838a741ea51d4a152982d235d5b0645718590017bc7344828", + "changedPathSha256": { + "src/shared/contracts/hosted/revision.ts": "3ae55b8d8ae9e92789313cd6c611ffbdaee4554a195e989de8d2e51bd6f958e6", + "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json": "f5799f7437434d51346647f8e1c7525f79d154b7e84d1b2e27124de1d6c1169f", + "test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json": "b48ecb344888c0c85d3d35c33ec5bffe94922405a61323bf49f4515e658fc29f", + "test/architecture/hosted-web/phase-1/contracts/revision.test.ts": "608fd717e7eece76c6ea0f3fcc37af26d6047ff94da7e60a34eb4c5deb061e42" + } + }, + "scopeProof": { + "ownedPathsMatchedBaseBeforeWork": true, + "nonOwnedPathsMatchPhaseStartSha": true, + "phaseStartDiffFromBaseIsRouterDocumentationOnly": true, + "changedAndUntrackedSecretScan": "zero matches", + "p1s2Status": "blocked" + }, + "unverifiedClaims": [ + "independent integration review acceptance", + "controller integration", + "complete Phase 1", + "P1.S2 authorization", + "production hosted behavior", + "production identity mapping" + ], + "blockers": [ + "base_failure: pnpm typecheck reports only the unchanged inherited Phase 0 test-tree diagnostics fingerprinted in checks" + ], + "adrRecommendations": [], + "estimateBuckets": [], + "nextAction": "Independently review this remediation candidate against the quoted finding and, only if accepted, integrate it on top of the router packet commit while keeping P1.S2 blocked." +} diff --git a/.codex-handoff/phase-02-p2-a.json b/.codex-handoff/phase-02-p2-a.json new file mode 100644 index 0000000000..de53ebb477 --- /dev/null +++ b/.codex-handoff/phase-02-p2-a.json @@ -0,0 +1,284 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.A", + "laneId": "p2-a-workspace-identity", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "status": "verified", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.A.RUNTIME_CONTEXT", + "path": "test/features/runtime-instance-context/RuntimeInstanceContext.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.A.GLOBAL_PATH_RATCHET", + "path": "test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts", + "proofLevel": "target_verified" + } + ], + "evidenceIds": ["P2.A.RUNTIME_CONTEXT", "P2.A.GLOBAL_PATH_RATCHET"], + "authority": { + "routerBaseSha": "d5afa87e79b1f2badd69e65262e5699c0fb61de7", + "foundationIntegrationCommit": "03ac8bc544953110ce4a835eea6aefc65f2a5ba6", + "foundationIntegrationHandoffPath": ".codex-handoff/phase-02-p2-if.json", + "foundationIntegrationHandoffSha256": "80c867114c55b44d4da9c5d918fc3e94a2fc0c3d71689b72e014b0da5b4fc07d", + "foundationIntegrationEvidencePath": "docs/research/hosted-web/phase-2/foundation-integration.json", + "foundationIntegrationEvidenceSha256": "dd90c4ce7bd7ea633320cf9496fdd306e2cba8f980d1faecbb49529b387acb41", + "foundationIntegrationIsAncestorOfCurrentBase": true, + "reviewedProducerBaseSha": "03ac8bc544953110ce4a835eea6aefc65f2a5ba6", + "admissionCanonicalBaseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "workspaceHeadAtHandoff": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "workspaceHeadMatchesAdmission": true, + "packetRevisionMatchesExecutionIndex": true, + "p2ADependencyMatchesExecutionIndex": true, + "p2AOwnershipMatchesExecutionIndex": true, + "reviewedProducerSourceAndTestBytesPreserved": true, + "baseDriftRequiredProductEdit": false, + "authorityMismatchCount": 0 + }, + "mandatoryReads": { + "commonContractOrderCompleted": true, + "assignedPacketCompleted": true, + "numberedLaneReadsCompletedInOrder": true, + "masterPlanHeading": "Phase 2: identity substrate and externally read-only team lifecycle", + "masterPlanTask": 1 + }, + "declaredWritablePaths": [ + "src/features/runtime-instance-context/contracts/runtime-instance-context.ts", + "src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts", + "test/features/runtime-instance-context/RuntimeInstanceContext.test.ts", + "test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts", + ".codex-handoff/phase-02-p2-a.json" + ], + "changedPaths": [ + ".codex-handoff/phase-02-p2-a.json", + "src/features/runtime-instance-context/contracts/runtime-instance-context.ts", + "src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts", + "test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts", + "test/features/runtime-instance-context/RuntimeInstanceContext.test.ts" + ], + "changedPathCount": 5, + "contentHashes": { + "src/features/runtime-instance-context/contracts/runtime-instance-context.ts": "4fdd3c006c5d63f29742dd2691d1811f77ef4a8c860b85170565d2109f2a96ee", + "src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts": "0306fd39c8cc67c6a69ddafb58d6244ec45a183c46f6bb6ab446977c5378631a", + "test/features/runtime-instance-context/RuntimeInstanceContext.test.ts": "d03ff8193a79c96f0d45623168e1a4d07632e4505226e8474094145db3ebfbdd", + "test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts": "3e1e80e2f6009250b6172416a69c57df660873584a403b9d6751749aaddbbd53" + }, + "baseDriftAdoption": { + "reviewedOutputId": "1a5c15334d7ec0900fbac80404bb6a9f6de0e9773bcf2051230f982fbee0a537", + "reviewedComponentPatchSha256": "4e1a46b462d2c9d7fe85d457a9ad4f87f86a5035fb9d166c1e14ce350dfee7a1", + "reviewedComponentPatchByteLength": 29656, + "terminalMaterializedPatchSha256": "3e790ba583ff0313240b4eb620daff5fe9da1aa35515fc3b105ed1dda7dc3753", + "terminalMaterializedPatchByteLength": 29652, + "terminalDifferenceClassification": "the controller-admitted terminal handoff differs from the reviewed component patch only by four blank diff-separator bytes", + "blankDiffSeparatorByteDelta": 4, + "reviewedPathCount": 5, + "reviewedSourceAndTestPathCount": 4, + "reviewedSourceAndTestBytesPreserved": true, + "ownedPathOverlapWithBaseDrift": false, + "genuineBaseDriftInteractions": [ + "current base caches phase-one identifier regular expressions in the accepted hosted identifier kernel", + "deployment and boot identifier prefixes, validation grammar, length limit, return values and failure semantics used by RuntimeInstanceContext remain unchanged" + ], + "resolution": "no product or test edit was required; the reviewed RuntimeInstanceContext behavior is preserved exactly while the handoff authority and fresh evidence are rebound to current canonical", + "currentBaseIdentifierRegressionPassed": true + }, + "contract": { + "canonicalDeploymentIdParserReused": true, + "canonicalBootIdParserReused": true, + "rootReferencesAreOpaqueBrandedValues": true, + "rootReferenceKinds": ["claude", "app-data", "workspace", "temp", "logs"], + "crossKindRootReferencesRejected": true, + "unknownFieldsRejected": true, + "accessorAndNonPlainRecordsRejected": true, + "mutableReferenceContainersRejected": true, + "sourceValuesDefensivelyCopied": true, + "deeplyFrozenOutput": true, + "operationAuthorizationExposed": false, + "processLaunchExposed": false, + "orchestrationPolicyExposed": false, + "serviceLocatorExposed": false, + "filesystemReadPerformed": false, + "mutableGlobalReadPerformed": false, + "compositionWiringPerformed": false + }, + "proofs": { + "P2.A.RUNTIME_CONTEXT": { + "proofLevel": "target_verified", + "positiveCases": [ + "canonical deployment and boot identity parsing", + "opaque kind-specific root references", + "deep freezing of context, nested references and workspace-root collection", + "defensive copying against post-creation source mutation", + "two isolated contexts in one test process" + ], + "negativeCases": [ + "missing context values", + "unknown top-level and nested fields", + "cross-kind deployment, boot and root references", + "non-string mutable root-reference values", + "accessor-backed values", + "custom record and array prototypes", + "sparse workspace-root collections", + "mutation of frozen outputs" + ] + }, + "P2.A.GLOBAL_PATH_RATCHET": { + "proofLevel": "target_verified", + "positiveCases": [ + "contract and domain sources pass the boundary scanner", + "domain imports only the shared value kernel and feature-local contract" + ], + "negativeCases": [ + "Node filesystem, path and process imports", + "child-process, Electron and main-process imports", + "mutable exported let and object state", + "launch/provider service-locator responsibility" + ] + } + }, + "checks": [ + { + "command": "pnpm exec vitest run test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 37, + "testsTotal": 37, + "toolVersion": "vitest 3.2.6" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 36, + "testsTotal": 36, + "classification": "focused regression proof for the only genuine current-base interaction" + }, + { + "command": "pnpm lint:fast:files -- src/features/runtime-instance-context/contracts/runtime-instance-context.ts src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts", + "exitCode": 0, + "errorCount": 0, + "warningCount": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 0, + "diagnosticCount": 0, + "classification": "clean at current canonical with the complete P2.A lane diff" + }, + { + "command": "pnpm exec prettier --check src/features/runtime-instance-context/contracts/runtime-instance-context.ts src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts .codex-handoff/phase-02-p2-a.json", + "exitCode": 0, + "checkedPathCount": 5 + }, + { + "command": "git diff --check && git diff --cached --check && git diff HEAD --check", + "exitCode": 0 + }, + { + "command": "git status --short --untracked-files=all", + "exitCode": 0, + "classification": "all five declared additions remain staged; only the declared lane handoff has the final base-drift adoption evidence update unstaged over its staged predecessor bytes" + }, + { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' src/features/runtime-instance-context/contracts/runtime-instance-context.ts src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts .codex-handoff/phase-02-p2-a.json", + "exitCode": 0, + "observedMatchLineCount": 2, + "classification": "the false prohibited-operation flag and scanner command only; no sensitive payload" + }, + { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' src/features/runtime-instance-context/contracts/runtime-instance-context.ts src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts .codex-handoff/phase-02-p2-a.json", + "exitCode": 0, + "observedMatchLineCount": 1, + "classification": "scanner command only; no personal or ambient absolute path value" + } + ], + "scopeProof": { + "declaredPathCount": 5, + "actualPathCount": 5, + "unexpectedPathCount": 0, + "stagedProducerPathCount": 5, + "unstagedBaseDriftHandoffPathCount": 1, + "barrelOrIndexPathChanged": false, + "compositionPathChanged": false, + "legacyPathUtilityChanged": false, + "siblingLanePathChanged": false, + "onlyDeclaredP2APathsChanged": true, + "gitStatusClassification": "all five declared additions remain staged; only the declared lane handoff has the final base-drift adoption evidence update unstaged over its staged predecessor bytes" + }, + "classifiedScans": { + "secretCredentialScan": { + "scopePathCount": 5, + "exitCode": 0, + "observedMatchLineCount": 2, + "allMatchesClassified": true, + "classification": "the false prohibited-operation flag and scanner command only; no sensitive payload" + }, + "privatePathScan": { + "scopePathCount": 5, + "exitCode": 0, + "observedMatchLineCount": 1, + "allMatchesClassified": true, + "classification": "scanner command only; no personal or ambient absolute path value" + }, + "highConfidenceSecretSignatureScan": { + "scopePathCount": 5, + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no high-confidence secret, access-key or private-key signature" + } + }, + "selfReview": { + "completeDiffReread": true, + "completeRebasedProductDiffReread": true, + "reviewedLaneBehaviorPreserved": true, + "currentBaseBehaviorPreserved": true, + "genuineBaseDriftInteractionsClassified": true, + "cleanArchitectureDependencyDirectionConfirmed": true, + "dddValueObjectInvariantsConfirmed": true, + "solidSingleResponsibilityConfirmed": true, + "interfaceSegregationConfirmed": true, + "browserSafeValueOnlyContractConfirmed": true, + "domainRuntimeIndependenceConfirmed": true, + "transportBoundaryConfirmed": true, + "deepImmutabilityConfirmed": true, + "strictFailClosedValidationConfirmed": true, + "safeNonReflectiveErrorConfirmed": true, + "noMutableExportedStateConfirmed": true, + "noMutableGlobalReadOrWriteConfirmed": true, + "noSiblingDependencyConfirmed": true, + "exactFivePathScopeConfirmed": true, + "secretAndPrivatePathClassificationsConfirmed": true, + "unsupportedClaimsAbsent": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "serial composition adoption of RuntimeInstanceContext", + "migration or removal of existing mutable global root reads", + "production root-reference allocation, resolution, containment or filesystem behavior", + "sibling P2.B through P2.E completion", + "fresh independent P2.R1 architecture/security acceptance", + "Phase 2 serial integration, milestone acceptance, commit, push, remote equality or successor launch", + "fresh independent remote-ref equality beyond the supplied bd6ac038c920180ee5398b96f2dbdc3d6f035e77 admission" + ], + "blockers": [], + "adrRecommendations": [], + "estimateBuckets": [], + "generatedAt": "2026-07-16T17:47:15Z", + "nextAction": "HOLD for controller-directed exact-five rematerialization and fresh P2.R1 review after all lane adoptions finish; do not launch successors" +} diff --git a/.codex-handoff/phase-02-p2-b.json b/.codex-handoff/phase-02-p2-b.json new file mode 100644 index 0000000000..ed62ca1563 --- /dev/null +++ b/.codex-handoff/phase-02-p2-b.json @@ -0,0 +1,274 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.B", + "laneId": "p2-b-team-identity", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "status": "verified", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.B.IDENTITY_RECORDS", + "path": "src/features/internal-storage/contracts/teamIdentityStorageContracts.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.B.IDENTITY_TOMBSTONES", + "path": "src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.B.ADOPTION_INTENTS", + "path": "src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts", + "proofLevel": "target_verified" + } + ], + "evidenceIds": ["P2.B.IDENTITY_RECORDS", "P2.B.IDENTITY_TOMBSTONES", "P2.B.ADOPTION_INTENTS"], + "authority": { + "workspaceHead": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "foundationIntegrationHandoff": ".codex-handoff/phase-02-p2-if.json", + "foundationIntegrationStatus": "verified", + "foundationAuthority": "activated", + "foundationIntegrationEvidence": "docs/research/hosted-web/phase-2/foundation-integration.json", + "foundationSourceCommit": "03ac8bc544953110ce4a835eea6aefc65f2a5ba6", + "foundationPresentAtCurrentBase": true, + "packetRevisionMatchedExecutionIndex": true, + "writablePathsMatchedExecutionIndex": true, + "reviewedProducerSourceAndTestBytesPreserved": true, + "baseDriftRequiredProductEdit": false + }, + "declaredWritablePaths": [ + "src/features/internal-storage/contracts/teamIdentityStorageContracts.ts", + "src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts", + "src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts", + "test/features/internal-storage/TeamIdentityStorage.test.ts", + ".codex-handoff/phase-02-p2-b.json" + ], + "changedPaths": [ + "src/features/internal-storage/contracts/teamIdentityStorageContracts.ts", + "src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts", + "src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts", + "test/features/internal-storage/TeamIdentityStorage.test.ts", + ".codex-handoff/phase-02-p2-b.json" + ], + "changedPathCount": 5, + "scopeProof": { + "onlyDeclaredWritablePathsChanged": true, + "sharedSchemaMigrationProtocolWorkerCoreCompositionOrIndexChanged": false, + "siblingPathChanged": false, + "stagedProducerPathCount": 5, + "unstagedBaseDriftHandoffPathCount": 1, + "unexpectedPathCount": 0, + "gitStatusClassification": "all five declared additions remain staged; only the declared lane handoff has the final base-drift adoption evidence update unstaged over its staged predecessor bytes" + }, + "baseDriftAdoption": { + "previousProducerBaseSha": "03ac8bc544953110ce4a835eea6aefc65f2a5ba6", + "admissionCanonicalBaseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "currentBaseHeadMatchedAdmission": true, + "ownedSourceOrTestPathOverlapWithBaseDrift": false, + "reviewedSourceAndTestPatchSha256": "8d119a1713d2b0a50818dd9517e698a1b8e1d9dc2c9043eb0c220cf7e56f913d", + "reviewedSourceAndTestBytesChangedForAdoption": false, + "genuineInteractions": [ + "current base adds storeImports.has to the shared internal-storage worker protocol and core without changing this lane's isolated operation registration boundary", + "current base keeps shared internal-storage schema version 4, so this lane's reserved serial-integration migration version 5 remains collision-free", + "current base caches phase-one identifier regular expressions without changing canonical TeamId or WorkspaceId parsing used by this lane" + ], + "resolution": "no lane product change was required; current-base behavior and the reviewed lane source/test bytes are both preserved", + "currentBaseRegressionTestPassed": true + }, + "remediation": { + "legacyTeamKey": { + "exactPattern": "^[a-z0-9][a-z0-9-]{0,127}$", + "silentTrimCaseFoldOrUnicodeNormalization": false, + "crossPlatformReservedNamesRejected": true, + "parserAndSQLitePolicyMatched": true + }, + "publicationState": { + "states": ["prepared", "file_published", "committed"], + "publishedTimestampDurable": true, + "publishedChecksumDurable": true, + "commitBeforePublicationRejected": true, + "restartRecoveryFromFilePublishedVerified": true, + "p2DPublishEvidenceAdapterCompatible": true + }, + "schemaProvenance": { + "componentSchemaVersion": 1, + "reservedIntegrationMigrationVersion": 5, + "exactDefinitionCount": 23, + "tableDefinitionCount": 4, + "indexDefinitionCount": 11, + "triggerDefinitionCount": 8, + "exactTypeNameTableAndSqlCompared": true, + "lookalikeAndUnexpectedObjectsRejected": true + } + }, + "implementation": { + "contract": { + "canonicalTeamId": true, + "exactLegacyKey": true, + "directoryFingerprint": true, + "workspaceBindingGeneration": true, + "preparedFilePublishedAndCommittedAdoptionStates": true, + "rawDatabaseRowsExposed": false + }, + "schema": { + "teamIdPrimaryKey": true, + "directoryFingerprintUnique": true, + "identityChecksumUniqueWhenPublished": true, + "activeLegacyKeyOwnerUnique": true, + "tombstonedKeyDeleteUpdateAndReplaceBlocked": true, + "drizzleAndRawDdlColumnsMatched": true, + "migrationFragmentIdempotent": true + }, + "operations": { + "reserveIdentity": true, + "prepareAdoption": true, + "recordIdentityFilePublished": true, + "commitAdoption": true, + "tombstoneLegacyKey": true, + "exactPreparePublishAndCommitRetryIdempotent": true, + "postTombstoneCommitRetryDoesNotReactivate": true, + "mismatchChecksumTamperingSchemaAndIllegalTransitionDistinct": true, + "nameBasedReplacement": false, + "lastWriteWinsRepair": false + } + }, + "testProof": { + "testFileCount": 1, + "testFilePassedCount": 1, + "testCount": 17, + "testPassedCount": 17, + "positive": [ + "idempotent raw-DDL installation and Drizzle-column parity", + "canonical identity reservation and typed record projection", + "durable prepared -> file_published -> committed restart recovery", + "exact prepare, publication and commit retry convergence", + "committed adoption retry after tombstone without reactivation", + "immutable tombstone creation and marker-checked cleanup" + ], + "negative": [ + "uppercase, whitespace, Unicode, punctuation, path and overlength LegacyTeamKey inputs", + "Windows device names AUX/CON/NUL/PRN/COM1-COM9/LPT1-LPT9", + "parser-bypassed invalid and reserved LegacyTeamKey inserts at SQLite", + "duplicate TeamId, directory fingerprint and exact LegacyTeamKey", + "commit before publication and commit timestamp before publication", + "prepare, publication, commit and checksum mismatch", + "stored intent checksum tampering and unknown row state", + "same-name wrong trigger/index definitions, altered table SQL and extra component trigger", + "tombstone update, delete, replace and reuse", + "illegal commit after prepared or file-published identity tombstone", + "missing component schema and invalid canonical input before writes", + "unmarked, pre-existing, ambient, home, path-escaped and symlink-escaped test roots" + ], + "testDatabaseTopology": "fresh marker-owned temporary runtime root", + "cleanup": "exact marker and real-path checked before narrow recursive removal" + }, + "checks": [ + { + "command": "pnpm exec vitest run test/features/internal-storage/TeamIdentityStorage.test.ts", + "exitCode": 0, + "testFilesPassed": 1, + "testFilesTotal": 1, + "testsPassed": 17, + "testsTotal": 17 + }, + { + "command": "pnpm exec vitest run test/features/internal-storage/InternalStorageWorkerCore.test.ts", + "exitCode": 0, + "testFilesPassed": 1, + "testFilesTotal": 1, + "testsPassed": 14, + "testsTotal": 14, + "classification": "focused base-drift regression proof for the current internal-storage worker behavior" + }, + { + "command": "pnpm lint:fast:files -- src/features/internal-storage/contracts/teamIdentityStorageContracts.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts test/features/internal-storage/TeamIdentityStorage.test.ts", + "exitCode": 0, + "errorCount": 0, + "warningCount": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 0, + "diagnosticCount": 0, + "classification": "clean" + }, + { + "command": "pnpm exec prettier --check src/features/internal-storage/contracts/teamIdentityStorageContracts.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts test/features/internal-storage/TeamIdentityStorage.test.ts .codex-handoff/phase-02-p2-b.json", + "exitCode": 0, + "checkedPathCount": 5 + }, + { + "command": "git diff --check && git diff --cached --check && git diff HEAD --check", + "exitCode": 0 + } + ], + "classifiedScans": { + "secretCredential": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' ", + "exitCode": 0, + "observedMatchLineCount": 4, + "allMatchesClassified": true, + "classification": "the scanner command, this classification, explicit workspace-authorization boundary and unverified-claim language only; no secret, credential, cookie, bearer or authorization value" + }, + "privatePath": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' ", + "exitCode": 0, + "observedMatchLineCount": 2, + "allMatchesClassified": true, + "classification": "the scanner command and this classification only; no private, home, temporary or real-project path value" + }, + "highConfidenceSecretSignature": { + "command": "rg -n '(-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,})' ", + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no high-confidence secret or private-key signature" + } + }, + "selfReview": { + "completeDiffReread": true, + "completeRebasedProductDiffReread": true, + "reviewedLaneBehaviorPreserved": true, + "currentBaseBehaviorPreserved": true, + "genuineBaseDriftInteractionsClassified": true, + "cleanArchitecture": "browser-safe contracts remain separate from main-process SQLite infrastructure", + "ddd": "canonical identity, exact legacy-key reservation, publication evidence, tombstone and adoption terminology own the storage invariants", + "solid": "schema declarations, transactional operations and safe test topology have separate reasons to change", + "dependencyDirectionValid": true, + "transportNeutral": true, + "filesystemIdentityPublicationAbsent": true, + "workspaceAuthorizationAbsent": true, + "criticalJsonFallbackAbsent": true, + "compositionAndWorkerRegistrationAbsent": true, + "exactSchemaDefinitionReviewComplete": true, + "transitionInvariantReviewComplete": true, + "safeRootAdmissionReviewComplete": true, + "secretAndPrivatePathClassificationComplete": true, + "unsupportedClaimsAbsent": true, + "findingsResolved": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "shared internal-storage migration and worker protocol registration", + "shared worker-core and composition wiring", + "durable restart through the registered shared worker", + "filesystem team.identity.json exclusive publication, fsync or recovery", + "workspace authorization, transport, Electron, HTTP, IPC or renderer behavior", + "independent architecture/security acceptance, integration, commit, push or Phase 2 milestone acceptance", + "fresh independent remote-ref equality beyond the supplied bd6ac038c920180ee5398b96f2dbdc3d6f035e77 admission" + ], + "blockers": [], + "generatedAt": "2026-07-16T17:36:17Z", + "nextAction": "after all five producers finish, the controller may request only combined P2.R1.ARCH_SECURITY" +} diff --git a/.codex-handoff/phase-02-p2-c.json b/.codex-handoff/phase-02-p2-c.json new file mode 100644 index 0000000000..293f8b8e77 --- /dev/null +++ b/.codex-handoff/phase-02-p2-c.json @@ -0,0 +1,355 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.C", + "laneId": "p2-c-workspace-binding", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "status": "verified", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.C.WORKSPACE_REGISTRY", + "path": "test/features/workspace-registry/core/WorkspaceRegistration.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.C.MOUNT_GENERATION", + "path": "test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.C.ROOT_ADMISSION", + "path": "test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P1.NEG.TEST_ROOT_ESCAPE", + "path": "test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts", + "proofLevel": "target_verified" + } + ], + "evidenceIds": [ + "P2.C.WORKSPACE_REGISTRY", + "P2.C.MOUNT_GENERATION", + "P2.C.ROOT_ADMISSION", + "P1.NEG.TEST_ROOT_ESCAPE" + ], + "authority": { + "workspaceHead": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "originalProducerInitialWorktreeClean": true, + "remediationInput": "atomically-staged-reviewed-producer-patch", + "remediationMutatedGitIndex": false, + "mandatoryReadOrderCompleted": true, + "packetRevisionMatchesExecutionIndex": true, + "p2CDependencyMatchesExecutionIndex": true, + "p2COwnershipMatchesExecutionIndex": true, + "foundationIntegration": { + "handoffPath": ".codex-handoff/phase-02-p2-if.json", + "status": "verified", + "foundationAuthority": "activated", + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + } + }, + "authorityMismatchCount": 0 + }, + "baseDriftAdoption": { + "reviewedProducerBaseSha": "03ac8bc544953110ce4a835eea6aefc65f2a5ba6", + "currentCanonicalShaFromAdmissionContract": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "workspaceHeadMatchesAdmissionCanonical": true, + "reviewedOutputId": "ed509af199e1c657f0a982e543349929bb685c0bda4fe96a2248e2a115126821", + "reviewedPatchSha256": "e816bb7faee36976aaa5f4bdb406e36d143ea54e75eec03963da028aef3434ed", + "reviewedPatchByteLength": 80116, + "reviewedPatchPathCount": 8, + "reviewedBytesMatchedBeforeHandoffAuthorityRefresh": true, + "reviewedProductionAndTestPathCount": 7, + "reviewedProductionAndTestBytesPreserved": true, + "baseRangeOwnedPathIntersectionCount": 0, + "baseRangeInteraction": "the canonical identifier parser now caches its existing phase-one regular expressions; canonical WorkspaceId parsing and every P2.C contract remain unchanged", + "sourceOrTestAdaptationPathCount": 0, + "handoffAuthorityRefreshPathCount": 1, + "genuineBaseDriftConflictCount": 0, + "liveRemoteQueryPerformed": false + }, + "declaredWritablePaths": [ + "src/features/workspace-registry/contracts/workspace-registration.ts", + "src/features/workspace-registry/core/domain/WorkspaceRegistration.ts", + "src/features/workspace-registry/core/application/AuthorizeWorkspaceOperation.ts", + "src/features/workspace-registry/main/infrastructure/ReadOnlyWorkspaceManifestAdapter.ts", + "test/features/workspace-registry/core/WorkspaceRegistration.test.ts", + "test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts", + "test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts", + ".codex-handoff/phase-02-p2-c.json" + ], + "changedPaths": [ + ".codex-handoff/phase-02-p2-c.json", + "src/features/workspace-registry/contracts/workspace-registration.ts", + "src/features/workspace-registry/core/application/AuthorizeWorkspaceOperation.ts", + "src/features/workspace-registry/core/domain/WorkspaceRegistration.ts", + "src/features/workspace-registry/main/infrastructure/ReadOnlyWorkspaceManifestAdapter.ts", + "test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts", + "test/features/workspace-registry/core/WorkspaceRegistration.test.ts", + "test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts" + ], + "changedPathCount": 8, + "scopeProof": { + "atomicallyStagedOriginalProducerPathCount": 8, + "remediatedAggregatePathCount": 8, + "undeclaredChangedPathCount": 0, + "originalProducerIndexPreserved": true, + "stagedDiffCheckExitCode": 0, + "aggregateDiffCheckExitCode": 0, + "reviewedProductionAndTestBytesPreserved": true, + "baseRangeOwnedPathIntersectionCount": 0, + "handoffOnlyBaseDriftMetadataRefresh": true + }, + "acceptanceProof": { + "workspaceRegistry": { + "stableRegistrationKeyMapping": true, + "startupBaselineMustExplicitlyDeclareEmptyOrPreviousState": true, + "revisionedDisplayChangePreservesWorkspaceId": true, + "workspaceIdReplacementRejected": true, + "declaredRootChangeRejected": true, + "unrevisionedMutationRejected": true, + "removalWithoutDisabledTombstoneRejected": true, + "duplicateKeyRejected": true, + "ambiguousWorkspaceIdRejected": true, + "ambiguousDeclaredRootRejected": true, + "disabledRegistrationRejected": true, + "unknownManifestAndRegistrationVersionsRejected": true + }, + "mountBinding": { + "bootScoped": true, + "predecessorDerivedOnlyFromPreviousSnapshotBindings": true, + "rawManifestPredecessorFieldRemoved": true, + "manifestSuppliedPredecessorRejected": true, + "forgedEmptyDeploymentPredecessorRejected": true, + "forgedNewRegistrationPredecessorRejected": true, + "strictNextGenerationRequired": true, + "initialGenerationMustBeOne": true, + "restartGenerationAdvanceVerified": true, + "priorBootRequestRejected": true, + "staleGenerationRequestRejected": true, + "changedDeclaredRootRejected": true, + "unavailableMountRejected": true, + "readOnlyMountMutationRejected": true + }, + "authorization": { + "operationSpecific": true, + "returnedBindingIdentityMustMatchRequestedWorkspace": true, + "operationTimeRevalidationPortRequired": true, + "uncheckedStartupSnapshotDoesNotImplementAuthorizationPort": true, + "intentIssuerIsModulePrivate": true, + "intentHasNoEnumerableTransportPayload": true, + "jsonSerializationFailsClosed": true, + "structuredCloneCarriesNoAuthority": true, + "rawHostPathAccessorAbsent": true, + "genericFilesystemAccessorAbsent": true + }, + "manifestAdapter": { + "injectedSourceOnly": true, + "preAdmissionRequiredBeforeSourceRead": true, + "forgedAdmissionWrapperRejected": true, + "oneStartupReadMaximum": true, + "writeAbsent": true, + "autoRegistrationAbsent": true, + "ambientScanAbsent": true, + "compositionAbsent": true, + "registrationCollectionLimit": 256, + "registrationCollectionDensityCheckedBeforeIteration": true, + "operationCollectionLimit": 5, + "operationCollectionDensityCheckedBeforeIteration": true, + "oversizedSparseDuplicateAndMalformedCollectionsRejected": true + } + }, + "resourceExhaustionReview": { + "manifestRegistrationsBoundedBeforeElementAccess": true, + "domainRegistrationsBoundedBeforeIteration": true, + "allowedOperationsBoundedBeforeElementAccess": true, + "registrationsMaximum": 256, + "allowedOperationsMaximum": 5, + "denseOwnIndexesRequired": true, + "boundedDuplicateDetection": true, + "attackerControlledUnboundedCollectionIterationAbsent": true + }, + "rootAdmissionNegativeMatrix": [ + { + "case": "unmarked", + "result": "rejected-before-source-read" + }, + { + "case": "pre-existing-or-unowned", + "result": "rejected-before-source-read" + }, + { + "case": "ambient-temporary-directory", + "result": "rejected-before-source-read" + }, + { + "case": "home", + "result": "rejected-before-source-read" + }, + { + "case": "real-project-or-ambient-workspace", + "result": "rejected-before-source-read" + }, + { + "case": "parent-symlink", + "result": "rejected-before-source-read" + }, + { + "case": "final-symlink", + "result": "rejected-before-source-read" + }, + { + "case": "lexical-escape", + "result": "rejected-before-source-read" + } + ], + "testRootSafety": { + "freshMarkerOwnedProjectRootsOnly": true, + "freshMarkerOwnedRuntimeRootsOnly": true, + "realProjectAccessed": false, + "cleanupNarrow": true, + "cleanupRequiresExactMarker": true, + "cleanupRejectsSymlinkRoot": true, + "cleanupRequiresCanonicalTemporaryContainment": true + }, + "checks": [ + { + "command": "pnpm exec vitest run test/features/workspace-registry/core/WorkspaceRegistration.test.ts test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts", + "exitCode": 0, + "testFilesPassed": 3, + "testFilesTotal": 3, + "testsPassed": 28, + "testsTotal": 28 + }, + { + "command": "pnpm lint:fast:files -- src/features/workspace-registry/contracts/workspace-registration.ts src/features/workspace-registry/core/domain/WorkspaceRegistration.ts src/features/workspace-registry/core/application/AuthorizeWorkspaceOperation.ts src/features/workspace-registry/main/infrastructure/ReadOnlyWorkspaceManifestAdapter.ts test/features/workspace-registry/core/WorkspaceRegistration.test.ts test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 0, + "diagnosticCount": 0 + }, + { + "command": "pnpm exec prettier --check src/features/workspace-registry/contracts/workspace-registration.ts src/features/workspace-registry/core/domain/WorkspaceRegistration.ts src/features/workspace-registry/core/application/AuthorizeWorkspaceOperation.ts src/features/workspace-registry/main/infrastructure/ReadOnlyWorkspaceManifestAdapter.ts test/features/workspace-registry/core/WorkspaceRegistration.test.ts test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts .codex-handoff/phase-02-p2-c.json", + "exitCode": 0 + }, + { + "command": "git diff HEAD --check", + "exitCode": 0 + } + ], + "iterationChecks": [ + { + "command": "initial focused Vitest run", + "exitCode": 1, + "classification": "test-only unknown-workspace fixture accidentally selected the helper default; corrected without production behavior change" + }, + { + "command": "initial exact-path fast lint", + "exitCode": 1, + "classification": "seven import-order diagnostics; mechanically corrected on the seven owned TypeScript paths" + }, + { + "command": "ad hoc tsx dependency-scanner invocation", + "exitCode": 1, + "classification": "sandbox environment blocked tsx IPC socket creation with listen EPERM; exact-source dependency scanning was moved into the focused adapter Vitest and passed there" + } + ], + "dependencyBoundaryProof": { + "coreNodeImportCount": 0, + "coreMainImportCount": 0, + "coreTransportImportCount": 0, + "contractRawHostPathFieldCount": 0, + "mainImportsCoreInwardOnly": true, + "exactProductionSourceDependencyScannerPassedInFocusedTest": true, + "publicBarrelEdited": false, + "productionCompositionEdited": false, + "siblingLaneImported": false + }, + "toctouReview": { + "authorizationBindingReadCountPerRequest": 1, + "authorizationCheckHasNoAwaitBoundary": true, + "operationTimeRevalidatedBindingRequiredByPort": true, + "mountPredecessorReadOnlyFromTrustedPreviousSnapshotBindings": true, + "manifestPredecessorAuthorityAbsent": true, + "startupManifestSnapshotIntentionallyCannotSatisfyAuthorizationPort": true, + "preAdmittedSourceOwnsCanonicalizationAndFilesystemProbePolicy": true, + "adapterNeverReceivesOrExposesAWorkspaceRawPath": true, + "liveFilesystemRevalidatorCompositionDeferredToSerialIntegration": true + }, + "classifiedScans": { + "scopePathCount": 8, + "secretCredential": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' ", + "exitCode": 0, + "observedMatchLineCount": 16, + "allMatchesClassified": true, + "classification": "authorization domain names, test assertions, scanner command text and explicit prohibited-payload language only; no secret, credential, cookie, bearer, password or authorization payload" + }, + "privatePath": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' ", + "exitCode": 0, + "observedMatchLineCount": 4, + "allMatchesClassified": true, + "classification": "root-admission negative-case labels, scanner command text and explicit real-project prohibition only; no private, home, task-temporary or real-project path value" + }, + "highConfidenceSecretSignature": { + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true + } + }, + "selfReview": { + "completeProductionSourceReread": true, + "completeFocusedTestReread": true, + "completeDiffReread": true, + "completeRebasedProductDiffReread": true, + "reviewedProductAndTestBytesPreserved": true, + "currentBaseBehaviorPreserved": true, + "genuineBaseDriftInteractionsResolved": true, + "cleanArchitectureConfirmed": true, + "dddAggregateInvariantsConfirmed": true, + "solidNarrowPortsConfirmed": true, + "dependencyInversionConfirmed": true, + "authorizationLeastPrivilegeConfirmed": true, + "resourceExhaustionBoundsConfirmed": true, + "snapshotTrustBoundaryConfirmed": true, + "toctouAssumptionsExplicitlyReviewed": true, + "transportBoundaryConfirmed": true, + "safeStaticErrorsConfirmed": true, + "exactEightPathScopeConfirmed": true, + "secretAndPrivatePathClassificationsConfirmed": true, + "unsupportedClaimsAbsent": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "public workspace-registry barrels and production registration", + "serial composition and persistence reconstruction of the trusted previous startup snapshot", + "serial composition of an operation-time filesystem revalidator", + "live same-boot mount disappearance, replacement, permission-downgrade and fingerprint probes on production filesystems", + "IPC, HTTP, preload, renderer or hosted transport exposure", + "independent P2.R1 architecture/security acceptance", + "Phase 2 serial integration, milestone acceptance, commit, push or fresh remote equality" + ], + "blockers": [], + "adrRecommendations": [], + "estimateBuckets": [], + "generatedAt": "2026-07-16T17:35:28Z", + "nextAction": "HOLD for controller-directed Phase 2 aggregate adoption; do not launch successors" +} diff --git a/.codex-handoff/phase-02-p2-d.json b/.codex-handoff/phase-02-p2-d.json new file mode 100644 index 0000000000..414b0dd6f8 --- /dev/null +++ b/.codex-handoff/phase-02-p2-d.json @@ -0,0 +1,416 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.D", + "laneId": "p2-d-roster-identity", + "packetRevision": "phase-02-jit-router-r1", + "remediationRevision": "p2-d-listing-safe-quarantine-v17-r6", + "adoptedProducerRevision": "p2-d-quarantine-remediation-v17-r5", + "baseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "status": "verified", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.D.IDENTITY_FILE", + "path": "src/features/team-lifecycle/main/infrastructure/TeamIdentityFileStore.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.D.DIRECTORY_LIFECYCLE", + "path": "src/features/team-lifecycle/main/infrastructure/TeamDirectoryLifecycleAdapter.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.D.BACKUP_COMPATIBILITY", + "path": "src/features/team-lifecycle/main/infrastructure/TeamIdentityBackupCompatibility.ts", + "proofLevel": "target_verified" + }, + { + "id": "P1.NEG.TEST_ROOT_ESCAPE", + "path": "test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts", + "proofLevel": "target_verified" + } + ], + "evidenceIds": [ + "P2.D.IDENTITY_FILE", + "P2.D.DIRECTORY_LIFECYCLE", + "P2.D.BACKUP_COMPATIBILITY", + "P1.NEG.TEST_ROOT_ESCAPE" + ], + "authority": { + "workspaceHeadAtStart": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "foundationIntegrationHandoff": ".codex-handoff/phase-02-p2-if.json", + "foundationIntegrationStatus": "verified", + "foundationAuthority": "activated", + "packetRevisionMatchedExecutionIndex": true, + "writablePathsMatchedExecutionIndex": true, + "mandatoryReadOrderCompleted": true, + "r5PatchMatchedAllSevenOwnedPathsAtStart": true, + "r5NoPhysicalDeletionBoundaryPreserved": true, + "r5DescriptorCappedReadsPreserved": true, + "atomicallyStagedOriginalProducerPatchPreserved": true, + "remediationLayerLeftUnstaged": true, + "gitCommitCreated": false, + "gitPushPerformed": false, + "successorLaunched": false, + "realProjectAccessed": false + }, + "changedPaths": [ + "src/features/team-lifecycle/core/application/ports/TeamIdentityPersistence.ts", + "src/features/team-lifecycle/main/infrastructure/TeamIdentityFileStore.ts", + "src/features/team-lifecycle/main/infrastructure/TeamDirectoryLifecycleAdapter.ts", + "src/features/team-lifecycle/main/infrastructure/TeamIdentityBackupCompatibility.ts", + "test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts", + "test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts", + ".codex-handoff/phase-02-p2-d.json" + ], + "changedPathCount": 7, + "checks": [ + { + "command": "pnpm exec vitest run test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 25, + "testsTotal": 25 + }, + { + "command": "pnpm lint:fast:files -- src/features/team-lifecycle/core/application/ports/TeamIdentityPersistence.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityFileStore.ts src/features/team-lifecycle/main/infrastructure/TeamDirectoryLifecycleAdapter.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityBackupCompatibility.ts test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 0, + "diagnosticCount": 0, + "classification": "clean" + }, + { + "command": "pnpm exec prettier --check src/features/team-lifecycle/core/application/ports/TeamIdentityPersistence.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityFileStore.ts src/features/team-lifecycle/main/infrastructure/TeamDirectoryLifecycleAdapter.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityBackupCompatibility.ts test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts .codex-handoff/phase-02-p2-d.json", + "exitCode": 0, + "checkedPathCount": 7 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff HEAD --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --check", + "exitCode": 0, + "classification": "the original seven-path producer patch remains material-whitespace clean in the index" + } + ], + "remediationProof": { + "p1DescriptorCappedReads": { + "findingId": "P1-01", + "descriptorOpenedNoFollow": true, + "maximumBytesConsumedPerRead": 4097, + "wholeFileReadCallsRemoved": true, + "oversizeRejectedBeforeUtf8Decode": true, + "concurrentGrowthRejectedBeforeUtf8Decode": true, + "beforeAndAfterMetadataStable": true, + "parentIdentityRecheckedBeforeAndAfterRead": true, + "identityFileConcurrentGrowthRegression": "verified", + "attemptOwnershipConcurrentGrowthRegression": "verified" + }, + "quarantineOnlyLogicalDeletion": { + "findingId": "P1-03", + "validatedLeafAtomicallyQuarantined": true, + "singleHiddenContainerPerBoundParent": true, + "quarantineContainerPathShape": "parent/.p2-d-removal-quarantine/", + "quarantineContainerDescriptorBound": true, + "quarantineContainerDeviceAndInodeReproved": true, + "movedDeviceAndInodeReprovedAgainstOpenDescriptor": true, + "originalLeafAbsenceRequired": true, + "quarantineContainedByValidatedContainerAndParent": true, + "quarantineContainerAndParentSyncedBeforeSuccess": true, + "teamsRootContainsNoConfigBearingQuarantineLeaf": true, + "realNonrecursiveTeamListingRegression": "verified", + "deletedTeamCannotReappearFromNestedQuarantine": true, + "identityChangeFailsClosedWithoutPhysicalRemoval": true, + "requestPathUnlinkRmdirOrRecursiveRm": false, + "physicalRetentionAndGcDeferredToHostedOperations": true, + "artifactCleanupLeafSwapRegression": "verified", + "teamDeletionLeafSwapRegression": "verified", + "artifactLogicalPathDisappearanceAndRecoveryRegression": "verified", + "teamLogicalPathDisappearanceAndRecoveryRegression": "verified" + }, + "cleanupOwnership": { + "callerPathAndRunIdAreAuthority": false, + "durableAttemptRegistryRequired": true, + "writeOnceOnDiskProvenanceRequired": true, + "registryAndProvenanceRecheckedImmediatelyBeforeQuarantine": true, + "forgedExactProvenanceWithoutRegistryRejected": true, + "onlyDescriptorProvedNestedQuarantineIgnoredForPristineCheck": true, + "nonDirectoryQuarantineImpostorRejected": true + }, + "ancestorSwap": { + "linuxDescriptorPath": "/proc/self/fd//", + "directoryHandlesOpenedNoFollow": true, + "identityFilesOpenedNoFollow": true, + "exclusivePublication": true, + "freshDeviceInodeRealpathRecheckBeforeFallbackMutation": true, + "outsideSentinelsUnchangedInReadPublishCleanupAdversarialTests": true + }, + "p2bRecoveryCompatibility": { + "states": ["prepared", "file_published", "committed", "tombstoned"], + "durableAuthorityLookupRequired": true, + "publicationRecordRereadBeforeCommit": true, + "lostPublicationRecordBlocksCommit": true, + "filePublishedInterruptionCommitsSameIdAndChecksumWithoutRepublish": true, + "missingAfterFilePublishedBlocks": true, + "p2bOwnedPathEdited": false, + "p2bSourceImported": false + } + }, + "adversarialRegressionMatrix": [ + { + "case": "identity file grows beyond its cap after descriptor stat and before descriptor read", + "expected": "consume exactly maxBytes + 1, reject before decoding and preserve the parent binding", + "result": "verified" + }, + { + "case": "attempt ownership file grows beyond its cap after descriptor stat and before descriptor read", + "expected": "consume exactly maxBytes + 1, reject before decoding and retain the artifact", + "result": "verified" + }, + { + "case": "validated attempt leaf is replaced at the atomic quarantine rename boundary", + "expected": "moved inode mismatch blocks logical success; the original logical path disappears while both the validated object and quarantined replacement sentinel remain physically recoverable", + "result": "verified" + }, + { + "case": "validated team leaf is replaced at the atomic quarantine rename boundary", + "expected": "moved inode mismatch blocks logical success after durable tombstone; the original logical path disappears while both the validated object and quarantined replacement sentinel remain physically recoverable", + "result": "verified" + }, + { + "case": "successful artifact cleanup and team deletion through nested quarantine containers", + "expected": "product-visible logical paths are absent, one config-free hidden container remains under each bound parent, and retained content can be recovered by an out-of-lane hosted operation", + "result": "verified" + }, + { + "case": "real TeamConfigReader nonrecursive listing after permanent delete", + "expected": "the original team is visible before deletion but neither the config-free container nor its nested config-bearing recoverable object is listed after deletion", + "result": "verified" + }, + { + "case": "attempt artifact pristine check encounters reserved quarantine name", + "expected": "ignore only a no-follow descriptor-proved nested directory container; reject a same-name non-directory impostor before ownership publication", + "result": "verified" + }, + { + "case": "caller supplies matching relativePath and ownerRunId without durable registry", + "expected": "artifact_ownership_unproven and no removal", + "result": "verified" + }, + { + "case": "caller forges exact on-disk ownership bytes without durable registry", + "expected": "artifact_ownership_unproven and no removal", + "result": "verified" + }, + { + "case": "identity-read ancestor replaced after descriptor acquisition", + "expected": "blocked with outside sentinel unchanged", + "result": "verified" + }, + { + "case": "identity-publication ancestor replaced at final-component open", + "expected": "blocked with no identity written outside", + "result": "verified" + }, + { + "case": "cleanup ancestor replaced before final identity recheck", + "expected": "blocked before quarantine with outside sentinel unchanged", + "result": "verified" + }, + { + "case": "process interruption after durable file_published and before commit", + "expected": "inspect existing file and commit the same durable publication", + "result": "verified" + }, + { + "case": "publication method reports success but durable lookup remains prepared", + "expected": "publication_not_durable and no commit", + "result": "verified" + } + ], + "crashMatrix": [ + { + "state": "prepared_without_file", + "result": "absent/read_only awaiting publication" + }, + { + "state": "file_exists_with_prepared_intent", + "result": "exclusive retry observes the same file, records publication durably, then commits" + }, + { + "state": "file_published_before_commit", + "result": "matching file remains read_only and commits the stored publication evidence" + }, + { + "state": "file_published_without_file", + "result": "missing_after_publication blocked" + }, + { + "state": "committed_without_file", + "result": "missing_after_commit blocked" + }, + { + "state": "partial_corrupt_future_mismatch_or_duplicate", + "result": "blocked without replacement" + } + ], + "rootNegativeMatrix": [ + { + "case": "unmarked_preexisting_ambient_home_or_current_real_project_root", + "result": "root_not_admitted before team access" + }, + { + "case": "symlink_escaped_team_directory", + "result": "unsafe_team_directory with outside sentinel unchanged" + }, + { + "case": "active team-directory ancestor replacement", + "result": "descriptor-bound effect or fail-closed binding mismatch; no outside effect" + }, + { + "case": "test cleanup", + "result": "only exact marker-token roots removed" + } + ], + "backupCompatibilityProof": { + "asyncInventoryIncludesCanonicalIdentity": true, + "shutdownSyncInventoryIncludesCanonicalIdentity": true, + "canonicalIdentityIndependentOfConfigReadiness": true, + "legacyIdentityEvidenceKeptDistinct": true, + "classification": "legacy_unverified", + "recoveryCapability": "not_verified" + }, + "classifiedScans": { + "exactScopePathCount": 7, + "secretCredentialScan": { + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no matching payload indicator" + }, + "privatePathScan": { + "exitCode": 0, + "observedMatchLineCount": 2, + "allMatchesClassified": true, + "classification": "negative-control labels only; no path value" + }, + "highConfidenceSecretSignatureScan": { + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no high-confidence signature" + }, + "flatQuarantinePrefixScan": { + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no former direct-child quarantine-prefix naming remains" + }, + "productionPhysicalDeletionCallScan": { + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no rm, unlink or rmdir call in the four P2.D production paths" + }, + "placeholderScan": { + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no unresolved placeholder marker" + }, + "textIntegrityScan": { + "exitCode": 0, + "pathCount": 7, + "regularFileCount": 7, + "symlinkCount": 0, + "nulByteCount": 0, + "validUtf8Count": 7 + } + }, + "scopeProof": { + "declaredWritablePathCount": 7, + "actualChangedPathCount": 7, + "unexpectedPathCount": 0, + "stagedOriginalPathCount": 7, + "existingTeamServicesEdited": false, + "sharedExportOrCompositionEdited": false, + "p2bOwnedPathEdited": false, + "siblingSourceConsumed": false + }, + "selfReview": { + "completeDiffReread": true, + "cleanArchitecture": "core defines value-only identity and attempt-registry ports; Node filesystem mechanics remain in main infrastructure; later serial composition selects P2.B-backed implementations", + "ddd": "TeamId, LegacyTeamKey, prepared/file_published/committed authority, write-once publication evidence, attempt ownership and durable tombstones are explicit domain language", + "solid": "identity registry, attempt ownership registry, identity publication, directory lifecycle and backup compatibility expose separate narrow responsibilities", + "filesystemSecurity": "directory descriptors bind effects to admitted directories; no-follow capped reads consume at most maxBytes + 1 and reject size or metadata drift before decoding; request-path deletion ends after atomic rename into one descriptor-proved parent/.p2-d-removal-quarantine/ container, moved-inode reproof and container/parent fsync, with no unlink, rmdir or recursive rm", + "cleanupCorrectness": "logical attempt removal requires matching durable registry state plus canonical write-once provenance, repeats both checks, atomically nests the validated leaf, fails closed unless the moved inode still matches, ignores only a descriptor-proved nested quarantine container during pristine ownership checks, and keeps all quarantined bytes recoverable", + "listingInvisibility": "real TeamConfigReader nonrecursive semantics see the team before delete and no team after delete; teamsRoot retains only a config-free hidden container while the config-bearing object stays one level deeper and recoverable", + "sameUidRaceBoundary": "pure Node/POSIX check-then-unlink cannot secure a quarantined final name against a same-UID swap; physical retention and GC remain a separate hosted-operations concern outside this product lane", + "recoveryCorrectness": "commit consumes only a freshly reread durable file_published record; missing or mismatched publication evidence blocks and committed absence never republishes automatically", + "transportBoundary": "no Electron, IPC, HTTP, Fastify, renderer or preload dependency was introduced", + "identityImmutability": "exclusive create with mode 0600 only; no overwrite, rename replacement or replacement TeamId path exists", + "dependencyDirection": "P2.D depends on its own application ports and the integrated identifier kernel, never on P2.B or P2.C implementation source", + "exactScopeConfirmed": true, + "scanClassificationsConfirmed": true, + "unsupportedClaimsAbsent": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "resolvedReviewFindings": [ + { + "id": "P1-01", + "severity": "P1", + "status": "remediated", + "proof": "descriptor-bound no-follow capped reads plus deterministic identity and ownership concurrent-growth regressions" + }, + { + "id": "P1-02", + "severity": "P1", + "status": "remediated", + "proof": "quarantine-only logical deletion with moved-inode reproof, parent fsync, no physical request-path removal, and deterministic artifact/team sentinel recovery regressions" + }, + { + "id": "P1-03", + "severity": "P1", + "status": "remediated", + "proof": "single nested quarantine container per bound parent with descriptor/container/moved-inode reproof, container and parent fsync, real nonrecursive listing invisibility, recoverability, and no physical request-path removal" + } + ], + "unverifiedClaims": [ + "existing create, delete, provisioning cleanup and backup call-site routing; those paths remain reserved to serial integration", + "the concrete P2.B and P2.C adapters and production composition", + "full backup recovery or a coordinated recovery point", + "the full ADR-28 openat2 hosted guard and final-runtime active-race topology", + "physical quarantine retention policy and garbage collection, which are hosted-operations concerns outside P2.D", + "production Electron, transport, provider and runtime behavior", + "combined architecture/security acceptance, serial integration, milestone acceptance, commit and push" + ], + "blockers": [], + "adrRecommendations": [], + "estimateBuckets": [ + { + "id": "EST-IDENTITY-WORKSPACE", + "classification": "bounded P2.D remediation within the existing identity/workspace envelope" + } + ], + "generatedAt": "2026-07-16T18:04:35Z", + "nextAction": "controller review of this bounded seven-path remediation only; HOLD with no successor, integration, commit or push initiated" +} diff --git a/.codex-handoff/phase-02-p2-e.json b/.codex-handoff/phase-02-p2-e.json new file mode 100644 index 0000000000..5dd4b61b31 --- /dev/null +++ b/.codex-handoff/phase-02-p2-e.json @@ -0,0 +1,305 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.E", + "laneId": "p2-e-legacy-adoption", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "status": "verified", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.E.READ_USE_CASES", + "path": "test/features/team-lifecycle/TeamLifecycleReadApi.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.E.TRANSPORT_NEUTRAL_API", + "path": "test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.E.LEGACY_READ_ADAPTER", + "path": "src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts", + "proofLevel": "target_verified" + } + ], + "evidenceIds": ["P2.E.READ_USE_CASES", "P2.E.TRANSPORT_NEUTRAL_API", "P2.E.LEGACY_READ_ADAPTER"], + "authority": { + "workspaceHeadAtAdmission": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "controllerSuppliedCanonicalSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "workspaceHeadMatchesControllerCanonicalSha": true, + "reviewedProducerBaseSha": "03ac8bc544953110ce4a835eea6aefc65f2a5ba6", + "reviewedOutputId": "2a79cdda9928a04b1e83f0fc5e58b7d9d4ef8d356c8d6230bbbfe57933a0b461", + "reviewedProducerPatchSha256": "7a854bfb3f8571717d80fa376e7aa2fa4d0f366eac3196bb2189b2ddf4cc2b01", + "reviewedProductGitDiffSha256": "a5a295250e6fe423dfa2adea409dcba934e5889ee5ab64d9059b7c993d5a1d43", + "reviewedProducerJobId": "agent-teams-hosted-web-refactor-p2-e-legacy-adoption-remediation-v17-r2", + "reviewedProductBytesMatchSource": true, + "reviewedProductByteMatchCount": 9, + "reviewedHandoffReplacedOnlyByThisAdoptionRecord": true, + "ownedPathBaseDriftCount": 0, + "ownedPathBaseDriftPaths": [], + "dependencyBaseDriftPaths": [ + "src/shared/contracts/hosted/app-error.ts", + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/query-context.ts", + "src/shared/contracts/hosted/revision.ts" + ], + "dependencyBaseDriftClassification": "compatible shared-contract hardening; no lane product-byte change required", + "acceptedFoundationIntegrationSha": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "acceptedFoundationIntegrationIsAncestor": true, + "acceptedIdentifierSha256": "73978dd8871f3af363810b9a90b4a42b464982a25898eac082677b9557d1dc41", + "acceptedHostedContractIndexSha256": "a11b722edd3a9fb1b4ea451bbbf2f01703a93897515092ceefc6bef5157bbac2", + "initialWorktreeClean": false, + "reviewedOutputMaterializedAtAdmission": true, + "packetRevisionMatchesExecutionIndex": true, + "ownershipMatchesExecutionIndex": true + }, + "declaredWritablePaths": [ + "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts", + "src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts", + "src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts", + "src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts", + "src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts", + "src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts", + "test/features/team-lifecycle/TeamLifecycleReadApi.test.ts", + "test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts", + ".codex-handoff/phase-02-p2-e.json" + ], + "changedPaths": [ + "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts", + "src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts", + "src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts", + "src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts", + "src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts", + "src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts", + "test/features/team-lifecycle/TeamLifecycleReadApi.test.ts", + "test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts", + ".codex-handoff/phase-02-p2-e.json" + ], + "changedPathCount": 10, + "contentHashes": { + "src/features/team-lifecycle/contracts/team-lifecycle-read.ts": "82b0fd6154bb397969af9a232b60566c4e9cec5565fbc204519143d63f2e19ef", + "src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts": "771f659956fc2007746641d3bc1c2f9b2babfaf98be567033a3693c778093a88", + "src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts": "72cb51010e4165e87ce54612dca1747cf5d7a0fae429526bb79d100f1810f6f4", + "src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts": "fadfbe6ebc4c81353f78904ecda2101cd933f695cf565c25ae3a8aff7a7c2d19", + "src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts": "8119a03c005e291e37cb5a2c8a9ef862b09acd406ceffe6f2373cc4d112c0826", + "src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts": "eb19ccab7cfe6720a89c18e848f69d7e075d238d98d76ad1666fb957c7adca9d", + "src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts": "28d1b3684519423872a055f58edfcdbcdc07adec3b7aed07fa345142e81d41e0", + "test/features/team-lifecycle/TeamLifecycleReadApi.test.ts": "850a9902e2ba71dd371026176dda647ee594d0e677d22006bdc59a802fe5b2da", + "test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts": "5b150345d2f8776e9ec2483ea90b624de2e1ad87d54b779873ac8cf580d420e8", + ".codex-handoff/phase-02-p2-e.json": null + }, + "implementation": { + "canonicalIdentity": "strict TeamId and WorkspaceId parsing on every Phase 2 entity and list item", + "schemaBehavior": "exact-key request rejection; known-field response projection with safe unsupported-version failures", + "revisionAndCursorBehavior": "opaque revision/cursor parsing, expected-revision conflict, bounded dense pages, duplicate rejection, deterministic ordering", + "queryContextPropagation": "the exact QueryContext object reaches every identity, legacy data and legacy runtime read without cloning or ambient reconstruction", + "preIoPolicy": "synchronous authorization, cancellation and deadline admission runs before every legacy I/O; follow-up reads recheck policy after awaited work", + "returnedTeamIdentity": "legacy detail and runtime teamName values must equal the identity-bound requested legacy key; alive identity pages may contain only bounded runtime-returned names", + "apiFacetMethods": [ + "listTeamLifecycle", + "getTeamLifecycleSnapshot", + "getRuntimeStateProjection", + "listAliveTeamProjections" + ], + "hostedMutationCapabilitiesAdded": false, + "ipcHttpRendererOrCompositionWiringAdded": false, + "sharedBarrelEdited": false + }, + "semanticFixtures": { + "phaseOneCorpus": [ + "current/success", + "empty", + "not-found", + "draft", + "provisioning", + "corrupt", + "partial", + "unavailable", + "stale", + "unexpected" + ], + "phaseTwoFocused": [ + "canonical deterministic list", + "canonical lifecycle snapshot", + "canonical runtime projection", + "canonical alive projection", + "draft", + "provisioning", + "corrupt", + "partial", + "unavailable", + "stale revision", + "unexpected source failure", + "invalid cursor", + "identity cross-attachment", + "additive-field discard", + "private-path-shaped display rejection", + "Phase 1 display-name then TeamId tie ordering", + "policy-before-I/O call ordering", + "exact QueryContext propagation to every legacy read", + "unauthorized context rejected before I/O", + "pre-cancelled context rejected before I/O", + "expired context rejected before I/O", + "cancellation before bounded alive identity resolution", + "deadline expiry before follow-up data read", + "wrong-team detail response", + "wrong-team runtime response", + "foreign alive identity binding" + ] + }, + "boundaryNegatives": [ + "core outer-runtime import", + "protocol-specific API surface", + "mutation method on read facet", + "raw team-name or project-path browser field", + "legacy name outside driven compatibility source", + "application invocation before validation", + "composition or production registration", + "synthetic or cross-kind canonical identity", + "sparse, duplicate, oversized or non-alive canonical page item", + "raw application/source error leakage", + "legacy read before authorization/deadline/cancellation admission", + "QueryContext copy or omission at identity/data/runtime ports", + "follow-up alive identity read after cancellation", + "follow-up data read after deadline expiry", + "wrong-team detail/runtime/alive cross-attachment" + ], + "checks": [ + { + "command": "pnpm exec vitest run test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 3, + "testFilesTotal": 3, + "testsPassed": 22, + "testsTotal": 22 + }, + { + "command": "pnpm lint:fast:files -- src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts", + "exitCode": 0, + "errorCount": 0, + "warningCount": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 0, + "diagnosticCount": 0 + }, + { + "command": "pnpm exec prettier --check src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts .codex-handoff/phase-02-p2-e.json", + "exitCode": 0 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --check", + "exitCode": 0, + "classification": "the staged reviewed producer patch has no whitespace error" + }, + { + "command": "git diff HEAD --check", + "exitCode": 0, + "classification": "the complete rebased product diff plus unstaged adoption handoff has no whitespace error" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 16, + "testsTotal": 16, + "classification": "additional Phase 1 contract and boundary regression evidence" + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/query-context.test.ts", + "exitCode": 0, + "testFilesPassed": 1, + "testFilesTotal": 1, + "testsPassed": 1, + "testsTotal": 1, + "classification": "additional QueryContext construction and rejection regression evidence" + }, + { + "command": "git status --short --untracked-files=all", + "exitCode": 0, + "observedPathCount": 10, + "classification": "exactly the ten declared writable paths are modified or untracked; zero sibling, barrel, composition, IPC, HTTP, renderer-client or shared mega-API path" + } + ], + "classifiedScans": { + "secretCredential": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts .codex-handoff/phase-02-p2-e.json", + "exitCode": 0, + "observedMatchLineCount": 7, + "allMatchesClassified": true, + "classification": "authorization appears only in contract/policy/self-review language, the scan command/classification, and an explicitly unverified production-composition claim; no secret, credential, token, cookie, bearer, password or authorization payload" + }, + "privatePath": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts .codex-handoff/phase-02-p2-e.json", + "exitCode": 0, + "observedMatchLineCount": 2, + "allMatchesClassified": true, + "classification": "the private-path scanner command and its classification text only; no private, home, temporary, task-temporary or real-project path value" + }, + "highConfidenceSecretSignature": { + "command": "rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,})' src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts .codex-handoff/phase-02-p2-e.json", + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true, + "classification": "no high-confidence secret signature" + } + }, + "selfReview": { + "completeDiffReread": true, + "reviewedProductBytesPreserved": true, + "currentBaseBehaviorPreserved": true, + "ownedPathBaseDriftAbsent": true, + "focusedChecksReplayedAtCanonicalBase": true, + "cleanArchitectureConfirmed": true, + "dddCanonicalIdentityAndLifecycleLanguageConfirmed": true, + "solidNarrowPortAndApiSegregationConfirmed": true, + "coreDependencyDirectionConfirmed": true, + "transportNeutralityConfirmed": true, + "safeStaticErrorsConfirmed": true, + "canonicalIdAndPrivatePathSafetyConfirmed": true, + "phaseOneSemanticCompatibilityConfirmed": true, + "exactQueryContextPropagationConfirmed": true, + "preIoAuthorizationDeadlineCancellationConfirmed": true, + "boundedAliveIdentityRecheckConfirmed": true, + "returnedLegacyTeamIdentityValidationConfirmed": true, + "noRouteConfigCheckCacheInvalidationOrRuntimeOverlayConfirmed": true, + "noMutationOrProductionWiringConfirmed": true, + "exactTenPathScopeConfirmed": true, + "secretAndPrivatePathClassificationsConfirmed": true, + "unsupportedClaimsAbsent": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "actual IPC wiring", + "actual test-HTTP wiring", + "renderer-client wiring", + "public feature exports and shared barrel integration", + "cross-transport conformance", + "production authentication and authorization composition", + "production identity-index and legacy-service composition", + "independent P2.R1 architecture/security acceptance", + "serial P2.I integration, commit, push, remote equality, successor execution, or Phase 2 milestone acceptance" + ], + "blockers": [], + "generatedAt": "2026-07-16T17:42:30Z", + "nextAction": "controller may include this rebased P2.E output in a fresh exact-five materialization after all lane adoptions finish; no successor is launched here" +} diff --git a/.codex-handoff/phase-02-p2-f0.json b/.codex-handoff/phase-02-p2-f0.json new file mode 100644 index 0000000000..291329d9ce --- /dev/null +++ b/.codex-handoff/phase-02-p2-f0.json @@ -0,0 +1,189 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.F0.IDENTITY", + "laneId": "p2-f0-identity", + "packetRevision": "phase-02-jit-router-r1", + "routerBaseSha": "d5afa87e79b1f2badd69e65262e5699c0fb61de7", + "baseSha": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "status": "verified", + "disposition": "ACCEPT", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.F0.CANONICAL_IDENTITY", + "path": "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "proofLevel": "target_verified" + }, + { + "id": "P2.F0.IDENTITY_COMPATIBILITY", + "path": "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "proofLevel": "target_verified" + } + ], + "evidenceIds": ["P2.F0.CANONICAL_IDENTITY", "P2.F0.IDENTITY_COMPATIBILITY"], + "declaredWritablePaths": [ + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/index.ts", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + ".codex-handoff/phase-02-p2-f0.json" + ], + "changedPaths": [ + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/index.ts", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + ".codex-handoff/phase-02-p2-f0.json" + ], + "changedPathCount": 5, + "authority": { + "integratedRouterCommit": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "integratedRouterParent": "d5afa87e79b1f2badd69e65262e5699c0fb61de7", + "integratedRouterPathCount": 12, + "integratedRouterMatchesExclusiveOwnership": true, + "initialWorktreeClean": true, + "phaseOnePredecessorDisposition": "ACCEPT", + "phaseOnePredecessorFindingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + } + }, + "contract": { + "teamIdFormat": "team_ followed by exactly 32 lowercase hexadecimal characters", + "workspaceIdFormat": "workspace_ followed by exactly 32 lowercase hexadecimal characters", + "opaqueBrandedTypes": true, + "compileTimeKindSeparation": true, + "runtimeCrossKindRejection": true, + "byteStableJsonRoundTrip": true, + "whitespaceRejected": true, + "nameLikeValuesRejected": true, + "pathLikeValuesRejected": true, + "phaseOneSyntheticParserPreserved": true, + "identityDerivationIntroduced": false, + "filesystemOrPersistenceIntroduced": false, + "clockOrRandomnessIntroduced": false, + "transportOrCompositionIntroduced": false + }, + "checks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 36, + "testsTotal": 36 + }, + { + "command": "pnpm lint:fast:files -- src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": "seven inherited Phase 0 diagnostics exactly match accepted Phase 1 baseline; zero diagnostic names an authorized P2.F0 path" + }, + { + "command": "pnpm exec prettier --check src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts .codex-handoff/phase-02-p2-f0.json", + "exitCode": 0 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git status --short --untracked-files=all", + "exitCode": 0, + "classification": "exactly the five declared writable paths are modified or untracked" + }, + { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts .codex-handoff/phase-02-p2-f0.json", + "exitCode": 0, + "classification": "scanner command text and authority terminology only; no secret, credential, cookie, bearer, token, password, or authorization payload" + }, + { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts .codex-handoff/phase-02-p2-f0.json", + "exitCode": 0, + "classification": "scanner command and classification text only; no private, home, temporary, task-temporary, or real-project path value" + } + ], + "typecheckClassification": { + "observedDiagnosticCount": 7, + "inheritedDiagnosticCount": 7, + "newDiagnosticCount": 0, + "baselineSources": [ + "docs/research/hosted-web/phase-1/integration-report.json", + "docs/research/hosted-web/phase-1/reviews/list-semantics.md" + ], + "diagnostics": [ + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts: TS7016 at 25:8, TS7031 at 66:31, TS18046 at 117:68, TS7031 at 413:48, TS7031 at 733:10", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts: TS7016 at 12:8", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts: TS2352 at 162:44" + ] + }, + "proofs": { + "P2.F0.CANONICAL_IDENTITY": { + "proofLevel": "target_verified", + "positiveCases": [ + "TeamId parse", + "WorkspaceId parse", + "compile-time type separation", + "JSON serialize/reparse byte stability" + ], + "negativeCases": [ + "unknown non-string values", + "short and long values", + "uppercase values", + "leading, trailing and embedded whitespace", + "display names and slugs", + "POSIX and Windows path-like values", + "TeamId/WorkspaceId cross-kind values" + ] + }, + "P2.F0.IDENTITY_COMPATIBILITY": { + "proofLevel": "target_verified", + "positiveCases": [ + "all frozen Phase 1 identifier fixtures", + "explicit synthetic TeamId parser" + ], + "negativeCases": [ + "Phase 1 cross-kind value", + "synthetic fixture ID rejected by canonical TeamId parser" + ] + } + }, + "selfReview": { + "completeDiffReread": true, + "cleanArchitectureConfirmed": true, + "dddIdentitySemanticsConfirmed": true, + "solidBoundedParserResponsibilityConfirmed": true, + "browserSafeValueOnlyContractConfirmed": true, + "safeNonReflectiveErrorsConfirmed": true, + "phaseOneCompatibilityConfirmed": true, + "noMutableLegacyIdentityDerivationConfirmed": true, + "noForbiddenDependenciesConfirmed": true, + "exactFivePathScopeConfirmed": true, + "secretAndPrivatePathClassificationsConfirmed": true, + "unsupportedClaimsAbsent": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "canonical identifier allocation, uniqueness, entropy, or collision handling", + "restart, rename, display-name-change, remount, persistence, registry, or legacy-adoption stability", + "independent P2.R0 architecture/security acceptance", + "foundation integration, push, remote equality, successor launch, or Phase 2 milestone acceptance", + "production filesystem, persistence, transport, Electron, main-process, provider, or composition behavior" + ], + "blockers": [], + "nextAction": "P2.R0.ARCH_SECURITY" +} diff --git a/.codex-handoff/phase-02-p2-if.json b/.codex-handoff/phase-02-p2-if.json new file mode 100644 index 0000000000..acec4c6018 --- /dev/null +++ b/.codex-handoff/phase-02-p2-if.json @@ -0,0 +1,241 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.IF.INTEGRATION", + "laneId": "p2-if-integration", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "canonicalSha": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "headSha": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "status": "verified", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.IF.INTEGRATION", + "path": "docs/research/hosted-web/phase-2/foundation-integration.json", + "proofLevel": "target_verified", + "sha256": "dd90c4ce7bd7ea633320cf9496fdd306e2cba8f980d1faecbb49529b387acb41" + } + ], + "evidenceIds": ["P2.IF.INTEGRATION"], + "authority": { + "canonicalHead": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "workspaceHead": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "authorityHeadEqual": true, + "workspaceMode": "verified_input_patch", + "mandatoryReadOrderCompletedByThisContinuedAttempt": true, + "authorityMismatchCount": 0 + }, + "predecessors": { + "p2F0": { + "status": "verified", + "disposition": "ACCEPT", + "handoffPath": ".codex-handoff/phase-02-p2-f0.json", + "handoffSha256": "53ce2854cad51ac973775d9d43e5573a6d87e1708ae672dc80d7fda9bb6ade3f", + "artifactPatchSha256": "659b9eb9286b6c9b40e45e43d9aa9871ddae9d26af903d8296d4db7d64f0094f", + "artifactManifestSha256": "d608ffa0de187918a1a23b8a8113669169e072442d25d12b51072c85070e1899", + "terminalState": "HOLD" + }, + "p2R0": { + "status": "verified", + "disposition": "ACCEPT", + "handoffPath": ".codex-handoff/phase-02-p2-r0.json", + "handoffSha256": "209903b5ab6e99a43b38ae282bac08cffdc60cc7e3273b117c47156e9bba4dee", + "artifactPatchSha256": "fa896f654b352f3b0027180d87ab5b4e2ec1b0b66f821183a417f0235d1d46d1", + "artifactManifestSha256": "48bc27492edfcdcc60cc5e75ca2c1a7fb1bd15747c28997afea175ce56c77749", + "reviewEvidencePath": "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + "reviewEvidenceSha256": "e207883a5a6ae5975cf2500df9b4738f600bca0ef6a220ad77fb2cd7ba34ccc0", + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "terminalState": "HOLD" + } + }, + "provenance": { + "integrationInputArtifactSha256": "fa896f654b352f3b0027180d87ab5b4e2ec1b0b66f821183a417f0235d1d46d1", + "expectedWorkspaceStagedPatchSha256": "53c4a1ad71c58643f5e3229d2a358038d52597f0bc3ce3defc8ecb55270afcac", + "workspaceStagedPatchSha256": "53c4a1ad71c58643f5e3229d2a358038d52597f0bc3ce3defc8ecb55270afcac", + "workspaceStagedPatchByteEqualsPreservedHandoffArtifact": true, + "preservedWorkspaceHandoffManifestSha256": "100cc533bba4964d70da327ed9fff47543f2ca4ae3b049ac5935fb87fc4fd4a6", + "preservedWorkspaceHandoffSummarySha256": "809f10950fbced85159e0f99aa498916bbc0af585894d2e8b60e119bf075f5ba", + "acceptedInputPathListSha256": "237d2bcb27e556070541617aeb8082ae2dda95b78d379f118d8a1986cd8a289e", + "acceptedInputContentManifestSha256": "800315b2cc5493cdef842a0dbabd77bd7a4a56f160bfe8d7dbdfcd546f1148d0", + "allArtifactAndAdmissionHashesMatched": true + }, + "acceptedInputPaths": [ + { + "path": ".codex-handoff/phase-02-p2-f0.json", + "sha256": "53ce2854cad51ac973775d9d43e5573a6d87e1708ae672dc80d7fda9bb6ade3f" + }, + { + "path": ".codex-handoff/phase-02-p2-r0.json", + "sha256": "209903b5ab6e99a43b38ae282bac08cffdc60cc7e3273b117c47156e9bba4dee" + }, + { + "path": "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + "sha256": "e207883a5a6ae5975cf2500df9b4738f600bca0ef6a220ad77fb2cd7ba34ccc0" + }, + { + "path": "src/shared/contracts/hosted/identifiers.ts", + "sha256": "73978dd8871f3af363810b9a90b4a42b464982a25898eac082677b9557d1dc41" + }, + { + "path": "src/shared/contracts/hosted/index.ts", + "sha256": "a11b722edd3a9fb1b4ea451bbbf2f01703a93897515092ceefc6bef5157bbac2" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "sha256": "7e8b88816245e95921dbcef8c444c572ec2d610cf32dee6d4934a65d33d63b42" + }, + { + "path": "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "sha256": "19c327b55a2d407c623e8eae6bb60d20913eca60d737499f161d180d212ee511" + } + ], + "materialization": { + "cleanTargetedCanonicalReconstructionCheckExitCode": 0, + "cleanTargetedCanonicalReconstructionApplyExitCode": 0, + "acceptedR0ArtifactCheckExitCode": 0, + "acceptedR0ArtifactApplyExitCode": 0, + "materializedPathCount": 7, + "materializedContentManifestMatchesWorkspace": true, + "acceptedBytesMutated": false + }, + "activation": { + "foundationAuthority": "activated", + "activationScope": "controller-consumable accepted foundation evidence at canonical authority", + "gitCommitCreated": false, + "gitPushPerformed": false, + "controllerIntegrationPerformed": false, + "successorLaunched": false, + "otherWorkerReviewed": false + }, + "changedPaths": [ + ".codex-handoff/phase-02-p2-f0.json", + ".codex-handoff/phase-02-p2-if.json", + ".codex-handoff/phase-02-p2-r0.json", + "docs/research/hosted-web/phase-2/foundation-integration.json", + "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/index.ts", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts" + ], + "changedPathCount": 9, + "scopeProof": { + "acceptedInputPathCount": 7, + "integrationOutputPathCount": 2, + "stagedAcceptedPathCount": 7, + "untrackedIntegrationOutputPathCount": 2, + "unexpectedPathCount": 0, + "onlyAcceptedInputsAndDeclaredIntegrationEvidenceChanged": true, + "acceptedInputUnstagedByteDifferenceCount": 0, + "productSourceOrTestEditedByIntegrator": false + }, + "checks": [ + { + "id": "identity-tests", + "command": "cd .. && pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "cwd": "src", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 36, + "testsTotal": 36, + "toolVersion": "vitest 3.2.6" + }, + { + "id": "lint", + "command": "cd .. && pnpm lint:fast:files -- src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "cwd": "src", + "exitCode": 0 + }, + { + "id": "typecheck", + "command": "cd .. && pnpm typecheck", + "cwd": "src", + "exitCode": 0, + "diagnosticCount": 0, + "classification": "clean at canonical integrated authority" + }, + { + "id": "prettier", + "command": "cd .. && pnpm exec prettier --check src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts .codex-handoff/phase-02-p2-f0.json .codex-handoff/phase-02-p2-r0.json .codex-handoff/phase-02-p2-if.json docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md docs/research/hosted-web/phase-2/foundation-integration.json", + "cwd": "src", + "exitCode": 0, + "checkedPathCount": 9 + }, + { + "id": "diff", + "command": "cd .. && git diff --check", + "cwd": "src", + "exitCode": 0 + }, + { + "id": "status", + "command": "cd .. && git status --short", + "cwd": "src", + "exitCode": 0, + "classification": "exactly seven staged accepted inputs and two untracked integration evidence outputs" + }, + { + "id": "accepted-staged-diff", + "command": "git diff --cached --check", + "exitCode": 0 + } + ], + "classifiedScans": { + "secretCredentialScan": { + "scopePathCount": 9, + "exitCode": 0, + "observedMatchLineCount": 8, + "allMatchesClassified": true + }, + "privatePathScan": { + "scopePathCount": 9, + "exitCode": 0, + "observedMatchLineCount": 7, + "allMatchesClassified": true + }, + "highConfidenceSecretSignatureScan": { + "scopePathCount": 9, + "exitCode": 1, + "observedMatchLineCount": 0, + "allMatchesClassified": true + } + }, + "selfReview": { + "completeIntegrationOutputReread": true, + "completeScopeDiffReread": true, + "cleanArchitectureDddSolidConclusionsPreserved": true, + "transportBoundaryPreserved": true, + "safeErrorsPreserved": true, + "phaseOneCompatibilityPreserved": true, + "noMutableIdentityDerivationIntroduced": true, + "exactNinePathScopeConfirmed": true, + "acceptedBytesRemainByteIdentical": true, + "secretAndPrivatePathClassificationsConfirmed": true, + "unsupportedClaimsAbsent": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "canonical identifier allocation, uniqueness, entropy and collision handling", + "restart, rename, display-name-change, remount, persistence, registry and legacy-adoption stability", + "production filesystem, persistence, transport, Electron, main-process, provider and composition behavior", + "successor execution, Phase 2 serial integration, milestone acceptance, commit and push" + ], + "blockers": [], + "generatedAt": "2026-07-16T11:22:27Z", + "nextAction": "controller may admit P2.A through P2.E subject to the fixed DAG, product slots and host budget" +} diff --git a/.codex-handoff/phase-02-p2-r0.json b/.codex-handoff/phase-02-p2-r0.json new file mode 100644 index 0000000000..354414a59d --- /dev/null +++ b/.codex-handoff/phase-02-p2-r0.json @@ -0,0 +1,283 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.R0.ARCH_SECURITY", + "laneId": "p2-r0-architecture-security", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "status": "verified", + "disposition": "ACCEPT", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.R0.ARCH_SECURITY", + "path": "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + "proofLevel": "target_verified", + "sha256": "e207883a5a6ae5975cf2500df9b4738f600bca0ef6a220ad77fb2cd7ba34ccc0" + } + ], + "evidenceIds": ["P2.R0.ARCH_SECURITY"], + "reviewer": { + "role": "independent-p2-foundation-architecture-security-reviewer", + "taskId": "agent-teams-hosted-web-refactor-p2-foundation-arch-security-review-v17-r1", + "provider": "codex", + "independentOfP2F0Producer": true, + "candidateBytesAltered": false, + "subagentsOrAdditionalReviewersUsed": 0 + }, + "authority": { + "canonicalSha": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "integratedRouterCommit": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "integratedRouterParent": "d5afa87e79b1f2badd69e65262e5699c0fb61de7", + "integratedRouterPathCount": 12, + "integratedRouterMatchesExclusiveOwnership": true, + "packetRevisionMatchesExecutionIndex": true, + "p2F0DependencyMatchesExecutionIndex": true, + "p2F0OwnershipMatchesExecutionIndex": true, + "phaseOnePredecessorDisposition": "ACCEPT", + "phaseOnePredecessorFindingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "mandatoryReadOrderCompleted": true, + "authorityMismatchCount": 0 + }, + "candidate": { + "brokerMaterializedPatchSha256": "659b9eb9286b6c9b40e45e43d9aa9871ddae9d26af903d8296d4db7d64f0094f", + "stagedGitDiffSha256": "bb3364ca91e51d29fbaa5f138b3af06279130704568d15eeaebfc402517e41a1", + "baseSha": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "changedPathCount": 5, + "changedPaths": [ + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/index.ts", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + ".codex-handoff/phase-02-p2-f0.json" + ], + "contentHashes": { + "src/shared/contracts/hosted/identifiers.ts": "73978dd8871f3af363810b9a90b4a42b464982a25898eac082677b9557d1dc41", + "src/shared/contracts/hosted/index.ts": "a11b722edd3a9fb1b4ea451bbbf2f01703a93897515092ceefc6bef5157bbac2", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts": "7e8b88816245e95921dbcef8c444c572ec2d610cf32dee6d4934a65d33d63b42", + "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts": "19c327b55a2d407c623e8eae6bb60d20913eca60d737499f161d180d212ee511", + ".codex-handoff/phase-02-p2-f0.json": "53ce2854cad51ac973775d9d43e5573a6d87e1708ae672dc80d7fda9bb6ade3f" + }, + "completeDiffReread": true, + "worktreeMatchesStagedCandidate": true + }, + "reviewerOwnedPaths": [ + "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + ".codex-handoff/phase-02-p2-r0.json" + ], + "changedPaths": [ + ".codex-handoff/phase-02-p2-f0.json", + ".codex-handoff/phase-02-p2-r0.json", + "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/index.ts", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts" + ], + "changedPathCount": 7, + "scopeProof": { + "candidatePathCount": 5, + "reviewerOutputPathCount": 2, + "totalWorkspaceChangedPathCount": 7, + "candidatePathsStaged": 5, + "candidateUnstagedByteDiffCount": 0, + "reviewerOutputsUntracked": 2, + "onlyDeclaredP2F0AndP2R0PathsChanged": true, + "producerPathsAlteredByReviewer": false + }, + "gateResults": { + "opaqueCanonicalIdentityBytes": "pass", + "compileTimeKindSeparation": "pass", + "runtimeCrossKindRejection": "pass", + "boundedFailClosedParsing": "pass", + "byteStableSerialization": "pass", + "whitespaceRejection": "pass", + "nameAndSlugRejection": "pass", + "posixWindowsAndTraversalPathRejection": "pass", + "phaseOneSyntheticCompatibility": "pass", + "noMutableNameOrPathIdentityDerivation": "pass", + "noFilesystemPersistenceClockRandomTransportOrCompositionDependency": "pass", + "browserSafeSharedContractBoundary": "pass", + "safeStaticErrors": "pass", + "secretAndPrivatePathSafety": "pass", + "producerSelfReviewComplete": "pass", + "exactScope": "pass" + }, + "checks": [ + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 36, + "testsTotal": 36, + "toolVersion": "vitest 3.2.6" + }, + { + "command": "pnpm lint:fast:files -- src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "exitCode": 0 + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": "exactly seven frozen inherited Phase 0 diagnostics; zero P2.F0/P2.R0-owned and zero new or unexpected diagnostics" + }, + { + "command": "pnpm exec prettier --check src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts .codex-handoff/phase-02-p2-f0.json", + "exitCode": 0 + }, + { + "command": "pnpm exec prettier --check docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md .codex-handoff/phase-02-p2-r0.json", + "exitCode": 0 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --check", + "exitCode": 0, + "classification": "material whitespace check for the staged immutable candidate" + }, + { + "command": "git status --short --untracked-files=all", + "exitCode": 0, + "classification": "exactly five staged candidate paths and two untracked reviewer evidence paths" + }, + { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' ", + "exitCode": 0, + "observedMatchLineCount": 2, + "allMatchesClassified": true, + "classification": "producer-handoff scan command and benign classification text only; no secret, credential, cookie, bearer, token, password, or authorization payload" + }, + { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' ", + "exitCode": 0, + "observedMatchLineCount": 2, + "allMatchesClassified": true, + "classification": "producer-handoff scan command and benign classification text only; no private, home, temporary, task-temporary, or real-project path value" + }, + { + "command": "rg forbidden dependency families src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts", + "exitCode": 1, + "observedMatchLineCount": 0, + "classification": "no forbidden dependency, runtime access, allocator, side effect, or transport/process coupling" + } + ], + "typecheckClassification": { + "observedDiagnosticCount": 7, + "inheritedDiagnosticCount": 7, + "ownedDiagnosticCount": 0, + "newDiagnosticCount": 0, + "unexpectedDiagnosticCount": 0, + "matchesFrozenBaseline": true, + "baselineSources": [ + "docs/research/hosted-web/phase-0/final-gate/inherited-typescript-diagnostics.json", + "docs/research/hosted-web/phase-1/integration-report.json", + "docs/research/hosted-web/phase-1/reviews/list-semantics.md" + ], + "diagnostics": [ + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "location": "25:8", + "code": "TS7016", + "classification": "inherited missing declaration for auth-artifacts-spike.mjs" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "location": "66:31", + "code": "TS7031", + "classification": "inherited implicit-any binding element code" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "location": "117:68", + "code": "TS18046", + "classification": "inherited unknown session value" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "location": "413:48", + "code": "TS7031", + "classification": "inherited implicit-any binding element operation" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "location": "733:10", + "code": "TS7031", + "classification": "inherited implicit-any binding element artifactId" + }, + { + "path": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "location": "12:8", + "code": "TS7016", + "classification": "inherited missing declaration for scan-evidence.mjs" + }, + { + "path": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "location": "162:44", + "code": "TS2352", + "classification": "inherited synthetic providerBackends.gemini fixture RuntimeConfig overlap diagnostic" + } + ] + }, + "producerSelfReview": { + "present": true, + "completeDiffReread": true, + "cleanArchitectureDddSolidReviewed": true, + "safeErrorsReviewed": true, + "exactScopeReviewed": true, + "secretAndPrivatePathReviewed": true, + "proofLevelsAndNamedNegativeCasesPresent": true, + "unverifiedClaimsPresent": true, + "blockerAndFindingArraysPresent": true, + "unsupportedIndependentAcceptanceClaimAbsent": true, + "terminalStateHold": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "canonical identifier allocation, uniqueness, entropy, and collision handling", + "restart, rename, display-name-change, remount, persistence, registry, and legacy-adoption stability", + "foundation integration, push, remote equality, successor execution, and Phase 2 milestone acceptance", + "production filesystem, persistence, transport, Electron, main-process, provider, and composition behavior" + ], + "finalValidation": { + "jsonParsePassed": true, + "reviewerOutputPrettierPassed": true, + "reviewMarkdownHashRecomputed": true, + "candidateContentHashesRecomputed": true, + "finalSevenPathSecretCredentialScan": { + "completedAfterFinalBytes": true, + "allMatchesClassified": true, + "classification": "scanner command and classification text, security conclusions, and explicit prohibited-payload language only; no secret, credential, cookie, bearer, token, password, or authorization payload" + }, + "finalSevenPathPrivatePathScan": { + "completedAfterFinalBytes": true, + "allMatchesClassified": true, + "classification": "scanner command, classification text, and explicit real-project prohibition language only; no private, home, temporary, task-temporary, or real-project path value" + }, + "highConfidenceSecretSignatureScanMatchCount": 0, + "trailingWhitespaceMatchCount": 0, + "finalChangedPathCount": 7, + "onlyAuthorizedPathsChanged": true, + "completeOutputReread": true, + "completeDiffReread": true + }, + "blockers": [], + "generatedAt": "2026-07-16T10:36:42Z", + "nextAction": "P2.IF.INTEGRATION" +} diff --git a/.codex-handoff/phase-02-p2-r1.json b/.codex-handoff/phase-02-p2-r1.json new file mode 100644 index 0000000000..6220b052df --- /dev/null +++ b/.codex-handoff/phase-02-p2-r1.json @@ -0,0 +1,328 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.R1.ARCH_SECURITY", + "laneId": "p2-r1-architecture-security", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "status": "verified", + "disposition": "ACCEPT", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.R1.ARCH_SECURITY", + "path": "docs/research/hosted-web/phase-2/reviews/parallel-wave-architecture-security.md", + "proofLevel": "target_verified", + "sha256": "1ec9d2a5b5d8a201d1c33f2c789c4d122607a945cc7847a457c6284a1e8a9986" + } + ], + "evidenceIds": ["P2.R1.ARCH_SECURITY"], + "reviewer": { + "role": "independent-p2-parallel-wave-architecture-security-reviewer", + "taskId": "agent-teams-hosted-web-refactor-p2-parallel-wave-arch-security-review-v17-r2", + "provider": "codex", + "independentOfLaneProducers": true, + "candidateBytesAltered": false, + "subagentsOrAdditionalReviewersUsed": 0 + }, + "authority": { + "canonicalSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "headMatchesCanonicalSha": true, + "packetRevisionMatchesExecutionIndex": true, + "phaseTwoControllerAndDagRead": true, + "allFiveLanePacketsAndHandoffsRead": true, + "aggregateDiffReadCompletely": true, + "allThirtyFiveCurrentPathsRead": true, + "foundationIntegrationEvidenceRead": true, + "foundationIdentifierSha256": "73978dd8871f3af363810b9a90b4a42b464982a25898eac082677b9557d1dc41", + "foundationHostedIndexSha256": "a11b722edd3a9fb1b4ea451bbbf2f01703a93897515092ceefc6bef5157bbac2", + "authorityMismatchCount": 0 + }, + "reviewedAggregate": { + "brokerMaterializedPatchSha256": "d4f80a5c60f9fc7925ccaeb4480cf4349bc3d267d71e76413d3f4b41b402fade", + "patchByteLength": 531340, + "patchLineCount": 13738, + "provenanceSha256": "172f0a87e6607d1a9fe2f88d57ccdb9644049250a938502d41b2910a11f97d3f", + "stagedGitDiffSha256": "a322c6c8312ca9163b7e4578bc0b8cb59239b8cc0e443edaf6b81e00a42d1468", + "baseSha": "bd6ac038c920180ee5398b96f2dbdc3d6f035e77", + "orderedReviewedOutputCount": 5, + "candidateChangedPathCount": 35, + "candidatePathsStaged": 35, + "candidateUnstagedByteDiffCount": 0, + "candidateBytesUnchangedByReviewer": true + }, + "reviewedOutputs": [ + { + "lane": "P2.A", + "reviewedOutputId": "58ac7a2cf712aec3254f8184ce6217def09a3b3200e0921f726a4f2f902b75f6", + "componentPatchSha256": "b231c636fc62c06994c5f12b8e1c2964b729b8fb60fc3f1f7c57ba0a80e5dfd3", + "changedPathCount": 5, + "handoffStatus": "verified", + "selfReviewComplete": true, + "terminalState": "HOLD", + "findingCounts": { "P0": 0, "P1": 0, "P2": 0 } + }, + { + "lane": "P2.B", + "reviewedOutputId": "684bd31df38eaba57259ead6003a8596d37442bd58fc797961d51b2a2395d21d", + "componentPatchSha256": "9f83993858436493e18f5272c0c2bfe58e2bf83adefbad817fc9bf84a83a8282", + "changedPathCount": 5, + "handoffStatus": "verified", + "selfReviewComplete": true, + "terminalState": "HOLD", + "findingCounts": { "P0": 0, "P1": 0, "P2": 0 } + }, + { + "lane": "P2.C", + "reviewedOutputId": "349546a6232433cc96381f109cfd24996230b23b2a99c36fec6a52843adc9d09", + "componentPatchSha256": "a001dfd1b5c91889ddecb7b8b63437a9fc994b2bac7134b4de20d7d2da408f2e", + "changedPathCount": 8, + "handoffStatus": "verified", + "selfReviewComplete": true, + "terminalState": "HOLD", + "findingCounts": { "P0": 0, "P1": 0, "P2": 0 } + }, + { + "lane": "P2.D", + "reviewedOutputId": "ba27e423827f9923b48157338dafbe2765a6ec405c9dc8ba2bf747fe46e8c47f", + "componentPatchSha256": "98cbb0ef4cfc08af4d8713d68a37e9dad48a588d3b9441cdf423f938bf4a0079", + "changedPathCount": 7, + "handoffStatus": "verified", + "selfReviewComplete": true, + "terminalState": "HOLD", + "findingCounts": { "P0": 0, "P1": 0, "P2": 0 } + }, + { + "lane": "P2.E", + "reviewedOutputId": "e3e56150f4fe8eed39c0504011ffb44d3b17610324420c5d2fb3394b34a5b2b7", + "componentPatchSha256": "d5430f2aa21ddd8eb57f51e43a237629702925a34f6c1120929b4dafdb8bf89e", + "changedPathCount": 10, + "handoffStatus": "verified", + "selfReviewComplete": true, + "terminalState": "HOLD", + "findingCounts": { "P0": 0, "P1": 0, "P2": 0 } + } + ], + "ownershipProof": { + "lanePathCounts": [5, 5, 8, 7, 10], + "declaredPathCount": 35, + "uniquePathCount": 35, + "pairwiseIntersectionCount": 0, + "allTenPairwiseIntersectionsEmpty": true, + "sharedEntrypointOrCompositionPathCount": 0, + "siblingImplementationImportCount": 0, + "allFiveLanesDisjoint": true, + "allFiveLanesSemanticallyIndependent": true + }, + "gateResults": { + "exactlyFiveValidApprovedLanes": "pass", + "pairwiseOwnershipAndSiblingIndependence": "pass", + "opaqueIdentifiers": "pass", + "safeRootAdmission": "pass", + "durablePublicationAndRecovery": "pass", + "toctouDefense": "pass", + "teamIdentityReadsBoundedBeforeAttackerControlledAllocation": "pass", + "teamIdentityOversizeAndRaceFailureClosed": "pass", + "teamRemovalPerformsZeroPhysicalDelete": "pass", + "descriptorProvedConfiglessNestedQuarantine": "pass", + "movedInodeAndContainerReproof": "pass", + "containerThenParentFsyncOrdering": "pass", + "realTeamConfigReaderListingInvisibility": "pass", + "nestedConfigAndSentinelRecoverability": "pass", + "queryContextAuthorizationCancellationDeadlineBeforeIo": "pass", + "queryContextRecheckedBeforeFollowupIo": "pass", + "cappedReadsAndExactDtoProjection": "pass", + "transportNeutrality": "pass", + "noHiddenHostedMutation": "pass", + "noPathSecretCredentialOrProviderPayloadLeakage": "pass", + "producerSelfReviewsComplete": "pass", + "exactScope": "pass" + }, + "checks": [ + { + "lane": "P2.A", + "command": "pnpm exec vitest run test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testsPassed": 37, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.A foundation regression", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testsPassed": 36, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.B", + "command": "pnpm exec vitest run test/features/internal-storage/TeamIdentityStorage.test.ts", + "exitCode": 0, + "testFilesPassed": 1, + "testsPassed": 17, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.B worker regression", + "command": "pnpm exec vitest run test/features/internal-storage/InternalStorageWorkerCore.test.ts", + "exitCode": 0, + "testFilesPassed": 1, + "testsPassed": 14, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.C", + "command": "pnpm exec vitest run test/features/workspace-registry/core/WorkspaceRegistration.test.ts test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts", + "exitCode": 0, + "testFilesPassed": 3, + "testsPassed": 28, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.D", + "command": "pnpm exec vitest run test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testsPassed": 25, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.E", + "command": "pnpm exec vitest run test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 3, + "testsPassed": 22, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.E lifecycle regression", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + "exitCode": 0, + "testFilesPassed": 2, + "testsPassed": 16, + "toolVersion": "vitest 3.2.6" + }, + { + "lane": "P2.E QueryContext regression", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/query-context.test.ts", + "exitCode": 0, + "testFilesPassed": 1, + "testsPassed": 1, + "toolVersion": "vitest 3.2.6" + }, + { + "command": "five exact per-lane pnpm lint:fast:files commands", + "exitCode": 0, + "laneTypeScriptPathCounts": [4, 4, 7, 6, 9] + }, + { "command": "pnpm typecheck", "exitCode": 0, "diagnosticCount": 0 }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "checkedPathCount": 35 + }, + { + "command": "git diff --check && git diff --cached --check && git diff HEAD --check", + "exitCode": 0 + }, + { + "command": "broker provenance pairwise ownership proof", + "exitCode": 0, + "lanePathCounts": [5, 5, 8, 7, 10], + "uniquePathCount": 35, + "pairwiseIntersectionCount": 0 + }, + { + "command": "classified aggregate secret/private-path/provider/transport/destructive-delete scans", + "exitCode": 0, + "highConfidenceSecretPayloadMatchCount": 0, + "productionPhysicalDeleteCallMatchCount": 0, + "allMatchesClassified": true + } + ], + "testSummary": { + "testFilesPassed": 17, + "testFilesTotal": 17, + "testsPassed": 196, + "testsTotal": 196, + "allRequiredLaneAndRegressionChecksReplayed": true + }, + "securityProofs": { + "boundedIdentityReads": { + "identityMaxBytes": 4096, + "rootMarkerMaxBytes": 2048, + "allocationRule": "maxBytes + 1 only", + "attackerControlledSizeAllocation": false, + "decodeOccursOnlyAfterCapAndMetadataStabilityChecks": true, + "failsClosed": true + }, + "teamRemoval": { + "productionRmUnlinkRmdirCallCount": 0, + "durableTombstoneBeforeMove": true, + "quarantineContainer": ".p2-d-removal-quarantine", + "containerHasDirectConfig": false, + "teamDirectoryNestedOneLevelBelowContainer": true, + "originalAndMovedInodeReproved": true, + "containerBindingReproved": true, + "fsyncOrder": ["quarantine_container", "parent"], + "postFsyncReproof": true, + "realTeamConfigReaderDoesNotListMovedTeam": true, + "nestedConfigAndSentinelRecoverable": true + }, + "queryContext": { + "authorizationBeforeIo": true, + "cancellationBeforeIo": true, + "deadlineBeforeIo": true, + "recheckedBeforeFollowupIo": true, + "sameContextObjectPropagated": true, + "unauthorizedCancelledExpiredZeroIo": true + }, + "leakage": { + "secretOrCredentialPayloadCount": 0, + "privateOrRealProjectPathPayloadCount": 0, + "providerCredentialOrPayloadCount": 0, + "unsafeReflectedErrorCount": 0, + "allTextMatchesClassified": true + } + }, + "reviewerOwnedPaths": [ + "docs/research/hosted-web/phase-2/reviews/parallel-wave-architecture-security.md", + ".codex-handoff/phase-02-p2-r1.json" + ], + "scopeProof": { + "candidatePathCount": 35, + "reviewerOutputPathCount": 2, + "totalWorkspaceChangedPathCount": 37, + "candidatePathsStaged": 35, + "candidateUnstagedByteDiffCount": 0, + "reviewerOutputsUntracked": 2, + "onlyAuthorizedAggregateAndReviewerPathsChanged": true, + "producerPathsAlteredByReviewer": false + }, + "findingCounts": { "P0": 0, "P1": 0, "P2": 0 }, + "findings": { "P0": [], "P1": [], "P2": [] }, + "unverifiedClaims": [ + "P2.I serial cross-lane composition and concrete adapters", + "production live filesystem revalidation and the full ADR-28 openat2 runtime topology", + "production IPC, HTTP, preload, renderer, provider and runtime wiring", + "backup recovery as a coordinated recovery point", + "physical quarantine retention policy and garbage collection", + "integration, commit, push, remote equality, successor execution and Phase 2 activation or milestone acceptance" + ], + "finalValidation": { + "jsonParsePassed": true, + "reviewerOutputPrettierPassed": true, + "reviewMarkdownHashRecomputed": true, + "aggregateArtifactHashRecomputed": true, + "stagedCandidateHashRecomputedAfterReviewerOutputs": true, + "candidateUnstagedByteDiffCount": 0, + "finalChangedPathCount": 37, + "onlyAuthorizedPathsChanged": true, + "completeOutputReread": true, + "completeDiffReread": true + }, + "blockers": [], + "generatedAt": "2026-07-16T18:48:33Z", + "nextAction": "P2.I.INTEGRATION" +} diff --git a/.codex-handoff/phase1-plan-candidate-reconcile-h4.json b/.codex-handoff/phase1-plan-candidate-reconcile-h4.json new file mode 100644 index 0000000000..aae207a71a --- /dev/null +++ b/.codex-handoff/phase1-plan-candidate-reconcile-h4.json @@ -0,0 +1,163 @@ +{ + "schemaVersion": 1, + "workKey": "phase1/plan/3bc0dfa7/82e07f79/candidate-reconcile/r3", + "taskId": "agent-teams-hosted-web-refactor-phase1-plan-candidate-reconcile-h4", + "phaseId": "phase-01", + "operation": "candidate-plan-reconciliation", + "status": "ready_for_independent_review", + "canonicalBaseSha": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "supersedes": { + "jobId": "agent-teams-hosted-web-refactor-phase1-plan-adoption-h3", + "patchSha256": "82e07f79d9f53d1ec9ba9253a380f24a786d547387857a5df4229f09b2a16295", + "decision": "REJECT", + "finding": "The adoption retained exact target-image admission as a Phase 1 prerequisite after accepted Phase 0 capability narrowing." + }, + "preservedApprovedPlan": { + "producerJobId": "agent-teams-hosted-web-refactor-phase1-plan-remediation-h2", + "independentReviewJobId": "agent-teams-hosted-web-refactor-phase1-plan-remediation-h2-review-i1", + "reviewDecision": "APPROVE", + "patchSha256": "cb38128a28e4f1edfacf5242579d421c16c295a638f98ec71cdb0a5aee42e830", + "scope": "Blocked Phase 1 planning content only; prior approval does not replace fresh review of this reconciliation." + }, + "targetImageReconciliation": { + "decisionId": "P0.D.TARGET_IMAGE", + "decisionPath": "docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json", + "phase0Gate": "closed_by_accepted_narrowing", + "phase1PrerequisiteState": "pass_by_narrowing", + "exactImageEarliestOwner": "phase-5", + "phase5AdmissionState": "fail_closed", + "phase5Admitted": false, + "canonicalSourceGapCount": 51, + "terminalSensitiveSurfaceCount": 9, + "productionCompositionAdmitted": false + }, + "prerequisiteEvaluation": [ + { + "gateId": "P1.GATE.BASE", + "state": "pass_for_planning_only", + "evidence": "Exact repository HEAD is the canonical base SHA." + }, + { + "gateId": "P1.GATE.P0_FREEZE", + "state": "blocked", + "requiredAction": "Review and integrate a Phase 0 freeze candidate reconciled with P0.D.TARGET_IMAGE." + }, + { + "gateId": "P1.GATE.P0_DIGEST", + "state": "blocked", + "requiredAction": "Record the integrated evidence-index digest and byte provenance." + }, + { + "gateId": "P1.GATE.TARGET_IMAGE", + "state": "pass_by_narrowing", + "requiredAction": "Retain exact image and admission as fail-closed Phase 5 work." + }, + { + "gateId": "P1.GATE.P0_FINAL", + "state": "blocked", + "requiredAction": "Run the final Phase 0 broad gate and classify the inherited seven-diagnostic typecheck comparison." + }, + { + "gateId": "P1.GATE.ESTIMATE", + "state": "blocked", + "requiredAction": "Reconcile unique estimate buckets, duplicates, and variance." + }, + { + "gateId": "P1.GATE.DECISIONS", + "state": "blocked", + "requiredAction": "Freeze the complete decision register with the accepted target-image narrowing." + }, + { + "gateId": "P1.GATE.PLAN_REVIEW", + "state": "pending", + "requiredAction": "Independently review this exact-base reconciled bundle." + }, + { + "gateId": "P1.GATE.SERIAL_BOOTSTRAP", + "state": "blocked", + "requiredAction": "After Phase 0 readiness and authorization, freeze exact IDs, paths, writers, fixtures, checks, and review pairs." + }, + { + "gateId": "P1.GATE.AUTHORIZATION", + "state": "blocked", + "requiredAction": "Receive fresh explicit Phase 1 implementation authorization." + } + ], + "phase1DesignBoundary": { + "applicationCoreTransportNeutral": true, + "ipcAdapterTestOnly": true, + "httpAdapterTestOnly": true, + "productionIpcRegistration": false, + "productionHttpRegistration": false, + "preloadOrRendererCutover": false, + "standaloneProductionCompositionPhase": "phase-5" + }, + "changedPaths": [ + ".codex-handoff/phase1-plan-candidate-reconcile-h4.json", + "docs/hosted-web-phases/phase-01/README.md", + "docs/hosted-web-phases/phase-01/architecture-and-contracts.md", + "docs/hosted-web-phases/phase-01/conformance-and-tests.md", + "docs/hosted-web-phases/phase-01/controller-packet.md", + "docs/hosted-web-phases/phase-01/execution-dag.md", + "docs/hosted-web-phases/phase-01/execution-packet-templates.md", + "docs/hosted-web-phases/phase-01/operations-and-risk.md", + "docs/hosted-web-phases/phase-01/packet-inputs.md" + ], + "scope": { + "planningOnly": true, + "phase1ImplementationAuthorized": false, + "phase1ImplementationPerformed": false, + "phase2WorkPerformed": false, + "productionCompositionPerformed": false, + "realProjectsUsed": false, + "dependencyOrLockfileChanged": false + }, + "verification": [ + { + "name": "canonical_base", + "result": "PASS", + "evidence": "HEAD is exactly 3bc0dfa7c00261785c0c752270cb302a9294e751." + }, + { + "name": "json_parse", + "result": "PASS", + "evidence": "All changed JSON parses successfully." + }, + { + "name": "links_and_anchors", + "result": "PASS", + "evidence": "Every relative Markdown path and local heading anchor in the eight-document bundle resolves." + }, + { + "name": "prettier", + "result": "PASS", + "evidence": "Prettier check passes for all changed documents and this handoff." + }, + { + "name": "diff_integrity", + "result": "PASS", + "evidence": "Tracked and untracked whitespace checks pass." + }, + { + "name": "scope", + "result": "PASS", + "evidence": "Exactly the eight Phase 1 documents and this handoff are changed." + }, + { + "name": "secret_and_path_scan", + "result": "PASS", + "evidence": "Focused changed-content scan finds no credential material, private keys, file URI, or user-specific host path." + }, + { + "name": "semantic_consistency", + "result": "PASS", + "evidence": "Target-image narrowing is a passed Phase 0-to-Phase 1 gate while image admission remains fail closed in Phase 5; all remaining prerequisites still block Phase 1." + } + ], + "commit": { + "requestedMessage": "docs(hosted-web): reconcile phase 1 plan with candidate", + "created": false, + "reason": "Linked-worktree runtime delegates add, commit, and push to Project Integration." + }, + "nextAction": "Independently review the exact nine-path reconciliation, then have Project Integration create the requested conventional commit if approved. Do not authorize Phase 1 until every remaining prerequisite passes." +} diff --git a/.codex-handoff/target-image-decision-h2.json b/.codex-handoff/target-image-decision-h2.json new file mode 100644 index 0000000000..80a777de12 --- /dev/null +++ b/.codex-handoff/target-image-decision-h2.json @@ -0,0 +1,155 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "taskId": "target-image-decision-h2", + "status": "ready_for_integration", + "canonicalSource": { + "commit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "tree": "4bc04a743c20ea48e06ada55c761d03881117cac" + }, + "decision": { + "id": "P0.D.TARGET_IMAGE", + "state": "accepted", + "outcome": "capability_narrowed", + "phase0Gate": "closed_by_accepted_narrowing", + "exactImageEarliestOwner": "phase-5", + "phase5AdmissionGate": "fail_closed" + }, + "proof": { + "path": "docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json", + "recordType": "phase-0-target-image-capability-narrowing-decision", + "normalizedFactSha256": "97f783c0c98b2437b368902f650d5558ca636f84ae6c9bbf0a176e1766e30ff0", + "canonicalSourceGapCount": 51, + "gapGroups": { + "composition": 21, + "image": 4, + "inventory": 3, + "profile": 12, + "provider_runtime": 2, + "terminal_negative": 9 + }, + "currentCandidateTerminalAbsencePasses": false, + "currentCandidateTerminalViolationCount": 4, + "phase5Admitted": false + }, + "currentCommitAuthority": { + "path": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "recordType": "w6-current-commit-artifact-scan", + "canonicalSourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "semanticSha256": "d91ceece21873f48f23a121cf1a9987534a6197017d350071b7c26a2e70b081c", + "proofLevel": "targeted_current_commit_build_observed", + "targetedBuildCompared": true + }, + "historicalProvenance": { + "path": "docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json", + "relationship": "historical_only_not_current_commit_authority" + }, + "drainEnvelopeConsumer": { + "envelopeId": "P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1", + "schemaPath": "docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json", + "schemaSha256": "764e707edc1148e45f18f1ac70a30b681e2a794b55d6f8946fb5b50601bc099f", + "authority": "phase-00-controller", + "projection": "exact_required_fields_no_lane_owned_wrapper" + }, + "artifactEvolution": { + "assumption": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet." + }, + "proofLevels": { + "artifactInventory": "targeted_current_commit_build_observed", + "currentTerminalRuleEvaluation": "targeted_current_commit_build_observed" + }, + "provenanceModel": { + "immutableSourceIdentity": "canonical commit/tree and authority bytes read from the canonical commit", + "postCommitEvidenceIdentity": "repositoryHeadAtVerification is reported separately and must equal or descend from the canonical source", + "comparison": "normalized deterministic facts and explicit authority provenance", + "liveDockerStateCompared": false, + "dockerSocketAssumed": false + }, + "reviewFindingResolutions": [ + { + "id": "TIP-R1-001", + "status": "resolved", + "resolution": "Immutable canonical source commit/tree no longer changes with repository HEAD; descendant evidence identity is separate and regression tested." + }, + { + "id": "TIP-R1-002", + "status": "resolved", + "resolution": "No live Docker probe participates in deterministic decision collection or comparison, and no Docker CLI, daemon or socket is assumed." + }, + { + "id": "TIP-R1-003", + "status": "resolved", + "resolution": "The report states the tested scope accurately; focused tests now mutate every image identity/profile field and every native provenance field." + } + ], + "scope": { + "disposition": "standalone_artifact_rejected_for_hosted_v1", + "realUserProjectsOpened": false, + "rawCredentialsRecorded": false, + "dockerSocketUsed": false, + "phase1AuthorizedOrImplemented": false, + "hostedCapabilityEnabled": false, + "terminalCapabilityEnabled": false + }, + "changedPaths": [ + ".codex-handoff/target-image-decision-h2.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json", + "docs/research/hosted-web/phase-0/auth-artifacts/report.md", + "docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json", + "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "scripts/hosted-web/phase-0/auth-artifacts/prove-target-image-admission.mjs", + "scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "test/architecture/hosted-web/phase-0/auth-artifacts/target-image-admission.test.ts" + ], + "checks": [ + { + "command": "git rev-parse HEAD; git rev-parse 42ec333848e29e97c41699b9fed73ed199740e3f^{tree}", + "exitCode": 0, + "result": "Exact canonical source commit 42ec333848e29e97c41699b9fed73ed199740e3f and tree 4bc04a743c20ea48e06ada55c761d03881117cac confirmed." + }, + { + "command": "TMPDIR=/tmp pnpm exec vitest run test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts test/architecture/hosted-web/phase-0/auth-artifacts/target-image-admission.test.ts test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts --maxWorkers=1 --minWorkers=1", + "exitCode": 0, + "result": "Three files and 94 tests passed, including 37 target-image narrowing/admission tests." + }, + { + "command": "TMPDIR=/tmp node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "exitCode": 0, + "result": "Combined W6 and target-image verifier passed, including normalized source provenance, the descendant relationship, all 51 Phase 5 obligations, current standalone rejection and terminal absence." + }, + { + "command": "node scripts/hosted-web/phase-0/auth-artifacts/prove-target-image-admission.mjs --require-admission", + "exitCode": 2, + "result": "Expected fail-closed negative: Phase 5 admission remains false with exactly 51 canonical-source gaps." + }, + { + "command": "pnpm exec tsc --noEmit ", + "exitCode": 0, + "result": "The focused target-image test compiles strictly without diagnostics." + }, + { + "command": "pnpm typecheck", + "exitCode": 2, + "result": "The repository-wide check retains seven pre-existing Phase 0 test diagnostics: five in auth-artifacts-spike.test.ts, one in evidence-scanner.test.ts and one in scan-runtime-surfaces.test.ts. The new target-image test has no diagnostic and its focused strict compile passes." + }, + { + "command": "pnpm lint:fast:files -- ", + "exitCode": 0, + "result": "Focused fast lint passed." + }, + { + "command": "pnpm exec prettier --check ", + "exitCode": 0, + "result": "All changed paths use Prettier style." + }, + { + "command": "git diff --check; exact scope/diff/JSON/secret/path scans", + "exitCode": 0, + "result": "No whitespace, out-of-scope path, malformed JSON, secret-like value, raw credential or real-user-project path finding." + } + ], + "remainingPhase5Requirements": "Instantiate one reviewed immutable production image and satisfy every preserved admission obligation before Phase 5 routes/capabilities or Phase 6 non-loopback mutations are admitted." +} diff --git a/.dockerignore b/.dockerignore index c2d844c25f..c839afd846 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,6 +28,7 @@ resources/runtime/* .home/ .serena/ .playwright-mcp/ +docker/secrets/ logs/ *.log .env diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 6a32d99c4e..722068aaab 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -11,14 +11,14 @@ Official Electron releases include limited pseudonymous telemetry. It is enabled Telemetry uses a pseudonymous app installation identifier rather than an account name or email. Development and self-built packages without the build-time Sentry DSN and PostHog key do not send this telemetry. -| Network activity | When | Mode | User-controlled | -|---|---|---|---| -| GitHub Releases API (auto-updater) | App launch | Electron only | Automatic; controlled by update settings | -| Sentry crash and performance telemetry | Telemetry enabled and an event occurs | Official Electron releases | Yes; can be disabled in Privacy settings | +| Network activity | When | Mode | User-controlled | +| -------------------------------------- | ---------------------------------------------------- | -------------------------- | ---------------------------------------- | +| GitHub Releases API (auto-updater) | App launch | Electron only | Automatic; controlled by update settings | +| Sentry crash and performance telemetry | Telemetry enabled and an event occurs | Official Electron releases | Yes; can be disabled in Privacy settings | | PostHog coarse product-usage telemetry | Telemetry enabled and a defined product event occurs | Official Electron releases | Yes; can be disabled in Privacy settings | -| Agent provider CLI/API traffic | A user launches or interacts with an agent runtime | Runtime-dependent | Yes | -| SSH connections | Settings > SSH | Electron only | Yes | -| HTTP server (`127.0.0.1` or `0.0.0.0`) | When enabled | Both | Yes | +| Agent provider CLI/API traffic | A user launches or interacts with an agent runtime | Runtime-dependent | Yes | +| SSH connections | Settings > SSH | Electron only | Yes | +| HTTP server (`127.0.0.1` or `0.0.0.0`) | When enabled | Both | Yes | ### Standalone / Docker mode @@ -59,6 +59,7 @@ Only the latest release is supported with security fixes. Please report vulnerabilities privately and do not open public issues for undisclosed security problems. Include: + - affected version/commit - vulnerability description - impact assessment diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 489ec4c71d..d9a954b443 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +permissions: + contents: read + on: push: branches: @@ -15,7 +18,9 @@ on: - 'landing/**' - 'bin/**' - 'scripts/**' + - 'docker/**' - 'agent-teams-controller/**' + - 'landing/**' - 'mcp-server/**' - 'packages/**' - 'resources/runtime/**' @@ -46,7 +51,9 @@ on: - 'landing/**' - 'bin/**' - 'scripts/**' + - 'docker/**' - 'agent-teams-controller/**' + - 'landing/**' - 'mcp-server/**' - 'packages/**' - 'resources/runtime/**' @@ -92,6 +99,14 @@ jobs: - name: Verify runtime lock run: node ./scripts/ci/verify-runtime-lock.mjs + - name: Resolve and fetch architecture baseline + id: architecture-baseline + env: + ARCHITECTURE_EVENT_BASELINE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + baseline_ref="$(node ./scripts/ci/resolve-architecture-baseline-ref.mjs)" + printf 'ref=%s\n' "$baseline_ref" >> "$GITHUB_OUTPUT" + - name: Setup pnpm uses: pnpm/action-setup@v6 @@ -122,7 +137,9 @@ jobs: - name: Validate workspace truth gate env: + FEATURE_ARCHITECTURE_BASELINE_REF: ${{ steps.architecture-baseline.outputs.ref }} NODE_OPTIONS: --max-old-space-size=8192 + SOURCE_FILE_SIZE_BASELINE_REF: ${{ steps.architecture-baseline.outputs.ref }} run: pnpm validate:ci test: @@ -169,6 +186,98 @@ jobs: - name: Test root shard run: pnpm test:ci --shard=${{ matrix.shard }}/2 + hosted-v1-e2e: + name: Hosted v1 production browser E2E (${{ matrix.suite }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + suite: [core, phase-6, phase-8] + env: + CADDY_IMAGE_DIGEST: ${{ vars.HOSTED_E2E_CADDY_IMAGE_DIGEST }} + KEYCLOAK_IMAGE_DIGEST: ${{ vars.HOSTED_E2E_KEYCLOAK_IMAGE_DIGEST }} + NODE_IMAGE_DIGEST: ${{ vars.HOSTED_E2E_NODE_IMAGE_DIGEST }} + PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Rebuild test SQLite native module for Node + run: node ./scripts/ci/rebuild-better-sqlite3-node.cjs + + - name: Cache Chromium + id: chromium-cache + uses: actions/cache@v5 + with: + path: /home/runner/.cache/ms-playwright + key: playwright-chromium-${{ runner.os }}-1.62.1 + + - name: Install Chromium once + if: steps.chromium-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install cached Chromium system dependencies + if: steps.chromium-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - name: Run bounded hosted v1 E2E + env: + HOSTED_E2E_ARTIFACT_DIR: ${{ runner.temp }}/hosted-v1-e2e-artifacts + HOSTED_E2E_SUITE: ${{ matrix.suite }} + run: | + CI=true sudo --preserve-env=CI,PATH,CADDY_IMAGE_DIGEST,HOSTED_E2E_ARTIFACT_DIR,HOSTED_E2E_SUITE,KEYCLOAK_IMAGE_DIGEST,NODE_IMAGE_DIGEST,PLAYWRIGHT_BROWSERS_PATH \ + "$(command -v pnpm)" test:hosted:e2e + + - name: Upload hosted v1 evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: hosted-v1-e2e-${{ matrix.suite }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/hosted-v1-e2e-artifacts + if-no-files-found: error + retention-days: 14 + + hosted-v1-team-lifecycle-ui: + name: Hosted v1 required team-lifecycle-ui suite + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run mandatory team lifecycle UI regression suite + run: pnpm exec vitest run --maxWorkers=1 test/renderer/components/team/HostedTeamLifecycleControls.test.tsx test/renderer/components/team/HostedTeamWorkspace.test.tsx + + - name: Run native process-anchor component regression (not production activation proof) + run: pnpm exec vitest run --maxWorkers=1 test/features/team-runtime-control/main/process-supervision/NodeAnchorSpawner.integration.test.ts + lint: name: lint (${{ matrix.scope }}) runs-on: ubuntu-latest @@ -247,6 +356,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Test Windows approval preview path safety + run: pnpm exec vitest run src/main/services/team/approvals/WindowsToolApprovalFileReader.windows.test.ts + - name: Test Windows CLI shell fallback run: pnpm exec vitest run test/main/utils/childProcess.windows.test.ts diff --git a/.github/workflows/codex-runtime-smoke.yml b/.github/workflows/codex-runtime-smoke.yml index 97143b73f8..39807a4eef 100644 --- a/.github/workflows/codex-runtime-smoke.yml +++ b/.github/workflows/codex-runtime-smoke.yml @@ -34,7 +34,7 @@ jobs: install: name: Install Codex runtime (${{ matrix.os }}) runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 30 strategy: fail-fast: false diff --git a/.github/workflows/opencode-upstream-tracker.yml b/.github/workflows/opencode-upstream-tracker.yml new file mode 100644 index 0000000000..70f3a722b3 --- /dev/null +++ b/.github/workflows/opencode-upstream-tracker.yml @@ -0,0 +1,66 @@ +name: OpenCode upstream tracker + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: opencode-upstream-tracker + cancel-in-progress: false + +jobs: + track: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Read latest upstream stable release + env: + GH_TOKEN: ${{ github.token }} + run: gh api repos/anomalyco/opencode/releases/latest > "$RUNNER_TEMP/opencode-upstream.json" + + - name: Compare immutable downstream base + id: compare + run: | + set +e + node scripts/ci/check-hosted-opencode-upstream.mjs \ + --lock opencode-hosted-runtime.lock.json \ + --upstream-release "$RUNNER_TEMP/opencode-upstream.json" \ + --report "$RUNNER_TEMP/opencode-upstream-report.md" \ + > "$RUNNER_TEMP/opencode-upstream-result.json" + status=$? + set -e + if [[ "$status" -ne 0 && "$status" -ne 2 ]]; then + exit "$status" + fi + echo "drifted=$([[ "$status" -eq 2 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Synchronize the single upstream tracking issue + env: + GH_TOKEN: ${{ github.token }} + DRIFTED: ${{ steps.compare.outputs.drifted }} + run: | + title='Track OpenCode upstream drift' + issue="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "$title in:title" --json number,title \ + --jq ".[] | select(.title == \"$title\") | .number" | head -n 1)" + if [[ "$DRIFTED" == 'true' ]]; then + if [[ -n "$issue" ]]; then + gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" \ + --body-file "$RUNNER_TEMP/opencode-upstream-report.md" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" \ + --body-file "$RUNNER_TEMP/opencode-upstream-report.md" + fi + exit 1 + fi + if [[ -n "$issue" ]]; then + gh issue close "$issue" --repo "$GITHUB_REPOSITORY" \ + --comment 'The immutable downstream base now matches the latest upstream stable release.' + fi diff --git a/.github/workflows/reviewrouter-codex.yml b/.github/workflows/reviewrouter-codex.yml index 741cab971e..e433416c7c 100644 --- a/.github/workflows/reviewrouter-codex.yml +++ b/.github/workflows/reviewrouter-codex.yml @@ -6,7 +6,7 @@ on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] schedule: - - cron: "17 */6 * * *" + - cron: '17 */6 * * *' permissions: {} @@ -23,12 +23,12 @@ jobs: id-token: write uses: 777genius/review-router/.github/workflows/reviewrouter-t0-reusable.yml@da20c20adf0f9ff637291c9947ff71bf00b30005 with: - runtime_ref: "da20c20adf0f9ff637291c9947ff71bf00b30005" - api_url: "https://api.reviewrouter.site" + runtime_ref: 'da20c20adf0f9ff637291c9947ff71bf00b30005' + api_url: 'https://api.reviewrouter.site' runtime_config_mode: oidc pr_number: ${{ format('{0}', github.event.pull_request.number) }} review_head_sha: ${{ github.event.pull_request.head.sha }} - provider_instance_id: "codex-rotating:1163183284" + provider_instance_id: 'codex-rotating:1163183284' workflow_schema_version: 4 max_changed_lines: ${{ vars.REVIEW_ROUTER_MAX_CHANGED_LINES }} review_timeout_minutes: ${{ fromJSON(vars.REVIEW_ROUTER_TIMEOUT_MINUTES || '60') }} @@ -51,7 +51,7 @@ jobs: uses: 777genius/review-router@da20c20adf0f9ff637291c9947ff71bf00b30005 with: mode: codex-oauth-refresh - api-url: "https://api.reviewrouter.site" - provider-instance-id: "codex-rotating:1163183284" - workflow-schema-version: "4" + api-url: 'https://api.reviewrouter.site' + provider-instance-id: 'codex-rotating:1163183284' + workflow-schema-version: '4' auth-json: ${{ secrets.REVIEWROUTER_CODEX_AUTH_JSON_R1163183284_Pc11a7080815e939d_E4_3c555371c528609493a910486221e465 }} diff --git a/.github/workflows/reviewrouter-interaction.yml b/.github/workflows/reviewrouter-interaction.yml index 098f2f35cf..abbaf72fa1 100644 --- a/.github/workflows/reviewrouter-interaction.yml +++ b/.github/workflows/reviewrouter-interaction.yml @@ -20,19 +20,19 @@ jobs: runs-on: ubuntu-24.04 if: ${{ github.event_name == 'workflow_dispatch' || ((github.event_name != 'issue_comment' || github.event.issue.pull_request) && github.event.comment.user.type != 'Bot') }} env: - RR_RUNTIME_REF: "626739854b5c67d94b3f0118738c106b4a232c41" - REVIEWROUTER_API_URL: "https://api.reviewrouter.site" - REVIEWROUTER_OIDC_AUDIENCE: "reviewrouter" - REVIEWROUTER_RUNTIME_CONFIG_MODE: "oidc" - REVIEWROUTER_STATIC_CONFIG_FALLBACK: "true" - REVIEWROUTER_COMMENT_TOKEN_MODE: "app-oidc" + RR_RUNTIME_REF: '626739854b5c67d94b3f0118738c106b4a232c41' + REVIEWROUTER_API_URL: 'https://api.reviewrouter.site' + REVIEWROUTER_OIDC_AUDIENCE: 'reviewrouter' + REVIEWROUTER_RUNTIME_CONFIG_MODE: 'oidc' + REVIEWROUTER_STATIC_CONFIG_FALLBACK: 'true' + REVIEWROUTER_COMMENT_TOKEN_MODE: 'app-oidc' CODEX_AUTH_JSON_PRESENT: ${{ secrets.REVIEWROUTER_CODEX_AUTH_JSON != '' && '1' || '0' }} - REVIEW_ROUTER_REVIEW_WORKFLOW_FILE: "reviewrouter-codex.yml" - REVIEW_ROUTER_MEMORY_ENABLED: "true" - REVIEW_ROUTER_MEMORY_PROTOCOL_VERSION: "1" - REVIEW_ROUTER_MEMORY_BUNDLE_ENDPOINT: "/api/action/v1/memory" - REVIEW_ROUTER_MEMORY_CANDIDATE_ENDPOINT: "/api/action/v1/memory-candidates" - REVIEW_ROUTER_MEMORY_COMMAND_ENDPOINT: "/api/action/v1/memory-commands" + REVIEW_ROUTER_REVIEW_WORKFLOW_FILE: 'reviewrouter-codex.yml' + REVIEW_ROUTER_MEMORY_ENABLED: 'true' + REVIEW_ROUTER_MEMORY_PROTOCOL_VERSION: '1' + REVIEW_ROUTER_MEMORY_BUNDLE_ENDPOINT: '/api/action/v1/memory' + REVIEW_ROUTER_MEMORY_CANDIDATE_ENDPOINT: '/api/action/v1/memory-candidates' + REVIEW_ROUTER_MEMORY_COMMAND_ENDPOINT: '/api/action/v1/memory-commands' steps: - name: Checkout ReviewRouter interaction runtime uses: actions/checkout@v6 @@ -45,14 +45,14 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "24" + node-version: '24' - name: Preflight ReviewRouter interaction id: preflight shell: bash env: GITHUB_TOKEN: ${{ github.token }} - REVIEW_ROUTER_MODE: "interaction-preflight" + REVIEW_ROUTER_MODE: 'interaction-preflight' REVIEW_ROUTER_DISCUSSION_MODE: ${{ vars.REVIEW_ROUTER_DISCUSSION_MODE || 'off' }} run: node .reviewrouter-runtime/dist/index.js @@ -83,7 +83,7 @@ jobs: shell: bash env: GITHUB_TOKEN: ${{ github.token }} - REVIEW_ROUTER_MODE: "interaction" + REVIEW_ROUTER_MODE: 'interaction' REVIEW_ROUTER_LEDGER_KEY: ${{ secrets.REVIEW_ROUTER_LEDGER_KEY }} REVIEW_ROUTER_DISCUSSION_MODE: ${{ vars.REVIEW_ROUTER_DISCUSSION_MODE || 'off' }} REVIEW_ROUTER_DISCUSSION_MAX_PER_PR: ${{ vars.REVIEW_ROUTER_DISCUSSION_MAX_PER_PR || '20' }} diff --git a/AGENTS.md b/AGENTS.md index 52509fcbe8..de8ffc12b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,18 @@ Start here: - Team Provisioning target architecture and migration rules: [docs/team-management/team-provisioning-target-architecture.md](docs/team-management/team-provisioning-target-architecture.md) - Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) +## Hosted Web + +For hosted-web execution, begin at [START_HERE.md](docs/hosted-web-phases/START_HERE.md) and follow +the phase that [EXECUTION_INDEX.json](docs/hosted-web-phases/EXECUTION_INDEX.json) marks as current. +Do not insert another document into the router's mandatory read order. Before a new product packet is +proposed or implemented, also apply the accepted +[Core v1 scope lock](docs/hosted-web-core-v1-scope-lock.md). It defines product scope and +preservation; it does not authorize execution or override current ownership/admission rules. +The monolithic plans ([hosted-web-e2e-completion-plan.md](docs/hosted-web-e2e-completion-plan.md), +[hosted-web-phase-0-execution-packet.md](docs/hosted-web-phase-0-execution-packet.md)) are reference +material; the execution index is the single source of truth for phase status. + GitHub repository disambiguation: - For this workspace, the canonical GitHub repository is `777genius/agent-teams-ai`. diff --git a/AGENT_CRITICAL_GUARDRAILS.md b/AGENT_CRITICAL_GUARDRAILS.md index a9a087fa52..b38550978e 100644 --- a/AGENT_CRITICAL_GUARDRAILS.md +++ b/AGENT_CRITICAL_GUARDRAILS.md @@ -7,6 +7,7 @@ These are the hard rules to keep agent work predictable and safe in this repo. - Use the desktop Electron app (`pnpm dev`) for normal local development and smoke checks unless browser-mode internals are explicitly requested. - Do not test agent teams, launch/provisioning, terminal runtime, task assignment, smoke-flow, or agent actions on real user projects. Use only new sandbox/test projects or explicitly test-only existing projects. Real projects such as `~/dev/projects/ai/claude-runtime` must not be used even for opening a runtime/terminal without fresh direct user permission. - Do not run `pnpm lint:fix` unless the user explicitly asks for broad formatting changes. +- Keep cross-feature production imports on public feature entrypoints; keep `core/domain` runtime-free and `core/application` dependent only on domain, contracts, and its own application models, use cases, and ports. Existing violations are exact edges in `scripts/ci/feature-architecture-baseline.json`: do not add exceptions, and run `pnpm guard:feature-architecture`. - Keep new production source files at or below 800 physical lines. Existing oversized files are frozen in `scripts/ci/source-file-size-baseline.json`; do not add exceptions or raise legacy caps. Run `pnpm guard:source-file-size` to verify the ratchet. - For Team Provisioning, do not add facade-inheritance layers, whole-service `ServiceHost` casts, or new `protected abstract` dependency slots. Use explicit composition, follow `docs/team-management/team-provisioning-target-architecture.md`, and run `pnpm guard:team-provisioning-architecture`. - Keep main, preload, renderer, and shared responsibilities separate. diff --git a/CLAUDE.md b/CLAUDE.md index b26981cb34..454fb9f21e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ When running build/typecheck/test commands, pipe through `tail -20` to avoid flo - `pnpm dev` - Desktop Electron app with hot reload - `pnpm build` - Production build - `pnpm typecheck` - Canonical type check using the project's pinned native TypeScript 7 compiler (do not additionally run global `tsc7`) +- `pnpm guard:feature-architecture` - Reject new Clean Architecture dependency violations while preserving exact legacy edges - `pnpm guard:source-file-size` - Reject new production files above 800 lines and prevent frozen legacy files from growing - `pnpm guard:team-provisioning-architecture` - Prevent new facade inheritance and implicit whole-service coupling in Team Provisioning - `pnpm lint:fix` - Lint and auto-fix diff --git a/docker/Dockerfile b/docker/Dockerfile index 2959e570da..a1899b7496 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,8 +9,36 @@ # ============================================================================= ARG NODE_VERSION=24.16.0 +ARG NODE_IMAGE_DIGEST +ARG KEYCLOAK_VERSION=26.3.2 +ARG KEYCLOAK_IMAGE_DIGEST -FROM node:${NODE_VERSION}-slim AS base +# ============================================================================= +# Keycloak production stages +# +# Keep the Keycloak augmentation in a pinned, separate target. The runtime +# stage starts only the pre-built PostgreSQL shape with --optimized, so the +# read-only service never attempts a runtime augmentation. +# ============================================================================= +FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}@${KEYCLOAK_IMAGE_DIGEST} AS keycloak-build + +RUN /opt/keycloak/bin/kc.sh build --db=postgres --health-enabled=true + +FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}@${KEYCLOAK_IMAGE_DIGEST} AS keycloak-runtime + +USER root + +COPY --from=keycloak-build /opt/keycloak/ /opt/keycloak/ + +RUN mkdir -p /caddy-trust \ + && touch /caddy-trust/root.crt \ + && chown 1000:1000 /caddy-trust /caddy-trust/root.crt \ + && chmod 0700 /caddy-trust \ + && chmod 0600 /caddy-trust/root.crt + +USER 1000 + +FROM node:${NODE_VERSION}-slim@${NODE_IMAGE_DIGEST} AS base WORKDIR /app @@ -25,13 +53,20 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # Install dependencies first (better layer caching) -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY patches ./patches +COPY vendor/terminal-platform/sdk ./vendor/terminal-platform/sdk +COPY vendor/terminal-platform/terminal-platform-node-stub ./vendor/terminal-platform/terminal-platform-node-stub +COPY scripts/ci/enforce-pnpm-install.mjs ./scripts/ci/enforce-pnpm-install.mjs +COPY scripts/ensure-electron-install.cjs ./scripts/ensure-electron-install.cjs RUN pnpm install --frozen-lockfile # Copy source and build COPY . . -RUN AGENT_TEAMS_DISABLE_SOURCEMAPS=1 pnpm standalone:build +RUN AGENT_TEAMS_DISABLE_SOURCEMAPS=1 pnpm standalone:build \ + && mkdir -p /app/bin \ + && node scripts/hosted-web/build-instance-lock.mjs \ + --output /app/bin/agent-teams-instance-lock # ============================================================================= # Production dependencies stage @@ -44,32 +79,67 @@ RUN apt-get update \ # Install production-only dependencies # (fastify, @fastify/cors, @fastify/static are externalized from the bundle) -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY patches ./patches +COPY vendor/terminal-platform/sdk ./vendor/terminal-platform/sdk +COPY vendor/terminal-platform/terminal-platform-node-stub ./vendor/terminal-platform/terminal-platform-node-stub +COPY scripts/ci/verify-hosted-no-terminal-artifact.mjs /tmp/verify-hosted-no-terminal-artifact.mjs RUN pnpm install --frozen-lockfile --prod --ignore-scripts \ - && pnpm rebuild node-pty cpu-features ssh2 + && pnpm rebuild better-sqlite3 \ + && node /tmp/verify-hosted-no-terminal-artifact.mjs --root /app --prune --require-better-sqlite3 # ============================================================================= # Production stage - minimal image with only runtime dependencies and built output # ============================================================================= FROM base -COPY --from=prod-deps /app/package.json /app/pnpm-lock.yaml ./ +COPY --from=prod-deps /app/package.json ./ COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=builder /app/agent-teams-controller ./agent-teams-controller +COPY --from=builder /app/bin/agent-teams-instance-lock ./bin/agent-teams-instance-lock # Copy built standalone server and renderer output COPY --from=builder /app/dist-standalone ./dist-standalone COPY --from=builder /app/out/renderer ./out/renderer +COPY scripts/hosted-auth-cli.mjs ./scripts/hosted-auth-cli.mjs +COPY scripts/ci/verify-hosted-no-terminal-artifact.mjs /tmp/verify-hosted-no-terminal-artifact.mjs +COPY docker/hosted-volume-init.sh /usr/local/bin/hosted-volume-init +COPY docker/hosted-entrypoint.sh /usr/local/bin/hosted-entrypoint # Create data directory for Claude session mount -RUN mkdir -p /data/.claude +RUN test -f /app/dist-standalone/index.cjs \ + && test -f /app/dist-standalone/assets/internal-storage-worker.cjs \ + && node /tmp/verify-hosted-no-terminal-artifact.mjs --root /app --require-better-sqlite3 --require-hosted-renderer-graph \ + && rm /tmp/verify-hosted-no-terminal-artifact.mjs \ + && mkdir -p /data/.claude /data/.agent-teams/data /data/.agent-teams/instance-lock /run/agent-teams /run/agent-teams-oidc /run/agent-teams-lifecycle-trust /caddy-trust /var/lib/agent-teams/lifecycle-owner-high-water \ + && install -o root -g root -m 0444 /dev/null /data/.agent-teams/instance-lock/instance.lock \ + && chown root:root /app/bin/agent-teams-instance-lock /data/.agent-teams/instance-lock \ + && chown root:node /data/.agent-teams \ + && chown node:node /data/.agent-teams/data \ + && chmod 0555 /app/bin/agent-teams-instance-lock /data/.agent-teams/instance-lock \ + && chmod 1770 /data/.agent-teams \ + && chmod 0700 /data/.agent-teams/data \ + && chown node:node /run/agent-teams /run/agent-teams-oidc /run/agent-teams-lifecycle-trust /caddy-trust /var/lib/agent-teams/lifecycle-owner-high-water \ + && chmod 700 /run/agent-teams /run/agent-teams-oidc /run/agent-teams-lifecycle-trust /caddy-trust /var/lib/agent-teams/lifecycle-owner-high-water \ + && install -o node -g node -m 0600 /dev/null /run/agent-teams-oidc/oidc-client-secret \ + && install -o node -g node -m 0600 /dev/null /run/agent-teams-lifecycle-trust/trust-anchor \ + && install -o node -g node -m 0600 /dev/null /run/agent-teams-lifecycle-trust/release-owner-pin.json \ + && install -o node -g node -m 0600 /dev/null /caddy-trust/root.crt \ + && chmod 0555 /usr/local/bin/hosted-volume-init /usr/local/bin/hosted-entrypoint ENV NODE_ENV=production ENV CLAUDE_ROOT=/data/.claude ENV HOST=0.0.0.0 ENV PORT=3456 +ENV AUTH_DATA_DIR=/data/.agent-teams/data +ENV PAIRING_CODE_FILE=/run/agent-teams/pairing.json +ENV AUTH_CONTROL_SOCKET=/run/agent-teams/control.sock +ENV AUTH_DRAIN_EVIDENCE_FILE=/run/agent-teams/drain-proof.json +ENV HOSTED_LIFECYCLE_ORCHESTRATOR_HIGH_WATER_ROOT=/var/lib/agent-teams/lifecycle-owner-high-water + +USER node EXPOSE 3456 -CMD ["node", "dist-standalone/index.cjs"] +ENTRYPOINT ["/usr/local/bin/hosted-entrypoint"] +CMD ["/usr/local/bin/node", "/app/dist-standalone/index.cjs"] diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile new file mode 100644 index 0000000000..ea308e83a8 --- /dev/null +++ b/docker/caddy/Caddyfile @@ -0,0 +1,41 @@ +{ + servers { + strict_sni_host on + } +} + +{$HOSTED_DOMAIN:agent-teams.localhost}:{$HOSTED_HTTPS_PORT:443} { + tls internal + @hosted_api path /api/hosted/v1/* + @hosted_non_api { + not path /api/hosted/v1/* + } + encode @hosted_non_api zstd gzip + header @hosted_api Cache-Control "no-store, no-transform" + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + } + + header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + reverse_proxy agent-teams-keycloak:3456 { + header_up Accept-Encoding identity + } +} + +{$KEYCLOAK_DOMAIN:auth.agent-teams.localhost}:{$HOSTED_HTTPS_PORT:443} { + tls internal + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + } + + reverse_proxy keycloak:8080 +} diff --git a/docker/caddy/Caddyfile.personal b/docker/caddy/Caddyfile.personal new file mode 100644 index 0000000000..7b70d1329a --- /dev/null +++ b/docker/caddy/Caddyfile.personal @@ -0,0 +1,26 @@ +{ + servers { + strict_sni_host on + } +} + +{$HOSTED_DOMAIN:agent-teams.localhost}:{$HOSTED_HTTPS_PORT:443} { + tls internal + @hosted_api path /api/hosted/v1/* + @hosted_non_api { + not path /api/hosted/v1/* + } + encode @hosted_non_api zstd gzip + header @hosted_api Cache-Control "no-store, no-transform" + reverse_proxy agent-teams-personal:3456 { + header_up Accept-Encoding identity + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + } +} diff --git a/docker/docker-compose.e2e.yml b/docker/docker-compose.e2e.yml new file mode 100644 index 0000000000..cc5b861094 --- /dev/null +++ b/docker/docker-compose.e2e.yml @@ -0,0 +1,254 @@ +name: ${COMPOSE_PROJECT_NAME:?Set a unique marker-owned Compose project name} + +x-production-app-image: &production-app-image + image: ${E2E_APP_IMAGE:?Set a unique marker-owned application image name} + build: + context: .. + dockerfile: docker/Dockerfile + args: + NODE_IMAGE_DIGEST: ${NODE_IMAGE_DIGEST:?Set the audited Node image digest} + KEYCLOAK_IMAGE_DIGEST: ${KEYCLOAK_IMAGE_DIGEST:?Set the audited Keycloak image digest} + labels: + org.agent-teams.hosted-e2e.source-head-commit: ${E2E_SOURCE_HEAD_COMMIT:?Set the exact source commit} + org.agent-teams.hosted-e2e.source-patch-sha256: ${E2E_SOURCE_PATCH_SHA256:?Set the exact source patch digest} + +x-e2e-hardening: &e2e-hardening + init: true + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + +services: + agent-teams-lifecycle-trust-init: + <<: [*production-app-image, *e2e-hardening] + user: '${E2E_APP_UID:?Set the non-root application uid}:${E2E_APP_GID:?Set the application gid}' + command: ['/usr/local/bin/hosted-volume-init', 'lifecycle-trust-anchor'] + pids_limit: 32 + cpus: 0.25 + mem_limit: 64m + stop_grace_period: 10s + volumes: + - agent-teams-lifecycle-trust:/run/agent-teams-lifecycle-trust + secrets: + - lifecycle_orchestrator_trust_anchor + - lifecycle_owner_release_pin + network_mode: none + restart: 'no' + + hosted-controller: + <<: [*production-app-image, *e2e-hardening] + user: '${E2E_APP_UID:?Set the non-root application uid}:${E2E_APP_GID:?Set the application gid}' + pids_limit: 256 + cpus: 1.0 + mem_limit: 1g + stop_grace_period: 45s + depends_on: + agent-teams-lifecycle-trust-init: + condition: service_completed_successfully + caddy: + condition: service_healthy + fake-runtime: + condition: service_healthy + synthetic-oidc: + condition: service_healthy + volumes: + - ${E2E_CLAUDE_DIR:?Set marker-owned Claude state}:/data/.claude:ro + - ${E2E_APP_DATA_DIR:?Set marker-owned app state}:/data/.agent-teams + - ${E2E_CADDY_DATA_DIR:?Set marker-owned Caddy state}:/caddy-data:ro + - ${E2E_RUN_DIR:?Set marker-owned runtime state}:/run/agent-teams + - ${E2E_FAKE_RUNTIME_STATE_DIR:?Set marker-owned fake runtime state}/auth-drain:/run/agent-teams-auth-drain:ro + - ${E2E_LIFECYCLE_RUN_DIR:?Set marker-owned external orchestrator runtime state}:/run/agent-teams-orchestrator:ro + - agent-teams-lifecycle-trust:/run/agent-teams-lifecycle-trust:ro + - ${E2E_LIFECYCLE_HIGH_WATER_DIR:?Set marker-owned lifecycle high-water state}:/var/lib/agent-teams/lifecycle-owner-high-water + - ${E2E_WORKSPACE_DIR:?Set marker-owned workspace}:/workspaces/sandbox + extra_hosts: + - '${OIDC_DOMAIN}:${E2E_CADDY_IP}' + tmpfs: + - /tmp:mode=1777 + environment: + NODE_ENV: production + CLAUDE_ROOT: /data/.claude + HOST: 0.0.0.0 + PORT: '3456' + AUTH_MODE: ${HOSTED_E2E_AUTH_MODE:?Set the isolated authentication mode} + AUTH_PUBLIC_ORIGIN: ${HOSTED_E2E_ORIGIN} + HOSTED_HTTPS_PORT: ${HOSTED_HTTPS_PORT} + AUTH_DEPLOYMENT_ID: deployment_hosted-v1-e2e + AUTH_RESTORE_GENERATION: '0' + AUTH_DATA_DIR: /data/.agent-teams/data + HOSTED_WORKSPACE_IDS: ${E2E_RUNTIME_WORKSPACE_ID},${E2E_TEAM_RUNTIME_WORKSPACE_ID} + HOSTED_COORDINATION_EVENT_RETENTION_INTERVAL_MS: ${HOSTED_E2E_RETENTION_INTERVAL_MS:-60000} + HOSTED_COORDINATION_EVENT_RETENTION_MAX_EVENTS: ${HOSTED_E2E_RETENTION_MAX_EVENTS:-10000} + PAIRING_CODE_FILE: /run/agent-teams/pairing.json + AUTH_CONTROL_SOCKET: /run/agent-teams/control.sock + AUTH_DRAIN_EVIDENCE_FILE: /run/agent-teams-auth-drain/drain-proof.json + HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET: /run/agent-teams-orchestrator/orchestrator-lifecycle.sock + HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE: /run/agent-teams-orchestrator/lifecycle-owner-admission.json + HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR_FILE: /run/agent-teams-lifecycle-trust/trust-anchor + HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE: /run/agent-teams-lifecycle-trust/release-owner-pin.json + HOSTED_LIFECYCLE_ORCHESTRATOR_HIGH_WATER_ROOT: /var/lib/agent-teams/lifecycle-owner-high-water + TRUSTED_PROXY_CIDRS: ${E2E_CADDY_IP}/32 + NODE_EXTRA_CA_CERTS: /caddy-data/caddy/pki/authorities/local/root.crt + OIDC_PROVIDER_ID: synthetic-oidc + OIDC_PROVIDER_NAME: Synthetic OIDC + OIDC_ISSUER: ${HOSTED_E2E_OIDC_ORIGIN} + OIDC_CLIENT_ID: agent-teams-hosted-e2e + OIDC_ROLE_CLAIM: realm_access.roles + OIDC_OWNER_ROLE_VALUES: agent-teams-owner + OIDC_ADMIN_ROLE_VALUES: agent-teams-admin + OIDC_MEMBER_ROLE_VALUES: agent-teams-member + OIDC_VIEWER_ROLE_VALUES: agent-teams-viewer + OIDC_DEFAULT_ROLE: viewer + AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP: ${E2E_LIFECYCLE_BOOTSTRAP} + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:3456/api/auth/status').then(r=>{if(!r.ok||r.headers.get('x-agent-teams-lifecycle-owner-readiness')!=='ready')process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 2s + timeout: 2s + retries: 60 + start_period: 10s + networks: + hosted-e2e: + ipv4_address: ${E2E_APP_IP} + + fake-runtime: + <<: [*production-app-image, *e2e-hardening] + user: '${E2E_APP_UID}:${E2E_APP_GID}' + entrypoint: ['/usr/local/bin/node'] + command: ['--experimental-strip-types', '/app/e2e/seedContainer.ts', 'fake-runtime'] + pids_limit: 32 + mem_limit: 128m + depends_on: + agent-teams-lifecycle-trust-init: + condition: service_completed_successfully + volumes: + - ./../test/fixtures/hosted-v1/seedContainer.ts:/app/e2e/seedContainer.ts:ro + - ${E2E_CLAUDE_DIR:?Set marker-owned Claude state}:/data/.claude + - ${E2E_APP_DATA_DIR:?Set marker-owned app state}:/data/.agent-teams + - ${E2E_LIFECYCLE_RUN_DIR:?Set marker-owned external orchestrator runtime state}:/run/agent-teams-orchestrator + - ${E2E_FAKE_RUNTIME_STATE_DIR:?Set marker-owned fake runtime state}/auth-drain:/run/agent-teams-auth-drain + - ${E2E_LIFECYCLE_LAUNCHER_DIR:?Set marker-owned lifecycle launcher input}:/run/agent-teams-lifecycle-launcher:ro + - agent-teams-lifecycle-trust:/run/agent-teams-lifecycle-trust:ro + - ${E2E_FAKE_RUNTIME_STATE_DIR:?Set marker-owned fake runtime state}:/e2e-state + tmpfs: + - /tmp:mode=1777 + environment: + E2E_BOOT_ID: ${E2E_BOOT_ID} + E2E_TEAM_ID: ${E2E_TEAM_ID} + E2E_WORKSPACE_ID: ${E2E_TEAM_RUNTIME_WORKSPACE_ID} + AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP: ${E2E_LIFECYCLE_BOOTSTRAP} + HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET: /run/agent-teams-orchestrator/orchestrator-lifecycle.sock + HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE: /run/agent-teams-orchestrator/lifecycle-owner-admission.json + HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR_FILE: /run/agent-teams-lifecycle-trust/trust-anchor + HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE: /run/agent-teams-lifecycle-trust/release-owner-pin.json + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "const f=require('fs');if(!f.statSync('/run/agent-teams-orchestrator/orchestrator-lifecycle.sock').isSocket()||!f.statSync('/run/agent-teams-orchestrator/lifecycle-owner-admission.json').isFile())process.exit(1)", + ] + interval: 1s + timeout: 1s + retries: 30 + network_mode: none + restart: 'no' + + synthetic-oidc: + <<: [*production-app-image, *e2e-hardening] + user: '${E2E_APP_UID}:${E2E_APP_GID}' + entrypoint: ['/usr/local/bin/node'] + command: ['--experimental-strip-types', '/app/e2e/seedContainer.ts', 'oidc-provider'] + pids_limit: 32 + mem_limit: 128m + volumes: + - ./../test/fixtures/hosted-v1/seedContainer.ts:/app/e2e/seedContainer.ts:ro + environment: + HOSTED_E2E_ORIGIN: ${HOSTED_E2E_ORIGIN} + HOSTED_E2E_OIDC_ORIGIN: ${HOSTED_E2E_OIDC_ORIGIN} + HOSTED_E2E_OIDC_ROLE: ${HOSTED_E2E_OIDC_ROLE:-owner} + OIDC_CLIENT_ID: agent-teams-hosted-e2e + PORT: '8080' + tmpfs: + - /tmp:mode=1777 + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:8080/health').then(r=>{if(!r.ok)process.exit(1)})", + ] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + networks: + hosted-e2e: + ipv4_address: ${E2E_OIDC_IP} + restart: 'no' + + caddy: + image: caddy:2.10.0-alpine@${CADDY_IMAGE_DIGEST:?Set the audited Caddy image digest} + user: '${E2E_APP_UID}:${E2E_APP_GID}' + <<: *e2e-hardening + cap_add: [NET_BIND_SERVICE] + pids_limit: 128 + ports: + - target: 443 + published: ${E2E_CADDY_PUBLISHED_PORT:?Set the marker-derived Caddy host port} + host_ip: 127.0.0.1 + protocol: tcp + volumes: + - ./e2e/Caddyfile:/etc/caddy/Caddyfile:ro + - ${E2E_CADDY_DATA_DIR}:/data + tmpfs: + - /tmp:mode=1777 + - /config:mode=0700,uid=${E2E_APP_UID},gid=${E2E_APP_GID} + environment: + HOSTED_DOMAIN: ${HOSTED_DOMAIN} + HOSTED_HTTPS_PORT: '443' + OIDC_BACKCHANNEL_PORT: ${E2E_CADDY_PUBLISHED_PORT:?Set the marker-derived Caddy host port} + OIDC_DOMAIN: ${OIDC_DOMAIN} + healthcheck: + test: + [ + 'CMD-SHELL', + 'test -s /data/caddy/pki/authorities/local/root.crt && wget -q --spider http://127.0.0.1:2019/config/', + ] + interval: 2s + timeout: 2s + retries: 60 + start_period: 5s + networks: + hosted-e2e: + ipv4_address: ${E2E_CADDY_IP} + aliases: ['${HOSTED_DOMAIN}', '${OIDC_DOMAIN}'] + hosted-e2e-ingress: {} + +networks: + hosted-e2e: + internal: true + ipam: + config: + - subnet: ${E2E_NETWORK_SUBNET} + hosted-e2e-ingress: + name: ${COMPOSE_PROJECT_NAME}_ingress + driver: bridge + internal: false + +volumes: + agent-teams-lifecycle-trust: + name: ${COMPOSE_PROJECT_NAME}_agent-teams-lifecycle-trust + +secrets: + lifecycle_orchestrator_trust_anchor: + file: ${E2E_LIFECYCLE_TRUST_DIR:?Set marker-owned lifecycle trust input}/trust-anchor + lifecycle_owner_release_pin: + file: ${E2E_LIFECYCLE_TRUST_DIR:?Set marker-owned lifecycle trust input}/release-owner-pin.json diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4b169984ac..51042bae7d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,31 +1,601 @@ -# ============================================================================= -# Agent Teams - Docker Compose -# -# Quick start: -# docker compose -f docker/docker-compose.yml up -# -# Then open http://localhost:3456 in your browser. -# -# Security note: -# The standalone server has zero outbound network calls — no telemetry, -# no analytics, no auto-updater. For maximum isolation, uncomment -# network_mode below. -# ============================================================================= +name: agent-teams-hosted services: - agent-teams-ai: + agent-teams-personal: + profiles: ['personal'] + container_name: agent-teams-hosted-controller + user: '1000:1000' + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 256 + cpus: 1.0 + mem_limit: 1g + stop_grace_period: 45s build: context: .. dockerfile: docker/Dockerfile + args: + NODE_IMAGE_DIGEST: ${NODE_IMAGE_DIGEST:?Set NODE_IMAGE_DIGEST to an audited sha256 digest} + KEYCLOAK_IMAGE_DIGEST: ${KEYCLOAK_IMAGE_DIGEST:?Set KEYCLOAK_IMAGE_DIGEST to an audited sha256 digest} + depends_on: + caddy-personal: + condition: service_healthy + agent-teams-lifecycle-trust-init: + condition: service_completed_successfully + volumes: + - ${CLAUDE_DIR:?Set CLAUDE_DIR to a dedicated hosted sandbox directory}:/data/.claude:ro + - agent-teams-data:/data/.agent-teams + - agent-teams-application-data:/data/.agent-teams/data + - agent-teams-lifecycle-trust:/run/agent-teams-lifecycle-trust:ro + - type: bind + source: ${HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR:?Set a dedicated external orchestrator run directory} + target: /run/agent-teams-orchestrator + read_only: true + bind: + create_host_path: false + - agent-teams-lifecycle-owner-high-water:/var/lib/agent-teams/lifecycle-owner-high-water + tmpfs: + - /run/agent-teams:mode=0700,uid=1000,gid=1000 + - /tmp:mode=1777 + environment: + NODE_ENV: production + CLAUDE_ROOT: /data/.claude + AUTH_DATA_DIR: /data/.agent-teams/data + HOST: 0.0.0.0 + PORT: '3456' + AUTH_MODE: personal + AUTH_PUBLIC_ORIGIN: ${HOSTED_PUBLIC_ORIGIN:-https://${HOSTED_DOMAIN:-agent-teams.localhost}} + HOSTED_HTTPS_PORT: ${HOSTED_HTTPS_PORT:-443} + AUTH_DEPLOYMENT_ID: ${AUTH_DEPLOYMENT_ID:-deployment_hosted-v1} + AUTH_RESTORE_GENERATION: ${AUTH_RESTORE_GENERATION:-0} + HOSTED_WORKSPACE_IDS: ${HOSTED_WORKSPACE_IDS:-} + PAIRING_CODE_FILE: /run/agent-teams/pairing.json + AUTH_CONTROL_SOCKET: /run/agent-teams/control.sock + AUTH_DRAIN_EVIDENCE_FILE: /run/agent-teams/drain-proof.json + HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET: /run/agent-teams-orchestrator/orchestrator-lifecycle.sock + HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE: /run/agent-teams-orchestrator/lifecycle-owner-admission.json + HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR_FILE: /run/agent-teams-lifecycle-trust/trust-anchor + HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE: /run/agent-teams-lifecycle-trust/release-owner-pin.json + HOSTED_LIFECYCLE_ORCHESTRATOR_HIGH_WATER_ROOT: /var/lib/agent-teams/lifecycle-owner-high-water + AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP: ${AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP:?Set the launcher-issued lifecycle read bootstrap} + TRUSTED_PROXY_CIDRS: ${HOSTED_CADDY_IPV4:-172.30.255.2}/32 + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:3456/api/auth/status').then(r=>{if(!r.ok||r.headers.get('x-agent-teams-lifecycle-owner-readiness')!=='ready')process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + networks: + hosted: + ipv4_address: ${HOSTED_AGENT_TEAMS_IPV4:-172.30.255.3} + restart: unless-stopped + + agent-teams-keycloak: + profiles: ['keycloak'] + container_name: agent-teams-hosted-controller + user: '1000:1000' + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 256 + cpus: 1.0 + mem_limit: 1g + stop_grace_period: 45s + build: + context: .. + dockerfile: docker/Dockerfile + args: + NODE_IMAGE_DIGEST: ${NODE_IMAGE_DIGEST:?Set NODE_IMAGE_DIGEST to an audited sha256 digest} + KEYCLOAK_IMAGE_DIGEST: ${KEYCLOAK_IMAGE_DIGEST:?Set KEYCLOAK_IMAGE_DIGEST to an audited sha256 digest} + depends_on: + keycloak: + condition: service_healthy + caddy: + condition: service_healthy + keycloak-volume-init: + condition: service_completed_successfully + agent-teams-keycloak-secret-init: + condition: service_completed_successfully + agent-teams-lifecycle-trust-init: + condition: service_completed_successfully + volumes: + - ${CLAUDE_DIR:?Set CLAUDE_DIR to a dedicated hosted sandbox directory}:/data/.claude:ro + - agent-teams-data:/data/.agent-teams + - agent-teams-application-data:/data/.agent-teams/data + - agent-teams-keycloak-trust:/caddy-trust:ro + - agent-teams-keycloak-secret:/run/agent-teams-oidc:ro + - agent-teams-lifecycle-trust:/run/agent-teams-lifecycle-trust:ro + - type: bind + source: ${HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR:?Set a dedicated external orchestrator run directory} + target: /run/agent-teams-orchestrator + read_only: true + bind: + create_host_path: false + - agent-teams-lifecycle-owner-high-water:/var/lib/agent-teams/lifecycle-owner-high-water + tmpfs: + - /run/agent-teams:mode=0700,uid=1000,gid=1000 + - /tmp:mode=1777 + # A non-root, one-shot initializer persists the copied secret in a + # dedicated volume. The application mounts that volume read-only and keeps + # its mutable local-control files on a separate tmpfs. + environment: + NODE_ENV: production + NODE_EXTRA_CA_CERTS: /caddy-trust/root.crt + CLAUDE_ROOT: /data/.claude + AUTH_DATA_DIR: /data/.agent-teams/data + HOST: 0.0.0.0 + PORT: '3456' + AUTH_MODE: oidc + AUTH_PUBLIC_ORIGIN: ${HOSTED_PUBLIC_ORIGIN:-https://${HOSTED_DOMAIN:-agent-teams.localhost}} + HOSTED_HTTPS_PORT: ${HOSTED_HTTPS_PORT:-443} + AUTH_DEPLOYMENT_ID: ${AUTH_DEPLOYMENT_ID:-deployment_hosted-v1} + AUTH_RESTORE_GENERATION: ${AUTH_RESTORE_GENERATION:-0} + AUTH_IDENTITY_KEY_FILE: /data/.agent-teams/data/hosted-auth-secrets/identity.key + HOSTED_WORKSPACE_IDS: ${HOSTED_WORKSPACE_IDS:-} + AUTH_CONTROL_SOCKET: /run/agent-teams/control.sock + HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET: /run/agent-teams-orchestrator/orchestrator-lifecycle.sock + HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE: /run/agent-teams-orchestrator/lifecycle-owner-admission.json + HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR_FILE: /run/agent-teams-lifecycle-trust/trust-anchor + HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE: /run/agent-teams-lifecycle-trust/release-owner-pin.json + HOSTED_LIFECYCLE_ORCHESTRATOR_HIGH_WATER_ROOT: /var/lib/agent-teams/lifecycle-owner-high-water + AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP: ${AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP:?Set the launcher-issued lifecycle read bootstrap} + OIDC_PROVIDER_ID: keycloak + OIDC_PROVIDER_NAME: Keycloak + OIDC_ISSUER: ${KEYCLOAK_PUBLIC_ORIGIN:-https://${KEYCLOAK_DOMAIN:-auth.agent-teams.localhost}}/realms/agent-teams + OIDC_CLIENT_ID: agent-teams-hosted + OIDC_CLIENT_SECRET_FILE: /run/agent-teams-oidc/oidc-client-secret + OIDC_ROLE_CLAIM: realm_access.roles + OIDC_OWNER_ROLE_VALUES: agent-teams-owner + OIDC_ADMIN_ROLE_VALUES: agent-teams-admin + OIDC_MEMBER_ROLE_VALUES: agent-teams-member + OIDC_VIEWER_ROLE_VALUES: agent-teams-viewer + OIDC_DEFAULT_ROLE: viewer + TRUSTED_PROXY_CIDRS: ${HOSTED_CADDY_IPV4:-172.30.255.2}/32 + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:3456/api/auth/status').then(r=>{if(!r.ok||r.headers.get('x-agent-teams-lifecycle-owner-readiness')!=='ready')process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + networks: + hosted: + ipv4_address: ${HOSTED_AGENT_TEAMS_IPV4:-172.30.255.3} + restart: unless-stopped + + keycloak: + profiles: ['keycloak'] + user: '1000:0' + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 512 + cpus: 1.5 + mem_limit: 1536m + stop_grace_period: 60s + build: + context: .. + dockerfile: docker/Dockerfile + target: keycloak-runtime + args: + KEYCLOAK_IMAGE_DIGEST: ${KEYCLOAK_IMAGE_DIGEST:?Set KEYCLOAK_IMAGE_DIGEST to an audited sha256 digest} + depends_on: + keycloak-postgres: + condition: service_healthy + caddy: + condition: service_healthy + keycloak-volume-init: + condition: service_completed_successfully + # The image ENTRYPOINT is kc.sh, so replace it before running the bootstrap + # shell. Keycloak itself still runs as the image's uid 1000/gid 0 identity. + entrypoint: ['/bin/bash', '-euc'] + command: + - | + client_secret="$$(cat /run/secrets/oidc_client_secret)" + admin_password="$$(cat /run/secrets/keycloak_admin_password)" + database_password="$$(cat /run/secrets/keycloak_database_password)" + for secret_value in "$$client_secret" "$$admin_password" "$$database_password"; do + case "$$secret_value" in + ''|*[!A-Za-z0-9_-]*) + echo 'Keycloak secrets must be non-empty base64url values' >&2 + exit 1 + ;; + esac + done + validate_domain() { + local variable_name="$$1" + local domain="$$2" + local labels=() + local label + case "$$domain" in + ''|.*|*.|*..*|*[!A-Za-z0-9.-]*) + echo "$$variable_name must be a valid ASCII DNS name" >&2 + exit 1 + ;; + esac + if (( $${#domain} > 253 )); then + echo "$$variable_name must be a valid ASCII DNS name" >&2 + exit 1 + fi + IFS='.' read -r -a labels <<< "$$domain" + for label in "$${labels[@]}"; do + case "$$label" in + ''|-*|*-) + echo "$$variable_name must be a valid ASCII DNS name" >&2 + exit 1 + ;; + esac + if (( $${#label} > 63 )); then + echo "$$variable_name must be a valid ASCII DNS name" >&2 + exit 1 + fi + done + } + validate_domain HOSTED_DOMAIN "$$HOSTED_DOMAIN" + validate_domain KEYCLOAK_DOMAIN "$$KEYCLOAK_DOMAIN" + if [[ "$${HOSTED_DOMAIN,,}" == "$${KEYCLOAK_DOMAIN,,}" ]]; then + echo 'HOSTED_DOMAIN and KEYCLOAK_DOMAIN must be distinct' >&2 + exit 1 + fi + case "$$HOSTED_HTTPS_PORT" in + ''|*[!0-9]*) + echo 'HOSTED_HTTPS_PORT must be an integer from 1 through 65535' >&2 + exit 1 + ;; + esac + if (( 10#$$HOSTED_HTTPS_PORT < 1 || 10#$$HOSTED_HTTPS_PORT > 65535 )); then + echo 'HOSTED_HTTPS_PORT must be an integer from 1 through 65535' >&2 + exit 1 + fi + expected_public_origin="https://$$HOSTED_DOMAIN" + expected_keycloak_origin="https://$$KEYCLOAK_DOMAIN" + if (( 10#$$HOSTED_HTTPS_PORT != 443 )); then + expected_public_origin="$$expected_public_origin:$$HOSTED_HTTPS_PORT" + expected_keycloak_origin="$$expected_keycloak_origin:$$HOSTED_HTTPS_PORT" + fi + if [[ "$$HOSTED_PUBLIC_ORIGIN" != "$$expected_public_origin" ]]; then + echo 'HOSTED_PUBLIC_ORIGIN must match HOSTED_DOMAIN and HOSTED_HTTPS_PORT' >&2 + exit 1 + fi + if [[ "$$KEYCLOAK_PUBLIC_ORIGIN" != "$$expected_keycloak_origin" ]]; then + echo 'KEYCLOAK_PUBLIC_ORIGIN must match KEYCLOAK_DOMAIN and HOSTED_HTTPS_PORT' >&2 + exit 1 + fi + realm_template="$$(cat /opt/keycloak/realm-template/realm-agent-teams.json)" + resolved_realm="$${realm_template//\$\{KEYCLOAK_CLIENT_SECRET\}/$$client_secret}" + resolved_realm="$${resolved_realm//\$\{HOSTED_PUBLIC_ORIGIN\}/$$HOSTED_PUBLIC_ORIGIN}" + case "$$resolved_realm" in + *'$${KEYCLOAK_CLIENT_SECRET}'*|*'$${HOSTED_PUBLIC_ORIGIN}'*) + echo 'Keycloak realm template substitution failed' >&2 + exit 1 + ;; + esac + printf '%s\n' "$$resolved_realm" > /opt/keycloak/data/import/realm-agent-teams.json + { + printf 'bootstrap-admin-username=%s\n' "$$KC_BOOTSTRAP_ADMIN_USERNAME" + printf 'bootstrap-admin-password=%s\n' "$$admin_password" + printf 'db-password=%s\n' "$$database_password" + } > /run/keycloak/keycloak.conf + chmod 0400 /run/keycloak/keycloak.conf /opt/keycloak/data/import/realm-agent-teams.json + unset client_secret admin_password database_password variable_name domain labels label \ + expected_public_origin expected_keycloak_origin realm_template resolved_realm + exec env -u KEYCLOAK_CLIENT_SECRET -u KC_BOOTSTRAP_ADMIN_PASSWORD -u KC_DB_PASSWORD \ + /opt/keycloak/bin/kc.sh --config-file=/run/keycloak/keycloak.conf \ + start --optimized --import-realm --http-enabled=true --proxy-headers=xforwarded + volumes: + - ./keycloak/realm-agent-teams.json:/opt/keycloak/realm-template/realm-agent-teams.json:ro + - agent-teams-keycloak-trust:/caddy-trust:ro + tmpfs: + - /run/keycloak:mode=0700,uid=1000,gid=0 + - /opt/keycloak/data/import:mode=0700,uid=1000,gid=0 + - /opt/keycloak/data/tmp:mode=0700,uid=1000,gid=0 + - /tmp:mode=1777 + secrets: + - oidc_client_secret + - keycloak_admin_password + - keycloak_database_password + environment: + KC_DB: postgres + KC_HEALTH_ENABLED: 'true' + KC_DB_URL: jdbc:postgresql://keycloak-postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin} + KC_HOSTNAME: ${KEYCLOAK_PUBLIC_ORIGIN:-https://${KEYCLOAK_DOMAIN:-auth.agent-teams.localhost}} + KC_HTTP_PORT: '8080' + KC_TRUSTSTORE_PATHS: /caddy-trust/root.crt + HOSTED_DOMAIN: ${HOSTED_DOMAIN:-agent-teams.localhost} + KEYCLOAK_DOMAIN: ${KEYCLOAK_DOMAIN:-auth.agent-teams.localhost} + HOSTED_HTTPS_PORT: ${HOSTED_HTTPS_PORT:-443} + HOSTED_PUBLIC_ORIGIN: ${HOSTED_PUBLIC_ORIGIN:-https://${HOSTED_DOMAIN:-agent-teams.localhost}} + KEYCLOAK_PUBLIC_ORIGIN: ${KEYCLOAK_PUBLIC_ORIGIN:-https://${KEYCLOAK_DOMAIN:-auth.agent-teams.localhost}} + healthcheck: + test: + [ + 'CMD-SHELL', + "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3", + ] + interval: 10s + timeout: 5s + retries: 18 + start_period: 40s + networks: + hosted: + ipv4_address: ${HOSTED_KEYCLOAK_IPV4:-172.30.255.4} + keycloak-backend: {} + restart: unless-stopped + + keycloak-postgres: + profiles: ['keycloak'] + user: '70:70' + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 256 + cpus: 1.0 + mem_limit: 768m + stop_grace_period: 60s + image: postgres:17.5-alpine@${POSTGRES_IMAGE_DIGEST:?Set POSTGRES_IMAGE_DIGEST to an audited sha256 digest} + secrets: + - keycloak_database_password + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD_FILE: /run/secrets/keycloak_database_password + volumes: + - keycloak-postgres-data:/var/lib/postgresql/data + tmpfs: + - /var/run/postgresql:mode=0775,uid=70,gid=70 + - /tmp:mode=1777 + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U keycloak -d keycloak'] + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s + networks: + keycloak-backend: + ipv4_address: ${HOSTED_POSTGRES_IPV4:-172.30.254.2} + restart: unless-stopped + + caddy: + profiles: ['keycloak'] + user: '1000:1000' + init: true + read_only: true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + pids_limit: 128 + cpus: 0.5 + mem_limit: 256m + stop_grace_period: 20s + image: caddy:2.10.0-alpine@${CADDY_IMAGE_DIGEST:?Set CADDY_IMAGE_DIGEST to an audited sha256 digest} ports: - - "3456:3456" + - '${HOSTED_HTTPS_PORT:-443}:${HOSTED_HTTPS_PORT:-443}' + - '${HOSTED_REDIRECT_PORT:-80}:80' volumes: - - ${CLAUDE_DIR:-~/.claude}:/data/.claude:ro + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + tmpfs: + - /tmp:mode=1777 environment: - - NODE_ENV=production - - CLAUDE_ROOT=/data/.claude - - HOST=0.0.0.0 - - PORT=3456 + HOSTED_DOMAIN: ${HOSTED_DOMAIN:-agent-teams.localhost} + KEYCLOAK_DOMAIN: ${KEYCLOAK_DOMAIN:-auth.agent-teams.localhost} + HOSTED_HTTPS_PORT: ${HOSTED_HTTPS_PORT:-443} + healthcheck: + test: + [ + 'CMD-SHELL', + 'test -s /data/caddy/pki/authorities/local/root.crt && wget -q --spider http://127.0.0.1:2019/config/', + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s + networks: + hosted: + ipv4_address: ${HOSTED_CADDY_IPV4:-172.30.255.2} + aliases: + - ${HOSTED_DOMAIN:-agent-teams.localhost} + - ${KEYCLOAK_DOMAIN:-auth.agent-teams.localhost} restart: unless-stopped - # Uncomment for maximum network isolation (no outbound connections): - # network_mode: "none" + + caddy-personal: + profiles: ['personal'] + user: '1000:1000' + init: true + read_only: true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + pids_limit: 128 + cpus: 0.5 + mem_limit: 256m + stop_grace_period: 20s + image: caddy:2.10.0-alpine@${CADDY_IMAGE_DIGEST:?Set CADDY_IMAGE_DIGEST to an audited sha256 digest} + ports: + - '${HOSTED_HTTPS_PORT:-443}:${HOSTED_HTTPS_PORT:-443}' + - '${HOSTED_REDIRECT_PORT:-80}:80' + volumes: + - ./caddy/Caddyfile.personal:/etc/caddy/Caddyfile:ro + - caddy-personal-data:/data + - caddy-personal-config:/config + tmpfs: + - /tmp:mode=1777 + environment: + HOSTED_DOMAIN: ${HOSTED_DOMAIN:-agent-teams.localhost} + HOSTED_HTTPS_PORT: ${HOSTED_HTTPS_PORT:-443} + healthcheck: + test: + [ + 'CMD-SHELL', + 'test -s /data/caddy/pki/authorities/local/root.crt && wget -q --spider http://127.0.0.1:2019/config/', + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s + networks: + hosted: + ipv4_address: ${HOSTED_CADDY_IPV4:-172.30.255.2} + aliases: + - ${HOSTED_DOMAIN:-agent-teams.localhost} + restart: unless-stopped + + keycloak-volume-init: + profiles: ['keycloak'] + user: '1000:1000' + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 32 + cpus: 0.25 + mem_limit: 64m + stop_grace_period: 10s + build: + context: .. + dockerfile: docker/Dockerfile + args: + NODE_IMAGE_DIGEST: ${NODE_IMAGE_DIGEST:?Set NODE_IMAGE_DIGEST to an audited sha256 digest} + KEYCLOAK_IMAGE_DIGEST: ${KEYCLOAK_IMAGE_DIGEST:?Set KEYCLOAK_IMAGE_DIGEST to an audited sha256 digest} + depends_on: + caddy: + condition: service_healthy + command: ['/usr/local/bin/hosted-volume-init', 'caddy-trust'] + volumes: + - caddy-data:/caddy-data:ro + - agent-teams-keycloak-trust:/caddy-trust + network_mode: none + restart: 'no' + + agent-teams-keycloak-secret-init: + profiles: ['keycloak'] + user: '1000:1000' + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 32 + cpus: 0.25 + mem_limit: 64m + stop_grace_period: 10s + build: + context: .. + dockerfile: docker/Dockerfile + args: + NODE_IMAGE_DIGEST: ${NODE_IMAGE_DIGEST:?Set NODE_IMAGE_DIGEST to an audited sha256 digest} + KEYCLOAK_IMAGE_DIGEST: ${KEYCLOAK_IMAGE_DIGEST:?Set KEYCLOAK_IMAGE_DIGEST to an audited sha256 digest} + command: ['/usr/local/bin/hosted-volume-init', 'oidc-client-secret'] + volumes: + - agent-teams-keycloak-secret:/run/agent-teams-oidc + secrets: + - oidc_client_secret + network_mode: none + restart: 'no' + + # Compose file-backed secrets retain host ownership and mode on local engines. + # Copy the lifecycle key through an image-seeded, application-owned placeholder + # so both profiles consume one exact mode-0400 file from a read-only volume. + agent-teams-lifecycle-trust-init: + user: '1000:1000' + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 32 + cpus: 0.25 + mem_limit: 64m + stop_grace_period: 10s + build: + context: .. + dockerfile: docker/Dockerfile + args: + NODE_IMAGE_DIGEST: ${NODE_IMAGE_DIGEST:?Set NODE_IMAGE_DIGEST to an audited sha256 digest} + KEYCLOAK_IMAGE_DIGEST: ${KEYCLOAK_IMAGE_DIGEST:?Set KEYCLOAK_IMAGE_DIGEST to an audited sha256 digest} + command: ['/usr/local/bin/hosted-volume-init', 'lifecycle-trust-anchor'] + volumes: + - agent-teams-lifecycle-trust:/run/agent-teams-lifecycle-trust + secrets: + - lifecycle_orchestrator_trust_anchor + - lifecycle_owner_release_pin + network_mode: none + restart: 'no' + +networks: + hosted: + internal: true + ipam: + config: + - subnet: ${HOSTED_NETWORK_SUBNET:-172.30.255.0/28} + keycloak-backend: + internal: true + ipam: + config: + - subnet: ${HOSTED_KEYCLOAK_BACKEND_SUBNET:-172.30.254.0/28} + +volumes: + agent-teams-data: + name: ${COMPOSE_PROJECT_NAME}_agent-teams-instance-lock + agent-teams-application-data: + name: ${COMPOSE_PROJECT_NAME}_agent-teams-data + agent-teams-lifecycle-owner-high-water: + name: ${COMPOSE_PROJECT_NAME}_agent-teams-lifecycle-owner-high-water + agent-teams-lifecycle-trust: + name: ${COMPOSE_PROJECT_NAME}_agent-teams-lifecycle-trust + agent-teams-keycloak-secret: + agent-teams-keycloak-trust: + keycloak-postgres-data: + caddy-data: + caddy-config: + caddy-personal-data: + caddy-personal-config: + +secrets: + lifecycle_orchestrator_trust_anchor: + file: ${HOSTED_SECRETS_DIR:?Set HOSTED_SECRETS_DIR to an absolute protected directory outside the repository}/lifecycle_orchestrator_trust_anchor + lifecycle_owner_release_pin: + file: ${HOSTED_SECRETS_DIR:?Set HOSTED_SECRETS_DIR to an absolute protected directory outside the repository}/lifecycle_owner_release_pin.json + oidc_client_secret: + file: ${HOSTED_SECRETS_DIR:?Set HOSTED_SECRETS_DIR to an absolute protected directory outside the repository}/oidc_client_secret + keycloak_admin_password: + file: ${HOSTED_SECRETS_DIR:?Set HOSTED_SECRETS_DIR to an absolute protected directory outside the repository}/keycloak_admin_password + keycloak_database_password: + file: ${HOSTED_SECRETS_DIR:?Set HOSTED_SECRETS_DIR to an absolute protected directory outside the repository}/keycloak_database_password diff --git a/docker/e2e/Caddyfile b/docker/e2e/Caddyfile new file mode 100644 index 0000000000..3a822352ce --- /dev/null +++ b/docker/e2e/Caddyfile @@ -0,0 +1,39 @@ +{ + servers { + strict_sni_host on + } +} + +{$HOSTED_DOMAIN}:{$HOSTED_HTTPS_PORT} { + tls internal + @hosted_api path /api/hosted/v1/* + @hosted_non_api { + not path /api/hosted/v1/* + } + encode @hosted_non_api zstd gzip + header @hosted_api Cache-Control "no-store, no-transform" + reverse_proxy hosted-controller:3456 { + header_up Accept-Encoding identity + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + } +} + +{$OIDC_DOMAIN}:{$HOSTED_HTTPS_PORT}, {$OIDC_DOMAIN}:{$OIDC_BACKCHANNEL_PORT} { + tls internal + encode zstd gzip + reverse_proxy synthetic-oidc:8080 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" + } +} diff --git a/docker/hosted-entrypoint.sh b/docker/hosted-entrypoint.sh new file mode 100644 index 0000000000..23c149b905 --- /dev/null +++ b/docker/hosted-entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/sh + +set -eu + +lock_parent=/data/.agent-teams/instance-lock +lock_name=instance.lock +lock_anchor="${lock_parent}/${lock_name}" + +if [ "$#" -eq 0 ]; then + echo 'instance_lock:entrypoint_refused:missing_command' >&2 + exit 64 +fi + +case "$1" in + /*) ;; + *) + echo 'instance_lock:entrypoint_refused:command_must_be_absolute' >&2 + exit 64 + ;; +esac + +runtime_gid="$(/usr/bin/id -g)" || { + echo 'instance_lock:entrypoint_refused:runtime_gid_failed' >&2 + exit 74 +} +state_security="$(/usr/bin/stat -c '%u:%g:%a' -- /data/.agent-teams)" || { + echo 'instance_lock:entrypoint_refused:persistent_state_stat_failed' >&2 + exit 74 +} +if [ "$state_security" != "0:${runtime_gid}:1770" ]; then + echo 'instance_lock:entrypoint_refused:persistent_state_security_invalid' >&2 + exit 74 +fi +if [ "$(/usr/bin/stat -c '%u:%g:%a' -- "$lock_parent")" != '0:0:555' ] \ + || [ "$(/usr/bin/stat -c '%u:%g:%a' -- "$lock_anchor")" != '0:0:444' ]; then + echo 'instance_lock:entrypoint_refused:anchor_security_invalid' >&2 + exit 74 +fi + +lock_device="$(/usr/bin/stat -c '%d' -- "$lock_anchor")" || { + echo 'instance_lock:entrypoint_refused:anchor_stat_failed' >&2 + exit 74 +} +lock_inode="$(/usr/bin/stat -c '%i' -- "$lock_anchor")" || { + echo 'instance_lock:entrypoint_refused:anchor_stat_failed' >&2 + exit 74 +} + +case "$lock_device:$lock_inode" in + *[!0-9:]* | :* | *: | *:*:*) + echo 'instance_lock:entrypoint_refused:anchor_identity_invalid' >&2 + exit 74 + ;; +esac + +exec /app/bin/agent-teams-instance-lock \ + "$lock_parent" "$lock_name" "$lock_device" "$lock_inode" -- "$@" diff --git a/docker/hosted-volume-init.sh b/docker/hosted-volume-init.sh new file mode 100644 index 0000000000..8bc52273b8 --- /dev/null +++ b/docker/hosted-volume-init.sh @@ -0,0 +1,183 @@ +#!/bin/sh + +set -eu + +readonly initializer_mode="${1:-}" + +if [ "$#" -ne 1 ]; then + echo 'usage: hosted-volume-init ' >&2 + exit 64 +fi + +case "$initializer_mode" in + caddy-trust) + if [ "$(id -u)" -ne 1000 ]; then + echo 'caddy-trust initialization requires the Caddy volume owner uid' >&2 + exit 65 + fi + + readonly caddy_root='/caddy-data/caddy/pki/authorities/local' + readonly root_certificate="$caddy_root/root.crt" + readonly trust_directory='/caddy-trust' + readonly trust_certificate="$trust_directory/root.crt" + + if [ ! -s "$root_certificate" ] || [ -L "$root_certificate" ]; then + echo 'Caddy local root certificate is unavailable' >&2 + exit 66 + fi + if [ ! -d "$trust_directory" ] || [ -L "$trust_directory" ]; then + echo 'Caddy trust volume is unavailable' >&2 + exit 67 + fi + if [ ! -f "$trust_certificate" ] || [ -L "$trust_certificate" ]; then + echo 'Caddy trust placeholder is unavailable' >&2 + exit 68 + fi + case "$(stat -c '%u:%g:%a' "$trust_certificate")" in + 1000:1000:600|1000:1000:444) ;; + *) + echo 'Caddy trust placeholder permissions are unsafe' >&2 + exit 69 + ;; + esac + if [ -n "$(find "$trust_directory" -mindepth 1 -maxdepth 1 ! -name root.crt -print -quit)" ]; then + echo 'Caddy trust volume contains an unexpected entry' >&2 + exit 70 + fi + + umask 022 + chmod 0600 "$trust_certificate" + install -m 0444 "$root_certificate" "$trust_certificate" + if [ "$(stat -c '%u:%g:%a' "$trust_certificate")" != '1000:1000:444' ]; then + echo 'Caddy trust handoff permissions are unsafe' >&2 + exit 71 + fi + ;; + oidc-client-secret) + if [ "$(id -u)" -ne 1000 ]; then + echo 'OIDC secret initialization must run as the application uid' >&2 + exit 72 + fi + + readonly source_secret='/run/secrets/oidc_client_secret' + readonly runtime_directory='/run/agent-teams-oidc' + readonly runtime_secret="$runtime_directory/oidc-client-secret" + + if [ ! -f "$source_secret" ] || [ -L "$source_secret" ]; then + echo 'OIDC source secret is not a regular file' >&2 + exit 73 + fi + if [ ! -d "$runtime_directory" ] || [ -L "$runtime_directory" ]; then + echo 'OIDC runtime directory is unavailable' >&2 + exit 74 + fi + if [ ! -f "$runtime_secret" ] || [ -L "$runtime_secret" ]; then + echo 'OIDC runtime secret placeholder is unavailable' >&2 + exit 75 + fi + if [ "$(stat -c '%u:%g' "$runtime_secret")" != '1000:1000' ]; then + echo 'OIDC runtime secret placeholder has an unsafe owner' >&2 + exit 76 + fi + + umask 077 + chmod 0600 "$runtime_secret" + install -m 0400 "$source_secret" "$runtime_secret" + if [ "$(stat -c '%u:%g:%a' "$runtime_secret")" != '1000:1000:400' ]; then + echo 'OIDC runtime secret handoff permissions are unsafe' >&2 + exit 77 + fi + ;; + lifecycle-trust-anchor) + if [ "$(id -u)" -ne 1000 ]; then + echo 'lifecycle trust initialization must run as the application uid' >&2 + exit 78 + fi + + readonly source_anchor='/run/secrets/lifecycle_orchestrator_trust_anchor' + readonly source_release_pin='/run/secrets/lifecycle_owner_release_pin' + readonly runtime_directory='/run/agent-teams-lifecycle-trust' + readonly runtime_anchor="$runtime_directory/trust-anchor" + readonly runtime_release_pin="$runtime_directory/release-owner-pin.json" + + if [ ! -f "$source_anchor" ] || [ -L "$source_anchor" ]; then + echo 'lifecycle trust source is not a regular file' >&2 + exit 79 + fi + if [ ! -f "$source_release_pin" ] || [ -L "$source_release_pin" ]; then + echo 'lifecycle release pin source is not a regular file' >&2 + exit 79 + fi + if [ ! -d "$runtime_directory" ] || [ -L "$runtime_directory" ]; then + echo 'lifecycle trust runtime directory is unavailable' >&2 + exit 80 + fi + if [ ! -f "$runtime_anchor" ] || [ -L "$runtime_anchor" ]; then + echo 'lifecycle trust placeholder is unavailable' >&2 + exit 81 + fi + if [ ! -f "$runtime_release_pin" ] || [ -L "$runtime_release_pin" ]; then + echo 'lifecycle release pin placeholder is unavailable' >&2 + exit 81 + fi + for runtime_trust_file in "$runtime_anchor" "$runtime_release_pin"; do + case "$(stat -c '%u:%g:%a' "$runtime_trust_file")" in + 1000:1000:600|1000:1000:400) ;; + *) + echo 'lifecycle trust placeholder permissions are unsafe' >&2 + exit 82 + ;; + esac + done + if [ -n "$(find "$runtime_directory" -mindepth 1 -maxdepth 1 ! -name trust-anchor ! -name release-owner-pin.json -print -quit)" ]; then + echo 'lifecycle trust volume contains an unexpected entry' >&2 + exit 83 + fi + + readonly source_size="$(stat -c '%s' "$source_anchor")" + case "$source_size" in + 64) ;; + 65) + if [ "$(tail -c 1 "$source_anchor" | od -An -tu1 | tr -d '[:space:]')" != '10' ]; then + echo 'lifecycle trust source must contain exactly 64 lowercase hexadecimal characters' >&2 + exit 84 + fi + ;; + *) + echo 'lifecycle trust source must contain exactly 64 lowercase hexadecimal characters' >&2 + exit 84 + ;; + esac + if ! LC_ALL=C grep -Eq '^[0-9a-f]{64}$' "$source_anchor"; then + echo 'lifecycle trust source must contain exactly 64 lowercase hexadecimal characters' >&2 + exit 84 + fi + readonly release_pin_size="$(stat -c '%s' "$source_release_pin")" + case "$release_pin_size" in + ''|*[!0-9]*) + echo 'lifecycle release pin must be a bounded non-empty file' >&2 + exit 84 + ;; + esac + if [ "$release_pin_size" -lt 1 ] || [ "$release_pin_size" -gt 1024 ]; then + echo 'lifecycle release pin must be a bounded non-empty file' >&2 + exit 84 + fi + + umask 077 + chmod 0600 "$runtime_anchor" + chmod 0600 "$runtime_release_pin" + install -m 0400 "$source_anchor" "$runtime_anchor" + install -m 0400 "$source_release_pin" "$runtime_release_pin" + for runtime_trust_file in "$runtime_anchor" "$runtime_release_pin"; do + if [ "$(stat -c '%u:%g:%a' "$runtime_trust_file")" != '1000:1000:400' ]; then + echo 'lifecycle trust handoff permissions are unsafe' >&2 + exit 85 + fi + done + ;; + *) + echo 'unknown hosted volume initializer mode' >&2 + exit 64 + ;; +esac diff --git a/docker/keycloak/realm-agent-teams.json b/docker/keycloak/realm-agent-teams.json new file mode 100644 index 0000000000..d87ca0bbf7 --- /dev/null +++ b/docker/keycloak/realm-agent-teams.json @@ -0,0 +1,83 @@ +{ + "realm": "agent-teams", + "enabled": true, + "displayName": "Agent Teams", + "registrationAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "bruteForceProtected": true, + "permanentLockout": false, + "failureFactor": 5, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "minimumQuickLoginWaitSeconds": 60, + "maxFailureWaitSeconds": 900, + "maxDeltaTimeSeconds": 43200, + "passwordPolicy": "length(12) and notUsername(undefined) and maxLength(128)", + "roles": { + "realm": [ + { "name": "agent-teams-owner", "description": "Agent Teams deployment owner" }, + { "name": "agent-teams-admin", "description": "Agent Teams administrator" }, + { "name": "agent-teams-member", "description": "Agent Teams member" }, + { "name": "agent-teams-viewer", "description": "Agent Teams read-only viewer" } + ] + }, + "clients": [ + { + "clientId": "agent-teams-hosted", + "name": "Agent Teams hosted", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "${KEYCLOAK_CLIENT_SECRET}", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": false, + "redirectUris": ["${HOSTED_PUBLIC_ORIGIN}/api/auth/oidc/callback"], + "webOrigins": ["${HOSTED_PUBLIC_ORIGIN}"], + "attributes": { + "backchannel.logout.url": "${HOSTED_PUBLIC_ORIGIN}/api/auth/oidc/backchannel-logout", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false", + "post.logout.redirect.uris": "${HOSTED_PUBLIC_ORIGIN}/", + "pkce.code.challenge.method": "S256" + }, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "email"], + "protocolMappers": [ + { + "name": "Agent Teams realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultRole": { + "name": "default-roles-agent-teams", + "description": "Default role", + "composite": true, + "clientRole": false, + "containerId": "agent-teams" + }, + "browserSecurityHeaders": { + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + } +} diff --git a/docker/vite.hosted-renderer.config.ts b/docker/vite.hosted-renderer.config.ts new file mode 100644 index 0000000000..fe6062145d --- /dev/null +++ b/docker/vite.hosted-renderer.config.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// @ts-expect-error The repository-owned JavaScript artifact verifier has no declaration file. +import { classifyForbiddenHostedRendererReference as classifyForbiddenReference } from '../scripts/ci/verify-hosted-no-terminal-artifact.mjs'; + +import type { Plugin } from 'vite'; + +const ROOT = resolve(__dirname, '..'); +const HOSTED_RENDERER_ROOT = resolve(ROOT, 'src/renderer/hosted'); +const HOSTED_RENDERER_OUTPUT = resolve(ROOT, 'out/renderer'); +const HOSTED_RENDERER_GRAPH_MANIFEST = 'hosted-renderer-graph.json'; + +interface HostedRendererGraphChunk { + readonly fileName: string; + readonly imports: readonly string[]; + readonly dynamicImports: readonly string[]; + readonly moduleIds: readonly string[]; + readonly sha256: string; +} + +interface HostedRendererGraphModule { + readonly id: string; + readonly importedSpecifiers: readonly string[]; + readonly resolvedImports: readonly string[]; + readonly resolvedDynamicImports: readonly string[]; +} + +interface HostedRendererGraphViolation { + readonly kind: string; + readonly reference: string; +} + +const classifyForbiddenHostedRendererReference = classifyForbiddenReference as ( + referenceValue: string +) => HostedRendererGraphViolation | null; + +function normalizeSlashes(value: string): string { + return value.split(sep).join('/'); +} + +function canonicalModuleId(value: string): string { + const virtualPrefix = value.startsWith('\0') ? '\0' : ''; + const unprefixedValue = virtualPrefix ? value.slice(1) : value; + const queryIndex = unprefixedValue.indexOf('?'); + const path = queryIndex < 0 ? unprefixedValue : unprefixedValue.slice(0, queryIndex); + const query = queryIndex < 0 ? '' : unprefixedValue.slice(queryIndex); + if (!isAbsolute(path)) return `${virtualPrefix}${normalizeSlashes(path)}${query}`; + + const repositoryPath = relative(ROOT, path); + if ( + repositoryPath !== '..' && + !repositoryPath.startsWith(`..${sep}`) && + !isAbsolute(repositoryPath) + ) { + return `${virtualPrefix}${normalizeSlashes(repositoryPath)}${query}`; + } + return `${virtualPrefix}${normalizeSlashes(path)}${query}`; +} + +function canonicalImportedSpecifier(value: string): string { + const unprefixedValue = value.startsWith('\0') ? value.slice(1) : value; + const path = unprefixedValue.split('?')[0]; + return isAbsolute(path) ? canonicalModuleId(value) : value.replaceAll('\\', '/'); +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function sortUnique(values: Iterable): readonly string[] { + return Object.freeze([...new Set(values)].sort((left, right) => left.localeCompare(right))); +} + +/** + * The desktop task-board renderer barrel also exports eager analytics adapters. + * Hosted composition resolves that public surface to only its reviewed browser exports. + */ +function createHostedTaskBoardRendererBoundaryPlugin(): Plugin { + const boundaryId = '\0hosted-task-board-renderer-boundary'; + const publicEntryDirectory = resolve(ROOT, 'src/features/team-task-board/renderer'); + const publicEntry = resolve(publicEntryDirectory, 'index.ts'); + const pageModule = resolve( + ROOT, + 'src/features/team-task-board/renderer/components/HostedTaskBoardPage.tsx' + ); + const transportModule = resolve( + ROOT, + 'src/features/team-task-board/renderer/composition/createHostedTaskBoardTransport.ts' + ); + return { + name: 'hosted-task-board-renderer-boundary', + enforce: 'pre', + resolveId(source) { + return source === '@features/team-task-board/renderer' || + source === publicEntryDirectory || + source === publicEntry + ? boundaryId + : null; + }, + load(id) { + if (id !== boundaryId) return null; + return [ + `export { HostedTaskBoardPage } from ${JSON.stringify(pageModule)};`, + `export { createHostedTaskBoardTransport, HOSTED_TASK_BOARD_PAGE_HTTP_PATH } from ${JSON.stringify(transportModule)};`, + ].join('\n'); + }, + }; +} + +/** + * Fails the hosted build on a forbidden final renderer edge and emits a stable, + * content-bound description of every JavaScript chunk and resolved module edge. + */ +export function createHostedRendererGraphProofPlugin(): Plugin { + const importedSpecifiers = new Map>(); + + return { + name: 'hosted-renderer-graph-proof', + // Run before aliases and other resolvers so the proof retains every source specifier. + enforce: 'pre', + buildStart() { + importedSpecifiers.clear(); + }, + resolveId(source, importer) { + if (importer) { + const sources = importedSpecifiers.get(importer) ?? new Set(); + sources.add(canonicalImportedSpecifier(source)); + importedSpecifiers.set(importer, sources); + } + return null; + }, + generateBundle(_options, bundle) { + const chunks = Object.values(bundle) + .filter( + (output): output is Extract => output.type === 'chunk' + ) + .sort((left, right) => left.fileName.localeCompare(right.fileName)); + if (chunks.length === 0) { + this.error('hosted-renderer-graph:no-javascript-chunks'); + } + + const finalModuleIds = new Set(chunks.flatMap((chunk) => Object.keys(chunk.modules))); + const graphChunks: HostedRendererGraphChunk[] = chunks.map((chunk) => ({ + fileName: normalizeSlashes(chunk.fileName), + imports: sortUnique(chunk.imports.map(normalizeSlashes)), + dynamicImports: sortUnique(chunk.dynamicImports.map(normalizeSlashes)), + moduleIds: sortUnique(Object.keys(chunk.modules).map(canonicalModuleId)), + sha256: sha256(chunk.code), + })); + for (const chunk of graphChunks) { + for (const reference of [...chunk.imports, ...chunk.dynamicImports]) { + const violation = classifyForbiddenHostedRendererReference(reference); + if (violation) { + this.error(`hosted-renderer-graph:${violation.kind}:${violation.reference}`); + } + } + } + + const graphModules: HostedRendererGraphModule[] = [...finalModuleIds] + .sort((left, right) => canonicalModuleId(left).localeCompare(canonicalModuleId(right))) + .map((moduleId) => { + const id = canonicalModuleId(moduleId); + const idViolation = classifyForbiddenHostedRendererReference(id); + if (idViolation) { + const importerTrace = [moduleId]; + while (importerTrace.length < 12) { + const importer = this.getModuleInfo(importerTrace[importerTrace.length - 1]) + ?.importers[0]; + if (!importer || importerTrace.includes(importer)) break; + importerTrace.push(importer); + } + this.error( + `hosted-renderer-graph:${idViolation.kind}:${idViolation.reference}:importers=${importerTrace + .map(canonicalModuleId) + .join('<-')}` + ); + } + + const specifiers = sortUnique(importedSpecifiers.get(moduleId) ?? []); + for (const specifier of specifiers) { + const violation = classifyForbiddenHostedRendererReference(specifier); + if (violation) { + this.error(`hosted-renderer-graph:${violation.kind}:${violation.reference}`); + } + } + + const info = this.getModuleInfo(moduleId); + if (!info) this.error(`hosted-renderer-graph:module-info-missing:${id}`); + const resolvedImports = sortUnique( + info.importedIds + .filter((resolvedId) => finalModuleIds.has(resolvedId)) + .map(canonicalModuleId) + ); + const resolvedDynamicImports = sortUnique( + info.dynamicallyImportedIds + .filter((resolvedId) => finalModuleIds.has(resolvedId)) + .map(canonicalModuleId) + ); + for (const resolvedId of [...resolvedImports, ...resolvedDynamicImports]) { + const violation = classifyForbiddenHostedRendererReference(resolvedId); + if (violation) { + this.error(`hosted-renderer-graph:${violation.kind}:${violation.reference}`); + } + } + + return { id, importedSpecifiers: specifiers, resolvedImports, resolvedDynamicImports }; + }); + + if (!graphModules.some((module) => module.id === 'src/renderer/hosted/main.tsx')) { + this.error('hosted-renderer-graph:hosted-entry-missing'); + } + + const graph = Object.freeze({ + schemaVersion: 1, + entryHtml: 'index.html', + chunks: Object.freeze(graphChunks), + modules: Object.freeze(graphModules), + }); + const graphSha256 = sha256(JSON.stringify(graph)); + this.emitFile({ + type: 'asset', + fileName: HOSTED_RENDERER_GRAPH_MANIFEST, + source: `${JSON.stringify({ ...graph, graphSha256 }, null, 2)}\n`, + }); + }, + }; +} + +export default defineConfig({ + root: HOSTED_RENDERER_ROOT, + publicDir: false, + plugins: [ + createHostedRendererGraphProofPlugin(), + createHostedTaskBoardRendererBoundaryPlugin(), + react(), + ], + resolve: { + alias: { + '@features': resolve(ROOT, 'src/features'), + '@renderer': resolve(ROOT, 'src/renderer'), + '@shared': resolve(ROOT, 'src/shared'), + }, + }, + build: { + outDir: HOSTED_RENDERER_OUTPUT, + emptyOutDir: true, + target: 'es2023', + sourcemap: process.env.AGENT_TEAMS_DISABLE_SOURCEMAPS === '1' ? false : 'hidden', + rollupOptions: { + input: resolve(HOSTED_RENDERER_ROOT, 'index.html'), + output: { + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + }, + }, + }, +}); diff --git a/docker/vite.standalone.config.ts b/docker/vite.standalone.config.ts index 703cd07f90..ba52905c64 100644 --- a/docker/vite.standalone.config.ts +++ b/docker/vite.standalone.config.ts @@ -1,8 +1,8 @@ /** * Vite build config for the standalone (non-Electron) server. * - * Produces a single CJS bundle at dist-standalone/index.cjs that can be - * run with `node dist-standalone/index.cjs`. + * Produces the standalone server at dist-standalone/index.cjs and the + * internal-storage worker beside its shared chunk under dist-standalone/assets. */ import { resolve } from 'path' @@ -28,7 +28,7 @@ const nodeBuiltins = new Set([ // (fastify ecosystem uses internal file resolution that doesn't survive bundling) const externalPackages = [ 'fastify', '@fastify/cors', '@fastify/static', - 'agent-teams-controller' + 'agent-teams-controller', 'better-sqlite3' ] // Stub native .node addons (ssh2/cpu-features have JS fallbacks) @@ -109,11 +109,16 @@ export default defineConfig({ ssr: true, rollupOptions: { input: { - index: resolve(ROOT, 'src/main/standalone.ts') + index: resolve(ROOT, 'src/main/standalone.ts'), + 'internal-storage-worker': resolve( + ROOT, + 'src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerEntry.ts' + ) }, output: { format: 'cjs', - entryFileNames: '[name].cjs' + entryFileNames: (chunk) => + chunk.name === 'internal-storage-worker' ? 'assets/[name].cjs' : '[name].cjs' }, external: (id) => { // Externalize Node.js built-ins diff --git a/docs/FEATURE_ARCHITECTURE_STANDARD.md b/docs/FEATURE_ARCHITECTURE_STANDARD.md index 3f436d13f9..3135327ee6 100644 --- a/docs/FEATURE_ARCHITECTURE_STANDARD.md +++ b/docs/FEATURE_ARCHITECTURE_STANDARD.md @@ -334,6 +334,39 @@ directly, but production integration code should not. Push transport and store access into feature hooks or genuine boundary adapters. Keep presentation-only projection in `renderer/view-models/`. +## Architecture Ratchet + +Run `pnpm guard:feature-architecture` for the repository-wide dependency gate. +It scans production source and enforces: + +- cross-feature dependencies use only the feature root or the explicit + `contracts`, `main`, `preload`, or `renderer` entrypoint +- `core/domain` stays independent from application, Node, Electron, frameworks, + transport, adapters, and infrastructure +- `core/application` depends only on domain, contracts, and its own application + models, use cases, and ports +- public feature entrypoints do not directly or transitively re-export adapters, + infrastructure, or concrete host boundaries hidden behind another directory + name + +Legacy violations are pinned as individual dependency edges in +`scripts/ci/feature-architecture-baseline.json`. The identity is the rule, +source path, and module specifier. Public API violations additionally include +the public entrypoint plus the exported and imported symbol names, so extending +an existing legacy barrel still fails the ratchet. Line numbers are deliberately +excluded so unrelated edits do not create noise. + +The baseline is a ratchet, not a general allowlist: + +- an existing edge may remain unchanged while its migration is pending +- a new violating edge fails even when it is added to a legacy file +- removed violations require their exact baseline entries to be removed +- CI compares the manifest with the PR base and rejects new exceptions +- new files and new features therefore start with no architecture exceptions + +Use `pnpm guard:feature-architecture -- --report` only when the full legacy +inventory is needed. The default successful output stays concise. + ## Browser and Tauri Friendly Guidance The default transport direction should be: @@ -462,8 +495,23 @@ A feature is reference-quality when: - at least the main domain and application rules are tested when those layers exist - architecture is enforced by lint rules +- every new production source file is at most 800 physical lines - feature has a concise standard or plan doc if it introduces a new pattern +The `pnpm guard:source-file-size` gate covers production code in the app and +workspace packages. Existing files above 800 lines are pinned to their exact +current line count in `scripts/ci/source-file-size-legacy.json`: they may only +shrink, and the exception must be removed once a file reaches 800 lines. +Never add a new legacy exception or raise an existing cap. Split new +responsibilities by domain/application/adapter/UI ownership instead. + +CI also ratchets the complete pre-existing policy in +`scripts/ci/source-file-size-baseline.json`, including root configuration and +production scripts outside the workspace source roots. Neither its global limit +nor any legacy cap may increase. A zero predecessor SHA on the first push of a +branch means there is no prior manifest to compare; the current-tree 800-line +policy still runs normally. + ## Recommended Test Coverage For medium and large features, cover at least: diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 0afc678a87..6a0420094d 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -40,6 +40,7 @@ Runtime gate: Draft body source for GitHub release: + Use self-hosted OpenAI-compatible models with team members. ### What's New @@ -119,6 +120,7 @@ Runtime gate: Draft body source for GitHub release: + This release focuses on fixes and stability. ### Fixes and Stability diff --git a/docs/hosted-approval-activation-v1-golden.json b/docs/hosted-approval-activation-v1-golden.json new file mode 100644 index 0000000000..d6233e44e6 --- /dev/null +++ b/docs/hosted-approval-activation-v1-golden.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "contract": "agent-teams.hosted-approval-activation/v1", + "proof": { + "algorithm": "HMAC-SHA-256", + "domain": "agent-teams.hosted-approval-activation-proof/v1", + "direction": "admission", + "proofField": "controllerProof", + "proofLast": true, + "keySemantics": "The shared HMAC authenticates cross-process integrity and peer possession; it does not prove exclusive product authorship because both product and orchestrator possess the key." + }, + "secretHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "binding": { + "deploymentId": "deployment_activation-golden", + "bootId": "boot_activation-golden", + "workspaceId": "workspace_activation-golden", + "teamId": "team_11111111111111111111111111111111", + "restoreGeneration": 4, + "mountBinding": { + "mountGeneration": 9, + "declaredRootHash": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "ownerBinding": { + "ownerAuthority": "owner-authority_activation-golden", + "ownerGeneration": 7, + "ownerSessionId": "owner-session_activation-golden", + "socketIdentity": { + "device": "11", + "inode": "12", + "uid": 1000, + "gid": 1000, + "mode": 384 + } + }, + "socketPath": "/run/agent-teams-orchestrator/approval-golden.sock", + "approvalGeneration": 3, + "approvalDigest": "sha256:e2aa623157def5fe8b748c883ed41f79f7ca260dfe07fd76024ed68fad26bbc8", + "artifactDigest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "activationCapability": "agent-teams.hosted-approval-activation-v1", + "wireCapabilityDigest": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "signedManifest": { + "format": "agent-teams.hosted-lifecycle-owner-admission/v4", + "manifestDigest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "releasePinDigest": "sha256:7777777777777777777777777777777777777777777777777777777777777777", + "launcherKeyId": "8888888888888888888888888888888888888888888888888888888888888888" + } + }, + "admission": { + "schemaVersion": 1, + "approvalGeneration": 3, + "authorities": [ + { + "deploymentId": "deployment_activation-golden", + "teamId": "team_11111111111111111111111111111111", + "runId": "run_99999999999999999999999999999999", + "planGeneration": 7, + "laneId": "primary", + "providerId": "opencode", + "credentialGeneration": 5, + "credentialId": "credential_activation-golden", + "sessionId": "session_activation-golden", + "runtimeInstanceId": "runtime_instance_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "deliveryOwnerId": "member_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + }, + "serializedUnsignedEnvelope": "{\"schemaVersion\":1,\"purpose\":\"agent-teams.hosted-approval-activation/v1\",\"binding\":{\"deploymentId\":\"deployment_activation-golden\",\"bootId\":\"boot_activation-golden\",\"workspaceId\":\"workspace_activation-golden\",\"teamId\":\"team_11111111111111111111111111111111\",\"restoreGeneration\":4,\"mountBinding\":{\"mountGeneration\":9,\"declaredRootHash\":\"2222222222222222222222222222222222222222222222222222222222222222\"},\"ownerBinding\":{\"ownerAuthority\":\"owner-authority_activation-golden\",\"ownerGeneration\":7,\"ownerSessionId\":\"owner-session_activation-golden\",\"socketIdentity\":{\"device\":\"11\",\"inode\":\"12\",\"uid\":1000,\"gid\":1000,\"mode\":384}},\"socketPath\":\"/run/agent-teams-orchestrator/approval-golden.sock\",\"approvalGeneration\":3,\"approvalDigest\":\"sha256:e2aa623157def5fe8b748c883ed41f79f7ca260dfe07fd76024ed68fad26bbc8\",\"artifactDigest\":\"sha256:4444444444444444444444444444444444444444444444444444444444444444\",\"activationCapability\":\"agent-teams.hosted-approval-activation-v1\",\"wireCapabilityDigest\":\"sha256:5555555555555555555555555555555555555555555555555555555555555555\",\"signedManifest\":{\"format\":\"agent-teams.hosted-lifecycle-owner-admission/v4\",\"manifestDigest\":\"sha256:6666666666666666666666666666666666666666666666666666666666666666\",\"releasePinDigest\":\"sha256:7777777777777777777777777777777777777777777777777777777777777777\",\"launcherKeyId\":\"8888888888888888888888888888888888888888888888888888888888888888\"}},\"admission\":{\"schemaVersion\":1,\"approvalGeneration\":3,\"authorities\":[{\"deploymentId\":\"deployment_activation-golden\",\"teamId\":\"team_11111111111111111111111111111111\",\"runId\":\"run_99999999999999999999999999999999\",\"planGeneration\":7,\"laneId\":\"primary\",\"providerId\":\"opencode\",\"credentialGeneration\":5,\"credentialId\":\"credential_activation-golden\",\"sessionId\":\"session_activation-golden\",\"runtimeInstanceId\":\"runtime_instance_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"deliveryOwnerId\":\"member_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"}]}}", + "controllerProof": "bb042768575c8b71ea5a6c1c89a016a038e813d6615a6935e6441bb20259fe90", + "signedEnvelope": "{\"schemaVersion\":1,\"purpose\":\"agent-teams.hosted-approval-activation/v1\",\"binding\":{\"deploymentId\":\"deployment_activation-golden\",\"bootId\":\"boot_activation-golden\",\"workspaceId\":\"workspace_activation-golden\",\"teamId\":\"team_11111111111111111111111111111111\",\"restoreGeneration\":4,\"mountBinding\":{\"mountGeneration\":9,\"declaredRootHash\":\"2222222222222222222222222222222222222222222222222222222222222222\"},\"ownerBinding\":{\"ownerAuthority\":\"owner-authority_activation-golden\",\"ownerGeneration\":7,\"ownerSessionId\":\"owner-session_activation-golden\",\"socketIdentity\":{\"device\":\"11\",\"inode\":\"12\",\"uid\":1000,\"gid\":1000,\"mode\":384}},\"socketPath\":\"/run/agent-teams-orchestrator/approval-golden.sock\",\"approvalGeneration\":3,\"approvalDigest\":\"sha256:e2aa623157def5fe8b748c883ed41f79f7ca260dfe07fd76024ed68fad26bbc8\",\"artifactDigest\":\"sha256:4444444444444444444444444444444444444444444444444444444444444444\",\"activationCapability\":\"agent-teams.hosted-approval-activation-v1\",\"wireCapabilityDigest\":\"sha256:5555555555555555555555555555555555555555555555555555555555555555\",\"signedManifest\":{\"format\":\"agent-teams.hosted-lifecycle-owner-admission/v4\",\"manifestDigest\":\"sha256:6666666666666666666666666666666666666666666666666666666666666666\",\"releasePinDigest\":\"sha256:7777777777777777777777777777777777777777777777777777777777777777\",\"launcherKeyId\":\"8888888888888888888888888888888888888888888888888888888888888888\"}},\"admission\":{\"schemaVersion\":1,\"approvalGeneration\":3,\"authorities\":[{\"deploymentId\":\"deployment_activation-golden\",\"teamId\":\"team_11111111111111111111111111111111\",\"runId\":\"run_99999999999999999999999999999999\",\"planGeneration\":7,\"laneId\":\"primary\",\"providerId\":\"opencode\",\"credentialGeneration\":5,\"credentialId\":\"credential_activation-golden\",\"sessionId\":\"session_activation-golden\",\"runtimeInstanceId\":\"runtime_instance_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"deliveryOwnerId\":\"member_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"}]},\"controllerProof\":\"bb042768575c8b71ea5a6c1c89a016a038e813d6615a6935e6441bb20259fe90\"}" +} diff --git a/docs/hosted-approval-wire-v4-golden.json b/docs/hosted-approval-wire-v4-golden.json new file mode 100644 index 0000000000..4c031631e7 --- /dev/null +++ b/docs/hosted-approval-wire-v4-golden.json @@ -0,0 +1,9 @@ +{ + "format": "agent-teams.hosted-approval-wire-golden/v1", + "consumerHead": "44ada5b51032a3aafe81ce8e30b50cdb4a06c909", + "capability": "{\"format\":\"agent-teams.hosted-approval-wire-capability/v1\",\"wireSchemaVersion\":4,\"ownerProofDomain\":\"agent-teams.hosted-runtime-approval.owner-proof/v1\",\"operations\":[\"approval_ingress_claim\",\"approval_ingress_ack\",\"approval_ingress_authority_resolve\",\"approval_decision_deliver\",\"approval_decision_reconcile\"],\"openCodeProtocol\":\"agent-teams-hosted-approval-v2\",\"openCodeSchemaVersion\":2}", + "capabilitySha256": "40a6c4a131b4e64c04b87337fbce667c91f274fb85b3879c6cdcac49dbbbd639", + "ownerProofKeyHex": "2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + "serializedUnsignedRequest": "{\"schemaVersion\":4,\"exchangeId\":\"approval-request_66666666666666666666666666666666\",\"operation\":\"approval_ingress_ack\",\"ownerBinding\":{\"ownerAuthority\":\"owner-authority_approval-wire\",\"ownerGeneration\":7,\"ownerSessionId\":\"owner-session_approval-wire\",\"socketIdentity\":{\"device\":\"11\",\"inode\":\"12\",\"uid\":501,\"gid\":20,\"mode\":384}},\"authority\":{\"actorId\":\"actor_approval-wire\",\"deploymentId\":\"deployment_approval-wire\",\"bootId\":\"boot_approval-wire\",\"restoreGeneration\":4,\"workspaceId\":\"workspace_33333333333333333333333333333333\",\"mountBinding\":{\"mountGeneration\":9,\"declaredRootHash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}},\"payload\":{\"outboxId\":\"runtime_permission:effect:5555555555555555555555555555555555555555555555555555555555555555\",\"generation\":3,\"ownerId\":\"owner_approval-wire\",\"leaseToken\":\"lease_approval-wire\"}}", + "requestOwnerProof": "a1043f9a8b698597c7bc30cf994c3caa529bc5a614dbdf972376f4b29b4bb1c9" +} diff --git a/docs/hosted-authentication.md b/docs/hosted-authentication.md new file mode 100644 index 0000000000..f8ffec75f2 --- /dev/null +++ b/docs/hosted-authentication.md @@ -0,0 +1,933 @@ +# Hosted v1 authentication + +Status: implementation in progress +Decision owner: `src/features/hosted-access` +Supersedes: any earlier hosted-web note that deferred OIDC + +## Decision + +Hosted v1 has exactly one public authentication mode per deployment: + +- `AUTH_MODE=personal` uses a ten-minute, one-time pairing challenge and one personal owner. +- `AUTH_MODE=oidc` uses generic OpenID Connect Authorization Code Flow with state, nonce and + PKCE. It never falls back to pairing when discovery, JWKS, token exchange or the IdP is + unavailable. + +The first successful startup durably claims the configured mode in +`hosted_auth_configuration`. A later startup with the other mode fails closed with +`hosted_auth_mode_change_requires_host_reset`; it cannot turn an OIDC outage into a personal +pairing surface. A mode change is possible only through the local Unix-socket CLI, a strictly newer +reset generation and exact, current, target-bound AR drain evidence. It atomically changes the +durable claim, invalidates personal credentials, revokes OIDC sessions, deletes pending OIDC login +attempts and appends its audit event. The serving process then refuses every HTTP and local +administration operation until it is restarted with the target profile. Personal credential reset +is a separate operation and also requires exact, current AR drain evidence. + +Keycloak is a supported OIDC deployment profile, not an application-core dependency. Both modes +resolve to immutable Agent Teams `UserId` values and the same server-side role/permission policy. +The desktop owner and hosted users use shared application use cases; Electron, Fastify, SQLite, +files and OIDC HTTP are outside `core`. + +One deployment owns one runtime root, supports multiple human users and has exactly one backend +controller. Desktop and hosted processes must never mutate the same runtime root concurrently. +Agent Runtime (AR) remains the only process-supervision, provider-execution and runtime-recovery +owner. Local runner/relay and hosted terminal are not v1 capabilities. + +Both Compose authentication profiles mount the same `agent-teams-data` authority volume and use +the same explicit controller container name. This makes a second profile, replica or Compose +project on the same Docker daemon fail admission before it can expose another controller, while +the durable mode claim independently refuses a mode change against that shared volume. The +deployment must never copy or mount this volume into a concurrently running desktop process. + +Export `HOSTED_SECRETS_DIR` as an absolute path outside the repository before every Compose +invocation. Compose interpolates the shared file before applying profiles. Both profiles require +`$HOSTED_SECRETS_DIR/lifecycle_orchestrator_trust_anchor` and +`$HOSTED_SECRETS_DIR/lifecycle_owner_release_pin.json`; a personal-only deployment therefore +cannot use an empty secret directory. From the repository root, run +`node scripts/hosted-auth-cli.mjs preflight` before Compose so the canonical path is proven to be +outside the repository Docker build context. + +## External lifecycle-owner handoff + +Compose never starts a lifecycle owner. Before either authentication profile starts, the one +external AR/lifecycle launcher must exclusively own a dedicated run directory and create its +`orchestrator-lifecycle.sock` there. Set `HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR` to the canonical +absolute path of that narrow directory, not `/run`, `/tmp`, a home directory or another shared +root. The controller receives the directory read-only and is the only Compose service that can +connect to the socket. The bind uses `create_host_path: false`, so a missing or misspelled owner +directory fails instead of silently creating an empty root-owned directory. The external owner +must admit container UID/GID `1000:1000` to connect to that socket without making unrelated runtime +state visible. There is no supported fallback owner, in-process lifecycle executor or second +controller. + +Release admission has two independent inputs. The external launcher writes +`lifecycle-owner-admission.json` beside the socket, as UID/GID `1000:1000` and mode `0400`, and +signs `agent-teams.hosted-lifecycle-owner-admission/v2\0` plus its exact canonical payload with an +Ed25519 launcher key. Separately, the reviewed +deployment/release process provisions +`$HOSTED_SECRETS_DIR/lifecycle_owner_release_pin.json`; the owner must not generate or rewrite this +pin. It is canonical one-line JSON (with an optional final newline) in this shape: + +```json +{ + "format": "agent-teams.hosted-lifecycle-owner-release-pin/v2", + "artifact": { + "artifactDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "imageReference": "registry.example/agent-teams-owner@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "artifactVersion": "1.0.0", + "protocolVersion": 2 + }, + "launcher": { + "algorithm": "ed25519", + "publicKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "keyId": "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" + } +} +``` + +`publicKey` is the canonical unpadded base64url encoding of the raw 32-byte Ed25519 public key; +`keyId` is the lowercase SHA-256 digest of those raw bytes. The manifest carries a detached +Ed25519 signature and the same key ID. Its signed payload binds the exact artifact metadata and +digest, live socket identity, bootstrap digest, mount generation, and expected owner binding. The +manifest artifact must match the independent pin exactly. A compromised runtime owner therefore +cannot approve a different build, socket, or bootstrap using the readiness HMAC key. + +Before signing, the launcher must resolve or measure the digest of the actual executable or image +bytes it is about to launch and compare that observation, plus image reference, artifact version, +and protocol version, with the release pin. It must derive the manifest artifact fields from that +measured launch observation and fail closed on any mismatch. Copying the pin's claimed metadata +into a payload without measuring the launched artifact is forbidden. + +The runtime owner and controller share one 32-byte proof key encoded as exactly 64 lowercase +hexadecimal characters. It authenticates readiness and command frames only; it is not a launcher +signing key. Store the controller copy, with at most one trailing newline, at +`$HOSTED_SECRETS_DIR/lifecycle_orchestrator_trust_anchor`; keep the enclosing directory mode +`0700` and both source files mode `0444`. The broad-looking source mode is required because local +Compose file secrets do not portably remap ownership, while the non-traversable parent protects +the host files. A networkless UID-1000 one-shot initializer validates the inputs and copies both +into the `agent-teams-lifecycle-trust` volume as UID/GID `1000:1000`, mode `0400`. The controller +mounts only that derived volume read-only. Give the external owner the same proof key through its +launcher's independent protected input; never put the key in the Compose environment, command +line, logs or the owner socket directory. Keep the Ed25519 private launcher key in a separate +protected launcher input that is never mounted into the controller, copied into the two-file +lifecycle trust volume, exposed through environment variables, or made available to the runtime +owner after the launcher handoff is complete. + +The bounded hosted-v1 E2E fixture deliberately co-locates fake launcher and fake owner code in one +test process so it can publish a live-socket manifest. Its private Ed25519 key is still delivered +through a fake-runtime-only `0400` mount that the controller cannot see. That co-location is +test-only and is not an acceptable production launcher/runtime topology. The fixture still feeds +its marker-owned trust anchor and release pin through Compose file secrets and the same networkless +`hosted-volume-init lifecycle-trust-anchor` handoff used by production; neither controller nor fake +owner bind-mounts the source trust directory. + +The same launcher transaction must emit the exact serialized lifecycle-read bootstrap bound to +the current deployment, runtime roots and owner instance. Export that unmodified value as +`AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP`; do not hand-author, cache across an owner +restart or copy it from the bounded E2E fixture. Start Compose only after the socket, proof key and +bootstrap describe the same live owner, the authenticated admission manifest is durable beside the +socket, and its artifact matches the independent release pin. A missing, stale, foreign or +mismatched handoff fails controller startup/readiness closed. + +For Personal mode, after the external launcher has completed that handoff, replace the bootstrap +placeholder with its exact one-line value and start the controller: + +```sh +HOSTED_DOMAIN=agent-teams.localhost \ +HOSTED_PUBLIC_ORIGIN=https://agent-teams.localhost \ +CLAUDE_DIR=/absolute/path/to/a/dedicated/hosted-sandbox/.claude \ +HOSTED_SECRETS_DIR=/absolute/path/outside/the/repository/agent-teams-hosted-secrets \ +HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR=/absolute/protected/path/to/the/owner-run-directory \ +AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP='REPLACE_WITH_EXACT_LAUNCHER_ISSUED_JSON' \ +HOSTED_WORKSPACE_IDS=-registered-hosted-workspace \ +NODE_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_NODE_MANIFEST_DIGEST \ +KEYCLOAK_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_KEYCLOAK_MANIFEST_DIGEST \ +CADDY_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_CADDY_MANIFEST_DIGEST \ +docker compose -f docker/docker-compose.yml --profile personal up --build -d +``` + +## Security boundary + +The public browser receives only opaque Agent Teams cookies: + +- `__Host-agent-teams-device`: personal mode, durable HttpOnly device-family grant. +- `__Host-agent-teams-session`: short HttpOnly Agent Teams session. +- two short `SameSite=Lax` HttpOnly cookies during an OIDC redirect: attempt id and state. + +Production cookies are `Secure`, `Path=/`, have no `Domain`, and use `SameSite=Strict` except the +OIDC redirect cookies. CSRF is returned only from the authenticated status/pair response, held in +renderer module memory and sent as `x-agent-teams-csrf`. It is never written to localStorage, +sessionStorage, IndexedDB or a URL. + +Pairing material is published once to `/run/agent-teams/pairing.json` with mode `0600`. Retrieve it +only on the Docker host: + +```sh +docker compose -f docker/docker-compose.yml --profile personal \ + exec agent-teams-personal node scripts/hosted-auth-cli.mjs pairing-code +``` + +The pairing code is never an HTTP query parameter and application logging must never include it. +The delivery file is removed after use. Pairing attempts are bounded and the challenge expires +after ten minutes. Once the durable transition consumes a challenge, presenting that code again +never reconstructs or reissues its device/session credentials; concurrent presentations have +exactly one winner. + +OIDC provider tokens are consumed only by the backend. The browser never receives an access, +refresh or ID token. The SQLite database stores no provider token and no password. The short-lived +PKCE verifier is AES-GCM encrypted with a key stored outside SQLite. SQLite stores keyed hashes of +Agent Teams session and pairing/device secrets. + +`AUTH_PUBLIC_ORIGIN` is configuration authority for redirects and origin checks. Request `Host`, +`Forwarded` and `X-Forwarded-*` values never choose an OIDC redirect. Fastify ignores proxy headers +unless `TRUSTED_PROXY_CIDRS` explicitly admits the proxy address. Caddy is the production HTTPS +edge. + +## Immutable identity and durable storage + +Internal-storage SQLite schema version 16 owns: + +- `hosted_auth_configuration`: the deployment's durable, mutually exclusive auth-mode claim, + monotonic mode-reset generation and crash-recovery marker for external key rotation; +- `users`: immutable Agent Teams `UserId`, display projection and disabled state; +- `external_identities`: unique `(issuer, subject)` binding to `UserId`; +- `operator_sessions`: opaque OIDC sessions and bounded idle/absolute deadlines; +- `role_snapshots`: the exact role captured at successful reauthentication; +- `local_role_assignments`: explicit local role overrides that become effective only after a new + OIDC authentication; +- `hosted_access_authority`: personal pairing/device/session projection plus a monotonic rollback + fence; +- `personal_owners`: exactly one personal operator-to-`UserId` binding; +- `oidc_login_attempts`: one-time state, nonce and encrypted PKCE transaction state; +- `oidc_logout_replay`: durable Keycloak/OIDC back-channel `jti` replay protection; +- `hosted_workspaces`: server-registered runtime workspaces plus immutable opaque public IDs; +- `hosted_workspace_grants`: default-deny per-`UserId` workspace grants bound to the current + restore generation; +- `auth_audit_events`: immutable authentication/authorization audit events. + +Personal pairing and renewal prepare the SQLite owner before the authority CAS, so an unavailable +identity store cannot consume a challenge or rotate a valid device grant. Active sessions and +renewals also require the authority `OperatorId` to match the durable personal-owner binding. A +split or incorrectly restored pair of stores fails unavailable rather than authenticating one +authority identity as another immutable `UserId`. + +Keycloak PostgreSQL is separate and owns passwords, MFA/passkeys, account recovery and lockout, +LDAP/AD/social federation, groups and base realm roles. Agent Teams owns application identities, +permissions, workspace/team access, sessions, role snapshots and audit. + +The auth key/PKCE encryption file and personal keyring live under +`AUTH_DATA_DIR/hosted-auth-secrets`, outside SQLite. Do not put that directory in application +backups, diagnostics or support bundles. Losing those keys intentionally invalidates sessions and +requires explicit access recovery. An explicitly configured `AUTH_IDENTITY_KEY_FILE` must be an +absolute normalized path in an owner-controlled, non-symlink directory; startup locks that +directory to mode `0700` before creating or reading the key. + +OIDC session and login-attempt hashes are also bound to the non-negative +`AUTH_RESTORE_GENERATION`. Increment that value for every Agent Teams SQLite restore, including a +database-only rollback that retains the live identity key. Cookies and pending authorization +transactions copied from the older database generation then become cryptographically unreachable. +Workspace grants are read only at the exact current generation, so a coordinated backup restored +under the required incremented generation also defaults to no browser workspace access until the +local administrator re-grants it. +Personal mode also detects the generation mismatch and remains fail closed until its documented +reset/recovery operation completes. + +Auth-mode reset stages a new personal keyring durably before the SQLite transaction. The +transaction advances the personal authority rollback fence to a credential-empty state that +references that staged keyring. On target-profile startup, recovery idempotently activates the +staged keyring, removes any old pairing delivery, rotates the Agent Teams identity/PKCE key and +then marks the reset generation complete. A crash before that marker leaves startup fail closed +and repeatable; it never reopens the old public mode. Users, immutable external-identity bindings, +the personal-owner binding, local roles, workspaces, audit history and back-channel replay history +remain durable across the mode change. + +## OIDC validation + +The generic provider: + +1. resolves the issuer discovery document without following redirects; +2. creates independent 256-bit state and nonce values and a high-entropy PKCE verifier; +3. sends `S256` PKCE and Authorization Code Flow parameters; +4. validates and snapshots a bounded role-claim mapping before durable auth-mode admission, + rejecting empty claim-path segments, control characters and any provider value assigned to + more than one local role; +5. bounds per-source login admission and the durable ten-minute attempt set; +6. compares the callback state to its HttpOnly cookie before atomically consuming the durable, + provider-bound login attempt and exchanging a code; +7. validates a supported asymmetric JWS signature against JWKS; +8. validates exact issuer, audience, authorized party for multi-audience tokens, expiry, issued-at + skew and nonce; +9. binds `(issuer, subject)` to an immutable `UserId`; +10. captures the configured role mapping and issues a new opaque Agent Teams session. + +OIDC has an explicit `oidc_provider_unavailable` path. Back-channel logout validates signature, +issuer, audience, expiry, the logout event claim, absence of nonce, `sid`/`sub`, and consumes `jti` +once in the same SQLite transaction that revokes matching sessions. Issuer, subject and provider +session identifiers—not the configurable provider display label—select sessions and replay state, +so renaming `OIDC_PROVIDER_ID` cannot strand sessions or reopen a consumed `jti`. Local logout +revokes only Agent Teams state. Global logout additionally returns the provider end-session URL. +Reauthentication creates a fresh role snapshot; it does not mutate an active session's privileges +in place. + +OIDC session configuration is bounded before any key or database mutation: idle lifetime must be +between one minute and one hour, absolute lifetime must be between five minutes and twenty-four +hours, and idle lifetime cannot exceed absolute lifetime. Defaults remain fifteen minutes idle and +eight hours absolute. Personal sessions retain the stricter fixed one-hour absolute policy. + +The first OIDC user is never implicitly owner. `OIDC_DEFAULT_ROLE` cannot be `owner`. Owner access +requires an explicit `OIDC_OWNER_ROLE_VALUES` match or the local role-management CLI. A local +assignment never rewrites an active session; it is captured into a new role snapshot only after +successful reauthentication. + +## Frozen roles and HTTP policy + +| Role | Query | SSE | Commands | Hosted management | Workspace management | Identity management | +| ------ | ----- | --- | -------- | ----------------- | -------------------- | ------------------- | +| owner | yes | yes | yes | yes | yes | yes | +| admin | yes | yes | yes | yes | yes | no | +| member | yes | yes | yes | no | no | no | +| viewer | yes | yes | no | no | no | no | + +The server classifier is fail closed: + +- `/api/auth/status`, personal pairing, OIDC begin/callback and signed back-channel logout are + public transport endpoints; +- `/api/events` requires the SSE permission; +- an exact route inventory admits deployment/project/session reads with query permission; +- only the lifecycle read POST and registered-workspace session lookup POST are read-shaped, and + both require CSRF; +- only registered-workspace pin/hide operations currently receive command permission; +- every unclassified API route is denied even when it uses a safe HTTP method; +- SSH, updater, notifications, arbitrary-path/file/config mutation APIs, terminal, provider relay + and legacy team launch/stop/runtime routes are absent in hosted v1; +- project/worktree resource routes require both a `hosted_workspaces` registration and a durable + current-generation grant for the authenticated immutable `UserId`; +- project lists, repository groups, recent projects and global search results are filtered against + the same per-principal grants. Global search does not read an ungranted project. + +Every unsafe request also requires an exact configured Origin and a same-origin/same-site browser +fetch context. CORS is restricted to `AUTH_PUBLIC_ORIGIN` when hosted auth is mounted. +Every authenticated response is marked `Cache-Control: no-store, private` and `Pragma: no-cache`; +a workspace-projection storage outage fails with an explicit `503` rather than returning an +apparently successful partial payload. +The hosted DTO projection replaces local scanner IDs with immutable opaque workspace IDs and +removes absolute paths, runtime roots, Git remotes (including embedded credentials), Git metadata +and repository identity internals from project, repository-group, recent-project, session and SSE +payloads. The hosted config projection removes Claude/SSH/custom-project paths, provider endpoint +URLs and ungranted session metadata. Hosted SSE suppresses legacy todo and notification payloads +that cannot yet be attributed to a granted workspace, and projects each event independently for +each connected principal. Each hosted event and keepalive revalidates the opaque session; grant +revoke immediately stops delivery without leaking the event to another principal. Personal host +reset first blocks every new public request, drains admitted HTTP work and closes every connected +stream; an early client disconnect releases the same idempotent drain fence, so an abandoned +socket cannot stall recovery. + +## Keycloak production profile + +Create a deployment-only secret directory outside the repository and every Docker build context, +then create four high-entropy files in it: + +- `lifecycle_orchestrator_trust_anchor` +- `oidc_client_secret` +- `keycloak_admin_password` +- `keycloak_database_password` + +Set `HOSTED_SECRETS_DIR` to that directory's absolute path before every Compose command. Compose +fails during interpolation when it is absent. Never place the directory beneath the repository, +including beneath `docker/`: Docker sends the repository root as the application image build +context, and ignored or untracked files can otherwise enter builder input or cache. The lifecycle +anchor has the exact lowercase-hex format specified above. Each of the other three files must +contain one non-empty base64url value (`A-Z`, `a-z`, `0-9`, `_` or `-`); a single trailing newline +is tolerated. Keep the external directory mode `0700` and all four files mode `0444`. +The apparently broad file mode is intentional for local Compose: file-backed secrets are +bind-mounted and its `uid`, `gid` and `mode` long-syntax fields are not portably applied. The +non-traversable parent protects the sources on the host, while Compose exposes each file only to +the services explicitly named in its `secrets` list. The non-root +`agent-teams-keycloak-secret-init` job copies the portable OIDC source into an image-seeded +placeholder on the persistent `agent-teams-keycloak-secret` volume, verifies UID/GID +`1000:1000`, and locks the result to mode `0400`. The application mounts that volume only +read-only at `/run/agent-teams-oidc`; its mutable local-control files remain on the separate +`/run/agent-teams` tmpfs. The handoff therefore survives an initializer or application restart +without granting the application write access to the secret. It never accepts the portable `0444` +source mount directly. The separate common lifecycle initializer applies the same portability +boundary to both authentication profiles and never exposes the source anchor to the application. +Do not make the source directory group/world traversable, and do not use these source files outside +this deployment. + +The Keycloak startup wrapper reads the Docker secrets into shell-local variables, resolves the +realm template into a private tmpfs and writes database/bootstrap configuration into a second +private tmpfs. It unsets the variables and explicitly removes credential environment keys before +replacing itself with Keycloak; secret values never enter the Compose environment, container +command line or persistent container layer. The Compose service replaces the Keycloak image's +`kc.sh` entrypoint with that bootstrap shell, then explicitly executes `kc.sh` as the image's +non-root UID 1000/GID 0 identity. Compose builds that image from the pinned Keycloak source digest +with `kc.sh build --db=postgres --health-enabled=true`; the read-only runtime uses +`start --optimized` and the same PostgreSQL/health build options instead of augmenting at startup. + +Resolve and review the multi-platform manifest digests for the exact image tags in the Compose +file, then provide them as `NODE_IMAGE_DIGEST`, `KEYCLOAK_IMAGE_DIGEST`, +`POSTGRES_IMAGE_DIGEST`, and `CADDY_IMAGE_DIGEST`. Each value must include the `sha256:` prefix. +Compose and the Dockerfile fail closed when a digest is absent; tags remain in the references only +as human-readable version labels. Record the four resolved digests with the deployment's upgrade +and backup evidence. + +Add both the application and Keycloak host names to local DNS, or `/etc/hosts`, pointing at the +Docker host. They must be distinct origins so Keycloak pages, scripts and cookies never share the +Agent Teams application origin. Start: + +```sh +HOSTED_DOMAIN=agent-teams.localhost \ +HOSTED_PUBLIC_ORIGIN=https://agent-teams.localhost \ +KEYCLOAK_DOMAIN=auth.agent-teams.localhost \ +KEYCLOAK_PUBLIC_ORIGIN=https://auth.agent-teams.localhost \ +CLAUDE_DIR=/absolute/path/to/a/dedicated/hosted-sandbox/.claude \ +HOSTED_SECRETS_DIR=/absolute/path/outside/the/repository/agent-teams-hosted-secrets \ +HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR=/absolute/protected/path/to/the/owner-run-directory \ +AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP='REPLACE_WITH_EXACT_LAUNCHER_ISSUED_JSON' \ +HOSTED_WORKSPACE_IDS=-synthetic-hosted-e2e \ +NODE_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_NODE_MANIFEST_DIGEST \ +KEYCLOAK_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_KEYCLOAK_MANIFEST_DIGEST \ +POSTGRES_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_POSTGRES_MANIFEST_DIGEST \ +CADDY_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_CADDY_MANIFEST_DIGEST \ +docker compose -f docker/docker-compose.yml --profile keycloak up --build -d +``` + +The profile starts Agent Teams, Keycloak, a separate PostgreSQL database and Caddy HTTPS. Caddy's +local root certificate is in the `caddy-data` volume; development clients must trust that +certificate explicitly. After Caddy health succeeds, the non-root `keycloak-volume-init` job +mounts Caddy data read-only and copies only `root.crt` into the persistent +`agent-teams-keycloak-trust` volume. It verifies a non-linked, UID/GID-`1000:1000`, mode-`0600` +placeholder or prior mode-`0444` handoff, rejects unexpected volume entries, and leaves the copied +certificate mode `0444`. +Agent Teams and Keycloak wait for that job, mount only this dedicated volume read-only at +`/caddy-trust`, and use it through `NODE_EXTRA_CA_CERTS` and `KC_TRUSTSTORE_PATHS`; neither +container mounts `caddy-data`, so the private root key and every other PKI artifact are absent +from their filesystems. Caddy itself does not wait for Keycloak: its health proves configuration +and CA readiness, while upstream dialing begins only after Keycloak starts. Replace internal TLS +with a public Caddy certificate in production. Caddy is explicitly UID/GID `1000:1000`, drops +every capability except `NET_BIND_SERVICE`, and owns its own data/config volumes. The dedicated +trust volume survives application restarts without reopening Caddy's private PKI tree. +The Agent Teams image runs as the unprivileged `node` user (UID/GID 1000); the dedicated +`CLAUDE_DIR` bind mount must therefore be readable and traversable by that container identity. +Writable auth data and the local-control runtime directory are explicitly owned by that identity. +The service-scoped OIDC secret remains readable through the dedicated read-only handoff volume +above, including on local Compose implementations that ignore secret UID remapping. +If `HOSTED_HTTPS_PORT` is not `443`, include that exact port in both `HOSTED_PUBLIC_ORIGIN` and +`KEYCLOAK_PUBLIC_ORIGIN`. Caddy serves both host names on that port, while Agent Teams redirects +remain on the application origin and OIDC discovery remains on the isolated Keycloak origin. + +The public-facing internal Compose network is the dedicated `172.30.255.0/28` subnet. Both public +profiles assign Caddy `172.30.255.2` and admit only that exact `/32` as a trusted proxy; the former +broad Docker private-address trust is intentionally forbidden because another container could +otherwise spoof the client address. Keycloak and PostgreSQL also share a separate internal +`172.30.254.0/28` backend network; PostgreSQL is attached only there, so neither Caddy nor Agent +Teams can reach it directly. If the public subnet conflicts with the host, change +`HOSTED_NETWORK_SUBNET` together with the Agent Teams, Caddy, and Keycloak public IP values. Change +`HOSTED_KEYCLOAK_BACKEND_SUBNET` together with `HOSTED_POSTGRES_IPV4` independently. + +The imported realm creates the confidential `agent-teams-hosted` client and four realm roles: +`agent-teams-owner`, `agent-teams-admin`, `agent-teams-member`, and `agent-teams-viewer`. The +container validates `HOSTED_DOMAIN` as an ASCII DNS name, requires `HOSTED_PUBLIC_ORIGIN` to match +that exact host and `HOSTED_HTTPS_PORT`, validates `KEYCLOAK_DOMAIN` and +`KEYCLOAK_PUBLIC_ORIGIN` independently, and rejects equal host names. It renders only the +application origin into redirect, web-origin and logout fields together with the client secret; +startup rejects any unresolved template placeholder. Caddy enables `strict_sni_host on` globally +and defines separate, exact application and Keycloak host blocks on the same internal/external +HTTPS listener; TLS SNI and the HTTP host therefore cannot select different upstreams. Personal +mode has only the exact application host block. A non-default public port does not break +container-to-Caddy issuer discovery. Keeping the hosts distinct prevents the application's +host-only `Path=/` session and +device cookies from being sent to Keycloak and prevents an IdP page from acquiring the +application's in-memory CSRF token through same-origin access. The realm disables public +registration, enables recovery and bounded brute-force lockout, and applies a +minimum-length/non-username password policy while leaving MFA/passkey enrollment and federation in +Keycloak. Redirect, post-logout and back-channel logout URLs are restricted to the application +origin. No application code calls the Keycloak Admin API. + +For an external Keycloak or another OIDC provider, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, +`OIDC_CLIENT_SECRET_FILE`, claim path and role-value mappings. Custom scope lists are validated as +OAuth scope tokens and always include `openid`. Issuer URLs cannot contain a query or fragment, +and provider endpoint/redirect URLs cannot contain fragments. Do not enable the Compose Keycloak +services. Confidential client secrets are accepted only through the protected file setting; the +process-environment form `OIDC_CLIENT_SECRET` is rejected. Discovery-driven token exchange prefers +`client_secret_basic`, supports `client_secret_post` when that is the provider's only advertised +shared-secret method, and permits `none` only when no client secret is configured. Malformed or +unsupported authentication-method metadata fails before sending an authorization code or secret. + +### Backup and upgrade + +Back up Keycloak PostgreSQL with `pg_dump` while retaining database and Keycloak image versions in +the backup record. Back up the Agent Teams internal SQLite through the existing coordinated online +backup path. Do not copy SQLite WAL files ad hoc and do not include `hosted-auth-secrets`, Docker +secret files, provider tokens, pairing delivery files or Caddy private keys in an application +support backup. + +The `agent-teams-lifecycle-owner-high-water` volume is live monotonic anti-ABA authority, not an +application rollback artifact. Preserve it across image upgrades and Agent Teams SQLite restores; +never replace it with an older backup, delete it while reusing the same owner authority/key, or +clone it into a concurrent controller. For a host migration, stop the controller and external +owner, copy the latest volume as a separate exact filesystem artifact, verify that copy, and start +only one owner with a new session and a generation strictly above the recorded high water before +issuing a fresh bootstrap. Loss or corruption requires an operator-controlled owner-authority and +proof-key rotation plus a fresh bootstrap; restoring an older high-water directory is not a +recovery path. The `agent-teams-lifecycle-trust` volume is only a derived handoff and is excluded +from backups: recreate it from the protected source while the controller is stopped, and only when +that source still matches the external owner. + +Before upgrading: + +1. take and verify separate Keycloak PostgreSQL and Agent Teams SQLite backups; +2. record current Agent Teams, Keycloak, PostgreSQL and Caddy image versions; +3. test realm/client import and database migration on copies; +4. drain the one backend controller; +5. when upgrading an `agent-teams-data` volume created by an older root-running image, stop the + controller and migrate that volume exactly once before starting the unprivileged image: + + ```sh + docker compose -f docker/docker-compose.yml --profile personal \ + run --rm --no-deps --user root --entrypoint chown \ + agent-teams-personal -R 1000:1000 /data/.agent-teams + ``` + + Use the corresponding `agent-teams-keycloak` service and `keycloak` profile if only that service + image is present. Keep the controller stopped throughout, verify the resulting ownership, and do + not apply this command to the read-only `CLAUDE_DIR` bind mount; + +6. upgrade PostgreSQL only through its supported major-version procedure; +7. start Keycloak and wait for readiness before Agent Teams; +8. prove OIDC login, role mapping, local/global/back-channel logout and restart recovery on a + synthetic workspace. + +Keycloak's `--import-realm` path creates the bundled realm on first boot; it does not reconcile an +already-existing realm in the persistent PostgreSQL database. Treat later realm/client template +changes as an explicit Keycloak configuration migration, review them against the deployed client, +and test them on a restored copy before production. Never delete the Keycloak database merely to +force the import to run again. + +Changing `$HOSTED_SECRETS_DIR/oidc_client_secret` alone does not rotate the credential in an existing +Keycloak realm and will stop new logins. For a planned rotation, drain and stop the Agent Teams +controller while leaving Keycloak, PostgreSQL and Caddy available; regenerate the +`agent-teams-hosted` client credential through the protected Keycloak administration console; +write that exact value to the protected secret source without placing it in shell arguments, +environment variables or logs; then force-recreate the one-shot +`agent-teams-keycloak-secret-init` job while the controller remains stopped, followed by the Agent +Teams controller. This refreshes the persistent read-only handoff rather than relying on a source +mount to remount during application restart. Keep the controller stopped until both sides contain +the same credential and prove a synthetic login immediately afterward. The application still never +uses the Keycloak Admin API during normal operation. + +Rollback restores each database only to its matching application version. Never restore the +internal SQLite authority projection without its monotonic rollback-fence domain. Increment +`AUTH_RESTORE_GENERATION` before starting against restored Agent Teams SQLite. A detected +projection/fence mismatch fails closed, and the new generation invalidates OIDC sessions and login +attempts from the restored snapshot even when the live identity key was retained. + +## Personal reset and access recovery + +The core personal authority already models the reset sequence: drain runtime, revoke device +families and sessions, stage and activate a new keyring, remove old pairing delivery, and publish +one new challenge under a strictly newer reset generation. The HTTP profile does not expose reset. +The operator-only CLI uses a mode-`0600` Unix socket under `/run/agent-teams`; it is never mounted +into Fastify or bound to TCP. Reset invokes the core use case only after a current, exact AR drain +evidence document is present. The local use case also closes the public request gate around the +complete transition; it reopens only after the authority reaches a durable completed state and its +audit append succeeds. A rejected or indeterminate reset stays closed for a local retry rather +than racing old browser work against credential revocation. If the controller exits after writing +the reset intent, personal-mode startup resumes that exact generation before mounting public +authentication; a still-requested stage must revalidate current AR evidence, while later durable +stages continue idempotently. + +The evidence file defaults to `/run/agent-teams/drain-proof.json`, must be a regular mode-`0600` +file, expires within fifteen minutes of observation, and has an exact non-secret shape. Personal +credential reset uses: + +```json +{ + "format": "agent-teams-runtime-drain/v1", + "deploymentId": "deployment_hosted-v1", + "restoreGeneration": 0, + "purpose": "host_reset", + "resetGeneration": 1, + "outcome": "drained", + "evidenceRef": "ar:drain:example-1", + "observedAt": 1750000000000, + "expiresAt": 1750000600000 +} +``` + +Auth-mode reset uses a distinct purpose and binds the proof to the requested target: + +```json +{ + "format": "agent-teams-runtime-drain/v1", + "deploymentId": "deployment_hosted-v1", + "restoreGeneration": 0, + "purpose": "auth_mode_reset", + "targetAuthMode": "personal", + "resetGeneration": 2, + "outcome": "drained", + "evidenceRef": "ar:drain:auth-mode-personal-2", + "observedAt": 1750000000000, + "expiresAt": 1750000600000 +} +``` + +The standalone composition has no runtime-mutation admission, which is sufficient only for +initial pairing. Personal reset refuses missing, stale, mismatched or unclassified evidence. +AR still needs to produce this public evidence document in the production controller integration. + +Local administration examples: + +```sh +docker compose -f docker/docker-compose.yml --profile keycloak \ + exec agent-teams-keycloak node scripts/hosted-auth-cli.mjs users list +docker compose -f docker/docker-compose.yml --profile keycloak \ + exec agent-teams-keycloak node scripts/hosted-auth-cli.mjs users disable usr_example123 +docker compose -f docker/docker-compose.yml --profile keycloak \ + exec agent-teams-keycloak node scripts/hosted-auth-cli.mjs roles set usr_example123 owner +docker compose -f docker/docker-compose.yml --profile personal \ + exec agent-teams-personal node scripts/hosted-auth-cli.mjs workspaces register -synthetic-id +docker compose -f docker/docker-compose.yml --profile personal \ + exec agent-teams-personal node scripts/hosted-auth-cli.mjs \ + workspaces grant usr_example123 -synthetic-id +docker compose -f docker/docker-compose.yml --profile personal \ + exec agent-teams-personal node scripts/hosted-auth-cli.mjs \ + workspaces revoke usr_example123 -synthetic-id +docker compose -f docker/docker-compose.yml --profile personal \ + exec agent-teams-personal node scripts/hosted-auth-cli.mjs personal-reset 1 +docker compose -f docker/docker-compose.yml --profile keycloak \ + exec agent-teams-keycloak node scripts/hosted-auth-cli.mjs auth-mode reset personal 2 +``` + +Role changes become effective after OIDC reauthentication. User disable atomically revokes that +user's active sessions; re-enable never restores them. Workspace registration alone grants no +browser access. Grant and revoke are per immutable `UserId`; workspace disable also deletes its +grants and is immediate for HTTP/SSE authorization checks. The personal reset command emits no +pairing secret; retrieve the new challenge with the separate `pairing-code` command. + +After `auth-mode reset` succeeds, stop the current profile and start only the requested target +profile against the same `agent-teams-data` volume. Do not start both profiles together. Directly +editing `AUTH_MODE`, SQLite, the keyring or the recovery generation is not a reset and remains +unsupported. + +For OIDC outage, keep serving an explicit unavailable result; never enable personal pairing on the +same deployment as a workaround. Recovery is IdP restoration or an explicitly configured local +owner-management CLI operation. A local OIDC identity/session-store or authentication-crypto +failure is also an explicit `503 identity_storage_unavailable`; it is never downgraded to an +invalid or anonymous session, never clears a valid cookie, and never enables pairing fallback. Do +not edit SQLite identities or roles by hand. Unclassified callback, logout and signed +back-channel persistence failures likewise return an operation-specific `503`; logout preserves +the session cookie unless durable local revocation or a known post-revocation provider failure is +confirmed. Personal session, renewal and CSRF verification also return an explicit unavailable +response when the authority projection, rollback fence or keyring cannot make a trustworthy +decision; those failures never become an anonymous pairing screen or rotate credentials. Personal +logout and forget-device likewise clear browser credentials only after the authority store +confirms session or device-family revocation. + +## Proof and current continuation ledger + +Implemented product surfaces: + +- provider-neutral roles, principals, exact permission matrix and fail-closed HTTP classifier; +- durable SQLite migrations and serialized internal-storage worker operations; +- complete personal authority adapter composition, delivery file and Docker-host retrieval CLI; +- cross-store personal-owner preparation before pairing consumption or device/session renewal, + plus exact SQLite authority revision/fence compare-and-swap validation; +- local Unix-socket administration for user listing/disable/enable, OIDC role assignment, workspace + register/disable/list/grant/revoke, drain-gated personal reset and target-bound durable + auth-mode reset; +- generic OIDC discovery/code/PKCE/JWS/claim validation and immutable identity binding; +- opaque OIDC sessions, role snapshots, audit, local/global/back-channel logout and durable replay + prevention with replay consumption and matching session revocation in one SQLite transaction; +- explicit OIDC session-authentication outage propagation, so local identity-store or crypto + failures produce a stable fail-closed `503` instead of masquerading as invalid credentials; +- explicit OIDC callback/logout/back-channel outage responses, including cookie preservation when + durable local logout cannot be confirmed; +- revocation-confirmed personal logout and forget-device responses, with credential cookies + preserved whenever the authority transition fails or is unavailable; +- Fastify cookie/origin/CSRF/role/workspace enforcement with the Compose edge restricted to + Caddy's exact static `/32`, rather than a spoofable Docker-private supernet; +- exact fail-closed legacy route inventory (including the reserved `/api` root), hosted-safe opaque + workspace projection, default-deny per-principal HTTP/global-search admission and path/Git-free + per-principal SSE; +- descriptor-bound OIDC client-secret reads with validated parent-chain ownership/modes, + no-follow/close-on-exec open flags where supported, opened-descriptor `fstat`, bounded reads and + pre/open/post device+inode identity checks; +- bounded no-follow descriptor reads for the deployment-owned identity key, personal keyrings, + pairing delivery and AR drain evidence, including stable private-parent and file-identity + checks; the Docker-host pairing CLI applies the same checks before emitting the one allowed + pairing-code response; +- browser auth gate and in-memory CSRF composition; +- browser local logout, OIDC provider/global logout and personal forget-device controls; +- personal and Keycloak Compose profiles, separate PostgreSQL, Caddy HTTPS, Docker secrets, realm + template and health checks; +- Keycloak secret materialization through private tmpfs-backed realm/config files, with no database, + bootstrap-admin or client credential in the Compose environment, provider environment or + container command line; +- immutable, operator-supplied image digest enforcement for Node, Keycloak, PostgreSQL and Caddy; +- frozen Docker installs copy `pnpm-workspace.yaml` with `package.json`, the lockfile and patches in + both builder dependency stages, so the lockfile's workspace-owned overrides and patched + dependencies are reproducible without weakening `--frozen-lockfile`; the builder also copies + both repository lifecycle-script dependencies before install, preserving the enforced + preinstall/postinstall boundary instead of disabling scripts; +- unprivileged Agent Teams runtime ownership, private UID/GID-bound tmpfs and service-scoped OIDC + secret delivery (including local Compose's read-only file-bind fallback), with an explicit + existing-volume ownership migration step; +- HTTPS routing for the Keycloak realm, account resources and administration console without + applying the Agent Teams browser CSP to Keycloak HTML; +- explicit `AUTH_MODE` standalone admission into the hosted cache-only/read-root path, so Compose + deployments cannot fall back to the legacy ambient filesystem watcher while the canonical + team-lifecycle envelope remains unavailable; + +Current focused proof: + +- [x] generic OIDC tests cover asymmetric signature, issuer, audience, nonce, expiry, durable state + replay, PKCE, back-channel authenticity, rejection of unsupported critical JOSE semantics + and explicit IdP outage/timeout/overload results; provider configuration proof also prevents + custom scopes from removing `openid`, rejects implicit default-owner escalation, validates + URL shape and proves discovery-selected `client_secret_basic`, `client_secret_post` and + public `none` token-endpoint authentication without silently downgrading a confidential + client; +- [x] synthetic generic OIDC Fastify E2E covers authorization code/state/nonce/S256 PKCE, + session-fixation resistance, opaque cookies, durable restart, local role refresh only after + reauthentication, local and signed back-channel logout, logout replay, explicit IdP outage + and the absence of personal-pairing fallback; +- [x] Fastify injection tests cover authentication, CSRF/origin denial, role escalation, + registered-workspace denial, SSE, unknown-route fail closure, canonical request-target + routing and untrusted proxy spoofing; +- [x] the existing personal authority suite covers pairing replay, bounded attempts, device/session + rotation, predecessor grace, forget-device and reset transitions; +- [x] synthetic personal Fastify E2E covers one-time pairing, opaque cookie flags, session-fixation + resistance, CSRF, local logout with device preservation, process restart/device renewal, + drain-gated reset/key rotation, old-credential revocation and forget-device; it also proves a + disabled personal owner cannot authenticate or rotate a device grant; +- [x] personal identity-storage outage proof preserves valid credentials, performs no renewal and + returns an explicit unavailable response instead of silently converting the outage to a + logged-out state; separate cross-store proof rejects an authority/SQLite owner mismatch for + both an active session and device renewal; +- [x] personal authority-storage outage proof preserves both short-session and durable-device + cookies, performs no renewal and returns `503 identity_storage_unavailable`; CSRF crypto or + storage failure likewise returns unavailable rather than misclassifying the request as a + forged token; +- [x] OIDC session-authentication outage proof normalizes local identity-store/crypto exceptions, + returns `503 identity_storage_unavailable` for status and protected routes, preserves the + credential cookie and never converts the outage into an anonymous session or pairing mode; +- [x] OIDC callback and signed back-channel persistence outages return operation-specific `503` + responses rather than invalid-credential/token errors, while an unconfirmed durable logout + returns `503 oidc_logout_unavailable` without clearing the session cookie; +- [x] personal logout/forget-device failure injection proves an unavailable authority transition + is not reported as success and cannot clear the browser's session or device credential; +- [x] production random-ID generation is regression-tested against every hosted identity parser; + this closes the base64url `-`/`_` first-character failure that the repeated personal E2E + exposed. +- [x] the OIDC secret-file boundary accepts only an exact, read-only file beneath a non-linked + `/run/secrets` directory that the application identity cannot modify; arbitrary external + secret paths retain strict process ownership and mode-`0600` enforcement; +- [x] identity-key, personal-keyring, pairing-delivery and drain-evidence reads are bounded and + descriptor-bound; focused replacement, weakened-mode, oversized-input and CLI symlink tests + prove path races cannot redirect a sensitive read or echo pairing material on failure; +- [x] OIDC session and login-state keyed hashes are bound to `AUTH_RESTORE_GENERATION`; synthetic + rollback proof advances the generation while retaining the repository and identity key and + confirms restored cookies and pending authorization transactions cannot authenticate; +- [x] native SQLite and composition tests cover atomic audited OIDC/personal mode transitions, + rollback on audit failure, monotonically fenced generations, revocation of both credential + stores, HTTP fail closure, restart-only staged-keyring recovery and identity-key rotation in + both directions; +- [x] an opt-in sandbox Chromium session E2E consumes the production `Secure`, `HttpOnly`, + host-only, `Path=/`, `SameSite=Strict` cookies, proves session fixation and pairing replay + resistance, enforces CSRF, persists the durable device grant across a real browser-process + restart, rotates both cookies on renewal, rejects both old cookies after host reset, pairs + again and clears both cookies on forget-device. The pairing codes cross into the browser + process over a private stdin pipe, never argv, environment, URL, storage or output; +- [x] the same opt-in Electron Chromium harness builds and loads the real `HostedAuthGate` React + component in a headless `WebContents`, types the one-time code into its password input, + renders protected owner content, invokes local logout and proves device-backed + reauthentication, invokes forget-device, returns to the anonymous pairing form and verifies + the code never entered URL, local storage, session storage or child output. Before browser + traffic starts, the harness physically copies the required native package graph to a + disposable tree, removes copied bindings, rebuilds only that tree for Electron 40.10.0 ABI + 143, and loads `better-sqlite3`, `node-pty` and `ssh2` there. It fingerprints the producer + workspace bindings before and after and reopens them under Node ABI 137, so the proof cannot + mutate the normal Node dependency tree in place; +- [x] production-profile static proof requires Caddy health, including durable local-root + materialization, before Agent Teams starts and loads the OIDC trust root; +- [x] production-profile static proof admits exactly one controller as the read-only consumer of an + external lifecycle-owner socket, requires a launcher-issued bootstrap and a mode-`0400` + trust-anchor handoff, and rejects every in-Compose owner candidate or second consumer; +- [x] the hosted-v1 Compose E2E uses one marker-owned fake-runtime owner and synthetic OIDC server + inside a bounded, network-isolated fixture. It proves the production wire/client behavior and + lifecycle-trust initializer path and cleanup harness only; it is not evidence that a + production external AR owner was deployed, and its generated bootstrap or key must never be + reused outside that fixture; +- [x] an opt-in Node-environment real-Keycloak/PostgreSQL harness now provisions an isolated Docker + network from exact Keycloak 26.3.2 and PostgreSQL 17.5-alpine immutable image references on + controller-owned ports 18080/18443, imports disposable member and owner accounts, preserves + provider cookies by domain/path/expiry, follows provider redirects until the Agent Teams + callback while checking state and S256 PKCE, proves two-workspace principal isolation, drives + local/global/back-channel logout and narrowly removes only its randomized containers and + network. Random database, admin, client and user credentials live only in mode-`0600` files + under the disposable test directory; their values never enter Docker arguments, the host + process environment, URLs or test output; + +Run the dedicated verifier on a Linux x64 host with the already-installed Node 24 producer +dependency tree and the exact project Electron binary. Do not run an in-place Electron rebuild: +the test creates, rebuilds and removes its own isolated dependency tree while leaving the producer +workspace on Node ABI 137: + +```sh +HOSTED_BROWSER_E2E_ELECTRON=/absolute/path/to/node_modules/electron/dist/electron \ + node node_modules/vitest/vitest.mjs run \ + test/features/hosted-access/HostedPersonalAuth.integration.test.ts \ + -t 'Chromium' +``` + +Run the real Keycloak proof only with reviewed immutable multi-platform image references and a +Docker daemon dedicated to synthetic tests: + +```sh +HOSTED_KEYCLOAK_E2E_KEYCLOAK_IMAGE=quay.io/keycloak/keycloak:26.3.2@sha256:REPLACE_WITH_AUDITED_DIGEST \ +HOSTED_KEYCLOAK_E2E_POSTGRES_IMAGE=postgres:17.5-alpine@sha256:REPLACE_WITH_AUDITED_DIGEST \ + node node_modules/vitest/vitest.mjs run \ + test/features/hosted-access/HostedOidcAuth.integration.test.ts \ + -t 'Keycloak hosted authentication' +``` + +The final production image build contains a mandatory Node-ABI probe that opens +`better-sqlite3` and loads `node-pty` and `ssh2`. With four reviewed multi-platform manifest +digests and a disposable hosted `CLAUDE_DIR`, run: + +```sh +CLAUDE_DIR=/absolute/path/to/a/disposable/hosted-sandbox/.claude \ +NODE_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_NODE_DIGEST \ +KEYCLOAK_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_KEYCLOAK_DIGEST \ +POSTGRES_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_POSTGRES_DIGEST \ +CADDY_IMAGE_DIGEST=sha256:REPLACE_WITH_AUDITED_CADDY_DIGEST \ + docker compose -f docker/docker-compose.yml --profile keycloak \ + build --no-cache agent-teams-keycloak +``` + +An image build that cannot load any of those three production modules fails in the `prod-deps` +stage. Keep the full build log and immutable digest inputs as the ABI/artifact evidence. + +Required proof or remaining implementation before this contract is complete: + +- [x] run the personal injection E2E against native SQLite on Node 24, including durable restart, + device renewal, reset, key rotation, replay resistance and forget-device; +- [x] add explicit SSE authentication injection coverage; +- [x] add browser-level cookie behavior proof with the opt-in headless Electron Chromium session + harness in `HostedPersonalAuth.integration.test.ts`; +- [x] pass native internal-storage migration, restart, rollback-fence, audit and durable + back-channel replay tests; these tests now fail instead of silently falling back or skipping + when the native SQLite binding is unavailable. Recovery also distinguishes the legacy + workspace table from an already-current v16 table restored with a historical + `user_version`, preserving the current table rather than selecting its removed + `workspace_id` column; +- [x] add sandbox-only Chromium network/session E2E for personal mode, including browser-process + restart, forget-device and host reset recovery; +- [x] add rendered hosted-browser UI E2E for the `HostedAuthGate` pairing, logout and forget-device + controls, using the real component bundle, Chromium DOM/events, production HTTP facade and + native SQLite sandbox; +- [ ] record an authorized sandbox deployment with the production external AR owner as the sole + lifecycle owner, one controller consumer, launcher-issued bootstrap, restart generation/session + advance and persisted high-water fence. The bounded fake-runtime Compose fixture is explicitly + insufficient for this deployment proof; +- [ ] run the implemented sandbox-only real-Keycloak E2E for member/owner mapping, + local/global/back-channel logout and replay, application restart, IdP outage and recovery. + The continuation worker could render both Compose profiles but its Docker API socket denied + container access, so the opt-in test remains intentionally unclaimed until an authorized + synthetic Docker runner records a passing result; +- [x] add the local owner/workspace/reset CLI, with new role snapshots taking effect only after + reauthentication and reset remaining absent from the public HTTP surface; +- [ ] wire the desktop local-owner principal to the common immutable `UserId` and shared + authorization/application-use-case context. The desktop HTTP server currently remains + correctly unmounted from hosted auth, but desktop identity convergence is not implemented. + The current desktop entrypoint only propagates the installation `clientId` store path into + child environments; no desktop IPC or shared application-use-case boundary accepts a + `HostedPrincipal`, `UserId` or authenticated `QueryContext`. Completion therefore requires + controller-owned changes to those shared use-case/IPC contracts and their desktop adapters, + not a synthetic principal created solely inside `hosted-access`; +- [x] implement drained, audited durable auth-mode reset; direct `AUTH_MODE` changes remain refused, + while the local operation requires target-bound AR evidence, atomically invalidates both + credential stores, blocks serving and completes crash-safe key rotation on target restart; +- [x] replace the standalone `runtimeDrained: () => true` placeholder with the AR public + drain-evidence file port and make personal reset fail closed without exact current evidence; +- [ ] integrate the production AR controller as the sole producer of the drain-evidence document; + no local CLI or browser operation may fabricate or bypass the proof; +- [x] enforce Docker deployment-level single-controller admission and shared auth runtime-root + exclusion: both profiles use one explicit container name and the same authority volume, so + a second profile, replica or Compose project on the same daemon conflicts before serving, + while the SQLite mode claim independently rejects cross-mode startup; +- [x] treat any explicit `AUTH_MODE` standalone process as hosted before constructing ambient + services, validate the exact administrator-mounted `CLAUDE_ROOT`, retain unavailable + canonical team-lifecycle reads without an AR envelope, and start cache-only rather than the + legacy filesystem watcher; +- [ ] integrate the desktop lifecycle with the same runtime-root admission protocol; no desktop + process may mount a hosted deployment's authority/runtime root concurrently; +- [x] keep the exact hosted-standalone legacy HTTP inventory synchronized as routes are added. The + executable inventory registers the production route composition, freezes all 79 current + legacy method/path pairs, and proves the exact server-side permission, CSRF and workspace + decision for every route; additions fail until the inventory explicitly classifies them; +- [x] implement registered-workspace attribution before re-enabling notification/todo SSE. The + bounded hosted event bridge admits file changes and error notifications only with a current + active workspace registration, correlates path-free todo events only after the session has + resolved to exactly one registered workspace, expires and bounds that correlation state, + suppresses ambiguous/team/aggregate events, and still rechecks the current principal session + and grant during every SSE delivery. Other denied browser APIs remain fail-closed; +- [x] enforce canonical team-to-workspace attribution for both hosted task-board routes at the + hosted authorization boundary. Resolution uses one bounded, revision-pinned canonical + lifecycle snapshot, rejects ambiguous/unresolved teams, and then requires the authenticated + user's current workspace grant. The outer HTTP host accepts only a complete + route-registration contribution and refuses malformed or non-hosted task-board mounting; +- [ ] provide the production AR-owned task-board page source, mutation-admission port and + authenticated human `QueryContext` factory, then mount the task-board browser client/page + through that seam. Standalone intentionally supplies none of those capabilities, so both + routes remain unmounted there rather than fabricating runtime mutation authority; +- [x] build the desktop renderer/main/preload bundles and the standalone server bundle, including + emitted `dist-standalone/assets/internal-storage-worker.cjs`; +- [ ] build the final production image and record the mandatory Node-ABI `better-sqlite3`, + `node-pty` and `ssh2` probe from the `prod-deps` stage; +- [x] retain the current provider-neutral Node crypto and explicit cookie boundary after reviewing + `openid-client@6.8.4` and `@fastify/cookie@11.1.2`: the implemented surface is smaller than + either package integration, is covered for the required algorithms/claims/cookie attributes, + and avoids introducing a second session owner. Neither package is imported and no + manifest/lockfile mutation is required; +- [x] pass the pinned TypeScript compiler, changed-source type-aware lint, changed-file fast lint, + Prettier check, focused tests, team-provisioning architecture guard, both Compose profile + renders and `git diff --check`; +- [x] pass the full feature architecture guard. Hosted standalone now requests a hosted-only scope + through the existing grandfathered internal-storage public composition factory; the concrete + narrow worker backend remains private to that feature, while the process-specific alias is + composed only after hosted root/identity admission in the outer HTTP host; +- [ ] pass full-tree type-aware lint and full tests. The first full changed-file lint invocation + exhausted its 4 GiB heap; an 8 GiB retry proved changed production source but surfaced + project-service exclusions for the Docker Vite config and CLI script. Exact type-aware lint + now passes all 13 owned hosted-auth test files with warnings only: synchronous promise-port + doubles carry documented test-only `require-await` exceptions, redundant assertions were + removed, and the browser cookie/Compose proof no longer relies on a complex parser or unsafe + regular expression. The latest full Vitest run reached 16 failed and 1,567 passing files, + with 45 failed, 16,731 passing and 72 skipped tests. This branch subsequently fixed and + focusedly re-proved all three historical hosted-workspace schema upgrade failures from that + run. + Remaining branch-attributable evidence assertions outside this ownership receipt still need + to update the phase-0 environment census for `AUTH_PUBLIC_ORIGIN`, `AUTH_DATA_DIR`, + `AUTH_CONTROL_SOCKET` and `TRUSTED_PROXY_CIDRS`; accept the hosted-access `main`/`renderer` + architecture added by the final contract; expect internal-storage schema version 16 rather + than 11 in the process-ownership migration test; and regenerate the standalone worker-entry + artifact evidence. Other failures were unrelated baseline/runtime issues, including + unavailable `/bin/zsh` and MCP dependencies, post-teardown renderer activity, test timeouts + and an overlong temporary IPC socket path. The source-size guard currently reports only two + unrelated legacy baseline caps that must move downward; +- [x] perform a P0-P2 security self-review covering replay, state/nonce/PKCE, cookie scope and + rotation, fixation, back-channel authenticity, trusted proxy handling, role escalation, + SQLite migration/rollback, exact route fail-closure and secret leakage; +- [ ] prove the repaired desktop Electron preload contract in the controller-owned runtime, with + hosted auth unmounted, and confirm no desktop/runtime provider environment receives hosted + auth secrets. The build now emits the CommonJS preload as + `dist-electron/preload/index.cjs` beneath the root `"type": "module"` package and the main + window loads that exact artifact, preserving `window.electronAPI` and the desktop `App` + composition instead of mounting `HostedAuthGate`. Producer execution of Electron is + intentionally forbidden for this remediation, so an authorized controller must record the + runtime assertion before this proof can be checked off. + +No claim of full personal or OIDC E2E completion is valid until every unchecked item has +authoritative evidence. diff --git a/docs/hosted-opencode-downstream-policy.md b/docs/hosted-opencode-downstream-policy.md new file mode 100644 index 0000000000..ed292325c2 --- /dev/null +++ b/docs/hosted-opencode-downstream-policy.md @@ -0,0 +1,129 @@ +# Hosted OpenCode downstream policy + +- Decision date: 2026-08-22 +- Status: temporary Core v1 compatibility policy +- Upstream: `anomalyco/opencode` +- Downstream: `777genius/opencode-anomaly` +- Functional patch: downstream PR #1 +- Reproducible artifact pipeline: downstream PR #2 + +## Decision + +Agent Teams does not maintain an independently evolving OpenCode distribution. The downstream is a +small, reviewable patch queue over a named upstream release. Upstream remains the source of product +features, fixes, and security updates. The downstream exists only for the hosted approval contract +that upstream OpenCode does not currently expose atomically and for the build evidence needed to pin +that contract to exact executable bytes. + +The current Core v1 candidate uses upstream `v1.18.4` at +`49c69c5ed3ccf706b61b3febb43c8aaff7f8325e`, functional source +`476b667c385210b19fbd15bcb57456cacb0ae9e7`, and reviewed patch SHA-256 +`dbd8b2c1eda38043e3bfc9e2b809f4ef393fa075349ed219109a7deaca0c590e`. The patch changes 17 +files. This pin is an acceptance baseline, not permission to stay on OpenCode `v1.18.4` indefinitely. + +## Why stock OpenCode is insufficient for this flow + +Stock OpenCode can list pending permission requests and reply to a request by ID. That is adequate +for a single trusted in-process operator, but not for the Agent Teams hosted flow where observation, +browser authorization, owner delivery, process replacement, and recovery are separate steps. + +The hosted owner must reject a decision when any observed authority changed between read and write: + +- runtime instance; +- config generation; +- session incarnation; +- request incarnation; or +- permission digest. + +An external `list -> reply(requestId)` sequence cannot make those checks and the mutation atomic. +The request can be cancelled, replaced, or recreated with the same visible ID after the list. A +blind retry after a transport failure can also duplicate or misattribute an effect. Downstream PR #1 +adds the conditional approval v2 endpoints and exact schema/fencing needed to fail closed in those +cases. + +If upstream adds an equivalent atomic conditional-reply contract, or exposes a supported plugin +boundary that can implement it with the same authority guarantees, the functional patch must be +removed and Agent Teams must return to stock OpenCode artifacts. + +## Why the artifact pipeline is separate + +Downstream PR #2 does not add runtime behavior. It proves that the reviewed source produces the +bytes installed by Agent Teams: + +- exact base, source tree, and regenerated patch identity; +- two independent builds with a reproducibility comparison; +- native verification on Linux x64/arm64, macOS x64/arm64, and Windows x64; +- a manifest binding every archive and executable SHA-256 to the source; and +- a protected prerelease that remains `productionEligible=false` until no-fake acceptance passes. + +This PR can be replaced by another immutable container or release pipeline, but the source-to-binary +binding cannot be removed. A mutable tag, latest download, or unverified locally built binary is not +acceptable for approval delivery. + +The current non-production candidate is `v1.18.4-agentteams.1`, produced by hardened workflow run +`32579388230`. Its manifest SHA-256 is +`99c5fa1dbc52ea3512cffa48f10d444c9fb7029171129d176ad4c85fa237b8cb`. + +## Upstream update policy + +The downstream must continuously follow upstream instead of accumulating an unbounded fork: + +1. Track upstream `dev` and stable releases in the fork. +2. After Core v1 no-fake acceptance, immediately port the 17-file patch to the newest supported + stable OpenCode release before production promotion. +3. For every later stable or security release, run the port and compatibility lane. Security fixes + take priority over feature work. +4. Regenerate the patch from the new exact base; never merge unrelated fork history into the + product pin. +5. Re-run focused approval tests, strict parsing tests, two-build reproducibility, all native + verifiers, artifact installation, and sandbox actual-owner E2E. +6. Update `opencode-hosted-runtime.lock.json` only from the resulting signed-off manifest and commit + the exact digest change. +7. Keep only the current supported patch line and short-lived migration branches. Do not add general + OpenCode product customizations to this downstream. + +The daily `OpenCode upstream tracker` workflow makes this policy observable. It compares the +immutable downstream base with GitHub's latest non-prerelease upstream release and maintains one +tracking issue while the pin is behind. The issue is a port trigger, not permission to update the +lock from unverified upstream binaries. + +Current drift and the next verified port are tracked in +[`agent-teams-ai#471`](https://github.com/777genius/agent-teams-ai/issues/471). This issue must remain +the single operational follow-up instead of relying on memory or turning the downstream into a +permanent fork. + +A downstream release is stale and production promotion stays closed when any of these is true: + +- a newer upstream security release has not been assessed; +- the patch no longer applies cleanly; +- upstream changed permission/session semantics without a compatibility decision; +- reproducibility or a native verifier fails; or +- the product lock, manifest, executable digest, and owner admission disagree. + +## Merge and activation order + +1. Review the functional patch in OpenCode PR #1. +2. Build and verify its exact source through PR #2. +3. Pin the produced non-production artifacts in Agent Teams PR #252. +4. Pin the same source and executable digest in orchestrator PR #44. +5. Run one new sandbox-only no-fake flow: + `request -> durable pending -> authenticated browser decision -> owner delivery -> reconciliation`. +6. Prove restart, stale authority, replacement, ambiguous settlement, cross-team isolation, and + cleanup cases. +7. Only then change the three product/orchestrator/manifest production capability gates together. + +The OpenCode PRs are therefore not independent product features that should merge early. PR #1 is +the required compatibility delta for the current contract. PR #2 is required supply-chain evidence +for the current release method. Both remain replaceable when upstream or the release architecture +provides equivalent guarantees. + +## Removal criteria + +Delete the downstream dependency when all of the following are true: + +- an upstream release provides equivalent atomic observation and conditional reply fences; +- Agent Teams adapters pass the same contract and adversarial tests against stock OpenCode; +- the no-fake sandbox E2E passes with a stock, immutable upstream artifact; and +- product and orchestrator pins are migrated in one coordinated change. + +Until then, treat the downstream as a bounded compatibility adapter, not a permanent product fork. diff --git a/docs/hosted-web-core-v1-scope-lock.md b/docs/hosted-web-core-v1-scope-lock.md new file mode 100644 index 0000000000..08b2bc71fb --- /dev/null +++ b/docs/hosted-web-core-v1-scope-lock.md @@ -0,0 +1,342 @@ +# Hosted Web Core v1 Scope Lock + +- Decision date: 2026-07-30 +- Status: accepted product-scope constraint +- Applies to: hosted-web planning and implementation after PR #252 +- Does not do: authorize a phase, worker, merge, deployment, or product mutation + +This document narrows the hosted-web release to one usable, secure core. It prevents historical +full-parity plans from expanding the release and prevents later workers from deleting or rebuilding +already implemented seams merely because production composition is not finished. + +## Authority and conflict resolution + +Use these sources for different questions: + +1. Repository safety and architecture guardrails remain authoritative. +2. `docs/hosted-web-phases/START_HERE.md` and `EXECUTION_INDEX.json` remain authoritative for current + execution status, ownership, and admission. +3. This file is authoritative for Core v1 product scope, simplifications, and preservation decisions. +4. `docs/hosted-web-e2e-completion-plan.md` remains design reference where it does not conflict with + this scope lock. +5. `docs/hosted-opencode-downstream-policy.md` defines why the temporary OpenCode downstream exists, + how it follows upstream, and when it must be removed. +6. Phase packets, `.codex-handoff`, and `docs/research/hosted-web` remain retained evidence. They are + not current product scope or execution authority unless the live router explicitly activates them. + +If an older table says that full TeamsAPI parity, automatic adoption, browser-local command receipts, +per-session subscription leases, or full recovery descriptors for every mutation are required in v1, +this scope lock wins. + +Do not edit the current live-head sync router to apply this decision. Regenerate a future executable +packet from this scope only after the router admits that work. + +## Core v1 release + +Core v1 must provide one complete browser workflow: + +1. deploy the supported production profile; +2. pair and authenticate a trusted browser; +3. select only a registered workspace through opaque identity; +4. list and inspect teams; +5. create and configure a draft with its initial roster; +6. prepare, launch, observe, reconnect, stop, and safely resume after a complete supported container + restart; +7. create, assign, update, and move tasks through the core Kanban flow; +8. send and receive team messages; +9. inspect bounded runtime status, logs, and failure diagnostics; +10. answer an approval when a supported provider operation requires an operator decision; and +11. log out, forget the current device, or reset access from the host. + +Every advertised action must work through the real hosted composition and have route/client +conformance plus focused contract or integration proof. Real-browser E2E validates the complete +workflow and the critical security, recovery, realtime, and process-ownership boundaries; it is not +duplicated once per action. An incomplete capability stays unadvertised and unmounted. + +The release does not require every historical Electron team screen or every TeamsAPI method. +Desktop behavior and shared feature code remain supported and tested even when their hosted +integration is deferred. + +## Deferred hosted expansion + +The following are not Core v1 release gates: + +- full change-review/read/apply UI; +- attachments and rich preview lifecycle; +- task comments, relationships, and clarification beyond the core task workflow; +- live member add/replace/remove/restore/restart/skip after initial team creation; +- soft-delete restore, permanent delete, and identity-repair UI; explicit draft discard may remain; +- cross-team administration; +- automatic startup adoption or repair of legacy team identity; +- a bundled Keycloak deployment or release-gated Keycloak integration; +- hosted terminal, WebSocket transport, Centrifugo, or a terminal daemon; +- multiple hosted writers, horizontal replicas, or multi-tenant isolation. + +Existing implementations of these capabilities are not dead code. Preserve their public feature +boundaries, tests, desktop adapters, and reusable core. Do not production-compose or advertise the +deferred hosted facet until a later scope decision and focused E2E gate promote it. + +## Preservation map + +The baseline below distinguishes implemented assets from production readiness. + +| Existing asset | Decision | Core v1 use | +| ------------------------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- | +| Hosted lifecycle read contracts, route, composition, and renderer list | `KEEP` | Continue the list/detail vertical slice; do not replace it with another hosted facade. | +| `ReadOnlyWorkspaceManifestAdapter` and workspace identity/grant contracts | `KEEP` | Use for registered workspace selection and read admission. | +| `application-command-ledger` core, storage, and current task-create wiring | `KEEP` | Reuse for HTTP retry/status where useful; do not replace it. | +| Lifecycle/runtime durable descriptors for launch, cancel, stop, and recover | `KEEP` | These are Tier B external workflows and retain effect recovery. | +| Hosted team-task-board contracts, routes, policies, and tests | `KEEP` | Compose only the Core v1 task operations; retain the broader surface unadvertised until promoted. | +| `hosted-access` contracts/core | `KEEP` | Compose pairing, device, session, logout, forget-device, and host reset first. | +| `TeamIdentityFileStore`, `TeamDirectoryLifecycleAdapter`, backup compatibility | `KEEP` | Reuse as identity/import infrastructure; do not wire automatic startup mutation or repair. | +| Roster adoption and identity reconciliation primitives | `KEEP` | Preserve stable IDs and read diagnostics; mutation is explicit and offline when later exposed. | +| Review, attachment, member recovery, destructive, and cross-team feature code | `KEEP` | Preserve desktop/shared behavior; hosted production composition is deferred, not deleted or re-created. | +| `.codex-handoff` and hosted research/evidence trees | `KEEP` | Retained historical evidence; do not bulk-delete, rewrite, or require every new worker to read all of it. | + +Before changing an existing asset, inspect whether it is production-composed, exported only, or +test-only. “Not production-composed” means integration remains; it is not permission to duplicate +the feature or remove its tests. + +## Two mutation tiers + +Do not apply the full external-effect recovery protocol to every mutation. + +### Tier A: local transactional mutation + +Use this tier when the mutation is fully app-owned, commits in one SQLite transaction or one +revision-checked local write, and cannot leave an ambiguous external effect. + +Required: + +- validated input and authorization; +- expected revision or equivalent conflict guard; +- transaction/atomic write; +- typed result and safe retry behavior. + +The existing application command ledger may be used for HTTP idempotency or command-status lookup. +Tier A does not require an `EffectDescriptor`, attempt lease, compensation saga, HMAC descriptor +catalog, or operator recovery state unless a concrete external ambiguity proves the need. + +### Tier B: durable external workflow + +Use this tier for process launch/cancel/stop/recover, provider/runtime delivery, destructive +filesystem effects, or another operation where a crash or lost response can duplicate or orphan an +external effect. + +Tier B keeps the full durable command descriptor, evidence, effect-recovery classification, +stable workflow reference, and explicit `operator_required` outcome when absence cannot be proven. + +Approval actual-owner admission is a two-generation lifecycle. The first owner generation may +publish only launcher-signed `provisioning` or `restart_required` state. Product approval routes +remain unmounted in both states. A later owner generation may publish `active` only when the same +launcher-signed lifecycle admission binds the exact approval snapshot SHA-256 digest, approval +generation, and current owner generation. Product must never derive ingress authority from an +owner-writable workspace or `.claude` JSON file. The active snapshot routes authority by its +immutable `partition.teamId`; there is no process-wide fixed team. + +The cross-repository lifecycle launch wire carries explicit `toolApprovalMode: 'auto' | 'manual'`. +`manual` is required to create pending hosted permission requests; legacy create callers default to +`auto`, while persisted v2 runtime plans require the field explicitly. Approval decision settlement +uses terminal `operator_required` plus a stable `reconciliationRef` when provider acceptance is +ambiguous. That record is neither automatically retried nor acknowledged; only the bounded +reconciliation operation may resolve it. Durable reconciliation is bound to the exact workspace, +authority and restore generations, team/run partition, approval generation, delivery generation, +provider delivery ID, and stable reconciliation reference. `delivered` closes the outbox; +`not_delivered` is the only outcome that authorizes a new pending lease and retry. +Before crossing the provider boundary, storage atomically moves the exact leased delivery generation +into `operator_required`, pins its stable reconciliation reference, and extends the fenced lease +beyond the maximum owner exchange timeout. A crash, timeout, or unavailable response therefore +remains reconciliation-only even after lease expiry; it never returns to normal delivery claim. +The explicit reconciliation call is unavailable while that boundary lease is still open, preventing +`not_delivered` from racing an in-flight provider call. + +Promoting a command from Tier A to Tier B requires a named crash/response-loss scenario. Do not +promote an entire feature preemptively. + +## Legacy identity import + +Core startup may scan, classify, and show diagnostics for legacy teams, but it must remain read-only. +It must not publish identity files, repair SQLite rows, rename directories, or silently attach a +directory by name. + +Any later import/adoption flow is explicit and operator initiated: + +1. stop the hosted controller and prove no active or unclassified runtime owns the team; +2. scan and show a preview with collisions and ambiguity; +3. require explicit confirmation; +4. perform the existing identity protocol; +5. restart and reconcile. + +This keeps the already implemented identity machinery useful while removing automatic startup +mutation and its recovery UI from Core v1. + +## Realtime and browser recovery + +Core v1 uses HTTP queries plus one authenticated SSE stream with durable cursors and bounded +snapshot/resynchronization. It does not introduce a WebSocket abstraction, Centrifugo, or a +transport-switching adapter before a second transport is required. + +This is still realtime: SSE pushes browser-visible lifecycle, task, message, readiness, and bounded +invalidation/reference events as they happen. Large log, tool-activity, review, and file payloads are +fetched by paginated HTTP query when their view is open. + +Build on the existing `coordination-events` journal, replay, snapshot, and cursor contracts and keep +their tests. Do not replace them with a second event store or transport-neutral framework. Hosted +work adds only the missing authenticated SSE adapter, composition, and browser reconciler. + +Do not add a durable per-session subscription lease or tracking flag for Core v1. Correctness must +not depend on which browser panel is visible. + +Command recovery is server owned. The browser may query recent/non-terminal commands for the +authenticated operator and action. It must not persist a command body, prompt, idempotency key, +pending-command locator, or replayable receipt in `localStorage`. + +Realtime release proof must cover snapshot-to-stream handoff, duplicate and gap handling, retention +expiry, reconnect, controller and complete production-container restart, slow consumers, and bounded +full resynchronization. A lost SSE connection may delay the UI, but it must not lose canonical state +or require a process restart. + +## Verification without matrix explosion + +Core v1 reduces duplicated harnesses and cross-product combinations, not behavioral coverage. + +### Deterministic pull-request gates + +- Keep focused domain, application, persistence, HTTP, SSE, security, and process-ownership tests. +- Exercise every supported provider through deterministic adapter, capability, parser, launch, + cancellation, and cleanup tests below the real process-supervisor boundary. +- Keep existing desktop regression gates. Changes to shared application, provider, parsing, + persistence, IPC, or runtime code add focused desktop regression tests; hosted work must not delete + or bypass the existing desktop suite. +- Prove capability conformance from server manifest to registered route, client facet, rendered + control, and test ID. Negative tests prove that an unavailable capability registers no route, + listener, effect, or control. + +### Real-browser Core v1 gates + +Use independently runnable suites against the built production composition and only newly created +sandbox projects. A shared harness may reuse the built deployment, but suites must not depend on +execution order or state left by another suite. + +The minimum proof groups are: + +1. pairing, session renewal, logout, forget-device, host reset, Origin, CSRF, cookie failures, + concurrent two-tab renewal of one device generation, lost rotated-cookie response, bounded + predecessor grace, post-grace replay-family revocation, fixed public authority and Host policy, + spoofed `Forwarded`/`X-Forwarded-*` rejection, and denial of direct access to the private app + listener; +2. create, prepare, launch, progress, cancel/stop, and provider failure, including anchor failure, + double-fork, ignored `TERM`, parent or main-process exit before descendants, `TERM`/`KILL` + escalation, descendant drain, PID-reuse refusal, ambiguous residual ownership, hard container + replacement, and zero surviving provider processes; +3. Tier B response loss before or after acceptance, server-owned recent/non-terminal lookup after + reload or logout/re-login, stable workflow reference, mismatched-body conflict, and no duplicate + external effect; +4. SSE snapshot handoff, disconnect/reconnect, reload, retention resync, and controller plus complete + production-container restart; +5. core task/Kanban and messaging flows, including revision conflict and an external writer; +6. workspace registration and containment, including traversal, stale-grant, and out-of-sandbox + rejection plus concurrent parent/final symlink, registration-root, rename, and bind-mount swaps + across file, Git, and provider-spawn effects with zero effect outside the sandbox marker; +7. capability degradation and recovery with no hidden desktop listener or unavailable browser call; +8. real lane-scoped runtime ingress, proving credential absence from the provider process tree, + replay protection, rotation and revocation, fixed run/lane/provider scope, wrong-body rejection, + and no cross-lane callback impersonation; and +9. provider approval prompt, allow, deny, timeout, reload recovery, and two-tab exactly-once answer + safety. + +These groups organize evidence; they do not replace the Core rows in the master plan's +`Real end-to-end verification design`. The same suites must retain stable TeamId and WorkspaceId +across failed-run retry and full restart, fresh mount generations and stale-reference rejection, all +advertised composite lane modes and ordering/partial-failure gates, verified external-file +attribution, app-exclusive/cooperative/uncoordinated writer rules, provider-mediated observed +outcomes, quiescent revalidation without stale replay, bounded logs and failure diagnostics, +independent readiness/admission failures, and built-artifact boundary checks. Expansion-only rows +remain deferred. Failure and chaos cases that prove these retained Core rows remain mandatory; shared +fixtures may remove duplication, but a happy path cannot replace an adversarial interleaving. + +An advertised action requires focused contract/integration coverage and must be exercised by the +smallest relevant browser workflow. It does not require a separate browser test file or a complete +provider/topology/failure cross-product. + +### Live provider and desktop release gates + +- Before release, run one sandbox-only live smoke for every supported provider, including Claude, + Codex, Gemini, and OpenCode when advertised. Every smoke proves + `create -> launch -> ready -> task -> message -> stop`; one smoke per provider family is + insufficient because authentication, flags, bootstrap, parsing, task/message delivery, and + shutdown differ. +- Live provider smoke is manual or scheduled and is not required on every pull request. Pull requests + use deterministic provider fixtures so all providers remain covered without flaky external calls. +- Run the project-defined full desktop regression and packaging gates before release. Do not create a + second hosted copy of the desktop matrix. + +The following are not acceptable simplifications: one provider standing in for a family at release, +one order-dependent browser mega-test, mocked HTTP/SSE at the browser boundary, removal of existing +desktop regressions, or weakening `coordination-events` replay and recovery coverage. + +## Small operational surface, retained recovery proof + +Core v1 does not add a backup UI, scheduler, background backup service, Prometheus exporter, or broad +load-testing platform. + +It still ships a stopped-stack operator backup/restore path and runbook. Proof must reject a running +controller and partial archive, verify the manifest, checksums, and SQLite integrity, restore only +into an empty target, and complete one production-shape restore drill. After integrity validation and +before service exposure, restore must rotate boot, event, browser device/session, and runtime +authority, establish fresh mount bindings, and never reuse backed-up sessions or pairing tickets. +Archive creation must publish its ready marker last. Disk exhaustion, interruption, or power loss +must leave the incomplete archive unadvertised and preserve the previous known-good recovery point. + +Minimum observability remains structured redacted logs with request and diagnostic IDs, live/ready +health endpoints, bounded log retention, owned-process leak evidence, and one bounded reference-scale +reconnect/lifecycle benchmark. These are release diagnostics, not a new metrics platform. + +## Authentication and deployment + +The built-in Core v1 path is personal pairing: + +- a one-time pairing code authorizes creation of a durable, hashed device family; +- the browser stores only Secure, HttpOnly cookies managed by the server; +- logout ends the current session; +- forget-device revokes the current device family; +- host reset advances the reset generation and revokes all device families. + +Generic OIDC remains an extension seam, not a Keycloak-shaped domain dependency. Keycloak, if added, +runs as a separate service or optional Compose profile and connects through standard OIDC. Core v1 +does not bundle, administer, back up, or require Keycloak. + +The one release-gated deployment profile is Docker Compose with Caddy as the TLS edge, one private app +instance, one app-state volume, explicit workspace mounts, and SQLite. Nginx and Traefik may be +documented as compatible reverse proxies later, but they are not separate Core v1 E2E matrices. + +## Documentation and implementation rules + +- Do not use the old `24k-40k fresh branch` estimate as remaining-work truth. Re-estimate from the + live PR head after this scope is integrated. +- Full-parity tables in the master plan are retained as expansion inventory, not Core v1 acceptance. +- Do not delete historical evidence to make the plan shorter. New workers should read this scope lock + and the active packet, then open historical evidence only for the exact decision they need. +- Do not create a new `.codex-handoff` file unless the active execution packet explicitly requires it. +- Do not rewrite provider/runtime internals merely to make them look web-native. Reuse current public + feature boundaries and add only the missing hosted adapter/composition. +- Security invariants around single-writer ownership, opaque workspace access, child environments, + process ownership, secure cookies/CSRF, and sandbox-only E2E remain release blockers. + +## Promotion rule + +A deferred capability may enter Core v1 only through a new explicit product decision that includes: + +1. why the core workflow is not usable without it; +2. the smallest hosted surface to promote; +3. expected implementation/test size; +4. security and recovery effect; and +5. a real-browser E2E acceptance path. + +Without that decision, workers preserve the code and keep the hosted capability unadvertised. +Approval production admission remains fail-closed for legacy signed owner payloads. Exact v2 and +v3 lifecycle owner admissions are read-compatible only and never mount approval routes. A future +coordinated v4 producer must sign a canonical, non-empty, uniquely team-sorted per-team route set +that binds each team to its workspace, owner generation/socket identity, artifact, and exact wire +capability digest. Until cross-repository golden fixtures prove that contract, delivery storage is +team-filtered and lease-fenced foundation only; no owner-writable routing fallback is admitted. diff --git a/docs/hosted-web-e2e-completion-plan.md b/docs/hosted-web-e2e-completion-plan.md new file mode 100644 index 0000000000..e9fcb8c63e --- /dev/null +++ b/docs/hosted-web-e2e-completion-plan.md @@ -0,0 +1,6310 @@ +# Hosted Web Runtime: End-to-End Completion Plan + +## Document status + +- Accepted Core v1 scope (2026-07-30): + [hosted-web-core-v1-scope-lock.md](hosted-web-core-v1-scope-lock.md). That file is authoritative for + current product scope, preservation, mutation tiers, legacy import, realtime, authentication, and + the release-gated deployment profile. Historical full-parity tables in this plan are expansion + inventory, not Core v1 acceptance. +- Status: reference plan; for the live phase status see + [docs/hosted-web-phases/EXECUTION_INDEX.json](hosted-web-phases/EXECUTION_INDEX.json) + (single source of truth) — the line below reflects the state at authoring time +- Status at authoring time (2026-07-11): architecture and execution plan, implementation not started +- Audited branch: refactor/hosted-web-runtime-boundary +- Audited commit: 45e115f08eac5b60884b3a00c1c9278857faca06 +- Audited pull request: #250, closed as superseded on 2026-07-11 +- Implementation strategy: clean implementation branch from the target base; audited PR is reference-only +- New implementation branch: intentionally not created until this plan is accepted +- Target base: refactor/team-provisioning-round2-reapply +- Base observed during the original PR audit: 37d0bab5cb1089113a8b86924054443405dba489 +- Current remote target-base head re-fetched during plan hardening on 2026-07-11: + 67548009b4d811d24ce3cb3ee0aed591e4922730 +- Base pinning rule: fetch and record the then-current remote SHA immediately before branch creation; + never treat the moving branch name or the SHA above as a permanent implementation base +- Pull request state at audit: draft, mergeStateStatus UNSTABLE; test, validate, and CodeQL failing +- Audit date: 2026-07-11 +- Intended deployment for v1: one operator, one isolated runtime root, one hosted controller writer + process per deployment; provider/CLI agents remain explicit external protocol writers +- Product parity decision: Core v1 is the complete lifecycle/task/message/diagnostic workflow locked + in `hosted-web-core-v1-scope-lock.md`; almost-full TeamsAPI parity is a later expansion target +- Scope-simplification decision (2026-07-29): v1 uses offline whole-deployment recovery, + one hosted schema compatibility version, server-owned command recovery, bounded cursor-based SSE + resynchronization, and minimal consumer-driven native guard verbs. The richer online recovery, + browser-local receipt, subscription-locator, and hosted-terminal designs are not v1 requirements. +- Autonomous execution entrypoint: [Hosted Web Execution Router](./hosted-web-phases/README.md) +- Packet contract: [Hosted Web Execution Packet Standard](./hosted-web-phases/PACKET_STANDARD.md) +- Packet strategy: materialize one phase just in time; do not treat later phase overviews in this file + as executable worker prompts + +## Executive conclusion + +The closed PR is a useful audit artifact, but it is not the foundation of the new implementation. +It exposed three incompatible execution paths: + + Electron renderer + -> preload IPC + -> src/main/ipc/teams.ts + -> TeamDataService / TeamProvisioningService + + Browser renderer + -> HttpAPIClient + -> HostedWebTransportClient + -> /api/hosted/v1/* + -> no production server implementation + + Docker + -> dist-hosted/server.cjs + -> static renderer only + -> intentional 404 for every /api/* request + +That diagram describes the rejected closed PR. The fresh target base already has a different +`standalone.ts` + Fastify + static renderer + Docker path. It serves real non-team APIs but does not +compose team APIs/lifecycle or browser security. The new work evolves that path and removes the +Electron-shaped browser facade; it does not recreate the closed PR's hosted server. + +The implementation must not be completed by adding aliases and more stubs to HttpAPIClient. +That would produce a demo faster, but would preserve the main architectural problem: +business behavior, provider handling, persistence, process ownership, IPC validation, and +Electron lifecycle are still mixed inside large main-process services. + +The recommended solution is a clean-branch strangler migration of the existing product baseline: + +1. Define feature-owned application contracts and a canonical capability model. +2. Extract shared application use cases behind small ports. +3. Keep the base branch's existing filesystem formats and large services behind compatibility adapters initially. +4. Make Electron IPC and hosted HTTP/SSE thin adapters to the same use cases. +5. Build one real hosted server composition that serves both the renderer and runtime APIs. +6. Prove the result using a built Docker artifact, a real browser, a real Fastify listener, + isolated temporary state/workspaces, and deterministic fake runtime executables. + +Historical estimate for a fresh implementation branch from the target base: + +- architecture skeleton plus a core usable hosted team lifecycle: approximately 13k-21k changed lines + including tests; +- internal single-tenant lifecycle milestone: approximately 18k-28k changed lines; +- full-parity expansion target with review, logs, approvals, diagnostics, attachments, + member operations, and preserved renderer reconciliation semantics, but without hosted terminal: + historically approximately 24k-40k net changed lines; +- hosted terminal is not estimated or packetized by this v1 plan; it requires a fresh post-v1 plan. + +These fresh-branch ranges are not a remaining-work estimate for the live PR. Re-baseline from the +current integrated head and count only the missing Core v1 composition, UI, and E2E work before +staffing another implementation slice. + +The 7,160 changed lines from the closed PR are not added to that total because they will not be +merged wholesale. Roughly 15-25% may be manually reimplemented or selectively ported after review, +mostly contracts, pure parsers, tests, and build discoveries. This is an order-of-magnitude planning +range, not a target to maximize diff size. Compatibility adapters keep the lower bound viable; +discovering more hidden lifecycle state in Electron composition moves the work toward the upper bound. + +The remaining upper-range cost is deliberate: hosted path containment cannot honestly be implemented +as a few TypeScript `realpath()` checks. V1 still includes a minimal Linux workspace guard, a provider +process anchor, strict child-environment/runtime-relay boundaries, restart-safe browser auth and fixed +proxy origin. It deliberately does not implement live cross-store disaster recovery, browser-local +pending-command receipts, subscription-locator leases or hosted terminal transport. Offline recovery, +server-owned command status and bounded snapshot-plus-SSE resynchronization are the accepted v1 +contracts. + +The historical expansion estimate is explicitly **net diff**, not a mechanical sum of overlapping +phase estimates. +Non-terminal phases revisit contracts, fixtures, composition, renderer migration and E2E across +several slices. Applying overlap/rework deduplication gave the historical full-expansion range of +approximately 24k-40k net lines. Live Core work must replace this model with a +checked-in estimate ledger by unique feature/package before implementation expansion; a deviation over +20% requires re-estimation rather than silently growing the branch. + +For this document, **net changed lines** means additions plus deletions in the final aggregate v1 diff, +with a line counted once after slice integration. It includes production code, focused tests, E2E, +small native guards and docs/migrations required by the feature. It excludes generated bundles, +lockfile/vendor churn, mechanical formatting and post-v1 terminal work. + +| Unique v1 bucket | Net changed lines | Confidence | Main uncertainty | +| --------------------------------------------------------------------------- | ----------------: | ---------- | -------------------------------------------------------------------------------- | +| contracts, feature skeletons, capability/route/architecture gates | 1.8k-2.8k | high | exact parity-ledger generator size | +| TeamId/WorkspaceId, workspace policy and minimal ADR-28 guard | 2.8k-4.5k | medium | legacy identity adoption and final Linux filesystem probes | +| lifecycle/runtime extraction, provider ingress, minimal ADR-30/31 ownership | 4.5k-7.0k | medium | how much deterministic provisioning can stay behind compatibility adapters | +| command/event/recovery, external-writer and offline backup compatibility | 2.5k-4.5k | medium | uncoordinated provider JSON semantics and resync thresholds | +| hosted composition, auth/proxy, packaging and production operations | 3.0k-5.0k | medium | standalone artifact/SQLite worker and deployment-topology failures | +| renderer transport/reconciler plus lifecycle-screen migration | 3.0k-5.0k | medium | hidden teamSlice/TeamDetailView state-machine behavior | +| tasks/messages/review/approvals/members/attachments remaining parity | 4.0k-6.5k | medium-low | actual visible-screen dependency closure after the action inventory | +| real-browser E2E, desktop regression, migration/rollout docs and tooling | 2.5k-4.0k | medium | reusable fixtures versus new production-shape harness work | +| **Full expansion total** | **24k-40k** | **8/10** | lower bound keeps strangler adapters; upper bound splits unsafe legacy authority | + +The full expansion is still large because that scope is nearly all team-management behavior, four provider +paths, remote authentication, runtime/process ownership, external JSON reconciliation and real +container/browser evidence. It is not 24k-40k solely to remove Electron imports. If the product goal +is Core v1, the old 24k-40k range must not be used as its remaining-work estimate; broad parity is +what added much of that surface. + +The 13k-28k milestones are implementation checkpoints, not a finished product. +The branch must not be marked Ready merely because list/create/launch/stop works. + +Complexity is 8/10. Initial bug/security regression risk is 8/10 on the clean branch: lower than +continuing the closed PR because contradictory scaffolding is excluded, but still very high because +provider processes, external JSON writers, renderer reconciliation and recovery are intrinsically +stateful. With the phased gates in this document, expected residual risk before release is 4/10. + +## Scope preserved + +The goal is not to build a shared multi-tenant SaaS platform. +The goal is to make the existing Agent Teams product runnable without Electron as a real, +safe, single-tenant hosted/server option with a useful web UI. + +### Required v1 outcome + +A user can: + +1. start a production hosted deployment; +2. authenticate from a browser; +3. see only registered workspaces through opaque identifiers; +4. list and inspect teams; +5. create a team draft/config; +6. prepare and launch a team through a provider-capability-aware runtime boundary; +7. observe provisioning progress and runtime state in real time; +8. create and update tasks and Kanban state; +9. send and receive team messages; +10. stop a team; +11. reload or reconnect without losing state; +12. restart the hosted backend and reconcile persisted state safely; +13. inspect bounded logs, runtime health, and failure diagnostics; +14. answer a tool approval safely when a supported provider flow requires it; and +15. log out, forget the current browser device, or reset access from the host. + +### Required provider outcome + +- Anthropic, Codex, Gemini, and OpenCode have explicit capability states. +- A provider is never assumed supported because a generic launch method exists. +- Unsupported or degraded provider paths fail before spawn with a typed error. +- OpenCode cannot remain a hidden standalone-only 501 WeakSet boundary. +- Provider credentials never travel through browser JSON payloads. + +### V1 runtime trust boundary + +V1 is single-tenant remote control, not a hostile-code sandbox. Browser input, provider protocol +messages, agent-authored files/content and stale processes are untrusted and fully validated, but an +operator-approved provider binary/agent process is tenant-trusted at the OS boundary in the default +single-runner profile. The design does not claim that protocol tokens alone isolate a malicious +same-UID process that can inspect sibling process/filesystem state. + +Consequences: + +- only operator-approved provider binaries and registered isolated workspaces are supported; +- plaintext device/session cookies and CSRF values remain browser+controller memory only; coordination + storage contains only their keyed hashes/metadata, and none enter runtime env/files. App state, + pairing material and audit data are outside runtime cwd/mount contracts; +- no live/adoptable runtime may exist when a plaintext pairing file is materialized. Startup first + acquires the lease and stops/classifies residual runtimes under the stop-owned policy; inability to + prove that boundary blocks pairing readiness; +- lane/run credentials limit accidental/stale/cross-route authority but are not advertised as an OS + sandbox between mutually malicious same-UID lanes; +- the capability/meta response reports `runtimeIsolation: trusted_process`, and diagnostics/docs use + the same term. Tests must not label it container/host sandboxing. + +Running untrusted third-party binaries, mutually hostile tenants or strong cross-lane containment +requires a separate runner isolation project using distinct OS identities/containers and a brokered +execution port. That is explicitly deferred rather than half-implemented inside this Electron +decoupling refactor. “Safe v1” means safe within this declared single-operator/trusted-runtime model. + +### Explicitly deferred unless promoted by the capability matrix + +- full change review/apply, attachments, rich task comments/relationships/clarification; +- live member add/replace/remove/restore/restart/skip after initial roster creation; +- soft-delete restore, permanent delete, identity repair UI, and cross-team administration; +- automatic startup identity adoption or repair; later import is explicit and offline; +- bundled Keycloak and release-gated generic OIDC integration. Core v1 retains only the generic + extension seam and ships built-in personal pairing; +- shared-process multi-tenancy; +- hostile provider-binary/same-UID runtime sandboxing and strong cross-lane OS isolation; +- horizontal replicas writing the same runtime root; +- arbitrary browser-selected host paths; +- desktop updater and native window controls; +- native file chooser; +- full built-in editor parity outside team review/attachment workflows; +- OS-native notifications; +- unrestricted shell/process execution; +- hosted terminal workspace. ADR-10 excludes it from v1 capabilities, image and UI + capability, mounts no terminal controls/routes and starts/packages no terminal daemon; +- schedules and non-team application administration; +- cross-team administration beyond the team-management contract, unless it is required by + an existing team screen included in the parity gate. + +Deferred functionality must be absent or visibly disabled through capabilities. +It must not be represented by throw, silent no-op, or fabricated empty success responses. + +### Release parity rule + +The release target is Core v1 capability parity, not TeamsAPI or ElectronAPI method parity. + +Required: + +- all operations in the Core v1 workflow; +- all state needed to launch, observe, direct, safely reconnect, and stop teams; +- explicit provider/runtime capabilities and typed degraded states; +- no browser stub behind a visible or advertised team control. + +The detailed API/action matrices below are retained as full-expansion inventory. A historical +`Required` label does not promote an action into Core v1. The scope lock and active capability +manifest decide what mounts and what the release E2E must prove. + +Hosted terminal is not an exception implemented as a throwing method. It is absent from the v1 +capability manifest and included web UI; its existing desktop facet continues through IPC unchanged. + +Allowed desktop-only exceptions: + +- native window controls; +- desktop updater; +- OS-native file chooser; +- OS-native notifications; +- opening Finder/Explorer or external desktop editors; +- other operations with no meaningful server-side equivalent. + +Every exception must be recorded in the capability matrix with a deliberate browser UX. + +## Verified current-state metrics + +The audited PR diff contains: + +| Category | Additions | Deletions | +| ----------------- | --------: | --------: | +| Tests | 3,697 | 26 | +| Production source | 3,258 | 185 | +| Build/docs/other | 205 | 50 | +| Total | 7,160 | 261 | + +Current target-base indicators at the plan-hardening snapshot 67548009b4d811d24ce3cb3ee0aed591e4922730: + +| File | Approximate LOC | Problem | +| ------------------------------------------------- | --------------: | --------------------------------------------------------------------------------------------------------------- | +| src/main/ipc/teams.ts | 5,751 | transport, validation, orchestration, and storage knowledge mixed | +| src/main/services/team/TeamDataService.ts | 3,932 | constructs many concrete dependencies and owns many workflows | +| src/main/services/team/TeamProvisioningService.ts | 639 | now a facade over many provisioning modules; preserve the round2 decomposition instead of rebuilding a monolith | +| src/renderer/store/slices/teamSlice.ts | 4,764 | transport calls, cache authority, optimistic lifecycle, race control, and UI state mixed | +| src/renderer/components/team/TeamDetailView.tsx | 3,757 | large presentation/orchestration surface | +| src/renderer/store/index.ts | 2,690 | global startup plus team event fanout, throttles, polling, and runtime event reconciliation | +| src/renderer/api/httpClient.ts | 1,756 | target base already claims the full ElectronAPI in browser mode and contains broad unavailable/stub behavior | +| src/renderer/components/team/TeamListView.tsx | 1,499 | hard Electron gate blocks the browser flow | +| src/main/http/teams.ts | 654 | legacy HTTP adapter knows filesystem, draft detection, cache invalidation, and runtime overlays | +| src/main/standalone.ts | 217 | already serves a non-Electron app, but omits team composition and uses unsafe remote defaults | +| src/main/services/infrastructure/HttpServer.ts | 205 | already serves static UI plus API; security/lifecycle policy is mixed into the server wrapper | + +The four renderer hotspots above total approximately 13,241 lines. The existing +teamSlice characterization suites add approximately 7,825 lines and encode many race and +structural-sharing guarantees that are not visible in the public TeamsAPI contract. They are +migration assets, not disposable implementation detail. + +AST-level audit of the pinned target interface finds 86 TeamsAPI methods, 20 ReviewAPI methods and +3 CrossTeamAPI methods. Every name appears in this document, but name presence is not parity: legacy +methods such as getData, raw-path review calls and event subscriptions must be decomposed/replaced +without losing their validation, side effects, error states, ordering or visible workflow. + +Important correction from the target-base audit: + +- `src/main/standalone.ts`, `HttpServer`, `docker/Dockerfile`, and `standalone:build` already form a + real static+API Node composition. The problem is incomplete/insecure team composition, not the + absence of a server. +- standalone currently omits `teamApis` and `teamDataApi`, so team routes are not registered even + though `registerTeamRoutes` exists. +- `HttpAPIClient implements ElectronAPI` is target-base debt, not only closed-PR debt. A clean branch + must strangle it; merely refusing to copy PR #250 does not remove it. +- target base already exposes useful compatibility seams: `TeamHttpHandlerApis`, `TeamHttpDataApi`, + the split provisioning composition, and feature-owned HTTP adapters in organizations, + recent-projects, and token-usage. +- target base also has a structured `src/main/services/team/runtime-control` domain/application + service with provider routing, command IDs and write-fence tests. Reuse its proven semantics, but + replace the mixed browser route, raw authority fields, in-memory-only replay fence and + action-before-event persistence gap. +- target-base Anthropic/Codex/Gemini launch is not a separate orchestrator executable: the + deterministic provisioning spawn flow launches one Claude-compatible CLI path and varies + provider args/model/auth/bootstrap inputs. OpenCode alone is routed through + TeamRuntimeAdapterRegistry. The first runtime refactor must wrap these two actual paths before + extracting strategies; it may not design against an imagined universal backend. +- target-base team-runtime-lanes has five concrete topologies, not an arbitrary provider graph: + primary_only, pure_opencode, pure_opencode_solo, pure_opencode_worktree_root_lanes and + mixed_opencode_side_lanes. OpenCode-led mixed teams are currently rejected. In mixed mode the + primary provisioning CLI starts first; queued OpenCode side lanes start after the primary's real + turn completion and then contribute to one persisted launch snapshot. This ordering and partial + outcome behavior require characterization before extraction. +- target-base `docker/vite.standalone.config.ts` currently replaces Electron imports and every + `.node` addon with empty build stubs. Its Docker production-dependency stage uses `--ignore-scripts` + and rebuilds selected terminal/SSH addons, but does not stage the internal-storage worker or prove + a Node-ABI `better-sqlite3` load. That is acceptable only while team/runtime features are absent; + it cannot remain the canonical hosted production build after those features are composed. +- target-base TeamBackupService already has a different best-effort identity mechanism: + manifest/registry `identityId` plus `_backupIdentityId` injected into CLI-owned config.json. It is + keyed by teamName, can rotate during same-name resurrection, and permanent delete currently removes + the directory before `markDeletedByUser`. It is migration evidence, not the canonical TeamId, and + must be adapted before any team.identity.json is published. +- target-base validateTeamName accepts only lowercase ASCII slug-shaped direct children and rejects + Windows reserved names, but TeamConfigReader discovery enumerates existing directories before that + route guard. Migration therefore cannot assume every legacy directory is valid or collision-free. +- target-base killProcessTree discovers descendants from a process-table snapshot and signals raw PIDs + best-effort; it does not fence PID/start-token reuse or persist the spawn-to-ownership gap. It is a + low-level compatibility helper, not sufficient ownership proof for hosted processRef/repair/delete. +- target-base Claude-compatible task/inbox paths and writes are primarily keyed by teamName plus + taskId/messageId and do not carry a trustworthy lifecycle RunId/generation. OpenCode runtime stores + do carry explicit run IDs/manifests. A generic filesystem watcher therefore cannot safely attribute + every external JSON change to whichever run is currently selected. +- target-base members.meta v1 has no stable MemberId. Its store deduplicates exact names and may drop + CLI auto-suffixed names when an active base name exists, while remove/restore paths also compare + lowercased names. Config, inboxes, tasks, logs and runtime manifests can therefore disagree about + whether two differently cased/suffixed strings are one logical member or separate evidence. +- target-base team renderer has non-TeamsAPI Electron reachability inside included flows: + ProjectPathSelector calls `api.config.selectFolders`; TaskCommentInput calls `getPathForFile`; + team editor components call `editor.*`, `openPath` and `showInFolder`; process/terminal/message UI + calls `openExternal`; ProcessesSection kills a raw PID; create/list flows assign organizations; + review registers desktop `onCmdN`. Global store startup also registers editor, Codex runtime and + team listeners. Capability flags applied only at click time would leave mount-time effects and + global subscriptions coupled to Electron. +- target-base `team.meta.json` is volatile launch/draft metadata, not a safe identity carrier: + TeamMetaStore accepts only version 1, normalizes known fields and rewrites the whole document; + launch writes it again, while failed-create cleanup deletes it and may recursively remove the team + directory. TeamId must use a separate immutable file/store and lifecycle-aware cleanup protocol. +- existing features such as terminal-workspace, internal-storage, member-work-sync, + member-log-stream, team-runtime-lanes, runtime-provider-management, organizations, + agent-attachments, running-teams, and workspace-trust must be reused or adapted rather than cloned. + +## Blocking defects and their actual location + +Defects below are tagged implicitly by their evidence: + +- **target base** means the problem exists before the new branch and must be migrated, not avoided; +- **closed PR** means the approach must not be salvaged; +- **both** means the PR failed to remove existing debt. + +This distinction is required for a real fresh start. Otherwise Phase 0 could mistakenly declare the +base clean while preserving the full Electron-shaped browser facade and incomplete standalone team +composition. + +### P0: the audited PR baseline is not green + +PR #250 at commit 45e115f currently reports failing test, validate, and CodeQL checks. +Observed failures include: + +- three TeamProvisioningService expectations around persisted launch-state liveness/cleanup; +- an HttpServer test failing because API-only startup emits an unexpected renderer-output warning; +- CodeQL high-severity js/missing-rate-limiting on the authorization hook in HttpServer.ts. + +These may include behavior/test-assumption or CI-environment issues, but they are unresolved until +reproduced and classified against the exact base SHA. No hosted work may use a red PR as its trusted +characterization baseline, and the CodeQL alert cannot be waived merely because the current bearer +auth is transitional. + +### P0: closed-PR production route mismatch + +- Browser transport owns /api/hosted/v1/\*. +- Real Fastify routes own /api/teams/\*. +- No production source registers /api/hosted/v1. +- Existing DTO shapes are incompatible, not merely differently named. + +Examples: + +| Operation | Hosted client expects | Legacy server returns/expects | +| -------------- | ---------------------------------------- | ----------------------------- | +| List teams | object with teams | TeamSummary array | +| Team snapshot | team, tasks, kanban, revision | TeamViewSnapshot | +| Launch request | workspaceRef and provider object | cwd and providerId | +| Provisioning | teamId | teamName | +| Alive teams | object with teamIds | TeamRuntimeState array | +| Runtime | terminalAvailable and activeProcessCount | legacy TeamRuntimeState | + +### P0: closed-PR Docker regression; target base has a different incomplete path + +- Docker builds hosted:build, not standalone:build. +- Docker runs dist-hosted/server.cjs. +- src/hosted/server.ts intentionally returns 404 for every API request. +- The previous full standalone image and runtime state mount were replaced by a static shell. +- The current Docker tests lock in API absence instead of proving a usable runtime. + +Target base does not have this static-only regression: its Docker image builds `standalone:build` +and runs the real Fastify/static server. However, standalone does not pass `teamApis`/`teamDataApi`, +mounts the Claude root read-only in the documented example, defaults remote CORS to wildcard, has no +browser session security, and composes only a subset of desktop lifecycle services. The new design +must evolve this existing path, not add a third server target. + +### P0: browser authentication is unusable in both paths + +- The closed PR's non-loopback HttpServer requires AGENT_TEAMS_HTTP_AUTH_TOKEN. +- HttpAPIClient and HostedWebTransportClient do not send Authorization. +- Native EventSource cannot set an Authorization header. +- There is no browser login/bootstrap/session/logout/rotation flow. +- Target-base standalone has no remote authentication and defaults CORS_ORIGIN to wildcard while + credentials are enabled. +- Loopback may run without auth, allowing an untrusted website to target a privileged local API. + +### P0: target-base runtime callbacks share the operator route surface + +Target base registers OpenCode bootstrap-checkin, deliver-message, task-event, heartbeat and +permission-answer under `/api/teams/:teamName/opencode/runtime/*` in the same route module as browser +team control. The adapter injects URL teamName into a raw body and has no distinct machine identity. + +The compatibility permission command also accepts cwd, expectedMembers and previousLaunchState from +the caller. RuntimeControlService serializes some operations only with an in-memory fence and invokes +the provider action before recording the event sink, leaving an ambiguous retry window if event +persistence fails. + +Hosted work must separate this machine ingress before enabling provider launch. Browser session +security, private networking, validation and idempotency alone are not substitutes for a run-scoped +machine credential and server-resolved authority. + +### P0: workspace boundary is unsafe + +- Hosted launch ultimately accepts any absolute cwd. +- Browser mapping currently sends the raw cwd as workspaceRef.id. +- There is no registered workspace catalog, descriptor-bound containment, or reliable symlink/mount + race defense. +- Hosted config routes can expose or change host-specific roots and project paths. +- A compromised session could point runtime actions at unintended host directories. + +### P1: browser team UI is intentionally unreachable + +TeamListView: + +- calculates canCreate as Electron-only; +- disables alive-team loading outside Electron; +- skips fetchTeams and fetchAllTasks outside Electron; +- returns an unconditional Electron-only placeholder. + +### P1: the claimed E2E is a contract fixture + +hostedWebStartup.fixture-e2e.test.ts: + +- replaces fetch with an in-memory fake router; +- replaces EventSource and WebSocket; +- creates fake hosted routes that do not exist in production; +- asserts that no real runtime launch was attempted. + +It is a useful contract/unit fixture, but it is not an end-to-end test. + +### P1: typed SSE and terminal contracts are dead production code + +- subscribeToTeamEvents has no production callsite. +- createTerminalSession and openTerminalStream have no production callsite. +- HttpAPIClient still opens the generic /api/events stream. +- The real event server has a global unscoped clients set with no event IDs, replay, or team filter. +- The existing terminal-workspace feature already uses a different gateway contract. +- The new hosted raw terminal contract duplicates that architecture instead of adapting it. +- Existing terminal-workspace bootstrap accepts renderer-supplied projectPath and returns runtime + slug, direct control/stream URLs, projectPath, and default shell. +- The gateway embeds a bearer in both WebSocket query URLs, binds a separate TCP listener and exposes + the complete WorkspaceTransportClient. Its validators only prove that create-session request, + mux-command and subscription spec are objects before forwarding them; current DTOs therefore permit + browser-selected program/args/cwd, imported routes and broad saved-session/layout operations. +- The vendored gateway uses `ws` defaults: inbound `maxPayload` is 100 MiB, outbound pumps call + `socket.send()` without a bufferedAmount/high-watermark wait, and control requests have no per-socket + serialization or method/rate/resource budget. +- The renderer transport opens independent control and stream sockets and automatically reconnects. + That cannot be secured by the plan's previous single-use ticket without a two-plane connection-grant + state machine and an authenticated HTTP regrant before reconnect. +- The daemon runtime slug is a deterministic hash of teamName. Startup attaches to any ready daemon + at that name and later refuses to stop it when this process did not spawn it. The local transport + uses overwrite-on-bind behavior, so pathname/readiness is not ownership evidence. +- Its daemon spawn inherits `process.env` wholesale; portable-pty then seeds each shell from the + daemon's base environment. Controller/session/provider/lease secrets could therefore reach a remote + shell unless the daemon itself starts from an allowlist-first environment. +- terminal-daemon silently falls back to in-memory persistence when its SQLite store fails, and native + PTY teardown currently kills only the direct child without typed wait/drained evidence. Hosted + composition needs an identity-resolving, constrained same-origin adapter plus terminal-platform + hardening; wrapping the current listener in an auth proxy is insufficient. +- Existing optional `client_event_id` deduplicates persisted command-history capture, not the PTY write + itself. A lost response therefore cannot justify automatic replay of input/paste or a structural mux + command without a stronger daemon-scoped result/evidence contract. + +### P1: target-base and closed-PR HttpAPIClient violate interface segregation and substitutability + +HttpAPIClient implements the complete ElectronAPI but cannot honor that contract. The exact stub +count differs between the moving base and closed PR, so CI must inventory reachable unsupported +methods structurally instead of relying on the historical count of 141. + +This causes three failure modes: + +1. visible controls throw after a click; +2. no-op methods silently discard user intent; +3. empty arrays or fake values look like successful responses and hide missing data. + +### P1: renderer state is a second hidden lifecycle implementation + +The current renderer is not a passive view over TeamsAPI. teamSlice and the store event wiring +implement a substantial client-side state machine: + +- context ID, context epoch, team epoch, request nonce, and request-scope stale-response guards; +- thin versus full snapshot deduplication and queued follow-up refreshes; +- selected-team and multi-pane caches with structural sharing; +- optimistic pending provisioning runs replaced by canonical server run IDs; +- provisioning and runtime run tombstones plus startedAt floors after stop -> relaunch; +- state-regression suppression for terminal provisioning states; +- event-driven refresh fanout with visibility gates, throttles, safety refreshes, and polling; +- message-head pagination serialization and feed revision checks; +- immediate projections for tool activity, lead activity/context, and approval events. + +A direct EventSource-to-Zustand rewrite can resurrect stopped runs, overwrite a newer context, +drop a follow-up refresh, erase a richer snapshot, or flood the server. The migration therefore +needs an explicit client reconciliation model and must preserve the existing race tests. + +There are also direct transport bypasses outside the renderer API facade: + +- AttachmentDisplay calls window.electronAPI.teams.getAttachments; +- ProcessesSection calls window.electronAPI.teams.killProcess; +- AdvancedCliSection calls window.electronAPI.teams.validateCliArgs. + +Editor/open-path/native-shell calls are separate desktop facets, but team operations above must +move behind capability-scoped application adapters. An architecture gate must prevent new direct +window.electronAPI.teams usage in renderer team features. + +### P1: closed-PR runtime-core is not a core boundary + +- runtime-core/main imports concrete @main/services types. +- providerJsonParsing is a copied bundle of concrete service references. +- team use cases are existing main binders. +- TeamRuntimeAdapter input contracts still carry raw cwd and teamName, so they are not yet the + safe canonical hosted boundary; a workspace/team identity adapter must resolve those internally. +- the architecture test only prevents renderer/preload/shared importing runtime-core/main; + it does not prevent runtime-core from depending on main details. + +Electron coupling was partially replaced by main-process coupling, not inverted. + +### P1: closed-PR team-application is far too narrow + +The slice currently owns only deleteDraftTeam. +List, read, create, launch, stop, tasks, messages, provider capabilities, events, +logs, review, approvals, and lifecycle recovery remain outside the application boundary. + +### P1: standalone lifecycle differs from desktop lifecycle + +Standalone does not compose the same: + +- runtime adapter registry; +- startup state reconciliation; +- prompt delivery watchdog; +- process health polling; +- stale runtime cleanup; +- member-work-sync replay/scan; +- task-comment journal initialization; +- internal-storage worker/SQLite backend and task-stall journal/monitor; +- team backup lifecycle; +- terminal-workspace lifecycle (documented gap, but deliberately outside hosted v1); +- team change event pipeline. + +Some apparently reusable provisioning code also retains desktop fallback dependencies. For +example, TeamProvisioningToolApprovalFacade holds BrowserWindow state, falls back to +ConfigManager.getInstance(), and dynamically requires Electron Notification. These must become +injected notification/settings ports; hosted must not rely on an unexecuted Electron branch being +harmless. + +A launch that appears to work may still diverge after restart, stale state, or partial failure. + +### P1: persistence authority is mixed + +The runtime tree contains: + +- CLI-owned, largely unversioned config/tasks/inbox JSON; +- app-owned metadata and launch state; +- OpenCode-specific versioned stores and manifests; +- JSONL transcripts and journals; +- multiple files updated as one logical operation without an aggregate transaction. + +In-process locks do not protect two Node writers. +Some read-modify-write flows may drop unknown fields written by newer CLI/runtime versions. + +### P2: production operations are incomplete + +The current hosted artifact lacks: + +- live and ready health endpoints; +- request IDs and safe error correlation; +- structured redacted logs; +- bounded request body/time/rate policies; +- SSE/process metrics; +- non-root runtime user; +- explicit persistent volumes; +- hardened container permissions; +- graceful admission stop and connection drain; +- migration/backup/rollback workflow. + +## Architecture options + +### Option 1: strangler around feature-owned application use cases - recommended + +🎯 9/10 🛡️ 9/10 🧠 8/10 + +Historical Core checkpoint: 13k-21k fresh-branch changed lines including broad tests/docs. The old +24k-40k range included full parity. Neither number is remaining-work truth for PR #252; re-baseline +from its live integrated head. Core still retains ADR-7 auth/proxy continuity, ADR-16 instance +locking, ADR-28 workspace guard, ADR-30/31 runtime/process ownership, ADR-32 offline recovery and +ADR-33 bounded snapshot/SSE resynchronization. ADR-34 full effect recovery applies only to Tier B. + +- Canonical /api/hosted/v1 contract. +- Shared application use cases. +- IPC and HTTP as thin input adapters. +- Current large services behind compatibility output adapters. +- Incremental extraction without rewriting all persistence at once. +- Best long-term Electron decoupling and testability. + +### Option 2: connect browser directly to legacy /api/teams and fill missing routes + +🎯 8/10 🛡️ 5/10 🧠 6/10 + +Approximate further changes: 8k-14k lines. + +- Faster path to a demo. +- Keeps Electron-shaped mega-interfaces and raw filesystem paths. +- Duplicates validation and transport behavior. +- Makes provider and lifecycle divergence harder to remove later. +- Likely requires a second rewrite. + +### Option 3: keep a static hosted shell and control another desktop/runtime service + +🎯 7/10 🛡️ 6/10 🧠 4/10 + +Approximate further changes: 3k-6k lines. + +- Acceptable only as an explicitly read-only or limited product. +- Does not satisfy a real hosted team lifecycle. +- Team mutations, terminal, credentials, and runtime ownership remain elsewhere. + +### Decision + +Use Option 1. + +Apply it from a fresh branch based on `refactor/team-provisioning-round2-reapply`, not by extending +the closed PR. Do not expand HttpAPIClient or the legacy route layer before feature ownership, +application ports, and the capability matrix exist. Otherwise the next several thousand lines will +cement the current coupling. + +### Fresh-start and salvage policy + +Starting over means resetting the architectural direction, not discarding verified knowledge or +rewriting the mature desktop product in one jump. + +Rules: + +1. The target base is the only code ancestry for the new implementation branch. +2. PR #250 remains a read-only reference for defects, test ideas, DTO field inventories, and build + discoveries. No merge, rebase, or whole-commit cherry-pick from it is allowed. +3. A piece may be ported only after identifying its owning feature, public contract, dependency + direction, threat model, and focused verification. +4. Prefer manual reimplementation when a commit mixes useful pure code with HttpAPIClient stubs, + static-only server assumptions, ElectronAPI widening, or legacy route duplication. +5. Ported tests must assert the new public seam. Tests that only prove a fake fetch, fake EventSource, + or fixture contract are renamed and kept below the E2E gate. +6. Each adopted asset is recorded in a short salvage ledger with source commit/file, target owner, + reason, modifications, and test evidence. This prevents accidental resurrection of rejected design. + +Good salvage candidates: + +- browser-safe DTO schemas and pure normalization after contract review; +- provider capability inventories and protocol fixtures with secrets removed; +- deterministic parsers and characterization tests for legacy JSON behavior; +- route-conformance test ideas, security findings, Docker build discoveries, and ADR evidence; +- pure renderer reconciliation cases if they preserve the current desktop semantics. + +Rejected by default: + +- a browser client implementing the complete ElectronAPI; +- blanket `not available`, no-op, or fabricated-success methods; +- static-only Docker as the default hosted artifact; +- duplicate lifecycle logic in a hosted-only service; +- provider/runtime callbacks placed under browser team routes or authenticated by browser session; +- raw host paths, PIDs, provider credentials, or direct gateway details in browser contracts; +- transport tests presented as end-to-end evidence; +- a single `team-application` or `runtime-core` facade owning every feature. + +The clean branch is considered correctly initialized only when it contains the exact target-base +commit, this accepted plan/ADRs, a green or explicitly classified baseline, and no production code +copied from the closed PR. + +## Target architecture + +### Runtime topology + + Browser + -> TLS/auth edge + -> hosted app private port + -> static renderer + -> /api/hosted/v1 HTTP + -> team-scoped SSE + -> no WebSocket/terminal route in v1 + -> application use cases + -> ports + -> filesystem compatibility repositories + -> provider/runtime adapter registry + -> process supervisor + -> event journal + -> workspace registry + + Provider/runtime process + -> private machine-authenticated /api/runtime/v1 ingress + -> runtime ingress use cases + -> current run/generation/token scope validation + -> team-runtime-control + feature mutation/outbox boundaries + + Provider/CLI filesystem writer + -> scoped watcher + stable parse/checksum + -> external mutation reconciliation + + Electron renderer + -> preload IPC adapter + -> the same application use cases + -> the same ports/adapters where semantics match + +### Feature-owned target shape + +The existing `docs/FEATURE_ARCHITECTURE_STANDARD.md` remains canonical. Each medium or large area +uses only the folders it really needs; empty ceremonial layers are forbidden. Target slices are +created or extended only when their first vertical use case is extracted. + + src/features/ + team-lifecycle/ + team-task-board/ + team-messaging/ + team-review/ + team-approvals/ + team-runtime-control/ # execution/liveness, not provider installation/settings + workspace-registry/ + terminal-workspace/ # desktop unchanged; hosted terminal is a separate future project + team-console/ # thin renderer composition; no business authority + +Existing slices are part of the target architecture, not migration debris: + +| Existing feature | Target role | +| ----------------------------- | ---------------------------------------------------------------------------------------------------- | +| `runtime-provider-management` | provider installation, connection/settings and user-facing availability; it does not spawn team runs | +| `team-runtime-lanes` | pure mixed-provider lane planning reused by `team-runtime-control` | +| `member-work-sync` | existing member synchronization contracts/use cases; expose HTTP through its public facade | +| `member-log-stream` | bounded member/task log streaming and projections | +| `terminal-workspace` | preserve desktop control/stream behavior in v1; hosted terminal is outside this plan | +| `internal-storage` | existing SQLite worker/backend extended for coordination tables; no second database subsystem | +| `agent-attachments` | attachment storage/validation reused by task-board, messaging and review workflows | +| `organizations` | existing cross-team organization/read-model boundary and HTTP adapter | +| `running-teams` | existing renderer projection reused inside `team-console` | +| `workspace-trust` | provider CLI trust/preflight policy; distinct from hosted workspace registration/authorization | + +The provider-runtime product area is implemented by `team-runtime-control` plus the existing +`runtime-provider-management` and `team-runtime-lanes` features. This prevents one new feature from +absorbing installation, preferences, lane planning, process ownership, lifecycle and settings UI. + +A full cross-process feature may contain: + + / + contracts/ # browser-safe DTOs, schemas, errors, routes/channels, capabilities + core/ + domain/ # only real invariants/state machines + application/ # commands, queries, ports, outcomes + main/ + composition/ # feature factory, not application-global composition + adapters/input/ # IPC and HTTP translation + adapters/output/ # legacy/provider/persistence implementations of owned ports + infrastructure/ # process, filesystem, database, protocol details + preload/ # thin IPC bridge when desktop exposes the feature + renderer/ + adapters/ # DTO to view-model mapping + hooks/ # client-facet and store orchestration + ui/ # presentational controls + +Hosted mode is an application composition, not a feature: + + src/main/standalone.ts # thin process entrypoint during migration + src/main/composition/hosted/ # app-level wiring and lifecycle + src/main/services/infrastructure/HttpServer.ts + +Refactor the existing standalone path in place. `standalone.ts` becomes config parsing plus +`createHostedApplication().start()`. The hosted composition wires feature facades, session policy, +workspace adapters, HTTP/SSE adapters, readiness and shutdown. It owns no team/provider/workspace +business rules. Do not create `src/features/hosted-runtime`, `src/hosted/server.ts`, or a second +Docker/server artifact. + +The v1 browser transport is small shared renderer infrastructure that knows HTTP, SSE, cookies, +request IDs, cancellation, bounded retry and wire decoding. Feature-owned clients map their own +routes/contracts onto it. It does not implement ElectronAPI and contains no team policy. +Any future hosted terminal chooses its transport in a separate security review rather than expanding +every v1 client preemptively. +There is no replacement mega-feature named `team-application`; shared application behavior lives +in the feature that owns the user-visible capability. + +### Capability-segregated renderer API + +Replace the single full ElectronAPI promise with small facets: + +- teamRead; +- teamLifecycle; +- provisioning; +- taskBoard; +- messaging; +- memberLifecycle; +- logs; +- review; +- toolApproval; +- terminalWorkspace (desktop-only in v1); +- desktopWindow; +- desktopUpdater; +- desktopFileChooser; +- editor. + +The server publishes a typed capability manifest. +The renderer composes only supported facets. + +The manifest has schemaVersion/revision and distinguishes deployment capability, provider +availability/auth state, and team/run-specific allowed actions. Capability changes emit a scoped +event and force revalidation. UI gating is advisory UX only: every command rechecks authorization, +workspace, current generation, provider readiness, and policy on the server. + +Capability granularity has three explicit levels rather than one boolean per old API method: + +- facet availability: the renderer may compose a feature client/widget; +- action support: a stable action ID such as `team.lifecycle.launch` has an implemented semantic + path in this deployment/provider topology; +- resource allowance: the current team/run/workspace snapshot says whether that action is presently + allowed and gives a safe reason code such as stale_generation, read_only_workspace, + provider_unavailable or recovery_required. + +The first two come from registered descriptors/features and deployment readiness. The third is a +versioned server projection and may change at runtime. Hosted UI never infers action support merely +from facet presence, and it never treats a temporary resource denial as proof that the action is +unimplemented. The parity ledger references stable action IDs; it does not force 109 legacy methods +to become 109 permanent capability booleans. + +Rule: + +> A capability marked supported may not throw "not available in browser mode", +> silently no-op, or fabricate empty data. + +Migration shape: + +- createElectronAppApi composes preload-backed facets; +- createHostedAppApi composes HTTP/SSE-backed v1 facets from the capability manifest; +- both return a registry of independently typed facets, not two implementations of one mega-interface; +- shared hooks/components depend on the narrow facet they use; +- target base's `api: ElectronAPI` proxy and HttpAPIClient remain a quarantined compatibility path + only for untouched non-team screens; browser team startup never resolves it; +- remove each HttpAPIClient team/review stub as its caller migrates; hosted v1 has no terminal facet at + all. The release gate is zero hosted team/review callsites through fake/no-op legacy methods and zero + terminal imports/effects from hosted route chunks. + +## Feature ownership and dependency design + +### Ownership matrix + +Every mutable rule, route, event, repository write, and renderer command has exactly one owning +feature. Other features consume its public contract or an explicitly published application port; +they do not import its internals or reproduce its state. + +| Feature | Owns | Does not own | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `hosted-access` | ADR-7 pairing challenge, device-grant family, operator session/CSRF lifecycle, auth cookies, renewal/logout/reset policy and browser-auth projection | TLS/proxy deployment, team authorization, runtime credentials, workspace policy, generic HTTP server | +| `team-lifecycle` | team identity/draft/config lifecycle, TeamWorkspaceBinding, TeamRoster/MemberId generations, prepare/launch/stop/delete/restore/recovery saga, run generation, member lifecycle orchestration and lifecycle projections | workspace root authorization, provider spawn mechanics, task/message contents, composite console snapshot, terminal protocol, HTTP server | +| `team-task-board` | tasks, revisions, relationships, comments, assignment, status transitions, Kanban ordering, review-request state and task projections | team process lifecycle, inbox delivery, review diff/patch execution | +| `team-messaging` | inbox/history pagination, send/delivery outcomes, message identity/deduplication, external inbox reconciliation | task mutation rules, provider process supervision, UI notification plumbing | +| `team-review` | review sessions, diffs/files, review comments and outcomes, bounded apply/checkout commands | arbitrary filesystem access, generic Git hosting, task board authority | +| `team-approvals` | pending approval projection, policy, claim/decision/idempotency/audit | renderer-only prompts, provider credentials, general runtime lifecycle | +| `team-runtime-control` | two segregated surfaces: outbound launch/process/liveness execution and inbound machine runtime events/delivery; consumes provider availability and lane plans from existing features | provider installation/settings, browser auth/API, team product state, renderer stores | +| `workspace-registry` | opaque workspace/repository identity, canonical root registration, containment/mount policy, operation authorization and revision | browser session, team lifecycle, terminal sessions, arbitrary project browsing | +| `terminal-workspace` | post-v1 only: terminal session authorization input, lifecycle, PTY/gateway mapping, output/backpressure and WS-facing contract; desktop adapter remains unchanged in v1 | v1 hosted composition, arbitrary shell API, team lifecycle decisions, raw browser host paths | +| `team-console` | selected-team UI context, feature-widget composition, bootstrap/reconnect sequencing and the entity-agnostic TeamTransportReconciler | lifecycle/task/message/review state or revision semantics, persistence, transport implementation or provider policy | + +App-level hosted composition owns immutable PUBLIC_ORIGIN/proxy/socket policy, route assembly, +health/readiness, static assets and SSE wiring. It injects those facts plus the narrow runtime drain +port into `hosted-access`; it does not own auth state/transitions. Post-v1 T1 adds WS/terminal drain +through separate ports. Deployment wiring remains outside product bounded contexts. + +Deferred cross-team operations remain a separate thin feature if later promoted. They must not +become a backdoor god API. + +Ownership resolution rules: + +1. The feature that validates and commits an invariant owns the command. +2. A screen or transport never owns business state merely because it displays or carries it. +3. A projection may join multiple feature read models, but may not mutate their backing state. +4. A saga coordinates commands through public application surfaces; it does not directly write + another feature's repository. +5. If two features need the same concept but with different semantics, keep separate types and map + explicitly. Do not merge them merely because their JSON shape currently matches. + +High-risk cross-feature workflows have one explicit coordinator and outcome contract: + +| Workflow | Coordinator/committed authority | Secondary effects and failure rule | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create/delete draft | `team-lifecycle` commits TeamId, identity anchor/index and draft generation | pre-commit failure tombstones/cleans only operation-owned root; accepted draft survives provisioning failures, while explicit DeleteTeamDraft owns later deletion | +| Launch/relaunch | `team-lifecycle` atomically accepts a new LifecycleRun/generation/immutable CompositeRuntimePlan and advances TeamLifecycle.currentRunRef | `team-runtime-control` executes lanes; failure never reopens an older run or deletes the draft/team, and ambiguous/partial effect remains recovering or operator_required | +| StartTask / create-startImmediately | `team-task-board` commits task revision, status and work interval | `team-messaging` persists/delivers owner notification after commit; failure does not roll back started work and is returned as not_required/persisted/delivery_failed, never inferred from owner presence | +| RequestReview | `team-task-board` commits review-request/task workflow state | notification/runtime delivery has an explicit outcome; `team-review` owns later diff/change-set decisions, not the task transition | +| Apply/reject/edit review | `team-review` claims changeSet/sourceGeneration decision and apply intent | workspace/Git adapter verifies source hash; stale is conflict, partial apply is recoverable and never marks review complete | +| Add/replace/remove/restore/restart member | `team-lifecycle` commits TeamRoster rosterGeneration/MemberId/memberRevision plus compatible config intent | runtime-control binds lane attempt to the plan snapshot and task-board preserves historical owner mapping; config success with failed stop/restart/reassignment is degraded/recovering, not clean success | +| Approval decision | `team-approvals` atomically claims/audits the decision | runtime-control delivery transitions delivering/delivered/delivery_unknown; provider timeout never reopens the decision | +| Delete/restore/permanent delete | `team-lifecycle` DeletionSaga owns TeamId lifecycle/tombstone generation | v1 runtime stop and task/message/review archives are explicit public steps/compensations; partial cleanup is operator_required and never delegated to RunRecoveryWorkflow | +| Attach/detach content | `agent-attachments` owns bounded blob lifecycle; task-board/messaging owns its reference | upload is unreferenced until the feature command commits; failed reference cleans by orphan TTL, delete refuses live references | + +An after-commit event may trigger a best-effort projection refresh, but any secondary effect visible +to the user has persisted delivery/saga status. No feature reports aggregate success by catching and +discarding another feature's failure as current startTask notification code does. + +### Small shared kernel + +The shared kernel is intentionally boring and stable. It may contain: + +- branded opaque IDs and parsing helpers: DeploymentId, BootId, WorkspaceId, TeamId, RunId, + MemberId, TaskId, ApprovalId, RequestId; +- UTC timestamp/duration primitives plus Clock; +- IdGenerator and deterministic test implementations; +- AppError envelope categories, not feature-specific error catalogs; +- ActorContext, RequestContext, cancellation/deadline, pagination and revision primitives; +- Result/Outcome helpers only if the repository already standardizes them. + +It must not contain Team DTOs, provider models, filesystem schemas, route manifests, repositories, +React types, Electron types, Fastify types, or generic `Manager`/`Service` abstractions. A concept +enters the shared kernel only when at least three features need identical semantics and its owner is +stable. Otherwise it stays feature-local. + +### Selective DDD boundaries + +DDD is used only where illegal transitions, concurrent writers, recovery, or multi-step rollback +make an anemic CRUD model unsafe. + +#### TeamLifecycle and LifecycleRun aggregates + +Draft/team existence and one execution attempt are different consistency boundaries. Do not put a +pre-launch draft into an aggregate whose identity requires RunId. + +`TeamLifecycle` identity is `(deploymentId, teamId)`. It owns identity/draft generation, +TeamWorkspaceBinding reference, deletion/tombstone state and at most one currentRunRef: + + draft -> idle -> active -> idle + \ | / + +------v------+ + soft_deleted --restore--> priorNonDeletedState + | + v + deleting -> deleted + +Only `soft_deleted` is restorable; `deleting` starts permanent deletion and cannot transition back. +`recovery_blocked` is an orthogonal admission condition with evidence, not a shortcut transition +that rewrites the last committed lifecycle state. A draft becomes idle/active only through the +provider-compatible materialization/accepted-launch workflow. Soft delete cannot hide a live run; +the saga stops/fences it before committing the deleted projection. + +`LifecycleRun` identity is `(deploymentId, teamId, runId, generation)`. It is created only when +LaunchTeam is accepted and the immutable CompositeRuntimePlan is persisted: + + accepted -> provisioning -> ready <-> degraded + accepted|provisioning -> cancelling -> cancelled + ready|degraded -> stopping -> stopped + provisioning|stopping -> failed (only with terminal evidence/cleanup status) + +Rules: + +- CreateTeamDraft/UpdateTeamDraft/DeleteTeamDraft never fabricate RunId; +- PrepareProvisioning is a bounded preflight result keyed by input/capability revision and does not + become a durable run merely because checks passed; +- every accepted launch creates a new runId and monotonically increasing generation, then updates + TeamLifecycle.currentRunRef through compare-and-commit; +- a terminal run is immutable except for bounded late diagnostic evidence; relaunch creates a new + run rather than reopening stopped/failed/cancelled state; +- commands for an older currentRunRef/generation fail with a typed stale-revision outcome; +- `ready` requires provider-specific verified evidence, not only a PID or config file; +- ready/degraded may recover in either direction from fresh evidence; cancel/stop first fence new + effects and enter their explicit transitional state before terminal cancelled/stopped; +- stop is idempotent for the same generation and cannot stop a newer run; +- failure preserves diagnostics and RunId-owned cleanup obligations without deleting the team/draft; +- aggregates contain business facts, never ChildProcess, tmux, Electron, timer, or watcher objects. + +ProcessSupervisor and filesystem repositories are ports used by lifecycle application commands. +They are not aggregate members. A single lifecycle application coordinator owns the compare-and- +commit between TeamLifecycle.currentRunRef and a newly accepted LifecycleRun; renderer, provider and +transport code cannot update either side independently. + +Initial authority is deliberately split by fact type, not duplicated: + +- internal-storage owns the accepted LifecycleRun identity/generation, immutable + CompositeRuntimePlan/checksum, TeamLifecycle.currentRunRef and lifecycle command/recovery status. + Acceptance of all four is one SQLite transaction before spawn; +- existing launch-state/journal stores own provider execution/bootstrap/progress evidence and required + provider-compatible projections after acceptance. They do not select the current run or replace the + immutable plan; every record must match RunId/generation/plan checksum or is stale/conflicting evidence; +- OwnedProcessSupervisor owns current process identity/exit observations for processes this instance + launched or safely adopted; +- provider adapters own interpretation of provider-specific freshness evidence; +- internal-storage additionally owns opaque identities, feature revision and event outbox, but does + not copy provider stdout/bootstrap/liveness evidence into a second provider state machine; +- LifecycleRun projection deterministically combines those facts. If durable intent and fresh + evidence disagree, it reports recovering/degraded/disconnected according to the transition table, + never whichever source was read last. + +If acceptance commits but the compatibility launch-state projection fails, the run remains accepted/ +recovering and no spawn occurs until projection recovery verifies the same plan checksum. If a legacy +provider file appears first, it is external evidence only and cannot create or replace currentRunRef. +Changing this authority split requires a separate storage migration ADR. It cannot happen as a +cleanup inside a transport or renderer PR. + +#### WorkspaceRegistration aggregate + +Identity is WorkspaceId, allocated from immutable operator registrationKey. It owns the declared root +descriptor, mount policy, read/write permissions, repository association, status, and registration +revision. Boot-scoped canonical realpath/device/mount evidence belongs to WorkspaceMountBinding, not +to stable WorkspaceId. Neither exposes the authoritative root path in a browser DTO. Realpath and +platform-specific containment checks remain in an output adapter but are required against the current +mountGeneration before every spawn or sensitive file operation, not only at registration. + +This aggregate belongs to `workspace-registry`, not the app-level hosted composition. Team lifecycle, terminal, +review/attachments, and Git/worktree commands call its narrow public authorization query. Hosted +composition supplies manifest/mount adapters; Electron supplies a compatibility adapter for +desktop-selected projects until opaque identity migration is complete. + +#### TeamWorkspaceBinding aggregate + +Identity is TeamId with monotonically increasing bindingGeneration. States are unbound, bound, +mismatch, rebinding and disabled. `team-lifecycle` owns the binding transition because it coordinates +team config/run safety; `workspace-registry` remains the authority that validates a candidate +WorkspaceId/root capability. Mutable legacy projectPath/cwd is evidence compared with the binding, +not its source of authority. Rebind is forbidden during an incompatible active run and uses the +mutation intent protocol so config/file evidence and internal binding cannot silently diverge. + +#### TeamRoster and member identity + +`TeamRoster` is a TeamId-scoped versioned consistency boundary owned by team-lifecycle. It has one +monotonic rosterGeneration and entities: + + MemberId + -> immutable LegacyMemberKey + -> memberRevision + -> active | removed + -> provider/model/role/workflow/isolation configuration + +MemberId is the browser/domain identity. LegacyMemberKey is the provider/config/inbox/log compatibility +name and is never browser authorization. It is validated with the current CLI naming policy plus a +case-folded uniqueness check. Removal and restore retain MemberId/LegacyMemberKey and advance revision; +role/provider/model/replace mutations retain the logical MemberId only when they explicitly target it +and advance memberRevision. A new logical member cannot reuse a removed key implicitly. + +Restart within one LifecycleRun does not create a new MemberId: the immutable CompositeRuntimePlan +binds MemberId + memberRevision + LaneId, and runtime-control advances a lane attempt/instance identity. +Late evidence from an older memberRevision/lane attempt is stale. Task owner/from strings and inbox/log +directory names resolve through the roster snapshot; unresolved or removed names remain historical +unbound projections rather than being attached to a new member. + +Legacy adoption reconciles config.json and members.meta.json by exact/case-folded key, removedAt and +provider evidence. A single unambiguous entry receives a new MemberId. Case collisions, conflicting +active/removed state or CLI auto-suffixed aliases do not silently merge/drop: they produce +`roster_identity_ambiguous` and block member/runtime mutation while safe diagnostics remain. A +provider-specific manifest may classify an auto-suffixed name as a RuntimeAlias for one plan/lane, +but RuntimeAlias never becomes a second MemberId without an explicit roster command. + +Canonical member commands accept TeamId, MemberId and expected rosterGeneration. Desktop IPC maps a +legacy memberName only after resolving exactly one current roster entity. `replaceMembers` is a +deterministic set-diff command over MemberIds/revisions, not delete-all/recreate-by-name. + +#### ApprovalDecision aggregate + +Identity is `(teamId, approvalId)`. States are pending, claimed, decided, expired, and cancelled. +It owns one-decision-only semantics, policy snapshot, actor attribution, claim expiry, idempotency +key, and audit fact. Multiple tabs can observe the same pending approval, but only one accepted +decision commits. + +#### Task aggregate + +Use a task aggregate only for revisions, dependency/relationship invariants, assignment/status +transitions, and work intervals. List, filter, activity, and Kanban reads are projections. Multi-task +relationship updates use a task-board application transaction/saga because one Task object cannot +atomically own both sides of a graph edge in provider-owned JSON. + +#### DeletionSaga and RunRecoveryWorkflow + +These are separate durable workflows with separate state and owners: + +- `DeletionSaga` is TeamId/lifecycle-generation scoped. It owns soft delete, restore, explicit draft + deletion and permanent deletion/tombstone. It coordinates v1 runtime stop and feature + archive/removal through public commands, stores completed steps/compensations and cannot mutate a + newer currentRunRef. +- `RunRecoveryWorkflow` is RunId/generation/plan scoped. It owns failed provisioning cleanup, + controller-restart reconciliation, ADR-22 residual classification and RunId-owned artifact cleanup. + It cannot delete TeamId, draft metadata, identity anchor, backups or another run generation. + +Each stores current step, target generation, request/workflow idempotency key, lease-fenced claim and +last safe error. Restart resumes or safely classifies each independently. They may reuse small durable +workflow scheduling/claim primitives, but there is no generic saga service allowed to write both +features' repositories or infer business compensations. + +Everything else - list queries, logs, provider catalogs, message pages, capability manifests, and +health - stays as simple validated projections. No aggregate/repository ceremony is added to reads. + +### Commands, queries, outcomes, and contexts + +Application APIs are explicit functions or cohesive modules; a class per use case is not required. +Transport request objects never enter application code. + +Every mutation receives a CommandContext containing: + +- stable actorId, current sessionId and authorization claims as distinct values; +- deploymentId and bootId; +- requestId and operation idempotency key where retry is legal; +- expected entity/run revision or generation where stale writes are dangerous; +- deadline/cancellation signal; +- workspace authorization resolved to an opaque registration. + +Every query receives a QueryContext with actor/session, deployment/boot identity, requestId, +deadline/cancellation, and authorized scope. Queries return immutable DTO-ready projections plus +revision/cursor metadata. They do not leak repository entities or mutable adapter objects. + +Commands return typed outcomes such as accepted, completed, no-op-idempotent, conflict, +unsupported-capability, degraded-provider, or retryable-busy. Expected business outcomes are not +encoded as arbitrary exception strings. Unexpected faults become safe AppError responses with a +diagnostic id; raw provider stderr and host paths remain server-side. + +Use-case example: + + launchTeam(command, context) + -> validate feature policy and expected generation + -> resolve WorkspaceRegistration + -> request team-runtime-control LaunchPlan + -> atomically commit command accepted + LifecycleRun + plan + currentRunRef + outbox + -> return LaunchTeamAccepted(commandId, runId) + + lifecycleWorkflowRunner(runId) + -> claim accepted run with lease fence + -> publish required compatibility projection + -> execute/adopt supervised process plan through ADR-22 + -> commit progress/terminal lifecycle facts and outbox events + +HTTP and IPC adapters both parse their own wire input into this command and map the same outcome to +transport-specific response mechanics. Neither adapter contains the sequence above. + +### Port design without interface explosion + +Create a port only for an independently replaceable, security-sensitive, failure-prone, or atomicity +boundary. Keep related operations cohesive when they must share a transaction or consistency model. + +Good port shapes: + +- HostedAccessRepository: atomically consume challenge, rotate/revoke device family and create/expire + sessions; AuthKeyring: load/create/rotate only under startup/reset rules; ResidualActorDrainPort: + typed v1 runtime drained-or-blocked evidence without process internals; +- TeamLifecycleRepository: load/commit lifecycle snapshot with expected revision; +- TeamMutationCoordinator: atomic file/journal operation boundary for related legacy artifacts; +- ProviderRuntimeRegistry: provider capabilities plus creation of a launch/adoption plan; +- OwnedProcessSupervisor: spawn/adopt/signal/observe only instance-owned processes; +- WorkspaceRegistry: resolve opaque identity and authorize a bounded operation; +- EventJournal: append-after-commit, replay by scoped revision, retention watermark; +- ApprovalRepository: claim/decide with compare-and-swap semantics; +- Post-v1 `TerminalSessionGateway`: create/resize/input/close for an authorized terminal session; it is + not created or injected by the v1 composition. + +Rejected shapes: + +- one method interface for every trivial function; +- `TeamManager`, `RuntimeService`, `StorageService`, or `PlatformAdapter` containing unrelated methods; +- ports carrying Electron event objects, Fastify requests, Node ChildProcess, React state, or raw + provider JSON beyond the adapter boundary; +- a universal repository with `get/save/delete`; +- inheritance hierarchies where independent capability composition is sufficient. + +Interface segregation is applied at consumer boundaries. A renderer hook for task comments receives +`TaskCommentsClient`, not TeamsAPI. A provider adapter implements only the team-runtime-control contracts +it genuinely supports. Unsupported capability is represented in the manifest and rejected before +invocation; there is no fake implementation that throws after the UI calls it. + +### Dependency direction and public entrypoints + +Allowed direction inside a feature: + + contracts <- renderer adapters/hooks/UI + contracts <- preload and input adapters + domain <- application + application ports <- output adapters/infrastructure + composition -> application + adapters + infrastructure + +Core rules: + +- `core/domain` imports only feature-local domain types and the tiny shared kernel; +- `core/application` imports feature contracts/domain and owned ports, never Electron, Fastify, + React, Zustand, fs, path, child_process, provider SDKs, or `@main/*`; +- input adapters perform authentication binding, schema parsing, wire mapping, and status/channel + mapping, then call application use cases; +- output adapters translate core intent to legacy services, filesystem, SQLite, provider protocols, + process supervision, or event journal operations; +- renderer UI imports neither global store nor transport directly; hooks/adapters own that seam; +- outside a feature, production code imports only its documented root or layer entrypoints; +- cross-feature deep imports and dependency cycles are build failures. + +Cross-feature behavior uses one of four explicit mechanisms: + +1. a synchronous public application command/query for required immediate work; +2. an after-commit domain/application event for eventually consistent reactions; +3. a composition-owned saga for multi-feature durable workflows; +4. a read-model projector for joined UI projections. + +Events are facts in past tense and contain stable IDs/revisions, not instructions or adapter objects. +Consumers must be idempotent. Publishing before the owning state commits is forbidden. + +### Renderer composition and read projections + +`team-console` replaces the orchestration responsibilities currently hidden in the global teamSlice +and oversized team-detail screen. It is a thin composition feature, not a backend bounded context. + +It owns only: + +- selected TeamId/WorkspaceId and navigation context; +- one bootstrap state machine for login/capabilities/selection/reconnect; +- TeamTransportReconciler, event cursor, bootId and feature-topic routing; +- composition of lifecycle, task-board, messaging, review, and approval renderer entrypoints; +- stale response suppression when selection, bootId, run generation, or request scope changes. + +Each owning feature keeps its own normalized read projection and mutation state. The console passes +opaque IDs and capability clients; it does not copy feature entities into a second canonical store. +Feature UI may use Zustand slices if useful, but slices expose feature-local actions/selectors and +cannot call another feature's adapter directly. + +Initial page bootstrap is a bounded query bundle assembled in the app composition from public feature +queries. Its envelope contains deploymentId, bootId, capability revision, event cursor, and a +per-feature revision vector. It is an optimization, not a separate source of truth. Each payload +member keeps its owning feature schema/version, and a partial unavailable feature returns a typed +degraded result rather than erasing other valid projections. + +Realtime routing follows ownership: + + scoped event envelope + -> TeamTransportReconciler validates deployment/boot/selection/cursor + -> route by feature topic + -> owning feature reconciler validates team/run/entity revision semantics + -> apply or request owning feature snapshot + +A cursor gap, schema mismatch, boot change, or retention watermark triggers a bounded rebootstrap. +It does not clear the last valid UI until authorization loss or confirmed deletion. Polling is a +fallback query source through the same reconciler, never a second reducer path. + +Renderer migration is a strangler, not a second store rewrite: + +1. Freeze current selectors/race/render behavior with existing characterization tests and a small + reference-scale performance fixture. +2. Introduce narrow facet clients behind the current teamSlice actions first; this changes transport + dependency without changing state authority. +3. Extract only scope/cancellation/event-cursor/topic routing into TeamTransportReconciler while + teamSlice remains the only writer. Move run/tombstone rules to team-lifecycle, task revision rules + to team-task-board, message head/older-page rules to team-messaging, and request resolution rules + to team-approvals. +4. Move one projection at a time to its owning feature, starting with lifecycle read state. A + compatibility selector may read the new projection for legacy components; events never write both + old and new canonical stores. +5. Migrate UI components/hook orchestration through feature renderer entrypoints, preserving existing + Radix primitives, localization, focus and keyboard behavior. +6. Delete each legacy field/action/event handler only after all selectors/callers are migrated and + desktop IPC plus hosted HTTP/event permutations pass the same reducer fixtures. + +Gates include bounded request/event fanout, no duplicate SSE subscription per browser session, no +extra full-team fetch when only one feature revision changes, structural sharing for unchanged +entities, and reference-scale render/heap budgets. Architecture can be clean and still unusable if +every event causes a 13k-line UI tree to refresh. + +### Composition roots + +There are three application compositions: + +- Electron composition: existing main-process shell, feature IPC adapters, desktop infrastructure; +- hosted Node composition: Fastify/static/auth/SSE shell plus the same feature application + factories and hosted infrastructure; +- deterministic test composition: in-memory/temporary adapters, fake clock/IDs, fake provider + executables, real application use cases, optionally real HTTP listener/browser. + +Feature factories accept explicit dependencies and return narrow public facades plus lifecycle +components. The app-level composition is the only place allowed to choose concrete adapters and +connect features. It may know every feature, but it contains no business decisions. + +Each long-lived component implements a small operational contract: + + start(signal) -> readiness result + readiness() -> typed reason set + stop(deadline) -> drain/flush result + +Startup is ordered and fail-closed: the ADR-16 launcher acquires and retains the kernel lease before +Node exists; Node validates the inherited descriptor, immutable config/mounts/artifacts, then binds the +private listener in `starting` admission mode (liveness and safe startup reason only), opens +coordination storage, recovers journals/sagas, classifies and stops residual runtimes required by +the trusted-process boundary, reconcile ADR-7 device/session/reset state and create plaintext pairing +material only when initial/reset recovery requires it, establish the watch-before-scan barrier, start +provider supervision, then advance explicit auth/read/mutation readiness states. +All registered product/runtime routes return typed 503 while their state is not ready. Failure unwinds +only components started by this instance in reverse order, removes owned bootstrap material and exits +non-zero after bounded diagnostics; it never leaves a pairing token beside an unclassified runtime +or reports mutation-ready before recovery/initial reconciliation completes. + +### Practical DRY policy + +Share stable semantics, not coincidentally similar syntax. + +Share early: + +- domain invariants and transition functions; +- browser-safe schemas and error/event envelopes; +- use cases called by both IPC and HTTP; +- revision/idempotency/reconciliation algorithms; +- security-sensitive normalization and redaction. + +Do not prematurely share: + +- IPC and HTTP wire mechanics; +- provider-specific DTOs merely because fields overlap; +- legacy JSON and app-owned SQLite records; +- UI view models and persistence entities; +- two parsers whose unknown-field or corruption behavior differs. + +Duplication is temporarily acceptable until equivalence is proven by contract tests or a third +consumer appears. Extraction must reduce semantic ownership, not merely line count. A shared helper +with feature flags or provider switches is usually evidence that the boundary is wrong. + +### Strangler sequence for legacy code + +The clean branch does not rewrite `teams.ts`, TeamDataService, teamSlice, and provider integrations +simultaneously. It moves one behavior at a time to a single new authority: + +1. Characterize current desktop behavior and record invariants. +2. Define the owning feature contract, application command/query, and ports. +3. Implement compatibility output adapters over the base branch's mature services/formats. +4. Make the existing IPC handler delegate to the new use case without changing desktop UX. +5. Run desktop characterization and parity tests. +6. Add hosted HTTP/SSE input adapters to that same use case. WS is added only by a future capability + that semantically requires it; v1 team operations do not prebuild a generic socket path. +7. Migrate renderer callers to narrow capability clients, TeamTransportReconciler and the owning + feature reconciler/reducer. +8. Remove the old handler/service path only when no caller uses it and both transports pass + conformance tests. +9. Replace compatibility output adapters with cleaner infrastructure later, behind unchanged ports, + only when persistence/provider behavior is understood. + +At no point may desktop and hosted both be authoritative for the same lifecycle mutation. Shadow +mode is read-only comparison only. Dual-write migration requires a durable journal and is avoided +unless a specific storage transition cannot be performed otherwise. + +### Architecture fitness functions + +The following automated checks land before broad implementation and fail CI: + +- forbidden-import test for Electron/Fastify/React/Zustand/fs/path/child_process/@main under all + `core/` and browser-safe `contracts/` folders; +- public-entrypoint test preventing cross-feature deep imports; +- dependency-cycle check across features and layers; +- TeamTransportReconciler isolation check: no lifecycle/task/message/approval entity imports, store + writes, run selection, tombstones or pagination logic; +- renderer check forbidding `window.electronAPI.teams` and direct hosted transport access in migrated + feature UI/hooks; +- hosted renderer reachability check rejecting any Electron/preload/mega-client/desktop-only + entrypoint from hosted route chunks and rejecting unavailable capability effects/listeners at mount; +- type-level check that hosted clients implement only advertised capability facets, never ElectronAPI; +- RouteCatalog conformance across feature descriptors, Fastify registration, hosted client, + authorization policy, capability manifest, and E2E coverage; +- trust-surface manifest check proving browser clients/cookies cannot reference runtime-ingress routes + and runtime credentials cannot authorize operator routes; +- command conformance proving IPC and HTTP reach the same application use case/outcome mappings; +- repository-write ownership check preventing direct config/tasks/inbox writes outside approved + output adapters; +- team-runtime-control check preventing direct spawn/kill outside OwnedProcessSupervisor; +- composition test cross-checking execution and machine-ingress provider IDs/verbs without requiring + every provider to implement unsupported ingress operations; +- schema fixture tests for current, legacy, future, partial, and corrupt provider artifacts; +- no-stub gate scanning reachable hosted methods for throw/no-op/fabricated-success implementations. + +Target base already violates several final rules, so gates use a ratchet rather than pretending the +repository is greenfield: + +- new feature/core/contracts code has zero exceptions from its first commit; +- legacy violations are captured once by stable file + symbol, not a fragile total count; +- CI rejects new or widened violations and rejects moving a violation to another legacy file; +- each vertical slice removes the entries for the behavior it migrates; +- final hosted readiness requires zero allowlisted team/review browser stubs, zero hosted terminal + imports/effects and zero direct Electron bypasses, even if unrelated desktop debt remains. + +Every temporary allowlist entry has an owner, exact reason, removal phase and focused regression +test. An entry without a removal gate is a failed architecture check, not documentation. + +### Explicit non-goals against overengineering + +- no generic application framework or plugin framework; +- no event sourcing as primary product storage; +- no distributed broker for single-instance v1; +- no CQRS infrastructure beyond practical command/query separation; +- no class/interface/file per use case requirement; +- no ORM over provider-owned or CLI-owned JSON; +- no universal repository, service locator, or dependency injection container; +- no multi-tenant authorization model hidden inside a single-tenant release; +- no rewrite of mature provider adapters solely to match folder aesthetics; +- no abstraction whose only implementation and only plausible consumer are the same module. + +### Pre-implementation evidence gates + +The plan does not pretend runtime unknowns can be removed by naming more interfaces. Each high-risk +unknown has a bounded evidence task that must close before dependent implementation expands. + +| Gate | Evidence required | Blocks | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| Base truth | exact remote base SHA, clean checkout, required-check results, pre-existing failure classification | all production work | +| Renderer truth | action/callsite inventory plus characterization of selection races, snapshot richness, message pagination, optimistic run replacement and tombstones | team-console/store migration | +| Identity/workspace truth | team metadata fixtures plus manifest schema for anchored/unanchored/duplicate/corrupt IDs, rename, missing/remounted/overlapping roots and forbidden rebinding | canonical IDs and first hosted mutation | +| Path-containment truth | built Linux guard in the final image; `openat2`/`statx`/seccomp/filesystem probe; successful file, Git and provider-spawn operations; deterministic parent/final symlink, rename, bind-mount and stale-generation race failures | every v1 hosted workspace read/write/Git/spawn capability | +| Persistence truth | authoritative file catalog, ADR-29 writer class/active-writer evidence per operation/provider version, atomicity, unknown-field behavior, size/permission limits and golden fixtures | first hosted mutation | +| Provider truth | per-provider launch/preflight/auth/liveness/stop/recovery matrix and deterministic adapter fixtures | capability advertisement and real launch | +| Runtime-ingress truth | producer/direction/schema/authority/idempotency/rate/credential mapping for bootstrap, delivery, task event, heartbeat and permission flows | machine route registration and provider launch | +| Credential/env truth | ADR-18/30 ProcessExecutionUnit exposure sets, key-provenance ledger, per-backend allowlist, relay derived scope, controller/out-of-set secret canaries and explicit same-UID non-isolation claim | any provider exec or machine ingress | +| Process truth | ADR-31 final-image anchor/pidfd/subreaper/control/status evidence, shutdown/rollback rules, PID/PGID reuse marker and double-fork/orphan scan | real ProcessSupervisor use | +| Terminal truth | no terminal route, daemon, socket, artifact, capability or browser control in v1 | negative image/import/browser gates | +| Hosted boundary truth | actual built Fastify/static/auth/SSE plus separate runtime-ingress topology, proxy/origin behavior and container filesystem/user model | browser E2E and deployment | + +Every gate produces versioned fixtures or a checked-in decision table, not only notes. A failed gate +narrows or disables the affected capability; it does not cause a generic abstraction or a silent +fallback. No real provider launch is needed to close these gates: deterministic fake +executables and sanitized artifact fixtures are the default. Any optional live provider proof uses +only a newly created sandbox project under the repository's critical safety guardrails. + +Decisions fixed before Phase 1: + +- single hosted writer per runtime root; +- single tenant/operator v1; +- pairing-to-durable-device-grant-to-short-session browser auth for v1, with host-controlled reset; +- canonical `/api/hosted/v1` namespace; +- HTTP for commands/queries and SSE for durable feature events in v1; WebSocket is reserved for the + post-v1 terminal capability and no generic WS abstraction is introduced early; +- compatibility reads/writes for legacy provider files plus app-owned SQLite for coordination data; +- team-runtime-control is the only team process-execution authority; runtime-provider-management + remains the provider installation/settings authority; +- opaque browser identities and registered workspace roots; +- ADR-28 Linux workspace guard is required for hosted workspace effects; Node path-string fallback is + forbidden and an unsupported kernel/seccomp/filesystem disables the affected capability; +- ADR-30 controller bearer stays in a per-lane relay and hosted child environments are allowlist-first; +- ADR-31 process anchor is required for hosted provider launch; PID/start-token kill fallback is forbidden; +- no broad ElectronAPI compatibility promise in browser mode; +- no visible capability without a real server implementation and negative security tests. + +These decisions may change only through an ADR that identifies affected contracts, migrations, +tests, rollout and rollback. Ordinary implementation convenience is not sufficient reason. + +## Architecture decisions + +### ADR-1: one real hosted server composition + +The target-base `standalone.ts` + `HttpServer` + `standalone:build` + Docker path is the migration +source. Refactor it into a thin entrypoint and app-level hosted composition; do not introduce a +parallel `hosted:build` server until/unless it replaces standalone in the same atomic slice. + +The default production hosted artifact serves: + +- built renderer assets; +- canonical hosted API; +- authentication/session endpoints; +- SSE; +- private machine runtime ingress; +- health/readiness. + +Do not port the closed PR's static-only hosted shell. If an existing developer preview ever serves +UI without runtime APIs, it is not built, documented, or tested as a hosted production artifact. + +Production browser deployment is same-origin and does not require CORS. Runtime ingress does not use +browser CORS semantics and remains private plus token-authenticated whether it shares the listener or +uses a separate internal listener. If a separate browser API origin is later enabled, it has an exact +allowlist; wildcard credentialed CORS is never a hosted default. + +### ADR-2: canonical versioned API + +- /api/hosted/v1 is canonical for the browser. +- Route constants and runtime parsers have one owner. +- Server and client import the same contract entrypoint. +- Target-base non-team `/api/*` routes remain temporarily for the existing standalone session viewer. +- Legacy `/api/teams` remains a compatibility adapter for loopback/desktop migration only; it is not + registered or advertised as the remote hosted team contract. +- No browser code calls legacy routes directly. +- A route/use-case manifest test proves legacy and canonical routes cannot acquire independent + business implementations while both exist. +- A same-origin meta endpoint exposes buildId, contractVersion, capabilitySchemaVersion, stable + deploymentId, and per-process bootId. The client detects incompatible versions and + offers/requires a reload. +- index.html is no-cache and assets are content-hashed/immutable so a deployment cannot silently run + stale JavaScript against a newer contract. No service worker is introduced without an explicit + upgrade/cache strategy. + +### ADR-3: IPC and HTTP share application semantics + +Both transports call the same use case. +Transport adapters only: + +- validate/parse input; +- establish auth/actor context; +- map DTOs; +- map typed errors to transport responses. + +They do not read config.json, mutate caches, or know storage paths. + +### ADR-4: explicit single-tenant v1 + +- One deployment equals one operator/tenant. +- One runtime root has one hosted controller/app writer process. +- Provider/CLI agents that write compatible JSON are explicit external protocol participants; their + writes are reconciled and cannot be assumed to acquire app locks. +- One process owns its child runtimes. +- Multi-user isolation is achieved with separate containers/OS identities. +- Shared-process SaaS is a separate future project. + +Hosted v1 must use a dedicated appDataRoot/coordination database and a deliberately mounted Claude +runtime root. A desktop Electron instance is another application writer, not a harmless provider +writer: it may not concurrently open the same hosted appDataRoot or mutate the same team root. The +supported handoff is stop desktop/hosted, flush and back up, run the compatibility scan/import, then +start exactly one controller. Read-only export/import is allowed; live desktop+hosted dual authority +is not. Startup metadata records deployment mode/root identity, and a detected incompatible desktop +owner or foreign app-writer marker keeps mutation readiness false. + +### ADR-5: explicit RuntimeInstanceContext + +Introduce immutable runtime context: + +- stable deploymentId; +- per-process bootId used for ownership and stale-response detection; +- claudeRoot; +- appDataRoot; +- workspaceRoots; +- tempRoot; +- logsRoot; +- platform; +- deploymentMode; +- capability policy. + +deploymentId is loaded from the validated app-owned state-root identity and follows ADR-26; it is not +derived from hostname/public URL or regenerated on every boot. bootId is always newly generated. + +Do not rely on module-global setClaudeBasePathOverride in hosted composition. + +TeamDataService/ConfigManager adapters that still depend on module-global roots are desktop +compatibility adapters only. Hosted adapters receive RuntimeInstanceContext explicitly and may reuse +pure parsing/writing helpers, but cannot hide a singleton/root override behind an application port. + +### ADR-6: opaque workspace IDs + +Browser requests never carry an authoritative host path. + +Core v1 execution lock: the identity model and already implemented file/SQLite primitives are +preserved, but hosted startup is read-only. It may inspect and classify legacy directories; it may +not automatically publish, repair, rename, or attach identity. The detailed replicated adoption and +repair protocol below is retained for an explicit offline operator import after controller/runtime +shutdown, not as a Core v1 startup workflow or maintenance UI. + +The `workspace-registry` application boundary maps: + + opaque workspaceId -> stable registration -> current boot mount binding -> policy/capabilities + +Every sensitive operation rechecks: + +- registration; +- current bootId/mountGeneration; +- allowed root containment; +- realpath; +- symlink/mount identity; +- permissions; +- operation-specific capability. + +Browser contracts use server-minted workspace/artifact/review/attachment references, not absolute +paths and not free-form relative paths. A compatibility endpoint that still receives a legacy +relative path must reject absolute/drive/UNC/NUL/dot-segment input before lookup and may return only +a newly minted opaque reference; it cannot execute the requested operation directly. + +For reads/writes below an agent-writable tree, lexical containment is insufficient. Hosted Linux uses +the ADR-28 workspace guard to resolve beneath a verified directory descriptor and to keep the checked +object/cwd bound to that descriptor through the effect. Node `realpath()`, post-read verification and +`O_NOFOLLOW` on only the final component are characterization-era desktop defenses, not the hosted +security boundary. If ADR-28 cannot prove an operation on the running kernel/filesystem/container, +that operation's hosted capability stays disabled; no path-string fallback is allowed. + +Team routes likewise use an opaque stable teamId. Existing legacy directories are adopted through +an app-owned TeamIdentityRegistry: + + opaque teamId -> legacy teamName/directory -> workspaceId -> identity generation + +`legacy teamName/directory` is modeled as immutable `LegacyTeamKey`, not as TeamId and not as the +mutable display name. It exists only because current CLI/provider/task/backup layouts require the +same directory segment across multiple roots. The canonical CreateTeamDraft request may carry a +non-authoritative `requestedTeamKey` solely as creation input to preserve current UX. The application +validates the exact lowercase ASCII policy (`^[a-z0-9][a-z0-9-]{0,127}$`), cross-platform reserved +names and direct-child semantics; it never accepts a path or silently normalizes a different key. +The renderer may suggest a slug, but the server decides availability and returns TeamId. + +The adoption/create intent reserves `(TeamId, LegacyTeamKey)` together and checks the identity index, +tombstones, team/task/backup roots and filesystem-aware case-folded directory inventory before any +file write. After commit, every browser URL/cache/command uses TeamId; LegacyTeamKey is adapter-only +provider evidence. It is never changed by display rename and is never reused within a v1 deployment, +even after permanent delete, because old runtime callbacks, backups, JSONL and watcher events still +carry teamName. A repeated display name receives a different suggested key/suffix. Legacy directories +that are unsafe, case-colliding, ambiguous across roots or outside the validated direct-child model +remain visible through a read-only `legacy_key_unsupported` projection and require explicit staged +maintenance migration; startup never auto-renames them. + +Identity uses a dedicated app-owned `team.identity.json`, never `team.meta.json`, config.json, +members.meta.json or a launch-state file. Version 1 contains only schemaVersion, immutable random +TeamId, createdAt and optional non-authoritative originDeploymentId. It contains no display name, +directory, workspace path/ID, provider or mutable lifecycle state. TeamMetaStore and provisioning +writers do not read/write/delete fields inside it. + +The existing TeamBackupService `identityId`/`_backupIdentityId` is not reused as TeamId. Adoption may +record it as untrusted legacy correlation evidence after checking config, backup manifest and registry, +but always mints a new canonical TeamId. This avoids turning a best-effort field in CLI-owned config, +which current same-name resurrection logic can rotate, into authorization identity. Backup schema v2 +stores the canonical TeamId separately and stops treating legacy backup identity rotation as team +identity creation. +Backup v2 indexes sets by canonical TeamId plus opaque BackupSetId and records LegacyTeamKey only as +compatibility metadata. Existing teamName-keyed backup directories may remain as migrated physical +locations initially, but lookup/resurrection/prune decisions never use their basename as identity. + +For a writable legacy team without an ID, adoption is a three-step replicated-identity protocol: + +1. Internal-storage claims a directory/adoption intent with candidate TeamId and `prepared` status; + this is recovery evidence, not yet a live identity mapping. +2. TeamIdentityFileStore publishes the file exactly once using a supported atomic exclusive-create + primitive, mode 0600, no-follow path checks and file/parent fsync. It has createIfAbsent/read and a + narrowly scoped recovery-republish operation, never arbitrary update/overwrite. +3. One SQLite transaction commits TeamId -> directory/workspace/generation, stores the identity-file + checksum and marks the intent complete. + +No live index mapping exists before file publication. The file is the portable anchor and the +committed SQLite record is the local admission/integrity authority; neither silently wins on +disagreement. The adoption intent also stores the canonical directory fingerprint, candidate ID, +workspace-binding evidence and expected absence/file checksum so recovery never guesses by name. +`DirectoryInstanceFingerprint` is local fencing evidence produced by the filesystem adapter from the +canonical parent/name plus available device/file identity and observed creation/config evidence. It is +not embedded in the portable identity file and is not expected to survive backup, cross-host restore or +an authorized move; those operations establish a new local fingerprint through their own staged saga. + +The allowed identity states are explicit: + + prepared -> file_published -> committed -> tombstoned + | | | + +----------> aborted +----> integrity_blocked + +`aborted` and `tombstoned` candidate IDs are never reused. `integrity_blocked` is a diagnosis/admission +state, not a new identity. Recovery/reconciliation follows this matrix: + +| File | SQLite intent/index | Required action | +| ---------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| absent | matching prepared intent | resume publish only if directory fingerprint and binding evidence still match; otherwise abort+tombstone candidate | +| valid | matching prepared/file_published intent, no committed row | verify global duplicate/tombstone/workspace constraints, then commit the same ID/checksum | +| valid | no intent or row | classify as portable restore/import; stage and validate before an explicit adoption/import decision, never silently attach by directory name | +| valid | matching committed row/checksum | healthy | +| absent | committed row | integrity-block and require explicit repair; automatic startup republish would hide deletion/data loss | +| different ID/checksum, duplicate ID, future/corrupt schema | any | integrity-block every affected mapping, retain both evidences and require explicit repair | + +The explicit repair command may republish the same committed ID only after operator authorization, +global duplicate/tombstone scan, directory fingerprint/workspace-binding verification and a durable +repair intent. It can never allocate a replacement ID in place. Crash recovery is idempotent at +every row above and emits one audit/outbox transition when the effective state changes. + +V1 exposes this through a distinct `team-identity-recovery` capability only when the deployment is +started in explicit maintenance mode. Normal team mutation admission and runtime launch are closed; +the paired durable OperatorId may read redacted evidence and submit one CSRF-protected command with +typed TeamId confirmation, expected file/index/backup hashes and an idempotency key. The server takes +a verified coordination backup before applying the durable repair intent. Maintenance mode cannot +mint a new identity for an existing committed mapping, accept a duplicate, or bypass workspace +registration. Every attempt is audited. The control is absent in normal mode, so ordinary TeamsAPI +parity cannot turn integrity failure into a casual repair button. + +Although the file is app-owned, it lives beside paths touched by provider/CLI processes. Watcher +deletion/change is therefore a security/integrity event, not a normal external projection update. It +never allocates or republishes a TeamId automatically. A partial/corrupt/future file keeps the last +committed identity visible for diagnostics only while mutation remains blocked. Identity events use +their own narrow watcher/reconciliation path and cannot be suppressed as a known config/task write. + +Lifecycle semantics are fixed: + +- `CreateTeamDraft` allocates TeamId and publishes/commits the identity before the draft becomes + externally visible. The durable draft and every later provisioning run reference that same TeamId; + prepare/launch failure and retry never mint a replacement identity; +- draft creation extends the adoption intent with normalized draft input and expected legacy + projection checksums: prepare candidate/binding, publish identity, write team.meta/members.meta + through their compatibility stores, verify, then atomically commit identity index + draftGeneration + - checksums + outbox in SQLite. Reads expose the draft only after commit. A crash with partial legacy + projections remains recovering and either completes the same candidate or tombstones/cleans only + operation-owned files; it never invents a second draft from directory presence; +- display-name changes never alter TeamId or require a directory rename. Any supported legacy + directory relocation preserves LegacyTeamKey/basename and moves the whole directory with + `team.identity.json`; v1 never renames the key. Soft-delete is a lifecycle marker, not a partial + anchor move, and restore keeps the same TeamId/LegacyTeamKey; +- failure before the draft+identity commit may abort/tombstone the candidate and remove only the + operation-created empty root through its cleanup intent. Failure of provisioning for an already + committed draft/team records a failed RunId and removes only attempt-owned bootstrap/temp/runtime + artifacts; it never recursively removes the team root, identity or retryable draft metadata; +- `DeleteTeamDraft` is an explicit idempotent lifecycle command with expected identity generation and + tombstone/cleanup saga. It is never an implicit catch-block side effect of prepare/spawn failure; +- permanent delete commits the identity tombstone/generation fence before deleting directory/files; + tombstone retention prevents late events or legacy-name reuse from attaching to a new team; +- backup/restore includes and verifies the identity file. Restoring a duplicate ID into one + deployment blocks both mappings instead of silently assigning a new ID; +- old app versions may ignore the new file, but rollback cannot use a version whose delete/backup/ + restore behavior loses it. Compatibility matrix must explicitly allow or refuse that downgrade. + +Publication has a hard prerequisite: every current path that can recursively delete, recreate, move, +restore or back up a team directory is either routed through the identity lifecycle adapter or blocked +for anchored teams. This includes deterministic create/spawn cleanup, draft cleanup, permanent delete, +backup restore/prune/reconciliation and same-name resurrection. A CI ratchet rejects new raw recursive +team-root removal outside the approved adapter. Soft delete may continue to mark config only because it +does not remove the anchor, but permanent delete must durably tombstone before filesystem removal. + +If TeamIdentityFileStore cannot publish the file, the team may appear as `identity_unanchored` +through a deployment-stable opaque index entry, but all hosted mutations are disabled and an +external rename may be treated as remove+new identity. Do not fabricate full stable-identity +capability. displayName and legacy teamName remain projections, not authorization/cache keys. +Desktop IPC may keep teamName-shaped compatibility DTOs until callers migrate; canonical +application and hosted mutation contracts use anchored teamId. + +Team -> workspace binding is server-owned versioned identity state, not whatever mutable +config.json projectPath/cwd was read last. Initial adoption canonicalizes the legacy path and must +match exactly one enabled registration/current mount binding; zero or ambiguous matches yield +`workspace_unbound`/read-only. The accepted WorkspaceId plus bindingGeneration is indexed in +internal-storage. `team.identity.json` deliberately does not embed deployment-specific WorkspaceId. + +Evidence precedence is deterministic: an already committed TeamWorkspaceBinding wins; otherwise a +valid provider config projectPath/cwd is considered, while team.meta cwd is considered only for a +pre-config draft. If config and draft/meta evidence coexist and canonicalize differently, adoption +produces `workspace_binding_mismatch` instead of selecting one. A new browser draft starts from an +authorized WorkspaceId, commits its binding, and only then writes the legacy cwd projection required +by current provisioning. Neither display metadata nor the existing backup manifest projectPath can +create authority. + +A browser-requested workspace/project/worktree change is a dedicated lifecycle command with expected +binding generation, no active incompatible run, full WorkspaceRegistry authorization and intent/ +recovery. It writes compatible legacy fields only after committing the new binding intent. An +external agent/CLI edit to projectPath/cwd is observed as `workspace_binding_mismatch`; it never +rebinds authority or becomes a spawn/file/Git cwd automatically. Until explicitly accepted +or repaired, affected mutations and runtime launch are disabled while safe diagnostics/read remain. +Every operation resolves cwd from `(TeamId, bindingGeneration, WorkspaceRegistration)`, then compares +legacy config evidence and requests an ADR-25 grant for the current mountGeneration; it never resolves +WorkspaceId from a request/mutable file or reuses a prior-boot mount grant at the last moment. + +Hosted v1 has one registration authority: a versioned operator-owned JSON startup manifest. An +environment variable may point to the manifest file but cannot encode workspace entries. There is no +browser registration endpoint and no new admin CLI in v1. + +Each entry contains immutable registrationKey, displayName, declared rootPath, access mode, +Git/worktree capabilities and optional allowedWorktreeRoot. Internal-storage assigns/persists opaque +WorkspaceId by registrationKey; the declared rootPath hash is immutable for that key. Duplicate +keys/current roots, relative paths, overlapping writable roots, missing required mounts or a changed +declared rootPath for an existing key fail mutation readiness. Boot-local canonical/mount evidence is +versioned separately by ADR-25. Relocation uses a new registrationKey/WorkspaceId in v1. + +Manifest removal disables the registration and retains its ID/tombstone; it never reassigns that ID. +Manifest changes require restart/reconciliation and invalidate affected sessions/capabilities. +Worktree creation is rejected unless repository and destination resolve inside explicitly allowed +roots; a sibling path is not implicitly trusted. Desktop uses a separate compatibility resolver for +locally selected projects, but cannot feed arbitrary paths into hosted composition. + +### ADR-7: browser session, not secret in JavaScript + +The earlier in-memory-session design was incomplete: the one-time pairing token was destroyed after +login, so session expiry, browser-cookie loss or backend restart could demand re-login when no +credential remained. Reissuing plaintext pairing material while same-UID runtimes were alive would +also violate the declared trusted-process boundary. + +Three continuity designs were evaluated: + +1. **Durable device grant plus short server session - chosen.** + 🎯 9/10 🛡️ 9/10 🧠 8/10, approximately 1,200-2,200 changed lines including proxy/security/browser + tests. It survives ordinary restart and idle expiry while keeping browser secrets HttpOnly. +2. **One durable long-lived session only.** 🎯 7/10 🛡️ 7/10 🧠 5/10, approximately 700-1,200 + changed lines. Simpler, but a normal absolute expiry or lost cookie forces disruptive re-pairing, + encouraging unsafe infinite session lifetimes. +3. **OIDC/passkey as mandatory v1 authentication.** 🎯 6/10 🛡️ 9/10 🧠 9/10, + approximately 1,800-3,500 application lines plus external identity/deployment work. Strong but it + broadens the single-operator Electron-decoupling scope and adds an availability dependency. + +Primary guidance: OWASP requires meaningless high-entropy server-side session IDs, TLS, server-side +idle/absolute expiry and ID renewal after authentication/privilege change. Cookie `__Host-` semantics +require Secure + Path=/ + no Domain, but cookies are not port-bound. Fastify warns that forwarded +host/protocol values are spoofable unless the exact proxy chain is trusted. References: +[OWASP session management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html), +[HTTP cookie prefixes](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-cookie-name-prefixes), +and [Fastify trustProxy](https://fastify.dev/docs/latest/Reference/Server/#trustproxy). + +The chosen state has three separate records: + + PairingChallenge (one-time host bootstrap) + -> OperatorDeviceGrant (durable re-authentication family) + -> OperatorSession (short browser authority) + +All plaintext values are independent 256-bit CSPRNG outputs. Coordination storage persists only +keyed hashes plus opaque record IDs, OperatorId, generation/family, issued/last-used/idle/absolute +expiry, replacement/grace metadata and revoked reason. Cookie values contain no identity/role/state. +Hash keys are app-owned bootstrap secrets, never child environment/config values. One versioned +`auth.keyring` is created with exclusive create, mode 0600, file+parent fsync and a random keyId/key +before auth storage opens; ordinary restart reuses it. Missing/corrupt keyring while auth rows exist +closes auth readiness and cannot silently generate a replacement/pairing file. Only a newer host reset +generation after runtime drain may revoke rows and rotate the keyring. ADR-26 excludes/rotates it and +revokes copied auth records rather than preserving browser authority. No key/value enters logs, +diagnostics, backup, provider mounts or child processes. + +Do not hand-roll Cookie header parsing/serialization. Target-base uses Fastify 5.8.5 but has no cookie +plugin. At plan time `@fastify/cookie` 11.0.2 is the current stable package and its package tests use +Fastify 5; its parser runs in `onRequest`, before dependent security hooks. The implementation gate +must recheck the then-current stable release/compatibility table, pin the exact reviewed version and +run malformed/duplicate/cookie-limit tests. The plugin only parses/serializes these opaque tokens; +server-side keyed hashes remain the authority, so plugin-signed stateless sessions are forbidden. +Reference: [`@fastify/cookie`](https://github.com/fastify/fastify-cookie/tree/v11.0.2). + +#### Pairing and operator recovery + +1. Initial startup issues a `PairingChallenge` only when there is no active device grant and ADR-31 + reconciliation proves no live/unclassified same-UID runtime. It atomically writes the + plaintext to one mode-0600 operator file under app-owned state, stores only its keyed hash and logs + only a retrieval instruction/path. The token is submitted once in a bounded POST body over TLS, + never URL/log/localStorage. +2. Successful exchange atomically consumes the challenge, removes the file, creates the first device + grant/session and rotates every pre-auth identifier. Failed comparison, attempt exhaustion and + expiry produce no grant/session; challenge cleanup is crash-idempotent. +3. A missing/expired browser device credential is recovered only through an operator-controlled + startup `pairingResetGeneration` in the immutable deployment manifest. The generation must be + strictly newer than the persisted consumed generation. Startup revokes every grant/session, + closes mutation/launch admission, drains ADR-31 runtimes, proves no residual actor, + then creates a new challenge. There is no browser/runtime endpoint that increments it. +4. Container deployments apply reset by changing the manifest and replacing the container, making + the container/process boundary the final drain proof. Non-container deployments must pass the + same anchor residual scan. If anything is unclassified, no plaintext file is created. +5. Restart with a valid durable device grant creates no pairing file. Restart with no grant may + reissue an expired initial/reset challenge only after the same zero-runtime proof; it does not + silently revoke an existing family or change OperatorId. + +Reset crosses SQLite and one filesystem keyring, so it uses a durable `AuthResetIntent` rather than +pretending one transaction covers both: + + requested -> draining -> new_key_staged -> authority_revoked + -> key_activated -> challenge_issued -> completed + +The staged key is created/fsync'd first but is not used. One SQLite transaction then revokes old +challenge/device/session rows, records consumed reset generation and expected new keyId. Only after +that commit may same-directory rename+parent-fsync activate the staged keyring; challenge creation/hash +occurs last under the matching active key. Startup compares file keyId, expected keyId and intent state +before any auth lookup. Crash resumes forward while admission stays closed: if a post-revocation stage +is lost, it may generate another key/update expected keyId because all prior authority is already +durably revoked; it may never switch key before revocation or issue plaintext under a mismatch. +`completed` is idempotent and the old key is securely unreferenced, not retained as an acceptance key. + +The reset generation is non-secret but host-controlled. A trusted runtime that somehow requests a +reset can at worst force fail-closed drain/DoS; it cannot obtain a challenge while still alive or +authorize itself. Operational docs must state that losing all device cookies requires a controlled +runtime-stopping re-pair, not deleting SQLite/session files by hand. + +#### Device grant and session lifecycle + +- Pairing returns a persistent `__Secure-atd` device cookie with Secure, HttpOnly, SameSite=Strict, + no Domain and Path restricted to the auth-renew endpoint, plus a short `__Host-ats` session cookie + with Secure, HttpOnly, SameSite=Strict, Path=/ and no Domain. The server never accepts either value + from headers/body/query or another cookie name. +- Sessions have server-enforced idle, absolute and renewal deadlines. Exact defaults are frozen in + Phase 0 from the product's long-running-control UX and OWASP ranges; production may narrow them but + cannot disable absolute expiry. Ordinary backend restart reloads/revalidates hashed records instead + of invalidating the operator unexpectedly. +- A same-origin POST to the dedicated renew route may use a valid device grant to mint a fresh session + after idle/absolute access-session expiry. It requires exact configured Origin/authority and + rate/attempt limits before grant lookup. Device grant and session IDs rotate after pairing, + privilege/security-policy change and on a bounded renewal schedule. +- Device-grant rotation stores only current/recent hashes. A bounded predecessor grace window and + family-size/rate cap allow response loss or simultaneous tabs to retry; any accepted predecessor + creates a newer generation, never restores an older one. Browser cookies are shared across tabs, so + a rotation-conflict loser retries session bootstrap with the now-current cookies. Reuse outside the + grace window revokes the family as suspected replay and requires host re-pair. +- Logout atomically revokes the current session and clears its cookie. `Forget this device` additionally + revokes the whole device family and clears both cookies; it explicitly warns that a later login + needs another device grant or host re-pair. Revocation/expiry is server-side, not cookie deletion only. +- Multiple devices are not silently enabled. V1 defaults to one active device family; pairing reset + replaces it. Supporting concurrent operator devices later requires an explicit product policy and + per-device revoke UI, not relaxed validation. + +Unsafe methods require a session-bound CSRF token held only in renderer memory and sent in one custom +header. The authenticated no-store bootstrap derives/returns a stable token from the presented session +secret and controller-only CSRF key, so reload/multiple tabs need no plaintext CSRF database value; +session rotation changes it. Exact Origin/authority validation runs before CSRF, body parsing, +idempotency claim or command creation. CORS and SameSite are defense in depth, never authorization. + +SSE authenticates the session and exact Origin before emitting any bytes. Session expiry closes with +a typed auth event/status; the UI preserves server-owned team state, renews through the device grant, +then obtains a new CSRF/meta/snapshot rather than interpreting 401 as deletion. Machine/runtime +credentials remain a disjoint ADR-14 surface. V1 registers no WebSocket upgrade route. + +#### Production origin and proxy contract + +Target-base standalone currently binds `0.0.0.0`, defaults CORS to `*`, publishes direct HTTP 3456 in +Compose and derives no authenticated public origin. That remains a local read-only/demo behavior only; +it cannot be incrementally called production auth. + +- Production requires one explicit normalized `PUBLIC_ORIGIN=https://dedicated-host[:port]` with no + credentials, path, query or fragment. Allowed Origin/authority and cookie policy come only from this + immutable value; they are never learned from Host, Forwarded or X-Forwarded-Host. +- Either Fastify terminates TLS directly, or a configured proxy-address/CIDR allowlist overwrites + forwarding headers and connects over a private listener/network. `trustProxy=true`, numeric hop + trust and arbitrary forwarding chains are forbidden. Requests from an untrusted socket cannot make + themselves secure or change client IP/authority with headers. +- Pairing/renew/session/SSE requests require one expected public authority and HTTPS evidence from + the direct TLS socket or trusted proxy. Ambiguous/multiple forwarded host/proto values, unexpected + authority, direct production HTTP and missing Origin on browser state-changing/upgrade requests fail + before cookies or bodies are processed. Private health checks use separate routes and never auth. +- The edge strips incoming forwarding headers, sets its own, redirects HTTP to HTTPS and emits HSTS. + The application port is not host-published in the production Compose profile. The current direct + `3456:3456` example is replaced or explicitly labelled insecure local demo. +- `__Host-` cookies do not bind to a TCP port. Hosted production therefore requires a dedicated + hostname with no untrusted sibling service on another port; exact Origin/authority checks remain + mandatory even with the prefix. +- Production never downgrades Secure/HttpOnly/SameSite attributes to make a test pass. Deterministic + E2E uses an ephemeral HTTPS edge/certificate and the real proxy allowlist. A separate loopback-only + developer profile may use explicit test cookies, but its capability manifest says + `productionAuth=false` and it cannot satisfy release readiness. + +Never put pairing, device, session, CSRF, runtime or terminal secrets in localStorage, URL/query, +renderer bundles, service-worker cache, logs, diagnostics or backup. OIDC may later replace the device +grant issuer at the TLS edge, but it does not create a second half-supported v1 login mode. + +### ADR-8: preserve filesystem compatibility first + +Do not migrate all runtime state to SQLite in the first hosted milestone. + +- Keep storageMode=legacy-files-v1. +- Put existing JSON/JSONL layouts behind repositories. +- Preserve unknown fields for CLI-owned files. +- Add schema/migrations only to app-owned files. +- Use a per-team write coordinator/journal for multi-file invariants. +- Fail closed or read-only on unknown future critical schemas. +- Quarantine corrupt critical state and rebuild only known derived caches. + +For new app-owned coordination state, prefer one decoupled/hosted-packaged instance of the existing +internal-storage SQLite capability rather than inventing several JSON stores. It may own deployment/ +team/workspace identities, idempotency/command records, event journal, approval policy/audit, and +deletion/repair intents transactionally. This does not move CLI-owned config/tasks/inboxes into the +database. If the required SQLite worker/native binding is unavailable or migration fails, hosted +mutation readiness is false; a silent JSON fallback is acceptable only for explicitly non-critical +legacy desktop projections. + +### ADR-9: one provider runtime authority + +`team-runtime-control` becomes the single authority that chooses and executes a runtime backend for +each planned lane. Target-base TeamRuntimeAdapterRegistry is a compatibility seed, not the final +universal interface: today OpenCode is adapter-routed, while Anthropic, Codex and Gemini flow through +the deterministic provisioning path and are launched as one Claude-compatible CLI process with +provider-specific arguments. Preserve that proven topology before improving its internals. + +Chosen v1 design: + +- `ProvisioningCliExecutionBackend` first wraps the existing deterministic provisioning flow for + Anthropic, Codex and Gemini without rewriting it. It owns the current Claude-compatible CLI launch, + provider argv/model/auth/preflight/bootstrap-evidence mapping and existing launch/reconcile state. +- `OpenCodeExecutionBackend` reuses the existing TeamRuntimeAdapterRegistry, manifests, delivery and + recovery semantics because they are materially different from provisioning-CLI lanes. +- both implement one small `LaneExecutionBackend` contract covering plan validation, launch, + observe, stop and recover outcomes; +- team-runtime-lanes selects lane topology; team-runtime-control maps each lane to exactly one backend; +- runtime-provider-management supplies installed/auth/settings facts but never executes a lane. + +Adding a provider extends a provider strategy or adds a genuinely different backend plus capability/ +conformance tests. It does not add another branch to lifecycle/HTTP/UI orchestration. A future move +of OpenCode under the provisioning backend requires a focused ADR and parity proof, not a cleanup. + +The compatibility boundary sits outside the existing deterministic flow. Phase 4 may ratchet +provider-specific resolution into strategies only after black-box characterization proves identical +argv, environment redaction, bootstrap materialization, progress, cancellation, persistence, +reconciliation and cleanup. It must not first decompose the legacy flow and then try to rediscover its +semantics. `claudePath` is an implementation detail of this backend, not a domain concept or a claim +that every provider is native Claude. + +The common contract covers only plan/preflight/launch/observe/stop/recover outcomes. Provider auth, +bootstrap, delivery journals and diagnostics stay tagged/provider-specific behind it. Do not force +every provider into OpenCode fields merely to make registry entries look uniform. + +A separate machine-ingress handler registry is allowed because it routes inbound +bootstrap/delivery/heartbeat commands rather than owning launch/process execution. Its provider IDs +and capability verbs must be cross-checked against the execution registry at composition/readiness; +neither registry may silently claim the other's operations. + +Unacceptable: + +- OpenCode through a registry while other providers branch inside a monolith; +- a second registry beside TeamRuntimeAdapterRegistry with overlapping execution ownership; +- hidden module-global mode flags; +- browser code selecting internal provider credentials or binary paths. + +### ADR-10: hosted terminal is outside v1 + +V1 keeps the desktop terminal unchanged and exposes no hosted terminal routes, WebSocket upgrades, +daemon, PTY launch, saved-session storage, browser controls or terminal readiness dimension. No v1 +workspace-guard, process-anchor, auth, backup or event requirement is justified only by a future +hosted terminal. + +A future hosted terminal starts as a separately scoped security project from the then-current code and +dependencies. It must choose its transport, shell isolation, authorization, backpressure and process +drain contracts at that time. Earlier terminal exploration is research/history, not an executable v1 +contract or release gate. + +### ADR-11: server truth with a small transport reconciler and feature-owned reducers + +The server is authoritative for teams, tasks, messages, lifecycle runs, approval policy, and +runtime observations. The renderer may cache projections and own ephemeral interaction state, but +it may not invent durable domain truth. + +Create a transport-neutral TeamTransportReconciler used by both IPC and HTTP clients. It owns only: + +- deployment/boot/session/selection request scope and cancellation tokens; +- event cursor plus envelope duplicate/gap/schema detection; +- feature-topic dispatch and bounded invalidation coalescing; +- reconnect/resync and fallback polling scheduling. + +It does not import lifecycle/task/message/approval entity types, compare their revisions, select +runs, own tombstones, paginate feeds, or mutate feature stores. Those rules live in feature-owned +pure reconcilers/reducers. A feature handler returns applied, ignored, or snapshot-required; the +transport reconciler only schedules/routs that result. + +Do not let raw EventSource callbacks, IPC callbacks, or polling promises mutate Zustand directly. +They first enter TeamTransportReconciler and then the owning feature reconciler. Preserve current +behavior through characterization tests, then simplify only after conformance is proven. + +Renderer failures use a transport-neutral AppError with code, safeMessage, retryable, +diagnosticId, and optional currentRevision. IpcError/unwrapIpc become IPC adapter details or are +renamed; application/store code must not branch on transport names or raw error strings. + +### ADR-12: feature ownership instead of a new team god-module + +The canonical ownership list is `hosted-access`, `team-lifecycle`, `team-task-board`, +`team-messaging`, `team-review`, `team-approvals`, `team-runtime-control`, `workspace-registry`, +and `team-console`, alongside the reused existing features listed above. `terminal-workspace` remains +the canonical desktop owner and is not part of hosted v1 composition. + +- No generic `team-application` facade owns all operations. +- Each feature publishes only browser-safe contracts and deliberate process-specific entrypoints. +- `team-console` composes renderer projections but owns no durable feature state. +- app-level hosted composition wires transports/security but owns no team, workspace, or provider policy. +- Cross-feature workflows use public commands/queries, after-commit events, or explicit durable sagas. +- Folder layers are created only when behavior needs them. + +### ADR-13: practical SOLID and selective DDD + +- ISP is enforced with capability client facets and provider-specific capability contracts. +- DIP ends at feature-owned application ports; use cases import no runtime framework. +- SRP separates transport parsing, application sequencing, persistence, provider processes, and UI + reconciliation. +- LSP forbids fake browser implementations of desktop contracts; unsupported behavior is absent from + the capability surface. +- DDD applies only to TeamLifecycle/LifecycleRun, TeamWorkspaceBinding/TeamRoster, + WorkspaceRegistration, ApprovalDecision, task revision/relations, ADR-7 device-grant/reset state + and durable deletion/recovery sagas. +- DRY follows proven semantic equivalence; transport/provider/persistence shapes may remain + intentionally duplicated until their invariants match. + +### ADR-14: browser control and machine runtime ingress are separate trust surfaces + +Target-base OpenCode runtime callbacks currently live under `/api/teams/:teamName/opencode/runtime/*` +beside operator routes and accept raw provider payloads. They must not be copied into the hosted +browser namespace. + +Canonical split: + +- `/api/hosted/v1/*`: operator browser session, CSRF/Origin policy, capability facets; +- `/api/runtime/v1/runs/:runId/*`: private machine ingress, no browser session/CSRF semantics, + authenticated by an opaque lane/run-scoped credential plus strict Origin-independent machine + policy; +- filesystem reconciliation: no HTTP credential, but bounded to registered runtime roots and + validated as external evidence rather than trusted commands. + +For each admitted runtime lane, the server creates a high-entropy opaque ingress credential, stores +only its hash and scope in app-owned coordination state, and gives the plaintext only to the +controller-owned ADR-30 lane relay over an inherited one-use pipe. The provider/agent process receives +no canonical `/api/runtime/v1` bearer in argv, environment, settings, MCP config or a run file. It +talks to its narrow local relay capability, which fixes lane identity and forwards canonical ingress. +The bearer never enters team config, renderer JSON, URL/query, logs, artifacts, SSE, diagnostics or +provider-neutral DTOs. A provider backend that cannot use this relay has no hosted machine-ingress +capability until an equally narrow adapter is designed and proved. + +Credential scope includes deploymentId, teamId, runId, generation, laneId/providerId and allowed +verbs. Lane restart or team relaunch creates a new credential; lane/team stop, delete and other +final lifecycle outcomes revoke the relevant credentials. Body IDs must +match the credential/current-run scope and cannot expand it. Private networking is defense in depth, +not authentication. + +No runtime-ingress credential grants operator actions such as approval decision, policy change, +launch/stop/delete, workspace registration, arbitrary message impersonation or +cross-lane control. A runtime may submit an approval request and bounded delivery acknowledgement; +the decision is accepted only through operator policy/API and sent to the provider through an +outbound runtime adapter. Provider-specific legacy naming such as `permission-answer` cannot weaken +that directionality in the canonical contract. + +Canonical ingress DTOs contain opaque IDs, bounded provider evidence and idempotency/sequence data. +They do not accept authoritative `teamName`, `cwd`, expected member definitions, prior launch state, +binary paths or credentials. The current OpenCode compatibility adapter resolves/validates those +values against server-owned run/workspace/member state before invoking canonical ingress use cases. +Sender/member/provider/lane identity is derived from the credential, not trusted from the body. +Message targets, task events and cross-team delivery are re-authorized against current team/task/ +organization policy; a correctly isolated relay token cannot select another member/team scope. +ADR-30 defines the weaker guarantee under the default same-UID `trusted_process` profile and forbids +describing lane tokens as hostile-sibling isolation. + +Replay rules: + +- delivery/task/permission mutations require a persisted idempotency key plus ADR-34 versioned + normalized-intent fingerprint and an effect class/evidence contract; +- bootstrap and heartbeat require current generation/session identity, bounded monotonic sequence or + deduplicated commandId, freshness window and rate limit; +- duplicate identical input returns the prior acknowledgement; conflicting reuse is rejected; +- stale/revoked/wrong-lane credentials produce no feature mutation or event. + +Target-base RuntimeControlService currently executes the provider handler and only then calls an +event sink. If sink persistence fails, the provider side effect may have committed while the caller +sees failure and retries. Canonical ingress therefore claims/persists the command first, records the +provider acknowledgement and outbox event in the recoverable command protocol, and never blindly +re-executes an ambiguous provider effect. Provider adapters must either be idempotent by canonical +commandId or expose bounded acknowledgement reconciliation; otherwise that verb is not advertised. + +Runtime ingress and operator control have different input adapters, auth policies, rate limits and +contracts even when they reach the same underlying run state. They are never two implementations of +one HTTP interface. + +The canonical machine ingress is shared by Electron and hosted compositions. Desktop is not exempt: +its owned runtimes can set Authorization headers and receive a lane/run-scoped secret through the +launch adapter. Legacy OpenCode `/api/teams/:teamName/opencode/runtime/*` remains only behind a temporary +loopback compatibility flag while runtime clients migrate, then is removed from route registration +and tests. Remote hosted readiness never depends on that flag. + +Machine schemas/parsers initially live under the feature's main-owned runtime-ingress adapter and +are not exported from browser-safe `contracts/index.ts`, preload, renderer facets or capability +manifest. If an independently versioned external runtime client later requires a distributable +schema package, that extraction is a separate compatibility decision; v1 does not create a generic +SDK preemptively. + +### ADR-15: static feature-owned route descriptors, no transport framework + +Each v1 HTTP/SSE endpoint has one static descriptor owned beside its input adapter. The app composition +collects descriptors into a read-only RouteCatalog before listener readiness. + +Illustrative shape: + + type RouteDescriptor = { + id: string; + owner: FeatureId; + trust: 'browser' | 'runtime' | 'public-health' | 'private-ops'; + method: HttpMethod; + path: string; + authPolicy: AuthPolicyId; + readiness: readonly ReadinessDimension[]; + requestSchema?: SchemaId; + responseSchema: SchemaId; + capability?: CapabilityId; + legacy?: { removalPhase: string }; + }; + +The registration function uses that same descriptor's method/path/schema references when calling +Fastify. Browser route constants/schemas are exported from the owning feature's browser-safe +contracts; runtime-ingress descriptors/schemas remain main-owned. Composition asserts unique +method+path and id, known auth policy, valid trust/auth combinations, registered handler, capability +owner, known ADR-21 readiness dimensions, and no legacy route without a removal phase. + +Security ordering is fixed by trust policy rather than repeated ad hoc in handlers. For browser +routes, Fastify `onRequest` first validates socket/proxy/PUBLIC_ORIGIN, bounded cookie/header shapes, +session/device policy and unsafe-method Origin/CSRF header before body parsing. Only then do bounded +content-type parsing/schema validation, readiness/capability admission, idempotency claim and the +application use case run. Pair/renew have dedicated pre-auth policies; runtime policies are disjoint. +No dormant terminal/WS trust kind is reserved in v1. A rejected auth/origin/CSRF request therefore +cannot allocate a body-sized object, consume an +idempotency key or leave a command row. Response policy adds no-store/redaction before serialization. + +The server capability manifest is derived from registered browser descriptors whose application +dependencies, authorization policy and provider/runtime capability are ready. Runtime, private ops, +health and legacy descriptors are never converted into browser capabilities. E2E reports descriptor +IDs, so route coverage and capability coverage share stable identifiers. + +Do not add decorators, reflection, OpenAPI code generation, a central route switch, or a generic +handler framework. RouteCatalog is metadata plus assertions; feature adapters still contain normal +Fastify translation code. OpenAPI may be generated later from the catalog/schemas, but is not a v1 +dependency or source of truth. + +### ADR-16: one local hosted writer is a kernel-held stable-inode lease + +V1 supports exactly one hosted controller for one deployment root on one Linux host. It does not +claim distributed consensus, multi-host failover or active-active replicas. `replicas: 1` is an +operator preference, not the safety mechanism: Docker permits one volume to be mounted into multiple +containers simultaneously. A second controller must fail before Node starts any stateful application +component. + +The previous atomic-directory design is rejected. A live owner can keep using an unlinked inode while +another process creates a new directory at the same path; metadata polling only notices after both +writers have existed. Target-base `fileLock.ts` is also not a seed for this lease: it is an app-local +`open('wx')` marker, closes its descriptor, trusts PID/age metadata and may delete a lock older than its +timeout even while the owner is alive. Neither design fences a paused, renamed or unlinked live writer. + +Three designs were evaluated: + +1. **Stable root-owned inode plus kernel `flock` held across the controller lifetime - chosen.** + 🎯 9/10 🛡️ 9/10 🧠 8/10, approximately 1,200-2,200 changed lines including the narrow launcher, + packaging and race/container tests. +2. **Orchestrator-only singleton (`replicas: 1`, systemd/Compose policy).** + 🎯 6/10 🛡️ 7/10 🧠 5/10, approximately 500-1,000 changed lines. Useful defense in depth, but it + cannot fence a second Compose project, manual start or misconfigured replacement sharing the root. +3. **Mutable lease directory/file with PID, heartbeat or TTL takeover.** + 🎯 3/10 🛡️ 3/10 🧠 4/10, approximately 500-900 changed lines. Rejected because pathname + replacement creates two distinct inodes and TTL/PID inference can steal from a live paused owner. + +The operator or one-shot init job provisions one deployment volume layout before the non-root app is +started: + + /data/agent-teams/ root-owned, not writable by runtime UID + instance.lock root-owned stable regular file, not replaceable by runtime UID + state/ runtime-UID-owned writable application state + +The anchor and state directory share the same root-owned deployment parent and one declared +DeploymentId/registration. The runtime UID must be unable to rename/unlink the anchor or parent but +may open the anchor for locking; giving it a writable deployment parent fails startup. A fresh named +volume is initialized explicitly rather than letting each app replica independently create its lock +anchor. Device/inode numbers are boot-local evidence, not durable deployment identity. + +`agent-teams-instance-lock` is a small, separately versioned Linux launcher/supervisor: + +1. Before starting Node, it descriptor-opens the declared deployment parent and `instance.lock` + without following symlinks, verifies a regular stable anchor, ownership/mode, non-writable parent, + expected state child, mount/filesystem support and manifest protocol/hash. +2. It obtains nonblocking `flock(LOCK_EX | LOCK_NB)`. Busy, unsupported, missing, replaceable or + ambiguous anchors produce a distinct fail-closed exit code. No migrations, listener, routes, + recovery, watchers, process inspection or provider effects have started at this point. +3. It forks/execs the pinned Node artifact and deliberately shares the same open-file description on + one reserved descriptor with Node while retaining its own duplicate. The launcher and controller + both keep the descriptor open: failure/accidental close in either half cannot release ownership + while the other half remains alive. The launcher's parent/control pipe makes signal forwarding, + controller exit and container exit one bounded lifecycle. +4. Node validates the inherited descriptor with `fstat`, records a random leaseId plus deploymentId, + bootId, hostname/OS boot ID, launcher/controller process identity, device/inode/mount evidence, + artifact/manifest hash and acquiredAt in diagnostic state, then keeps the descriptor in one + `InstanceLeaseGuard` until shutdown. Metadata and heartbeat are observability only; they never + grant, renew, steal or release ownership. +5. If the launcher dies, Node's duplicate still holds the lock; control-pipe EOF first closes + mutation/runtime admission, drains within policy and exits. If Node dies, the launcher still holds + the lock until child/container teardown is initiated. Clean release occurs only after Node has + closed admission, drained owned effects, flushed durable state and exited. There is no TTL stealing, + PID-file takeover or live lock-anchor deletion path. +6. Every hosted child spawn has an explicit descriptor policy. The lease FD is controller-only and + must be absent from process anchors, providers, relays, PTYs, Git, diagnostics and arbitrary helper + descendants. Final-image tests compare `/proc//fd` inode evidence and prove that killing a + child cannot prolong or acquire the instance lease. + +Linux `flock` ownership is attached to an open file description, duplicate descriptors share it, it is +released only after all duplicates close and it survives `execve`. Conversely, `unlink` removes a +name while an open file remains alive, which is exactly why the anchor pathname must be outside the +runtime UID's writable namespace. Primary references: +[flock(2)](https://man7.org/linux/man-pages/man2/flock.2.html), +[unlink(2)](https://man7.org/linux/man-pages/man2/unlink.2.html), and +[Docker volume lifecycle](https://docs.docker.com/engine/storage/volumes/). + +Only host-local filesystems whose cross-process and same-volume cross-container `flock` behavior +passes the final deployment probe are supported. NFS/CIFS/other network-volume semantics are rejected +for v1 even if a particular mount appears to work. `flock` is advisory, so all app/controller writers +must enter through this launcher; provider/CLI writers remain separately governed external +participants under ADR-24/29. Desktop and any legacy writer cannot share the hosted writable roots. + +Tests start two real final-image containers with the same provisioned volume and anchor, plus a second +Compose project/manual invocation. Exactly one reaches Node/application startup; losers perform zero +migration, recovery, listener bind, spawn or write. The suite also attempts anchor unlink/rename, +parent replacement, same-name recreation, controller/launcher SIGSTOP/SIGKILL, PID reuse, duplicate-FD +close ordering, child-FD leakage, full-container crash and clean handoff. An unsupported mount or +failure of any invariant disables hosted mutation/runtime readiness; there is no atomic-directory or +PID-marker fallback. + +### ADR-17: production hosted build has no fake platform implementation + +The canonical hosted artifact is allowed to exclude a capability, but it may not make an imported +Electron/native dependency appear functional through an empty module. In the production hosted +dependency graph: + +- importing `electron`, `electron-updater`, preload modules or an Electron-only adapter is a build + error; the current broad `electronStub()` is removed from the canonical server build; +- required native modules are externalized and copied/rebuilt for the pinned Node runtime/OS/arch; + they are never matched by the current catch-all `.node` empty stub; +- an optional native feature is omitted at composition and absent from the capability manifest when + its artifact is missing. It does not register an adapter that fails only after user interaction; +- internal-storage has a separately emitted worker entry inside the same hosted artifact, a stable + production URL, and a fixed hosted resolution to the Node-ABI SQLite package. It never tries to + execute the Electron-ABI addon or a TypeScript source path at runtime; +- required provider/controller/MCP helpers use an explicit artifact inventory with source, + target path, executable bit, version/hash and startup probe; +- image build performs smoke loads/queries for every required worker/native/helper artifact before + the image is published, and startup readiness repeats bounded functional probes. + +Post-v1 T1 extends this inventory with one source-compatible terminal-platform artifact set. V1 does +not stage or probe that set. + +This does not create a second server or packaging system. `standalone:build` remains the one hosted +build and is hardened to emit the server, renderer, worker entries and artifact manifest. Electron +packaging keeps its own ABI/artifact path. A bundle scan and negative fixture prove a new Electron +deep import or unstaged native addon fails CI instead of silently becoming `{}`. + +### ADR-18: one immutable composite runtime plan per lifecycle generation + +`team-runtime-lanes` remains the sole topology planner. `team-runtime-control` executes its result; +neither HTTP nor `team-lifecycle` reconstructs lanes. In the same internal-storage transaction that +accepts LifecycleRun and advances TeamLifecycle.currentRunRef, persist an immutable +CompositeRuntimePlan containing planVersion/hash, team/run/generation, lead provider, topology mode, +ordered lane IDs, rosterGeneration plus MemberId/memberRevision/LegacyMemberKey-to-lane mapping, +backend binding, registered workspace/cwd identity, explicit ProcessExecutionUnits and +ADR-25 mountGeneration plus required-versus-optional member policy. Recovery uses this exact plan. +Config or mount-generation changes require a new +generation; they cannot mutate the meaning of an accepted run. + +V1 preserves the target-base topology/ordering contract: + +- non-OpenCode-led primary members execute through one provisioning-CLI primary lane; +- mixed OpenCode members and pure-OpenCode worktree-root members use deterministic per-member side + lanes with stable lane IDs; +- the primary lane reaches its characterized first-real-turn gate before queued mixed side lanes are + admitted; pure OpenCode modes keep their current adapter ordering; +- OpenCode-led mixed teams remain an explicit unsupported capability until the planner itself gains + that topology and conformance tests. HTTP cannot bypass the planner rejection. + +A `LaneId` is lifecycle/protocol topology, not automatically a process or credential-isolation +boundary. The planner snapshots each actual `ProcessExecutionUnit` with executionUnitId, backend, +member/lane set, resolved binary policy, environment-policy hash and a metadata-only +`CredentialExposureSet` of provider SecretRef IDs/classes. The set never contains secret values. +Target-base mixed provisioning may intentionally run multiple provider members inside one +Claude-compatible primary process; that unit receives the minimum union of credentials proven +necessary for those members and reports `credentialIsolation: shared_execution_unit`. Dedicated +OpenCode/other side processes report `dedicated_execution_unit` only when their exposure set is +actually disjoint. + +Credential isolation is enforced between ProcessExecutionUnits, not invented between logical members +that share one process. Splitting the characterized primary process solely to improve isolation would +change bootstrap/order/auth/runtime semantics and requires a separate topology ADR plus parity proof. +Conversely, no adapter may merge units or widen a persisted exposure set after run acceptance; a new +provider/member/credential need requires a new plan generation. + +Composite state is derived from lane/member evidence, not overwritten by whichever lane finishes +last. Each lane has its own run identity, phase, attempts, process/session evidence, credential and +terminal result. Team `ready` requires every required member/lane to have fresh verified evidence; +side-lane failure produces the characterized partial/degraded outcome and member diagnostics rather +than rewriting successful primary members as failed. Optional-member policy, if supported by the +pinned base, must be explicit in the plan snapshot; absence never silently makes a member optional. + +Cancel/stop/recovery are generation-fenced composite commands: + +1. Persist the composite intent and close admission for new lanes. +2. Stop/cancel all started owned lanes and mark never-started queued lanes cancelled. +3. Reconcile every lane outcome with bounded parallelism and deadline. +4. Commit the composite terminal/degraded/operator-required result plus event. + +A partial stop never returns clean success. Retrying targets the same plan/generation and only +unfinished lanes. Restart recovery cannot re-plan from changed config, duplicate a queued side lane, +or reuse a primary credential for a side lane. Provider/lane characterization covers every current +topology, primary failure before side-lane admission, side-lane partial failure, cancel at each gate, +duplicate turn-complete, restart with queued/running lanes and stale manifests. + +Active target-base runs at rollout are not reverse-engineered into a canonical plan. A per-TeamId +cutover fence has two states: `legacy_drain` and `canonical`. If one current legacy generation is +unambiguously known, only its existing status/cancel/stop/recovery adapter remains enabled; new launch, +member topology edits and workspace rebind are blocked. When that generation is terminal and cleanup +is verified, one durable transition enables canonical mode and all later launches require ADR-18 +plans. Ambiguous/multiple legacy candidates remain recovery-blocked and are never resolved by mtime/ +newest-run selection. Default hosted startup has no adoptable live run because of the pairing/stop- +owned boundary; this drain path primarily protects desktop/in-place upgrades. + +### ADR-19: build-time parity ledger, not a replacement mega-interface + +A checked-in build-time parity ledger is the release traceability authority. It is not imported by +runtime application code and does not generate a universal client/service. Each record maps one +pinned legacy surface member or visible renderer action to its semantic replacement. + +Minimum record fields: + + source: TeamsAPI | ReviewAPI | CrossTeamAPI | renderer-action + sourceMember/actionId + sourceSignatureHash and pinnedBaseSha + owningFeature + replacementKind: direct | decomposed | merged | browser-native | desktop-only + capability/action IDs + application command/query IDs + RouteCatalog descriptor IDs and/or IPC channel IDs + authorization policy and effect class + revision/idempotency/event obligations + rollout state and legacy removal phase + characterization/unit/conformance/E2E test IDs + +`getData` is recorded as decomposed into feature-owned projections/bootstrap members; raw-path +review methods map to opaque file/change-set operations; legacy `on*` subscriptions map to scoped +event topics; `showMessageNotification` maps to explicit browser notification capability. A mapping +does not require preserving an unsafe signature or one route per old method. It requires preserving +the intended user-visible semantics or explicitly classifying the behavior as desktop-only/deferred +before its control is rendered. + +Parity for a mutation means the ledger captures and tests its preconditions, authorization, +validation, canonical effects, partial outcomes, idempotency/revision behavior, emitted projection/ +events, error states and recovery. A method name returning 200 is not parity. A query additionally +captures completeness, pagination/bounds, freshness/source generation and redaction. + +Characterization compares semantics on isolated cloned fixtures, never by dual-running mutations +against one team. With deterministic clock/IDs and fake provider/process boundaries, the legacy IPC +path and replacement use case receive equivalent valid/invalid scenarios; normalized outcomes, +authoritative file deltas and projection changes are compared. Transport-only fields, safer opaque +IDs/redaction and intentionally fixed legacy defects are recorded as reviewed expected deltas. A +legacy security bug is not preserved merely to make the diff green, but its replacement behavior and +UI consequence require an explicit ledger note/test. + +CI uses the TypeScript AST to enumerate the pinned TeamsAPI/ReviewAPI/CrossTeamAPI members and hashes +their normalized signatures. It also inventories renderer actions/callsites through a maintained AST +scanner plus explicit dynamic-call annotations. The gate fails on an unmapped addition/removal/ +signature change, duplicate ownership, missing referenced capability/route/use case/test, required +action without E2E, desktop-only action still visible in hosted UI, or a legacy callsite remaining +after its removal phase. + +Phase 0 checks in an immutable `legacy-surface-baseline` generated from the recorded base SHA; CI +never fetches a moving remote to reconstruct history. A migrated legacy member may disappear from +current source only when its ledger record is in the declared removal state, replacement evidence is +green and the callsite scanner reports zero remaining consumers. The historical record stays in the +ledger. A genuinely new current member/action requires a new record; an intentional signature change +updates its current hash and semantic obligations in the same reviewed change. This lets the strangler +delete the mega-interface gradually without either freezing it forever or forgetting what it replaced. + +RouteCatalog remains runtime route metadata, the capability manifest remains runtime availability, +and feature contracts remain source code. The parity ledger references their stable IDs and CI +cross-checks them; it does not merge them into a god manifest or become a code-generation framework. + +### ADR-20: hosted renderer cuts Electron reachability before component mount + +Hosted mode is not implemented by mounting the desktop tree and hoping every `typeof api.method`, +`window.electronAPI?` or click handler stays dormant. The renderer composition resolves capabilities +before feature entrypoints mount and constructs only supported facets/listeners/widgets. + +Rules: + +- feature UI/hooks receive narrow facets/callbacks through their public renderer entrypoint; they do + not read `window.electronAPI`, the global ElectronAPI object or hosted transport directly; +- subscription registration belongs to the owning feature mount lifecycle. Hosted bootstrap does not + execute the current global store block that registers editor/Codex/team listeners indiscriminately; +- desktop-only editor/open-in-folder/native chooser/shortcut components are behind desktop renderer + entrypoints or lazy capability boundaries and are neither mounted nor imported by the hosted team + route chunk. A hidden button around an already-running effect is insufficient; +- browser replacements are deliberate adapters: WorkspaceRegistry selector for selectFolders, + Blob/File upload for getPathForFile, safe allowlisted browser navigation for openExternal, local + keyboard handling for onCmdN, opaque processRef for kill, and browser-native download/preview where + desktop openPath/editor has no hosted equivalent; +- organization assignment is composed only when its real organization facet/action is supported; + otherwise the selector and submit-side call are both absent, not caught after team creation; +- optional chaining/function-existence checks remain only inside the quarantined legacy adapter. + Migrated feature code gates by typed facet/action state and cannot treat structural method presence + as support. + +CI maintains two complementary gates: + +1. AST/import-graph reachability: from the hosted renderer entry/route chunks, reject Electron/preload + imports, direct `window.electronAPI`, quarantined mega-client imports and desktop-only entrypoints. +2. Runtime mount/action conformance: render every capability permutation, assert unavailable feature + effects/listeners never register, then exercise every rendered interactive control against the + real browser transport with zero unavailable/no-op/console-error calls. + +Shared presentational components may remain common; platform behavior arrives as a small facet or +callback. Do not fork the entire TeamDetailView into unrelated desktop/web copies. Migrate one +feature-owned subtree at a time and delete the legacy branch once both compositions use the shared +presentational surface. + +### ADR-21: readiness is a lattice, not one global boolean + +The application exposes independent readiness dimensions: + +| Dimension | Minimum proof | Consumers | +| --------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| live | process/event loop and listener respond | public liveness probe only | +| serve | renderer assets, canonical origin/security shell and safe error handling ready | edge routing/startup UI | +| auth | ADR-7 hash keys/store, pairing/reset/device/session policy and trusted proxy/origin ready | pair/renew/session/logout | +| read | identity/workspace scan and required read projections stable | list/detail/status queries | +| mutation | ADR-16 kernel lease FD/launcher, SQLite, migrations/recovery, watch barrier, writable mounts and command journal ready | create/update/delete commands | +| runtime-control | mutation ready plus artifacts, process supervisor, planner and selected backend ready | launch/stop/recovery commands | +| machine-ingress | current lane/run credential/replay/effect handler ready | scoped runtime callbacks only | +| terminal | statically not offered in v1 | no v1 consumer/global-ready effect | +| recovery-point | optional internal app-owned coordination-backup driver ready | migration/repair tooling only | + +RouteDescriptor declares the required static readiness dimensions; feature/action policy adds dynamic +workspace/provider/run requirements. Admission evaluates both and returns typed 503/retry guidance +without invoking the use case. A failed provider probe disables that provider action, not unrelated +task reads. SQLite/recovery failure may leave serve/read available in explicit read-only mode. Stop, +status, token revocation and recovery routes required to drain previously accepted work remain +available through a narrowly defined recovery admission even when new mutation admission is closed. +`recovery-point` never advertises a live deployment-wide backup. ADR-32 restore runs only from the +stopped-stack one-shot tool and is not inferred from ordinary server readiness. + +`/health/live` contains no dependency details. `/health/ready` means serve-ready for edge traffic and +returns only a coarse startup/degraded code; it is not evidence that every product mutation works. +Authenticated meta/capability projections expose safe per-dimension/action reason codes and revision. +Private operator diagnostics may show deeper redacted reasons. A readiness revision change emits a +scoped capability event and invalidates affected action allowances. + +Tests cover every single-dimension failure and recovery, route admission without handler invocation, +read-only emergency mode, drain/recovery exceptions, and transition ordering. No code sets a global +`isReady=true` before all earlier startup barriers have completed, and no provider-specific outage +takes the login/team-read UI offline. + +For v1 the terminal dimension is statically `not_offered`, is omitted from the required-readiness +conjunction and has no registered RouteDescriptor. It is not reported as a degraded release failure. + +### ADR-22: process ownership is a durable spawn protocol, not a PID map + +Default hosted v1 is `container_owned_stop`. The controller, provider children and a +minimal init run in one container PID namespace/lifecycle; `preserve-for-adoption=false`. A production +backend restart means replacement/restart of that complete container, not an in-place Node hot restart +that leaves children behind. SIGTERM still performs bounded graceful stop, but container teardown is +the final orphan boundary. Cgroups/namespaces used for lifecycle accounting do not upgrade the declared +`trusted_process` security profile. + +Traditional `kill(pid)` has a race even after checking `/proc`: the target may exit and its PID may be +recycled before the signal. Linux explicitly provides pidfds to avoid that race. A process group is +also not a durable identity after its leader/tree disappears. Hosted therefore uses the ADR-31 +`agent-teams-process-anchor`; Node never signals a persisted raw PID or PGID. + +Every hosted provider spawn follows one recoverable protocol: + +1. persist a spawn intent with TeamId/RunId/generation/member/lane, resolved binary identity, + WorkspaceId/bindingGeneration/mountGeneration, ADR-30 child-environment policy hash, relay scope + hash and random spawnNonce before any process effect; +2. spawn the process anchor without a shell from an app-owned neutral cwd, with dedicated control and + status pipes. The hosted adapter consumes the server-only ADR-25 grant and supplies only the fixed + expected root/mount evidence required by ADR-28; the grant itself is never serialized. The anchor + revalidates that evidence, becomes the stable ownership-group leader/subreaper, then forks/execs + the allowlisted provider child; +3. the anchor opens a pidfd for the main child, owns/reaps descendants and reports one bounded ready + record containing anchor/main-child identity, workspace evidence and spawnNonce checksum. Raw argv, + environment and secrets never enter the status protocol; +4. the application verifies the runtime/bootstrap handshake against the same run/lane/nonce and only + then commits `ProcessOwnershipRecord`. A failed handshake is anchor-owned cleanup, not adoption; +5. normal stop writes a typed TERM/grace/KILL request to the live control pipe. The anchor keeps its + process-group identity allocated while forwarding signals, uses pidfd for the main child, reaps + descendants, emits `drained`, then exits. Node observes status/EOF and never falls back to PID kill; +6. controller pipe EOF/parent death triggers the same bounded anchor cleanup. If the anchor/control + protocol is missing, corrupt, wedged or exits before `drained`, the run becomes + `unclassified_residual` and only full container replacement may restore mutation/pairing readiness. + +The anchor is not a provider manager or domain service. It knows only descriptor-bound cwd entry, +process group/subreaper mechanics, signal escalation, bounded status framing and reaping. It cannot +select providers, teams, credentials, retry policy or lifecycle state. The feature-owned +ProcessSupervisorPort owns the semantic plan; the hosted adapter translates that plan to the anchor. + +Desktop and explicit non-container development keep `process_owned_stop` behind a separate adapter: +POSIX process groups plus start-token checks and Windows Job Objects. They cannot be substituted for +the hosted anchor contract if they provide weaker drain evidence; LSP is enforced by distinct +capability records, not one interface that sometimes guesses. + +A crash before anchor spawn proves no effect from the intent. A crash after spawn but before committed +ownership is still container-owned because the control pipe/parent-death path orders cleanup; missing +`drained` evidence remains `unclassified_residual`. Startup never guesses, kills by name, reconnects +to a stale pipe or treats a PID file as proof. Unknown processes outside persisted intents are observed +only. The production artifact probes anchor protocol/build hash, subreaper, pidfd and TERM/KILL/reap +behavior inside the final container. + +Primary references: [pidfd_open(2)](https://man7.org/linux/man-pages/man2/pidfd_open.2.html), +[pidfd_send_signal(2)](https://man7.org/linux/man-pages/man2/pidfd_send_signal.2.html), +[PR_SET_CHILD_SUBREAPER](https://man7.org/linux/man-pages/man2/PR_SET_CHILD_SUBREAPER.2const.html), +and [Docker init/reaping guidance](https://docs.docker.com/engine/containers/multi-service_container/). + +`processRef` remains a random opaque lookup key to the server-side record, never an encoded PID. +Retention keeps anchor ownership facts long enough to reject late stop/kill retries while +secrets, argv and environment values never enter browser projections or durable ownership records. + +### ADR-23: one hosted schema version until a second public format exists + +V1 does not build a generated compatibility matrix for every state family before the first hosted +state format has shipped. The release artifact carries one small immutable contract: + + artifactVersion + hostedStateSchemaVersion + minimumReadableHostedStateVersion + orderedMigrationIdsAndChecksums + +Startup validates this contract beneath the ADR-16 instance lease, reads the hosted schema header +without mutation, and refuses unknown future state. Supported forward migrations are journaled, +idempotent and verified before mutation/runtime admission opens. An incompatible migration requires +an ADR-32 offline backup first. There is no automatic downgrade or partial per-family rollback. + +Legacy desktop/provider import remains a separate compatibility workflow because those files are not +the hosted coordination schema. Before introducing the second incompatible public hosted format, this +ADR must be revisited with evidence from real upgrade/rollback needs. Until then, independent per-family +read/write ranges, oldest-drainer calculations and generated rollback classifications are deferred. + +### ADR-24: external file writes are team-scoped unless run identity is proven + +Claude-compatible config/tasks/inboxes are a shared team protocol and generally do not carry a +trustworthy RunId/generation. A path under LegacyTeamKey resolves TeamId, but it does not prove which +provider process or run wrote the bytes. The watcher records an `ExternalFileActor` with path-key, +content fingerprint and observation sequence; it never invents current RunId/member attribution from +mtime, selected UI run, task owner or claimed JSON fields. + +Provider-specific artifacts may produce `VerifiedRunActor` only when their adapter validates embedded +RunId/generation/lane against currentRunRef, immutable plan and provider manifest/credential evidence. +Run-scoped facts such as heartbeat, approval acknowledgement, runtime delivery and process liveness +cannot enter through a generic task/inbox watcher. Unverifiable claimed member/run fields remain +untrusted content/diagnostics, not authorization or audit actor identity. + +TeamLifecycle stores a monotonically increasing `fileWriterEpoch` and watcher watermark. Before a new +canonical launch after any prior run, RunRecoveryWorkflow must complete a quiescence barrier: + +1. fence old runtime ingress and new old-generation effects; +2. terminate and verify every ADR-22 ownership unit, or enter unclassified-residual block; +3. drain queued watcher notifications through a captured sequence watermark; +4. perform a bounded stable rescan of catalogued team/task/inbox paths and commit resulting team- + scoped revisions/outbox events; +5. close the previous fileWriterEpoch and atomically accept the new LifecycleRun/plan/currentRunRef + with the next epoch. + +The barrier uses capture-drain-rescan-recapture with bounded retries. If new observations or unstable +fingerprints keep crossing the barrier, LaunchTeam is not accepted and returns typed +`external_writer_busy`/retry guidance; it never waits forever or guesses a quiet timeout. Immediately +before the first spawn, the workflow revalidates plan input checksums/roster/binding generation. A +post-accept config/topology change moves the run to recovering/conflict before spawn rather than +executing a stale plan. + +Watcher observations captured at/below the closed watermark cannot later be routed as new-run events. +An external file write observed after the new epoch remains a valid team-scoped external mutation if +the owning feature schema/invariants allow it, but is not falsely labelled as produced by the new run. +During DeletionSaga's mutation fence, new external writes are retained as bounded conflict evidence +and keep deletion recovering/operator_required; they are never silently dropped or used to resurrect +the team. This protocol adds no RunId fields to CLI-owned JSON and preserves provider compatibility. + +### ADR-25: stable workspace registration and boot-scoped mount evidence are separate + +WorkspaceId is stable deployment identity keyed by the operator manifest's immutable registrationKey. +Filesystem `realpath`, device/inode, mount ID, filesystem type and access probes are observations of a +particular boot/container mount and cannot be permanent WorkspaceId identity. The workspace registry +therefore creates one `WorkspaceMountBinding` per enabled registration and boot: + + WorkspaceId + + bootId + + monotonic mountGeneration + + declaredRootHash + + canonicalRealPath/root stat/mount evidence + + effective read/write/Git/worktree capabilities + + observedAt/health + +Every successful startup binding advances mountGeneration, even when the observed fingerprint looks +unchanged. Browser resource refs, review file IDs, Git handles and +CompositeRuntimePlan store WorkspaceId + mountGeneration and expire/fail stale after restart or +remount. TeamWorkspaceBinding remains stable by WorkspaceId/bindingGeneration; it resolves a fresh +mount binding for each operation rather than persisting dev/inode as domain identity. +Post-v1 T1 extends the same fencing to terminal sessions. + +Within one boot, mount disappearance, root replacement, symlink swap, permission downgrade or +fingerprint change immediately disables affected admission and invalidates outstanding grants. The +workspace-registry adapter revalidates the current binding and returns a narrow +`WorkspaceAccessGrant` for one operation/capability; the hosted output adapter consumes it through +ADR-28, while desktop has a separately characterized compatibility adapter. Grants are not a generic +filesystem port and are not cacheable across mountGeneration. A grant is server-only/non-serializable, +carries no browser-visible path accessor and is consumable only by the named output adapter/operation. + +Across a full container restart, the same registrationKey + declaredRootPath may legitimately observe +different dev/inode/mount IDs. This is accepted as a new boot binding only after manifest validation, +overlap/permission/filesystem probes and confirmation that no non-terminal workflow intends to resume +effects against the old mountGeneration. Default stop-owned container lifecycle makes new launch safe; +ambiguous recovery remains blocked until operator reconciliation. A changed declaredRootPath for the +same key is never treated as remount and remains forbidden. + +The operator-controlled manifest/mount mapping is the v1 cross-boot trust root. Without an external +signed volume identity there is no portable proof that different bytes mounted at the same declared +path are the same project; the product must not claim otherwise. Fingerprint changes are audited and +shown in private diagnostics. Backup/restore preserves WorkspaceId/registrationKey/binding history but +never restores a stale mount fingerprint as current authority. + +### ADR-26: v1 restore is an offline whole-deployment replacement + +DeploymentId is a random immutable app-owned identity persisted in the state root and included in the +verified backup manifest. Product v1 supports only `replace_deployment` disaster recovery into an +empty target root. The operator explicitly selects that mode and attests the source controller is +offline; the single-host/single-operator model cannot cryptographically prove a remote copied source +will never be restarted and does not claim to. + +Replacement preserves DeploymentId, OperatorId, TeamIds, MemberIds, WorkspaceIds/registrationKeys, +tombstones, committed commands/audit and compatible non-terminal workflow records. It always creates +a new bootId, rotates eventEpoch, revokes every copied ADR-7 device/session family and browser cursor, +revokes/reissues runtime ingress credentials, requires a fresh pairing exchange after +activation and establishes fresh ADR-25 mount bindings. Auth hash/pepper material is never restored as +usable authority. ProcessOwnershipRecords are historical evidence only; no PID/process is adopted +from a backup. Non-terminal workflows recover or become operator_required before mutation readiness. + +Restore preflight runs only while the application/controller stack is stopped. A one-shot tool +acquires the ADR-16 lock, verifies a complete checksum-bound backup, requires an empty destination +volume and restores that one app-owned deployment volume. It never performs a live or partial merge, +never activates across several mutable roots and never restores a stale workspace mount as current +authority. Workspace repositories and Keycloak/PostgreSQL have separately documented operator backup +procedures; v1 does not claim one application transaction across those external systems. + +After files are restored, normal startup validates ADR-23, rotates boot/event/session/runtime +authority and establishes fresh ADR-25 mount bindings before mutation opens. An interrupted restore is +discarded by recreating the still-empty destination and retrying from the immutable archive; v1 does +not add a cross-root resumable activation journal. + +Creating a second independent deployment from a backup is `fork_deployment`, not restore. It would +need a new DeploymentId/OperatorId, event history and idempotency namespace decisions plus explicit +TeamId/WorkspaceId import semantics, and is deferred from v1. Tests that need duplicate fixtures +generate synthetic isolated state rather than invoking an undocumented production fork. + +### ADR-27: command recovery is server-owned + +The application command ledger is the only recovery authority. The browser generates and sends a +bounded idempotency key for the current request but does not persist pending-command receipts, +idempotency keys or command intent in localStorage and does not coordinate them through +BroadcastChannel. + +Authenticated bootstrap/reconnect responses include the actor's bounded recent non-terminal and +recently completed command summaries. A command accepted by the server has an opaque commandId and a +durable status; the client may query that ID but never reconstruct or replay the mutation from local +state. If transport failure occurs before the client receives commandId, the recent-command projection +is the recovery surface. If no matching durable command exists, retry requires a new explicit user +action and idempotency key. Same-key/different-intent conflicts remain enforced by ADR-34. + +### ADR-28: hosted workspace effects require a descriptor-bound Linux guard + +The target stack is Node 24 on Debian slim. Local inspection shows that current file services perform +path-string reads followed by post-read `realpath()` checks and Git services do `realpath()` and later +`execFile(..., { cwd: string })`. Provider process launch likewise accepts a cwd pathname rather than +an already verified directory object. That leaves a check/use window when an agent-writable parent or +mount is renamed or replaced. + +This is a platform limitation, not something another TypeScript validator fixes: + +- Node's documented `O_NOFOLLOW` flag rejects a symlink in the final component; Linux explicitly + distinguishes that from `RESOLVE_NO_SYMLINKS`, which covers all path components; +- Node child-process `cwd` is a string or file URL and does not transport an already verified + directory descriptor; +- Linux `openat2` was added in kernel 5.6 specifically to constrain resolution of untrusted paths + with flags such as `RESOLVE_BENEATH` and `RESOLVE_NO_MAGICLINKS`; +- Docker added `openat2` to its default seccomp profile in the 20.10 line, but the actual production + kernel/seccomp/filesystem combination still needs an executable probe. + +Primary references: [Node filesystem API](https://nodejs.org/api/fs.html), +[Node child process API](https://nodejs.org/api/child_process.html), +[Linux openat2(2)](https://man7.org/linux/man-pages/man2/openat2.2.html), and +[Docker 20.10 security notes](https://docs.docker.com/engine/release-notes/20.10/). + +Three approaches were evaluated: + +1. **Small descriptor-bound Linux guard with consumer-driven verbs - chosen.** + 🎯 9/10 🛡️ 9/10 🧠 7/10, approximately 1,800-3,200 changed lines including packaging and + adversarial tests. It closes the controller's path-string race without prebuilding unused file, + worktree or terminal operations. +2. **Per-run mount/user namespace sandbox for every provider and Git command.** + 🎯 7/10 🛡️ 10/10 🧠 10/10, approximately 7,000-14,000 changed lines. It is stronger isolation, + but contradicts the accepted `trusted_process` v1 scope and materially changes provider auth, + debugger and workspace semantics. Keep it as a future isolation profile. +3. **Node-only `realpath`/`lstat`/post-check fallback.** 🎯 3/10 🛡️ 3/10 🧠 3/10, + approximately 700-1,300 changed lines. It is rejected because deterministic rename/symlink races + can invalidate the checked pathname before read, write or spawn. + +The chosen artifact is a narrowly scoped, one-shot Linux executable named +`agent-teams-workspace-guard`, implemented without a new runtime package ecosystem. A small C source +uses the kernel ABI directly and is compiled in a dedicated Docker build stage; the final image +contains only the stripped binary and its build metadata. It is not a daemon, shell, general RPC +server, privilege escalation mechanism or browser-callable API. CI builds it with warnings-as-errors, +ASan/UBSan tests and protocol fuzz/size tests; the release image runs non-root and the guard receives +no browser/controller session secrets. + +The guard initially exposes only operations required by an enabled v1 consumer: bounded +`probe`/`stat`/`list`/`read`, atomic `replace`, and `exec-approved`. `create`, generic `rename`/`remove`, +worktree mutation and any shell/PTY verb are absent until a feature-specific consumer, policy and +adversarial test are accepted. `exec-approved` reads a bounded one-use launch envelope from a +dedicated inherited FD, +verifies and enters the cwd, closes unintended descriptors, then calls `execve` directly with a +server-built argv/env. There is no `sh -c`, command string, glob or browser-provided binary/argument +vector. The envelope is never placed in argv, environment or a predictable file. + +Every invocation receives a server-resolved registration record, expected bootId/mountGeneration and +expected root `statx` evidence. It: + +1. opens the current canonical registered root as `O_PATH|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW`; +2. compares descriptor identity and mount ID with the current ADR-25 grant; +3. resolves relative segments from that descriptor with + `RESOLVE_BENEATH|RESOLVE_NO_MAGICLINKS`, rejecting absolute paths, `..`, magic links and escape; +4. rejects crossing a submount with `RESOLVE_NO_XDEV` by default. An intentional nested mount must be + a separate workspace registration; it is never silently inherited; +5. binds the actual effect to opened parent/target descriptors. Create/replace uses same-directory + temporary files, bounded mode/ownership, file fsync, `renameat2` policy and parent fsync; +6. returns a typed result containing the consumed mountGeneration and safe error code, never the host + path. `EAGAIN`, `EXDEV`, identity drift or unsupported syscall is fail-closed, not a Node retry on + a raw pathname. + +Relative symlinks that remain beneath the registered root may be supported for explicitly read-only +verbs through `RESOLVE_BENEATH`; magic links and absolute escape are always rejected. Mutation verbs +default to no symlink traversal unless a verb-specific test proves the exact semantics. This avoids +the false choice between allowing every symlink and breaking every repository that contains one. + +`exec-approved`/its audited descriptor-entry primitive is used in two v1 adapters: + +- provider/process supervision starts ADR-31 from an app-owned neutral cwd. The process anchor uses + the shared descriptor-entry primitive before it forks/execs the already allowlisted provider; the + workspace guard does not become a second waiting supervisor; +- Git uses only an application-owned subcommand/argument allowlist. Environment and explicit config + disable system/global config, hooks, pager, external diff/textconv, credential prompting and + external fsmonitor; network/remotes are unavailable unless a separate capability is later designed. + This is required because official Git documentation confirms `worktree add` normally invokes the + `post-checkout` hook. See [Git hooks](https://git-scm.com/docs/githooks) and + [Git config environment](https://git-scm.com/docs/git-config). + +The guard proves **selection and cwd integrity for controller-mediated effects**. It does not claim +that an authenticated interactive shell, provider process or Git binary is confined after exec: +those run as the same non-root deployment user under ADR-14's explicit `trusted_process` profile. +Stronger hostile-runtime filesystem confinement requires option 2 and is not smuggled into the v1 +claim. Likewise, the guard does not authorize an operation; it consumes an already authorized +WorkspaceAccessGrant and adds a final descriptor-bound enforcement layer. + +Readiness has a separate `workspaceGuard` dimension with version/build hash, kernel, syscall, +seccomp, root-fingerprint, atomic-write/fsync and approved-exec probe outcomes. Required production +launch, Git mutation and review/file mutation capabilities are absent unless their exact +verb probe passes in the final image. Read-only team projections that do not touch a registered +workspace may remain available. There is no `realpath()` compatibility fallback in hosted mode. + +The Phase 0 feasibility test must run the built guard inside the target container while an adversary +loop repeatedly swaps a parent directory, final symlink, root rename and bind mount. It must prove: + +- no byte is read from or written outside the marker-owned sandbox; +- no approved process starts with cwd outside the granted descriptor tree; +- stale mountGeneration and root identity produce zero effects; +- expected in-root symlink policy is deterministic; +- approved Git operations cannot execute a repository hook or external helper; +- a missing/blocked `openat2`, `statx` mount identity or required atomic primitive keeps the related + readiness dimension false. + +This gate is before feature expansion because failure changes the supported host envelope or forces +the explicitly larger namespace-sandbox design; discovering it after TeamsAPI wiring would invalidate +spawn, review and Git assumptions simultaneously. + +### ADR-29: uncoordinated external JSON writers cannot share a lossless direct-mutation claim + +ADR-28 prevents path escape, but it does not turn provider-owned JSON into a transactional database. +Linux `rename()` atomically replaces a destination name; it does not condition that replacement on +the destination still having the inode/hash previously read. `RENAME_NOREPLACE` helps create-only +publication, not compare-and-swap replacement. An inotify queue may coalesce events or overflow, so a +watcher cannot reconstruct bytes already overwritten by a racing process. Node likewise documents +that concurrent filesystem modifications are not synchronized. + +Primary references: [Linux rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html), +[Linux inotify(7)](https://man7.org/linux/man-pages/man7/inotify.7.html), and +[Node filesystem API](https://nodejs.org/api/fs.html). + +Every file family in the ownership catalog therefore has exactly one `writerCoordination` class: + +| Class | Meaning | Mutation rule | +| ------------------------ | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `app_exclusive` | only the controller writes while its ADR-16 lease is held | normal expectedRevision + journal + ADR-28 atomic publication | +| `cooperative_external` | every real writer is proven to honor the same lock/fencing or a provider semantic command/ack protocol | mutate only through that characterized protocol; a fixture that merely resembles the writer is insufficient | +| `uncoordinated_external` | a provider/CLI version may write without the controller's fence | no concurrent direct browser replacement while that writer can be alive | + +The classification is per provider version and operation, not a blanket label for `tasks/*.json` or +`config.json`. A new provider/version defaults to `uncoordinated_external` until its real adapter and +artifact fixtures prove otherwise. Watchers remain observation/cache-invalidation mechanisms, never +proof that all intermediate writes were captured. + +For `uncoordinated_external`, the application has only three truthful choices: + +1. perform the direct mutation after a durable quiescence barrier proves every relevant writer is + stopped, ADR-24 watermark/rescan is closed, and the source revision is re-read; +2. submit a provider-mediated workflow to the actual writer and report `accepted`, `running`, + `observed`, `failed`, or `operator_required` separately. Success is emitted only when the expected + semantic change is observed at a newer source generation; delivery alone is not mutation success; or +3. expose the projection read-only with `external_writer_active`/`provider_mutation_unsupported`. + +There is no automatic queue-and-replay of a stale direct JSON body after the writer stops. The user +must confirm against the new revision, because task ownership, status, relationships or approval +context may have changed. Domain merge/retry is allowed only for a catalogued commutative operation +with a property test; generic object spread or last-writer-wins is forbidden. + +This rule applies to task/Kanban edits, member/config changes, inbox/message delivery, review edits, +backup/restore and lifecycle projections independently. For example, a provider adapter may prove a +cooperative inbox append but not task-file replacement. The capability response and UI action state +include the operation's current writer class/reason without revealing paths. Controls are gated before +click and can change as a run enters/leaves quiescence. + +Near-parity release does not waive this invariant. Every required active-run mutation must either have +a characterized cooperative/provider-mediated path with real observed outcome, or be explicitly +specified as quiescent-only in the product acceptance matrix. If product acceptance requires direct +live mutation for an uncooperative provider, that requires changing the provider writer or introducing +the stronger broker/isolation architecture; it cannot be solved inside a web transport adapter. + +The deterministic E2E suite includes a hostile fake writer that replaces the same JSON at every +protocol boundary. It must prove that app-exclusive/cooperative operations commit exactly once, +uncoordinated direct mutations are never admitted while active, provider-mediated workflows do not +claim success before observation, and quiescent mutation rechecks the final source generation. + +### ADR-30: controller credentials stay in a controller-owned lane relay + +Current provisioning builds an environment by spreading `process.env` plus shell/provider state, +then adds control URLs and bootstrap tokens. Node documents that a child receives the supplied +environment and descendants inherit it. On Linux, `/proc//environ` and `/proc//fd` access is +governed by ptrace checks; Docker's defaults reduce some inspection syscalls but do not create a +security boundary between trusted same-UID siblings. Therefore an env variable or mode-0600 file is +not accepted as hostile-sibling secrecy in the v1 profile. + +Primary references: [Node child process environment](https://nodejs.org/api/child_process.html), +[Linux environment inheritance](https://man7.org/linux/man-pages/man7/environ.7.html), +[proc PID file descriptors](https://man7.org/linux/man-pages/man5/proc_pid_fd.5.html), and +[Docker seccomp profile](https://docs.docker.com/engine/security/seccomp/). + +Three designs were evaluated: + +1. **Controller-owned per-lane relay plus allowlist-first child environment - chosen.** + 🎯 9/10 🛡️ 8/10 🧠 8/10, approximately 1,800-3,200 changed lines. The canonical runtime bearer + never enters the provider process tree, and provider payloads cannot choose canonical lane scope. +2. **Bearer in environment or mode-0600 run file.** 🎯 3/10 🛡️ 4/10 🧠 3/10, + approximately 500-900 changed lines. It is rejected for hosted mode because current code broadly + copies environments and same-UID confidentiality cannot be claimed. +3. **Separate UID/container/mount namespace per lane.** 🎯 8/10 🛡️ 10/10 🧠 10/10, + approximately 8,000-16,000 changed lines. This is the correct future hostile-runtime profile, but + it changes provider auth/storage/debug/terminal topology beyond accepted v1 scope. + +`RuntimeIngressRelay` is a process-owned adapter, not a new team service. One relay instance is bound +to exactly one `(deploymentId, TeamId, RunId, generation, LaneId, credentialGeneration, allowedVerbs)` +and cannot accept those authorities from provider payloads. The controller gives the canonical bearer +to the app-owned relay over a dedicated inherited pipe/FD, the relay reads it once, closes the bootstrap +FD and keeps only bounded in-memory material. No plaintext bearer is persisted. + +The provider receives only the provider-native local MCP/bridge connection descriptor for that relay. +Hosted relay endpoints are private, per-lane, short-lived and absent from browser/API/events/logs. A +request can invoke only the relay's fixed lane verbs; body team/run/member/provider identifiers are +ignored or must equal derived scope. Relay stop/restart rotates both local endpoint identity and the +canonical server credential. A shared global relay that routes by body `teamName` is forbidden. + +The descriptor may contain a provider-visible opaque `LaneRelayHandle`. It is a narrow local +capability, not the canonical server bearer: it maps to one immutable relay session, cannot authorize +operator or another-lane verbs, expires with credentialGeneration and is safe to revoke independently. +It is still treated as sensitive in logs/artifacts and is not claimed secret from same-UID siblings. +One app-managed relay process may host several isolated LaneRelaySession records to reuse the existing +MCP HTTP child, but lookup is exclusively by the opaque handle and server-owned registry; payload +`teamName`/RunId never selects a session. + +This removes accidental bearer inheritance and narrows compromise, but it does **not** claim that a +malicious same-UID process cannot discover or call another local relay. Under `trusted_process`, lane +credential scopes are correctness/replay fences against stale, malformed or externally sourced calls, +not hostile sibling authentication. Capability/meta and security documentation state this explicitly. +Promoting hostile sibling isolation requires option 3 and new threat-model tests. + +Each hosted ProcessExecutionUnit receives an environment built from an empty +`HostedChildEnvironmentPolicy`, not `{...process.env}` or a login-shell dump. The allowlist is +backend/version/execution-unit-specific and contains only characterized execution necessities: + +- base execution: minimal PATH, HOME/USER, locale, TERM when needed, temp/config/cache roots; +- explicit provider settings and only the `SecretRef`-resolved provider credentials in the immutable + ADR-18 CredentialExposureSet for that execution unit; +- explicitly reviewed proxy/TLS variables when deployment policy enables them; +- provider-native relay descriptor and non-secret run/lane correlation values; +- where the characterized provider bootstrap requires it, one one-use spawn/bootstrap nonce that can + prove only the first matching anchor/run handshake, expires on consume/timeout and cannot authorize + any runtime-ingress verb. It is classified and tested separately from the canonical bearer. + +Controller-only names are denylisted in depth even if accidentally added to an allowlist: pairing, +browser session/CSRF, runtime ingress bearer, ADR-16 lease descriptor/launcher control pipe, database/ +state encryption, terminal ticket, private readiness/metrics and internal control credentials. The +reserved lease FD is also excluded by descriptor policy, not only by environment filtering. +Loader/runtime injection variables such as +`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`, `NODE_OPTIONS`, `NODE_PATH`, `BUN_OPTIONS`, `PYTHONPATH`, +arbitrary `GIT_*`, `SSH_ASKPASS` and credential-helper variables require an explicit provider policy; +they never flow from the controller ambient environment by default. + +Provider API keys/OAuth material are a separate unavoidable provider trust decision: the selected CLI +may need them. They are scoped to the persisted ProcessExecutionUnit exposure set, excluded from logs/ +diagnostics/ownership records and materialized only through the owning provider adapter. A mixed +Claude-compatible unit may legitimately contain several providers' credentials because current +topology runs those members in one process; the product reports that shared exposure rather than +claiming per-member isolation. No separate execution unit receives a credential outside its set. The +plan does not relabel provider credentials as controller credentials or claim the CLI cannot read +credentials it must use. + +Phase 0 produces an environment provenance ledger for every emitted key: + + output key -> source class -> owner -> executionUnitId -> provider/backend/version + -> CredentialExposureSet membership -> secret class -> required probe/test + -> child/descendant visibility -> redaction rule + +Architecture tests reject `process.env`/shell-env spread in hosted spawn, relay and MCP-config graphs. +Golden fake CLIs print only environment key names plus keyed test hashes, never values, so CI proves +required keys exist, forbidden keys do not, and no execution unit receives a secret outside its +persisted exposure set. +Diagnostic bundles run a structured secret-canary scan across argv, environment snapshots, generated +settings/MCP files, stdout/stderr, events and retained artifacts. + +### ADR-31: hosted provider runs use a stable process anchor, not check-then-kill + +ADR-22's original `stat PID -> kill PID` idea had the same check/use flaw as path validation. Linux +pidfds exist specifically because a PID can be recycled between inspection and signal. A pidfd safely +targets one process, but it does not by itself own/reap an arbitrary descendant tree. Hosted v1 +therefore combines a stable anchor, process group, subreaper and control/status pipes. + +Three designs were evaluated: + +1. **Per-run process anchor inside the existing container - chosen.** + 🎯 9/10 🛡️ 9/10 🧠 7/10, approximately 1,500-2,800 changed lines including focused race tests. +2. **Delegated cgroup v2 per lane with `cgroup.kill`.** 🎯 7/10 🛡️ 10/10 🧠 10/10, + approximately 5,000-9,000 changed lines plus a stricter host contract. Stronger, but ordinary Docker + deployments do not guarantee safe writable cgroup delegation to a non-root app. +3. **PID/start-token recheck followed by `kill()`/`killpg()`.** 🎯 3/10 🛡️ 4/10 🧠 3/10, + approximately 500-1,000 changed lines. Rejected because revalidation and signal are not atomic. + +`agent-teams-process-anchor` is a second narrow Linux artifact compiled beside, but not merged with, +the ADR-28 workspace guard. They may share audited syscall/framing utilities at build time; their +protocols, binaries, ports and readiness dimensions stay separate for SRP and least capability. + +The v1 anchor protocol is intentionally limited to provider lifecycle: + +1. receive a sanitized execution envelope plus expected registered-root/mount evidence over inherited + pipes, verify protocol/hash, independently revalidate and enter the descriptor-bound cwd using the + shared ADR-28 primitive, set no-new-privileges and close every descriptor except declared + stdio/control/status/relay-bootstrap descriptors. The server-only WorkspaceAccessGrant is consumed + by the adapter and never crosses the process boundary; +2. create and remain the process-group leader, enable `PR_SET_CHILD_SUBREAPER`, fork the provider main + child into that group and obtain its pidfd before exposing `ready`; +3. forward bounded stdout/stderr separately, reap orphaned descendants, and report only typed lifecycle + frames: ready, main_exit, escalation, drained, protocol_error; +4. on typed stop, control-pipe EOF or verified parent death, forward TERM, wait the configured grace, + then KILL the anchored group if needed. The live anchor keeps the PGID allocated through escalation; +5. exit successfully only after the owned group is drained and descendants reaped. Missing `drained` + evidence is never inferred from PID absence. + +The anchor has no network listener, provider registry, filesystem mutation API or secret storage. It +cannot relaunch, adopt, hot-restart, supervise terminal daemons, choose policy or interpret team state. +Status framing is bounded and treats provider output as unrelated bytes. A provider child +intentionally escaping the process group remains outside the hostile-runtime guarantee; deterministic +fixtures test accidental double-fork/daemon behavior through the subreaper, and an escape/ambiguous +result forces whole-container cleanup rather than a larger recovery protocol. + +Container init remains necessary to reap anything outside a lane anchor and Docker replacement is the +ultimate stop boundary. The anchor improves normal stop/crash evidence; it does not turn one container +into a multi-tenant sandbox. Hot Node restart stays forbidden because control pipes and relay secrets +are intentionally non-adoptable. + +Phase 0 must prove anchor feasibility in the final image: pidfd/subreaper availability, controller +SIGKILL/control EOF, main-child early exit, double-fork, TERM ignore, output flood, anchor crash, PID/ +PGID churn and repeated stop. No signal may reach a marker-owned unrelated process. If the probe fails, +hosted provider launch remains disabled; Node PID fallback is not an accepted degraded mode. + +### ADR-32: v1 recovery is an offline immutable deployment archive + +V1 does not coordinate a live point-in-time transaction across app SQLite, provider files, workspace +repositories and Keycloak/PostgreSQL. Those systems do not share a transaction, and implementing a +live quiescence/participant/activation protocol before the first hosted release adds more failure +states than it removes. + +The supported product workflow is operator-coordinated downtime: + + stop the complete Compose deployment + -> acquire/verify the ADR-16 instance lock from a one-shot backup tool + -> create and verify one immutable app-volume archive + -> back up Keycloak/PostgreSQL separately + -> restart + +The app archive contains the app-owned SQLite snapshot, deployment/identity anchors, command/event +state required by ADR-23 and a checksum-bound manifest. It excludes browser/auth plaintext, runtime +credentials, process records as adoptable authority, caches, sockets and workspace repository bytes. +The one-shot tool uses the supported SQLite backup API or a proven cleanly closed database path; it +never copies a live database/WAL pair. Publication writes a ready marker last and retains the previous +known-good archive until the new one verifies. + +Restore requires the application stack to remain stopped, an empty destination app volume, exclusive +ADR-16 lock acquisition, manifest/checksum/SQLite integrity validation and an operator-supplied current +workspace registration manifest. It restores the whole app volume, never selected files or mtime +winners. Normal startup then rotates browser/runtime authority, advances event and mount generations, +and runs command/lifecycle recovery before mutation admission. + +Keycloak/PostgreSQL and mounted workspace repositories keep their native backup procedures. The +documentation provides one Compose-oriented runbook but does not claim atomic recovery across them. +Workspace repositories should normally be recovered through Git or the operator's infrastructure +backup. If a coordinated multi-system online recovery point becomes a real product requirement, it is +a separately estimated post-v1 project. + +The already implemented narrow `coordination_backup` may remain as an internal app-owned migration or +repair snapshot. It is not exposed or described as a complete deployment disaster-recovery point. +V1 has no live multi-system recovery readiness dimension, participant quiescence graph, resumable +cross-root activation, partial restore or automatic downgrade path. + +Chosen v1 estimate: approximately 800-1,500 changed lines including the one-shot tool, manifest, +Compose runbook and stopped-stack backup/restore tests. + +### ADR-33: bounded snapshot plus direct SSE resynchronization + +V1 uses direct same-origin Fastify SSE for server-to-browser notifications. It does not add a custom +WebSocket protocol or a separate realtime service. Browser mutations continue through authenticated +HTTP commands; realtime delivery never becomes a second command or authorization path. + +Bootstrap returns a stable projection plus an opaque non-secret cursor `C0`. SQLite-only projections +read snapshot rows and `C0` in one SQLite read transaction. Projections that include external files +capture `C0` before the bounded stable scan, so events committed during the scan are replayed after +that lower barrier. Reducers fence duplicates by event ID plus resource revision/generation. + +The browser then opens: + + GET /api/hosted/v1/events?after= + +The cursor carries no authority. The endpoint still requires the HttpOnly operator session, exact +Origin/authority and scope authorization, and access logs redact the query value. The server registers +its coalescing wake-up listener before querying committed journal rows strictly after the cursor, then +rechecks the durable high watermark until caught up. Wake-ups are hints; committed rows remain the +authority. + +V1 has no browser subscription locator, cursor lease, durable client receipt or client-managed retention +pin. If the cursor belongs to another deployment/event epoch, falls behind retained history, +or any projection/revision check is ambiguous, the server returns `resync_required`. The browser +discards speculative transport state, fetches a fresh bootstrap snapshot and reconnects. Native +EventSource may use `Last-Event-ID` for short transient reconnects; an unsuccessful recovery always +falls back to the same full bootstrap. + +Events are bounded projection updates or invalidation hints, never command bodies, secrets, raw +provider payloads or an alternative database. A reconnect may cost one extra snapshot, which is the +accepted single-tenant v1 trade-off for removing locator/lease/replay state machines. + +Centrifugo is not required for v1. Application code publishes through a narrow feature-owned event +port so a future Centrifugo adapter may replace the delivery edge without changing domain events, +command recovery or authorization. If adopted later, the application remains source of truth, +Centrifugo client publish/RPC stays disabled, and connection authorization must derive server-side +subscriptions from the existing app session. The preferred integration is a same-origin connect proxy: +the application validates its existing HttpOnly session and returns only the authorized server-side +channels. Keycloak/OIDC, when enabled, authenticates the user to the application; it is not a shortcut +around application RBAC at the realtime edge. + +Adopt Centrifugo only when measured connection fanout, multi-instance realtime gateways, presence or +channel fanout justify another service. Its memory engine is still single-node and loses history on +restart; Redis is required when those gateway instances must share history/presence. Even then, +cursor-gap recovery falls back to the application bootstrap snapshot because Centrifugo is delivery, +not durable product state. References: [connection proxy](https://centrifugal.dev/docs/server/proxy), +[history and recovery](https://centrifugal.dev/docs/server/history_and_recovery), and +[engines/scaling](https://centrifugal.dev/docs/server/engines). + +Chosen v1 estimate: approximately 700-1,400 changed lines for the Fastify SSE adapter, bootstrap +cursor, bounded reconnect handling and browser tests. + +## Capability and parity matrix + +Before implementation, every TeamsAPI, ReviewAPI, CrossTeamAPI method and every visible renderer +action must be classified. The checked-in manifest is regenerated/verified from the pinned base so +new methods cannot bypass the plan merely because the branch moved after this audit. + +| Capability | Hosted v1 | Notes | +| ------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Pair/device/session/renew/logout | Required | ADR-7 restart-safe production prerequisite; host reset recovery | +| Workspace catalog | Required | opaque IDs only | +| List teams | Required | real data, no fake empty arrays | +| Team snapshot/bootstrap | Required | composed bundle of feature-owned projections plus revision vector; no second aggregate authority | +| Create draft/config | Required | idempotent | +| Prepare provisioning | Required | provider capability/preflight | +| Launch/relaunch | Required | fake-runtime and provider matrix proof | +| Provisioning progress | Required | SSE and snapshot reconciliation | +| Cancel provisioning | Required | owned run only | +| Runtime state/alive | Required | typed degraded states | +| Stop team | Required | idempotent | +| Task create/read/update | Required | revision plus ADR-29 direct/cooperative/provider-mediated/quiescent semantics | +| Kanban updates/order | Required | invariant-safe writes with ADR-29 writer admission | +| Team messages/inbox | Required | durable, paginated, and classified separately from task-file writes | +| Provider delivery status | Required for OpenCode | explicit degraded state | +| Failure diagnostics | Required | redacted diagnostic ID | +| Team/member edits and recovery | Deferred hosted expansion | initial roster creation remains Core v1; preserve desktop/shared feature code | +| Team delete/restore | Deferred hosted expansion | explicit draft discard may remain in Core v1 | +| Identity integrity diagnostics/repair | Deferred hosted expansion | Core startup is read-only; later import/repair is explicit and offline | +| Logs/activity | Required | paginated, bounded, redacted | +| Review workflow | Deferred hosted expansion | no raw path leakage when promoted | +| Tool approvals | Required when provider flow can block | minimum prompt/allow/deny/timeout surface only | +| Terminal workspace | Deferred post-v1 | capability absent in v1; ADR-10/35 retained for a separate terminal project | +| Attachments | Deferred hosted expansion | size/type/path limits when promoted | +| Cross-team operations | Deferred hosted expansion | explicit capability when promoted | +| Built-in editor | Desktop-only initially | hidden in browser | +| Updater/window controls/file chooser | Desktop-only | separate facets | + +No blanket TeamListView Electron gate remains after the first vertical slice. +Each unsupported control uses its own capability. Core v1 release waits only for the required Core +rows above; deferred rows stay unadvertised. + +### Detailed TeamsAPI parity inventory + +This inventory is the full-expansion checklist snapshot from the audit. +Implementation may split or rename methods, but desktop/shared semantics cannot disappear. +The audited interface contained 86 method names; every one is represented below, including event +subscriptions and the deliberate desktop/web notification split. ADR-19 turns this human-readable +snapshot into an immutable AST/signature baseline plus semantic traceability records; the table alone +is never accepted as conformance evidence. + +| TeamsAPI group/methods | Hosted release status | Hosted implementation rule | +| ---------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| list, getData | Required | lifecycle list plus composition of feature-owned projections; legacy getData adapter cannot become the new aggregate authority | +| getTaskChangePresence | Deferred hosted expansion | bounded derived projection | +| setChangePresenceTracking, setToolActivityTracking, setTaskLogStreamTracking | Desktop compatibility; no Core hosted mutation | Core uses SSE invalidations plus on-demand queries, not session leases or global flags | +| getClaudeLogs | Required, rename provider-neutrally later | bounded/redacted log query | +| deleteTeam, restoreTeam, permanentlyDeleteTeam | Deferred hosted expansion | confirmation, CSRF, audit, backup policy when promoted | +| getSavedRequest, deleteDraft | Required | repository/use-case boundary | +| prepareProvisioning | Required | provider capability/preflight facade | +| getWorktreeGitStatus, initializeGitRepository, createInitialGitCommit | Required for launch UI | registered workspace only; no raw path | +| createTeam, launchTeam | Required | idempotent application commands | +| getProvisioningStatus, cancelProvisioning | Required | owned run, typed state | +| getLaunchFailureDiagnostics | Required | safe diagnostic bundle/ID, no secret payload | +| sendMessage, getMessagesPage | Required | durable pagination and delivery semantics | +| getOpenCodeRuntimeDeliveryStatus | Required | provider-specific adapter behind neutral contract | +| getMemberActivityMeta | Required | revisioned snapshot | +| createTask, getTask | Required | revisioned task application use cases | +| requestReview | Deferred hosted expansion | review workflow command when promoted | +| updateKanban, updateKanbanColumnOrder | Required | expected revision and invariant-safe write | +| updateTaskStatus, updateTaskOwner, updateTaskFields | Required | expected revision | +| startTask, startTaskByUser | Required | provider/runtime-aware notification result | +| processSend | Required | owned lead process only | +| processAlive, aliveList, stop | Required | idempotent lifecycle facade | +| createConfig, updateConfig | Required | repository command, unknown-field preservation | +| getMemberLogs, getLogsForTask | Required | bounded/redacted/provider-neutral DTO | +| getTaskActivity, getTaskActivityDetail | Required | paginated/bounded | +| getTaskLogStreamSummary, getTaskLogStream | Required | bounded on-demand query and size policy; no durable browser subscription lease | +| getTaskExactLogSummaries, getTaskExactLogDetail | Required | stable IDs/source generation checks | +| getMemberStats, getAllTasks | Required | bounded projections | +| addMember, replaceMembers, removeMember, restoreMember, updateMemberRole | Deferred hosted expansion | initial roster configuration remains Core; preserve desktop/shared lifecycle code | +| addTaskComment, setTaskClarification | Deferred hosted expansion | revision and structured task refs when promoted | +| getProjectBranch, setProjectBranchTracking | Required where launch/review UI uses it | registered workspace adapter | +| getAttachments | Deferred hosted expansion | authorized metadata/content boundary when promoted | +| killProcess | Decomposed; no raw browser process control | Core uses lifecycle stop/cancel through ProcessSupervisor ownership | +| getLeadActivity, getLeadContext, getMemberSpawnStatuses, getTeamAgentRuntime | Required | runtime observability facade | +| retryFailedOpenCodeSecondaryLanes | Deferred hosted expansion | typed provider command when promoted | +| restartMember, skipMemberForLaunch | Deferred hosted expansion | owned run/member checks when promoted | +| softDeleteTask, restoreTask, getDeletedTasks | Deferred hosted expansion | task repository transaction when promoted | +| showMessageNotification | Desktop-only implementation; browser gets web notification capability | no fake no-op in shared team facet | +| addTaskRelationship, removeTaskRelationship | Deferred hosted expansion | atomic symmetric invariants when promoted | +| saveTaskAttachment, getTaskAttachment, deleteTaskAttachment | Deferred hosted expansion | quotas, MIME policy, authorized storage when promoted | +| onProjectBranchChange, onTeamChange, onProvisioningProgress | Required | typed SSE/event journal | +| respondToToolApproval, onToolApprovalEvent | Required | high-security command/event pair | +| validateCliArgs | Required for advanced launch UI | server-side safe parser, no execution | +| updateToolApprovalSettings | Required | audited repository command | +| readFileForToolApproval | Required as safe preview use case | registered workspace, bounded/redacted content | + +Before Core release, ADR-19 verification plus the runtime capability/action manifest must prove that +every advertised Core row is implemented/replaced with semantic evidence and every deferred or +desktop row is absent from the hosted mount. A visible hosted control cannot depend on an +unclassified method or a runtime route merely existing. + +### Team-screen dependencies outside TeamsAPI + +TeamsAPI parity alone does not make the current team screens operable. The renderer audit found +additional feature APIs in the same user flows. They must be mapped deliberately: + +| Dependency | Hosted rule | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| ReviewAPI getTaskChanges/getTeamTaskChangeSummaries | deferred hosted expansion; preserve desktop/shared contracts | +| Review file watch/invalidation | deferred; when promoted use revisioned safe fileRef events with no host path | +| CrossTeamAPI | deferred hosted expansion; capability-gate controls | +| Organizations assignment during create | implement if organization selector is advertised; otherwise omit selector before submit | +| Provider/model/account status | replace installer-shaped browser APIs with server capability/catalog/auth-status projections | +| Project path/folder selection | replace native chooser and raw paths with WorkspaceRegistry catalog selection | +| Task comment/file input getPathForFile | deferred hosted expansion; no local path extraction when promoted | +| TerminalWorkspaceAPI | desktop IPC unchanged; absent from hosted v1 capability/UI, post-v1 contract reserved by ADR-10/35 | +| Browser notifications | optional web Notification capability with permission UX; never pretend OS notification succeeded | +| Open external URL | browser-safe allowlisted navigation, not Electron openExternal | +| Editor/search/open-in-folder | desktop-only initially; attachment/review web UX cannot depend on them | +| ProcessesSection raw PID control | opaque processRef/owned-process action plus safe URL projection; raw PID never crosses browser contract | +| Desktop review shortcut onCmdN | renderer-local keyboard handler when review facet is mounted | +| Global editor/Codex/team subscriptions | feature-owned mount/unmount registration; hosted bootstrap never initializes unavailable listeners | + +Release conformance is user-flow based, not interface-name based. For each mounted Core screen, +enumerate non-TeamsAPI calls as well as TeamsAPI calls. Deferred review/attachment controls are absent +instead of being counted as broken Core parity. + +The included review UI currently depends on the full ReviewAPI, whose desktop contract accepts raw +projectPath/filePath and sometimes sends original/modified file bodies back to main. Do not expose +that shape over hosted HTTP. Canonical hosted review contracts use: + +- teamId/taskId/changeSetId/sourceGeneration; +- opaque fileId and hunkId/context hash; +- bounded content/preview pages; +- expected file content hash and expected review revision; +- idempotency key for apply/reject/save commands. + +Server-side workspace/review adapters resolve paths and compute/apply hunks. The browser cannot +choose a host file path or claim arbitrary original content. getAgentChanges/getTaskChanges, +summaries/stats/content, conflict/preview, apply/reject/edit, decision persistence, file-change +events, and Git file history must each be implemented or the corresponding review control must be +removed from the hosted screen. External file changes advance sourceGeneration and invalidate stale +decisions; stale apply returns 409 with a refetch requirement, never a best-effort patch. + +ReviewAPI release inventory: + +| Current methods | Hosted rule | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| getAgentChanges, getTaskChanges, getTeamTaskChangeSummaries, getChangeStats | bounded source-generation reads | +| invalidateTaskChangeSummaries | server-derived invalidation; browser command only if still needed after event design | +| getFileContent | opaque fileId plus bounded content/snippets, no raw path | +| applyDecisions, rejectHunks, rejectFile, previewReject, saveEditedFile | expected source generation/hash, server-side path/hunk resolution, idempotent mutations | +| checkConflict | replace raw path contract with fileId + expected content hash | +| watchFiles, unwatchFiles, onExternalFileChange | session-scoped review subscription over canonical SSE; no global watcher flags | +| loadDecisions, saveDecisions, clearDecisions | revisioned server repository keyed by team/task/changeSet/source generation | +| getGitFileLog | workspace-scoped bounded Git projection by fileId | +| onCmdN | desktop shortcut adapter only; hosted keyboard navigation is renderer-local | + +CrossTeamAPI `send`, `listTargets`, and `getOutbox` are required only when the cross-team composer +capability is enabled. If deferred, the composer omits cross-team targets and pending-cross-team +surfaces; it must not call a stub that returns an empty target list. + +## Data ownership catalog required before writes + +Create a checked-in catalog with, for every persisted file: + +- path pattern; +- owning system; +- authoritative versus derived status; +- schema/version; +- maximum supported size; +- locking model; +- ADR-29 writerCoordination class by provider/version/operation and evidence that makes a writer active; +- atomicity model; +- unknown-field preservation policy; +- corruption/recovery policy; +- backup requirement; +- secret/redaction classification. + +Minimum categories: + +| Category | Examples | Initial policy | +| ----------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| CLI-owned team data | config.json, tasks/_.json, inboxes/_.json | preserve layout and unknown fields | +| Team identity anchor | team.identity.json + committed SQLite identity row/checksum | replicated write-once identity; file is portable anchor, row is local admission/integrity authority, disagreement blocks | +| Legacy directory identity | immutable LegacyTeamKey reservation/tombstone across team/tasks/backups | creation/adoption adapter evidence only; never browser authorization or display identity, no v1 key reuse | +| Team roster identity | app-owned TeamRoster/MemberId/memberRevision registry plus legacy name evidence | browser/domain authority; config/members.meta/inbox/log names are compatibility projections, ambiguity blocks mutation | +| Legacy backup correlation | backup manifest/registry identityId and config.json \_backupIdentityId | migration evidence only; never TeamId or authorization, preserved/mapped as ADR-32 `legacy_unverified` then retired | +| App-owned launch/draft metadata | team.meta.json, members.meta.json | explicit version/migrations; never identity authority | +| Launch/runtime state | launch-state, bootstrap state/journal | recovery-blocking when corrupt | +| OpenCode runtime evidence | versioned stores/manifests | reuse mature manifest principles | +| JSONL transcripts | projects and agent logs | read-only/append semantics, bounded parsing | +| Derived caches | list/snapshot/search caches | rebuildable | +| Deployment/workspace/team identity | new app-owned versioned registry | stable IDs, migration and tombstones | +| Workspace mount binding | app-owned boot-scoped mountGeneration/fingerprint/health | current operation authority only; never stable WorkspaceId identity or restored as current | +| Event/idempotency/command state | new app-owned journal/store plus CommandFingerprintKeyring | versioned normalized-intent HMAC, bounded retention, key-preserving recovery and crash recovery | +| External observation fencing | app-owned fileWriterEpoch, watcher watermark, actor classification | never written into CLI JSON; closes cross-run attribution/relaunch races | +| Approval policy/audit/deletion saga | new app-owned security state | durable, versioned, fail-closed | +| Pairing/device/session auth | keyed hashes/expiry/revocation in app SQLite; plaintext cookies/controller memory | excluded/revoked on restore; ordinary restart preserves valid grants/sessions | +| CSRF/session transient values | derived or short-lived server/controller memory | never backed up; restart/rotation invalidates | +| Post-v1 terminal state families | absent in v1; T1 adds access/ownership audit, operational history and local sockets | separately versioned, migration/readiness gated and excluded from v1 compatibility/backup manifests | + +Verified write hazards that the catalog and repositories must address: + +- ConfigManager.persistConfig is fire-and-forget. Multiple atomic writes can complete out of order + and persist an older in-memory snapshot after a newer one. +- TeamTaskWriter uses only an in-process promise lock. External CLI/runtime writers and a second + Node process are not excluded. +- TeamKanbanManager performs unlocked read-modify-write for task state, column order, and garbage + collection. Concurrent browser/event cleanup mutations can overwrite each other today. +- config.json has multiple direct write paths across TeamConfigReader, TeamDataService, + TeamTranscriptProjectResolver, provisioning maintenance/restart flows, and backup restore. A new + HTTP repository beside them would not create a single authority. +- task relationship updates lock two paths in-process but perform two independent renames. A crash + can leave blockedBy/blocks or related edges asymmetric. +- TeamInboxWriter combines a cross-process lock file with an in-process lock and post-write verify, + but lock expiry/PID reuse/filesystem semantics still need an explicit deployment policy. +- atomicWriteAsync fsyncs the temporary file only best-effort and does not fsync the parent + directory. It provides replacement atomicity on supported local filesystems, not a durable + multi-file transaction. +- TeamLaunchStateStore writes launch-state.json and its summary projection sequentially, catches + write failures without propagating them, silently returns on a missing-team-directory race, and + clears both files best-effort. Callers can observe success while authoritative/derived launch + files disagree or remain stale. +- TeamProvisioningLaunchStateStoreBoundary serializes only inside one process. Its in-memory + writtenRunId guard is lost on restart and does not coordinate another writer. +- team.meta.json and members.meta.json normalize known fields and rewrite complete documents; + their unknown-field and future-version behavior must be intentional before hosted writes, and + neither may carry TeamId or other write-once authority. +- TeamBackupService writes `_backupIdentityId` into CLI-owned config.json best-effort, keys registry/ + manifests by teamName, may rotate identityId during resurrection, and marks permanent deletion only + after the team directory is removed. Canonical identity publication must not reuse these semantics + or race the current async/sync backup paths. +- TeamBackupService currently skips a team until config.json is valid. Canonical identity and durable + draft backup cannot inherit that gate because a legitimate pre-provisioning draft has no provider- + ready config yet. +- TeamBackupService enumerates/copies/prunes independent files and can continue after per-file errors; + its process-local mutex does not fence provider/CLI writers, its shutdown path is a separate sync + implementation, and partial restore chooses files by validity/mtime. ADR-32 treats this output as + `legacy_unverified`, not a consistent recovery point, and removes automatic partial activation from + hosted mode. + +Required initial write model: + +1. Enforce one hosted controller writer process per runtime root with an instance lease held for + process life; provider/CLI external writers are handled through compatibility reconciliation. +2. Serialize each aggregate through a per-team coordinator; ConfigManager uses a monotonic queued + writer and exposes flush()/failure state for readiness and shutdown. +3. Use expectedRevision for HTTP writes and re-read under the coordinator before mutation. +4. Preserve unknown fields for CLI-owned documents by patching the parsed raw object, not by + serializing a narrowed DTO. +5. For multi-file invariants, write an intent journal with operationId, before/after checksums, and + recovery status; replay or compensate idempotently after restart. +6. Define supported filesystems. If advisory/exclusive lock and atomic rename guarantees cannot be + established (for example some network volumes), readiness is false or hosted writes are + read-only. +7. On successful atomic replacement, fsync file and parent directory where supported. Classify + unsupported durability as an explicit degraded capability, not silent success. +8. Watcher reconciliation must ignore known self-write operation IDs without dropping external + writes that race immediately after them. +9. Repository writes return a durable outcome or typed degraded/failure result. Critical state + stores may not log-and-swallow persistence failure; readiness and command status expose it. +10. Treat launch-state as authoritative only for its catalogued provider execution/bootstrap facts + and summary as derived. Accepted LifecycleRun/plan/currentRunRef remain internal-storage authority. + Journal/checksum the legacy projection or rebuild it after crash rather than pretending two + renames are atomic or letting it select a newer run. +11. Enforce ADR-29 before write. `app_exclusive` uses bounded compare/hash/write/post-read verification; + `cooperative_external` uses only its proven shared protocol; `uncoordinated_external` direct + mutation requires durable writer quiescence or remains read-only/provider-mediated. Post-read + verification and watcher events may detect a conflict but are never described as preventing or + recovering an already overwritten external update. +12. Implement ADR-32 backup participants only over catalogued ownership boundaries. No generic + recursive root copier may infer authority, consistency, inclusion or restore precedence from a + pathname, mtime or parse success. +13. Route every app-owned config.json mutation through one repository/coordinator, including + provisioning, language, project binding, member lifecycle, transcript repair, and restore. Add + an architecture/search gate against new direct config writes outside that adapter. + +### Mutation commit protocol + +SQLite and CLI-owned files cannot share a real transaction. The plan therefore uses one explicit +recoverable protocol instead of implying that `UnitOfWork` makes them atomic: + +1. Resolve the ADR-34 CommandDescriptor, build its versioned normalized intent fingerprint and claim + `(deploymentId, stableActorId, commandKind, idempotencyKey)` in internal-storage with `prepared` + status. SessionId is recorded for audit but is not the actor identity: logout/ + re-login must still find an ambiguous command instead of launching it twice. +2. Under the owning feature's keyed coordinator, re-read authoritative files/evidence and verify + expected revision/run generation/workspace authorization. +3. Persist an operation intent containing only safe IDs, target revisions, before-checksums and the + ordered ADR-34 EffectDescriptors/states, then transition to `running`; each step records + `attempting` immediately before its first externally visible effect. +4. Resolve ADR-29 writerCoordination for every touched file against the current provider/run state. + Refuse the direct effect unless writers are app-exclusive, demonstrably cooperative, or durably + quiesced; a provider-mediated workflow leaves this immediate-command protocol before file effect. +5. Apply the minimum admitted legacy-file mutations through the existing compatibility writers, + preserving unknown fields and required provider/CLI semantics. +6. Re-read and verify the intended invariant. A partial multi-file result remains `recovering`, not + successful. +7. In one internal-storage transaction, advance the feature revision, mark the command committed, + record recovery completion, and append the bounded outbox event. +8. Publish the outbox event to live SSE after commit. Delivery may repeat; consumers deduplicate by + eventId/revision. +9. On startup, recover `prepared`/`recovering` operations before mutation readiness. Replay only when + checksums/generation still match; otherwise compensate or surface a typed operator-required state. + +Steps 3-6 above are for bounded mutations whose intended invariant is complete within the command. +Workflow-starting commands such as LaunchTeam, StopTeam, permanent delete and some member recovery use +a stricter acceptance/execution split: + +1. Claim/validate the browser command and compute the immutable workflow input without external effect. +2. In one internal-storage transaction mark the command `committed` with typed `accepted` outcome, + create the LifecycleRun/saga plus initial workflow state, persist immutable plan where applicable, + advance currentRunRef/generation and append the acceptance outbox event. +3. Return `202` plus stable commandId and workflowRef. The durable workflow runner claims pending work + with lease fencing; before each filesystem/process/provider effect it uses the normal operation + intent/recovery protocol and ADR-22 spawn protocol. +4. Workflow progress/terminal failure is recorded on LifecycleRun/saga and emitted separately. It + never changes the already committed acceptance command to failed and never causes retry of the + original key to create another workflow. + +Thus command `committed` means the application decision and stable workflow identity are durable; it +does not mean a provider is ready, a stop completed, or deletion finished. Command-status responses +include the workflowRef/current workflow summary when present. Renderer success copy distinguishes +accepted/in progress from completed, and waits on canonical workflow projection/events rather than an +HTTP request kept open through provisioning. + +The allowed immediate-command state machine is: + + prepared -> running -> committed + | | | + | +------> recovering -> committed + | |----> failed + | `----> operator_required + `------------------------------> failed + +A workflow-starting command may transition `prepared -> committed(accepted)` in its single acceptance +transaction because no external effect precedes that commit; later work belongs to the referenced +workflow state machine, not the command record. + +`failed` is terminal only when the coordinator proves no external effect occurred or a compensation +was verified. Any ambiguous effect remains `recovering` or `operator_required`; it is never flattened +to a retryable 500. The command record retains safe typed outcome/error, attempts, last transition, +recovery owner and next diagnostic action. Claim/recovery ownership records the ADR-16 leaseId/boot +generation and can advance only while `InstanceLeaseGuard` proves its inherited descriptor is open. +There is no supported state in which a still-running controller releases and reacquires the lease. + +The command status endpoint returns prepared/running/committed/recovering/failed/operator_required +for timed-out clients. Retrying the same key/ADR-34 fingerprint returns the same status/outcome; same +key with a different normalized intent is an `idempotency_mismatch`. A response is never success merely +because file writes were scheduled. +Retention cannot delete a non-terminal command, its intent, or referenced outbox row. Terminal +records are removed only by a bounded transactional compactor after their retry window and audit/ +recovery retention expire. + +Browser mutation keys are short-lived request values. ADR-27 recovery across timeout, reload and +re-login comes from the authenticated server-owned recent-command projection; the browser never +persists the sensitive request body or key for automatic replay. Runtime ingress uses a separate scope +`(runId, laneId, credentialGeneration, verb, commandId/sequence)`; a browser key and machine key can +never collide or authorize each other's command. The single-operator deployment has one durable +OperatorId created with deployment identity. Pairing creates sessions for that actor; it does not +create a new actor on every login. + +External CLI/agent writes use the inverse path: watcher obtains a stable parsed projection, validates +the owning feature invariant, records a new external revision plus outbox event transactionally, and +then publishes. Self-write suppression is keyed by operationId + verified resulting checksum, not a +time window, so a separately observed racing external write is not mistaken for the app's own event. +This is observation correctness, not proof that an uncoordinated writer's already-overwritten bytes +can be recovered; ADR-29 prevents admitting that unsafe concurrency in the first place. + +### ADR-34: command identity and effect recovery are versioned contracts + +The phrases `request hash`, `replay idempotently` and `compensate` are not implementation permission. +Without a stable fingerprint and effect-specific proof, a release can reinterpret the same key or +repeat an external effect after crashing between the effect and its journal update. Hosted promises +at-most-once command acceptance plus evidence-driven workflow convergence; it does not claim magical +exactly-once delivery to uncooperative files, CLIs or providers. + +Primary design evidence is consistent: the IETF Idempotency-Key draft treats a server-generated +request fingerprint as part of key uniqueness; AWS rejects the same client token with different +parameters and recommends labeling created resources with the request identifier; compensations are +domain-specific workflows that can themselves fail and must be resumable/idempotent. References: +[IETF Idempotency-Key draft](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07), +[AWS EC2 idempotency](https://docs.aws.amazon.com/ec2/latest/devguide/ec2-api-idempotency.html), +[AWS safe retries](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/), +and +[Azure compensating transaction pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction). + +Every Tier B external workflow has a checked-in `CommandDescriptor` owned by its feature: + + commandKind + inputSchemaVersion + fingerprintVersion + + idempotencyScope + retentionClass + + normalizedIntentProjection + ordered EffectDescriptor[] + +Tier A local transactional mutations use validation, authorization, expected revision/transaction, +typed outcomes, and optional application-ledger idempotency. They do not acquire an effect +descriptor/recovery saga merely because they are HTTP mutations. The tier boundary and promotion +rule are normative in `hosted-web-core-v1-scope-lock.md`. + +Fingerprint rules are fixed: + +1. Authenticate, authorize, bound and validate the exact command schema before claiming a key. + Mutation schemas reject unknown intent fields. Transport metadata, cookie/session/request IDs and + object key order never affect identity; every semantically relevant client choice and materialized + default does. Locale/model/provider policy belongs in the fingerprint only when it changes intent. +2. The feature builds an explicit typed `normalizedIntentProjection`; infrastructure never hashes raw + JSON. Opaque IDs, expected revisions/generations and attachment/content digests are included, while + server-derived current state is persisted separately as acceptance preconditions/evidence. +3. A deterministic length-delimited encoder and keyed HMAC produce the stored fingerprint. The record + contains descriptor/schema/fingerprint/key versions and digest, never the prompt, message, path, + secret or complete command body. Golden vectors cover field order, omitted-vs-default, Unicode, + integer bounds, arrays and every schema migration. +4. Algorithms and fingerprint key versions remain readable/computable for at least the longest + idempotency retention window. ADR-23 blocks a binary that cannot compare a retained record; it may + not recompute old commands with the newest algorithm or prune them to make startup pass. + `CommandFingerprintKeyring` is a separate app-owned integrity secret, not an ADR-7 auth/session + key: rotation adds a new active version while retaining referenced old keys, verified ADR-32/26 + replacement preserves it with command records, and missing/corrupt material closes mutation rather + than treating every retry as new. Host auth reset does not rotate or delete it. +5. The unique claim scope is `(DeploymentId, stableActorId, commandKind, idempotencyKey)`. The same key + and fingerprint returns the durable command/workflow outcome; the same key with another descriptor, + schema or fingerprint returns typed `idempotency_mismatch` and creates no command/effect. Concurrent + identical claims converge through one database uniqueness constraint. +6. Auth/schema/readiness rejection before claim consumes no key. Once a row is claimed, every + no-effect business rejection or later outcome is durable; changing intent requires a new key. + +Each external step has exactly one recovery class, frozen in its `EffectDescriptor` and persisted on +the operation intent: + +| Recovery class | Required proof before automatic retry/commit | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `transactional_local` | effect and command/outbox commit share one internal-storage transaction | +| `idempotent_by_operation_id` | adapter/provider durably deduplicates the same operationId and exposes semantically equivalent lookup/result | +| `reconcilable_by_unique_evidence` | immutable or exclusivity-protected evidence ties the desired outcome to this operationId/exact before-after generation; mere current-state equality or mtime is insufficient | +| `compensatable` | feature-owned inverse command, preconditions and evidence are persisted; compensation is itself idempotent/reconcilable and cannot overwrite newer external work | +| `non_reconcilable` | no automatic retry after an attempt may have crossed the boundary; recovery becomes `operator_required` with safe diagnostics | + +The durable effect state is +`not_started -> attempting -> observed_succeeded | observed_absent | ambiguous`, with separate +`compensating -> compensated | ambiguous`. The coordinator writes `attempting` before crossing the +boundary. A crash/timeout from `attempting` may retry only after the declared class's lookup proves +deduplication or absence; otherwise it remains ambiguous. A checksum that merely matches desired bytes +does not prove which writer produced them. A compensation never means restore an old snapshot over +concurrent work and is never selected by generic infrastructure. + +Examples are explicit: an app-exclusive atomic JSON replacement may be reconcilable by operation-bound +before/after checksum plus ADR-16/29 exclusivity; an active uncoordinated provider file write is not; +a provider launch is retryable only through its persisted RunId/spawnNonce/provider idempotency and +ADR-22 evidence, never because no PID was observed; a message/task delivery requires a durable +provider acknowledgement or unique observable envelope marker, otherwise it is operator-required. + +Fault injection pauses before/after `attempting`, external call/rename/spawn/ack, evidence query, +command commit, compensation and event publication. Negative fixtures return stale or coincidentally +equal state, reuse an operationId with changed intent and lose the first response. The gate proves no +automatic path can produce a second run/message/task/file effect or call `committed` without its +descriptor-required evidence. + +### External-writer observation algorithm + +Filesystem notifications are lossy hints, never the mutation log and never domain events by +themselves. One shared infrastructure scheduler handles bounded observation mechanics; owning +features provide file-pattern registration, parser, authority classification and reconciliation +command. It does not become a generic repository or interpret feature entities. + +For each explicitly catalogued path key: + +1. Startup performs a bounded inventory scan after lease/recovery and before mutation readiness. + Watch registration happens before the scan barrier, then queued notifications are drained, so a + write between scan and watch cannot disappear. +2. Native watch/rename events receive a monotonic observation sequence and enqueue one coalesced + observation by TeamId/registered file identity plus the earliest/latest covered sequence. They do + not capture currentRunRef. Queue length, attempts and bytes are bounded; overflow marks the affected + feature dirty and schedules a scoped rescan rather than dropping silently. +3. Stable read performs lstat/stat, size/type/containment checks, bounded byte read, then stat again. + Device/inode/size/mtime identity must remain compatible and the content checksum is authoritative. + A changing file retries with jitter up to a deadline; partially written JSON never advances state. +4. Apparent deletion is confirmed by a parent-directory rescan after the atomic-replace debounce and + checked against in-flight self-write intents. Only the owning feature decides whether absence is + deletion, optional empty state, replacement, corruption or a blocked invariant. +5. The feature parser validates schema/version and returns a complete projection plus source + fingerprint. Corruption keeps the last valid read projection visible, records a bounded + diagnostic and disables only mutations whose invariant depends on that artifact. +6. Under the feature coordinator, compare the fingerprint with stored observation/self-write + evidence and classify ADR-24 ExternalFileActor versus provider-verified run evidence. A real change + transactionally records source generation, observation sequence/fileWriterEpoch, actor kind, + feature revision and the durable event row. A semantic no-op updates observation health without + emitting entity churn. +7. Periodic bounded scoped scans repair missed native events. They are adaptive and never recursively + poll all of CLAUDE_ROOT or workspace contents; only manifest/catalog paths and active subscriptions + are eligible. + +Watchers stop admission before shutdown, drain observation work to a deadline, persist dirty scopes +for startup rescan, and never claim clean readiness while a critical scope is overflowed, repeatedly +unstable or corrupt. Metrics expose counts/latency/retry/dirty reasons without filenames, paths or +payloads. Tests cover atomic rename, truncate-then-write, delete/recreate, same-mtime content change, +notification loss, overflow, repeated corruption, self-write followed immediately by external write, +and shutdown/restart with queued observations. +The ADR-24 relaunch/deletion quiescence barrier uses this same sequence/watermark API; it is not a +second watcher or a sleep-based quiet-period heuristic. + +## Consumer-owned port catalog + +Ports are introduced by the application consumer, beside the use case that needs them. The names +below are boundary responsibilities, not mandatory one-interface-per-bullet files. Cohesive methods +stay together when they share atomicity, security and failure semantics. + +| Owner | Minimum boundary | Initial implementation/reuse | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `hosted-access` | challenge/device/session atomic repository, auth keyring, cookie/CSRF policy, PUBLIC_ORIGIN facts and v1 runtime residual drain evidence | new app-owned SQLite tables plus narrow key/challenge filesystem adapter and Fastify input/output adapters | +| `team-lifecycle` | lifecycle/TeamRoster state compare-and-commit, team/member config compatibility, runtime-control client, after-commit event sink | Electron compatibility adapter over TeamDataService; hosted context-bound repositories reusing pure parsers plus split provisioning/launch-state contracts | +| `team-runtime-control` | provider capability source, immutable roster-bound lane planner input, owned process supervisor, runtime evidence store, binary/credential resolver, lane/run ingress credential store and machine event/delivery boundary | runtime-provider-management facade, team-runtime-lanes, existing TeamRuntimeAdapterRegistry, runtime-control service and provisioning runtime modules | +| `workspace-registry` | stable registration repository, boot mount-binding probe, operation-scoped current-generation grant authorizer | app-owned internal-storage tables plus Node realpath/stat/mount adapter | +| `team-task-board` | task document repository and aggregate mutation coordinator | compatibility adapters over TeamDataService/task writers/Kanban manager | +| `team-messaging` | message-page source, append/delivery gateway, delivery status source | inbox writer/readers and existing runtime delivery APIs | +| `team-review` | bounded review source/apply gateway and review subscription source | current review services plus agent-attachments where applicable | +| `team-approvals` | compare-and-claim approval repository, runtime answer gateway, audit sink | internal-storage coordination state plus runtime-control adapter | +| `terminal-workspace` | desktop-only; no hosted v1 boundary or adapter | existing desktop terminal-platform feature/gateway | +| hosted app composition | validated public-origin/proxy/socket facts, route admission and lifecycle component registry | explicit hosted-access/security/feature factories; no auth repository transitions | + +`RuntimeInstanceContext` is an immutable value passed at composition time, not a port. Clock, +IdGenerator and cancellation are small structural dependencies used only where determinism is +needed; they do not require a global platform package. + +The owned process supervisor contract requires: + +- implement ADR-22 prepare-spawn/capture-identity/verify-handshake/commit-or-classify protocol rather + than inserting a PID into memory after spawn; +- spawn takes a resolved binary identity, argv array, registered cwd, explicit environment allowlist, + current WorkspaceAccessGrant/mountGeneration, owner teamId/runId/memberId, resource policy, and + cancellation signal; +- never pass browser text through a shell command string; +- return a random opaque processRef backed by the durable ProcessOwnershipRecord, never an encoded PID; +- own stdout/stderr truncation, redaction, backpressure, and diagnostic correlation; +- own process-group/job-object termination with TERM/grace/KILL and bounded waits; +- distinguish adopted verified processes from merely observed PIDs; never kill an unverified one; +- expose readiness/degraded/resource state without leaking host commands or environment values. + +Do not introduce: + +- a global FileSystemPort exposing generic read/write/list/watch; +- a universal TeamRepository or UnitOfWork spanning lifecycle, tasks, messages and approvals; +- RuntimeEnvironment/Platform/Manager facades that simply mirror Node APIs; +- a generic event bus used as hidden synchronous control flow; +- interfaces for pure functions or one stable implementation with no isolation/security value. + +Filesystem and Git operations remain narrow adapter details such as canonicalizeWorkspace, +compareAndCommitTaskDocuments, appendInboxMessage, or loadReviewFile. The app-owned internal-storage +backend may share one SQLite transaction internally, but each feature sees only its own repository +contract. After-commit event publication uses an explicit outbox/event-journal boundary, not an +untyped DomainEventBus. + +## HTTP contract rules + +### Standard response/error behavior + +- One namespaced error envelope. +- No raw Error.message for 5xx responses. +- Error response contains safe code, safe message, retryable flag, and diagnosticId. +- Server logs may include a redacted correlated cause. +- Every request receives requestId. +- Hosted routes never serialize the desktop AppConfig wholesale. Use explicit browser-safe settings + projections; redact roots, CLI paths, SSH profiles, credentials, internal ports, and secret refs. +- Cache-Control is no-store for auth, capabilities that include auth state, diagnostics, approvals, + settings, and mutation responses unless a safer explicit policy is proven. + +Status mapping is consistent: 400 malformed/validation, 401 missing/expired session, 403 +authorization/CSRF/origin/policy, 404 non-visible resource, 409 revision/idempotency/lifecycle +conflict, 413 size limit, 422 valid request that fails provider/workspace preflight, 429 admission or +rate limit with Retry-After, 503 unavailable dependency/read-only mode, and 500 only with a safe +diagnosticId. Do not mix 200 `{ success: false }`, thrown raw strings, and transport-specific errors. + +### Browser and runtime-ingress separation + +- Browser cookies authorize `/api/hosted/v1` and explicitly allowlisted legacy non-team browser + routes; they never authorize `/api/runtime/v1`. Post-v1 terminal grants remain separately scoped. +- Lane/run-scoped runtime credentials authorize only their bound `/api/runtime/v1/runs/:runId/*` + verbs and member/lane identity; they never authorize + browser queries, approvals, terminal, workspace administration, or arbitrary team mutation. +- Runtime routes have provider/event-specific body limits, token/run/lane rate limits, current-run + fencing and persistent replay protection before parsing expensive payload sections. +- Legacy `/api/teams/:teamName/opencode/runtime/*` stays loopback-only during compatibility and is + removed before remote hosted mutation capability is advertised. +- Browser capability manifests and generated clients do not contain runtime ingress routes. +- Runtime acknowledgements contain only accepted/duplicate/conflict state plus safe opaque IDs; no + workspace path, server state snapshot or credential material. + +### Mutation safety + +- Launch accepts an idempotency key. +- Create draft/config accepts an idempotency key. +- Stop is idempotent. +- Every retryable/destructive mutation defines an ADR-34 descriptor, scope, retention window, + versioned normalized-intent HMAC, effect recovery classes and conflict behavior. Reusing a key with + different normalized intent is `409 idempotency_mismatch`. +- Accepted long-running commands return 202 with commandId plus workflowRef (runId/sagaId) and a + status link. A client timeout + is ambiguous until that status is queried; the UI must not infer failure and issue a new launch. +- Browser disconnect does not cancel an accepted command. Cancellation is a separate authorized, + generation-scoped command. +- Task and Kanban mutations use expected revision or If-Match. +- Stale mutation returns 409 stale_revision with current revision metadata. +- Request IDs, idempotency keys, IDs, and strings are length-bounded. + +### Resource limits + +- aggregate/header count, Cookie bytes/count and exact authority-cookie value length/duplication; +- JSON body size; +- attachment size/type; +- pagination bounds; +- request timeout/cancellation; +- provider probe timeout; +- process output cap; +- SSE backlog cap; +- per-session HTTP concurrency, SSE connection/subscription and watcher quotas. + +## Realtime model + +### Event envelope + +Every event includes: + +- eventId; +- opaque eventCursor assigned by the journal; +- scope kind and scopeId (instance/catalog, workspace, team, run, or session); +- teamId/workspaceId/runId when applicable; +- safe actor kind/reference (`operator`, `verified_runtime`, `external_file`, `recovery`), with runId/ + memberId present only when attribution is proven; +- fileWriterEpoch/observation sequence for accepted external-file mutations where relevant; +- event type; +- team/snapshot revision; +- emittedAt; +- payload; +- schemaVersion. + +Keep resource dataRevision and journal eventSequence distinct. Durable snapshot mutations advance +dataRevision; ephemeral runtime/tool/log observations may advance eventSequence without changing +dataRevision. Ordering never depends on emittedAt or filesystem mtime. + +The public cursor is an opaque encoding of `(deploymentId, eventEpoch, eventSequence)`, not a bare +integer for clients to increment. `eventEpoch` is durable and rotates only when the journal is +intentionally reset/restored without continuous history. A cursor from another deployment/epoch, +ahead of the journal, malformed, or below retention returns `resync_required`; it never silently +starts at now. `bootId` remains a process/session freshness fence but is not substituted for durable +journal epoch. SSE `id` equals the opaque cursor and eventId remains the delivery-deduplication ID. + +### Event delivery + +- one deployment/epoch monotonic event sequence for the authenticated session stream; +- resource-scoped revisions for team/workspace/catalog snapshots; +- bounded replay journal; +- SSE id field; +- Last-Event-ID support; +- initial non-secret cursor query from bootstrap, redacted from access logs; +- Last-Event-ID for short automatic reconnect and full bootstrap on unsuccessful recovery; +- heartbeat; +- slow-consumer disconnect policy; +- resync_required event when a cursor falls behind retained history. +- sequence assignment occurs in one event-journal coordinator after a durable application mutation + commits or after a validated external projection is accepted; +- event payloads are bounded. Large logs/diffs/attachments are fetched by opaque reference rather + than placed on SSE; +- journal retention is bounded by age and bytes, and the current earliest/latest cursor is exposed + in diagnostics/metrics without leaking event payloads. + +The transactional outbox event and replay-journal event are one durable row, not two asynchronously +copied sources of truth. The committing transaction assigns eventId/epoch/sequence and writes the +bounded payload/reference. Post-commit live fanout is only a wake-up/low-latency delivery step; a +fanout failure does not roll back the command or require a second event write. SSE replay reads +committed journal rows from the requested cursor, so crash after commit but before live fanout is +recovered naturally on reconnect. Subscribers never see prepared/uncommitted rows. + +Retention advances one explicit watermark transactionally and never deletes a row referenced by a +non-terminal command/recovery saga. Slow/offline clients below the watermark receive +resync_required. High-volume logs/tool output emit bounded durable invalidation/reference events; +the content itself remains in its bounded source and is not copied into SQLite/SSE merely to preserve +one cursor. + +The browser uses one authenticated session SSE stream for catalog, workspace, team, run, and +approval events, so one Last-Event-ID can replay cross-scope ordering. Core v1 uses bounded +structural invalidations and fetches visible high-volume tool/log/activity data by paginated query. +It has no durable per-session subscription lease or correctness-critical visibility flag. Do not +return to the current module-global client set or global tracking flags shared by all sessions. + +Target-base generic `/api/events` may remain temporarily only for explicitly allowlisted non-team +standalone UI events under browser session auth. Team/review/approval/runtime facets never subscribe +to it, and canonical team events are never broadcast into its unscoped channel/data protocol. The +route ledger must show zero hosted team dependence before release. + +### Reconciliation + +Agents and provider runtimes can modify JSON outside HTTP use cases. +Therefore events come from both: + +1. application use cases; +2. filesystem watcher reconciliation. + +Client strategy: + +1. fetch a stable snapshot containing its resource revision vector and ADR-33 lower eventCursor + barrier; +2. subscribe from that eventCursor; +3. apply deduplicated events; +4. refetch on gaps or resync_required; +5. remain correct after duplicate delivery and reconnect. + +The snapshot builder follows ADR-33. A SQLite-only projection reads rows, revision vector and cursor +from one read transaction. A projection containing external files captures retained cursor `C0` +before the stable scan, returns that same lower cursor and tolerates replayed duplicates; it never +captures the latest cursor after projection. It retries if external generation/hash changes or the +journal retention floor overtakes `C0`. Resource revision is never reused as Last-Event-ID. If the +journal cannot prove the returned barrier is retained, the response/stream yields +snapshot_retry/resync_required before incremental events. + +## Renderer state, authority, and migration invariants + +### Authority matrix + +| State | Authority | Browser persistence | Reconciliation rule | +| ---------------------------------------------- | -------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Team/task/Kanban/message data | server repositories | memory cache only | revisioned snapshot plus events | +| Provisioning/runtime run state | lifecycle coordinator | memory cache only | runId + generation + terminal tombstone | +| Tool approval requests/results | runtime coordinator | memory cache only | event/command pair, dedupe by teamId/runId/requestId | +| Tool approval policy | server repository | optional non-authoritative UI mirror | server version wins; audited updates | +| Launch defaults such as last model/effort/fast | user preference | localStorage allowed | validate against current capability catalog before use | +| Pending/recent command status | server command/workflow registry | memory only; no browser locator/key persistence | query by authenticated actor/action; never replay a stored browser request | +| Graph layout and panel dimensions | renderer | localStorage allowed | namespace by stable deploymentId and teamId | +| Workspace identity/mount | WorkspaceRegistry | never persist raw host path; mountGeneration memory only | stable workspaceId plus boot-scoped mountGeneration/display metadata | +| Team identity | server | stable opaque teamId | teamName remains display/legacy alias, not cache authority | +| Member identity/roster | TeamRoster | memory cache only | memberId + rosterGeneration/memberRevision; legacy name is display/provider evidence | +| Branch/review source | server workspace adapter | memory cache only | key by workspaceId/repositoryId, never raw path in browser | + +Security-sensitive settings such as autoAllowAll, timeout allow/deny, and safe-command policy are +not merely browser preferences. The current implementation stores them in localStorage, syncs only +best-effort to an in-memory main-process map, and can silently fall back to defaults after restart. +Hosted v1 must persist them server-side with schema/version, actor, updatedAt, and audit event. +Changing policy while approvals are pending must have one deterministic re-evaluation rule. + +### Canonical client state tuple + +For each team, the reconciler tracks at minimum: + + deploymentId + bootId + contextEpoch + teamId + teamGeneration + bindingGeneration + workspaceMountGeneration + rosterGeneration + fileWriterEpoch + snapshotRevision + instanceEventCursor + currentRunRef: { runId, generation } | null + ignoredRunIds/tombstones with bounded expiry + +Events and responses lacking a matching identity/generation are ignored. Tombstones are bounded by +time and count but cannot expire while a conflicting run remains observable. A backend restart +changes bootId and forces capability/session revalidation plus a fresh snapshot; stable +deploymentId/teamId preserve safe UI preferences. A restart must not be confused with a normal +reconnect or with a different state root reusing the same public URL. +Provisioning progress, lane/runtime state and stop/cancel controls are feature projections of this +single currentRunRef. No reconciler may independently select a newer provisioning or runtime run. + +Hosted renderer context is the authenticated server deployment, not Electron `local`/`ssh` mode. +Repository/project selectors are projections of WorkspaceRegistry. Existing context-epoch guards +remain useful, but their identity comes from deploymentId/session/workspace selection; browser code +does not instantiate SSH/local filesystem providers or branch on `isElectronMode` for team data. + +### Snapshot/event/poll algorithm + +1. Capture context/team generation before every request. +2. Fetch a snapshot containing its revision vector and ADR-33 same-transaction/lower replay cursor; + do not describe it as the latest/current cursor. +3. Commit only if the captured scope is still current and the response is not older than the + visible projection. +4. Subscribe from the snapshot cursor before declaring the view live. +5. Deduplicate events by eventId and reject an event older than the current aggregate/run/file-writer + generation; a replay already represented by the snapshot is expected, not an error. +6. Apply pure event reducers when the payload is sufficient; otherwise schedule one coalesced + bounded snapshot refresh. +7. On a gap/resync_required/schema mismatch, pause incremental application, fetch a fresh snapshot, + and resume from its cursor. +8. Poll only as a health/recovery fallback. A poll response follows the same scope/revision checks + as an event-triggered fetch. +9. Keep the last valid snapshot visible during retryable refresh failures; clear it only for an + authoritative deletion/generation change. +10. Reconnect/poll retries honor Retry-After, exponential backoff with jitter, browser offline and + visibility state, and a per-tab request budget. Auth/session errors stop ordinary retry loops, + attempt one coalesced ADR-7 device renewal across tabs, then either re-bootstrap session/meta/ + snapshot or enter explicit host-pairing recovery; they never loop mutation requests. + +### Existing invariants that cannot regress + +- concurrent initial list/task fetches remain single-flight, with at most one required follow-up; +- a late response from a previous context, team generation, delete, stop, or launch is ignored; +- a thin snapshot cannot erase member/runtime data from a newer full snapshot; +- message head refresh and older-page loading remain serialized and cursor-safe; +- current provisioning run selection never falls back to an arbitrary newest team run; +- optimistic pending runs are replaced atomically by the returned canonical run; +- stopped/deleted runs cannot be resurrected by late SSE, poll, or watcher events; +- provisioning terminal states cannot regress, except an explicitly modeled ready -> disconnected; +- high-frequency process/tool/log events retain visibility gates and bounded fanout; +- semantic no-op observations preserve object identity to avoid renderer churn; +- cleanup removes timers, subscriptions, pending approvals, runtime tool layers, and cached + projections for the exact team generation only. + +The existing teamSlice and context-race suites are mandatory characterization input. Extract pure +reducers and reconciler tests alongside them; do not replace the suite wholesale with snapshots. + +## High-risk interaction contracts + +### Tool approvals + +Tool approval is a server-authoritative state machine, not a transient notification: + +1. The approval key is teamId/runId/requestId. requestId alone is not globally unique. +2. A pending-approval snapshot endpoint lets a freshly logged-in/reloaded browser recover prompts + that were emitted before its SSE subscription. +3. Request events and terminal resolution events are journaled and replayable. Duplicate request + events do not duplicate sheets. +4. The decision command carries expected run/generation and an idempotency key. The first accepted + manual/timeout/auto decision wins; later decisions return an explicit already_resolved result. +5. The UI removes a request only after an accepted decision or authoritative terminal event. A + retryable network failure keeps it visible. +6. Timeout and policy re-evaluation run only on the server clock. Browser timers are display-only. +7. Auto-allow policy is persisted server-side and defaults fail-closed after corruption or unknown + schema. `autoAllowAll` requires an explicit warning/confirmation and audit event. +8. Tool input is schema-bounded and redacted. File previews use an opaque approval-scoped fileRef, + registered workspace containment, maximum bytes, binary detection, and expiry; browser requests + never submit an arbitrary filePath. +9. Multiple tabs are supported: all receive the terminal event, but only one decision can claim the + response. Stale run, stopped team, expired prompt, and unwritable process are distinct safe codes. +10. Audit records contain actor/session, team/run/request, tool category, policy/manual source, and + result, but not command secrets, full prompts, file contents, or credentials. +11. Runtime-originated approval requests/acknowledgements enter only through authenticated machine + ingress or validated provider evidence. Browser decisions enter only through the operator API; + neither credential can call the other direction. +12. The accepted browser decision is persisted before provider delivery. Provider delivery uses a + canonical idempotency key and transitions through delivering -> delivered or + delivery_unknown/recovering; an HTTP timeout never causes a blind second answer. +13. Current OpenCode permission payload fields such as cwd, expectedMembers and previousLaunchState + are compatibility evidence only. The server resolves current workspace/member/run authority and + rejects a mismatch before changing approval or launch state. + +### Destructive and process-control actions + +- delete/restore/permanent-delete and killProcess require fresh server authorization, CSRF, + explicit target identity, and audit records; +- killProcess accepts a runtime-owned processRef plus generation, never a browser-supplied PID; +- permanent delete is blocked while an owned runtime or journal recovery is active unless a + separately authorized stop-and-delete workflow completes; +- retrying a timed-out destructive request uses its idempotency key and status endpoint rather than + blindly issuing the command again; +- undo/restore semantics and retention period are server policy, not local UI assumptions. +- identity repair is available only through the ADR-6 maintenance-mode capability: normal mutation + admission/runtime launch are closed, evidence hashes and typed TeamId must match, a coordination + backup precedes the repair intent, and the action cannot allocate or accept a different ID; +- Permanent delete is a journaled idempotent saga over the v1 ownership catalog (team/task files, + app metadata, launch/runtime evidence, event/review/approval state, internal + storage, backups according to retention policy). A partial delete resumes safely; it cannot leave + a live runtime pointing at a half-removed directory. +- Stop/delete first revoke current run-ingress credentials, fence new runtime/file + mutations, and only then terminate owned processes/remove state. Late callbacks for the revoked + generation are acknowledged as stale or rejected without recreating team state. +- Runtime process projections expose processRef, safe label/state, ownership, and resource summary, + not raw PID, command, environment, host port, or loopback URL. +- Opening an agent-started web server requires a separate allowlisted preview-proxy capability with + target ownership, port policy, auth, origin isolation, header/body limits, and SSRF protection. + Until implemented, the hosted Process Open control is absent; never send server localhost URLs to + the browser and expect them to work. + +### Attachments and browser file handling + +- message/task attachments are addressed by opaque attachmentId and streamed through authorized, + bounded endpoints rather than embedded as unbounded base64 JSON; +- upload validates declared and detected MIME, filename, size, count, aggregate team quota, and + storage containment; partial uploads are cleaned after expiry; +- download sets safe Content-Type, Content-Disposition, nosniff, and cache policy; +- hosted non-image attachment UX is download/preview. It must not call the desktop editor with a + host file path; +- malware scanning can be a deployment capability, but its unavailable/degraded state and policy + must be explicit before uploads are enabled. + +### Advanced launch controls + +- Hosted validation is a provider-specific allowlist parser, not a blacklist and not a promise to + execute arbitrary CLI text. It returns normalized structured options. +- Reject flags that can change cwd/workspace, config/settings paths, credentials, control URLs, + MCP/bootstrap transport, output destinations, permission policy, or process execution mode unless + that option has a dedicated authorized contract. +- Bound token/argument count and length; use argv arrays without shell parsing. +- Worktree input is a logical name/strategy. The server chooses and validates the destination under + the registered worktree root. +- skipPermissions/auto-approval is deployment policy-gated and visibly high risk; a browser cannot + enable it when the server capability forbids it. +- Do not persist raw custom arguments, tokens, host paths, or prompts in localStorage. Only safe + normalized launch preferences such as provider/model/effort/fast may be remembered. + +### Untrusted agent/runtime content + +Treat team names, task text, messages, Markdown, tool input/output, logs, diagnostics, branch names, +filenames, process labels, and URLs as attacker-controlled even in a single-operator deployment. + +- render Markdown through the existing audited sanitizer with raw HTML disabled or sanitized; +- allow navigation only to explicit http/https schemes and use noopener/noreferrer; +- never construct HTML, CSS, file URLs, command lines, or WebSocket targets from raw agent strings; +- bound and virtualize logs/messages, sanitize ANSI/control sequences, and do not log full unsafe + payloads on parse failure; +- escape spreadsheet/formula-like exports if later added; +- security tests include stored/reflected XSS payloads delivered through JSON files, SSE, logs, + approval inputs, filenames, and provider diagnostics. + +### Git and worktree controls + +- Git operations accept workspaceId/repositoryId and structured options, never command text or raw + paths; execute through argv with bounded output/time. +- Status/branch projections are keyed by repository identity. Browser responses expose repository + labels and relative paths only where needed, not absolute host paths. +- Repository initialization and initial commit are explicit confirmed mutations with idempotency and + audit. They cannot run automatically merely because launch preflight sees a non-repository. +- Define hook/filter policy for server-owned Git mutations. If hooks are disabled, enforce an empty + hooks path for that invocation; if enabled by deployment policy, treat their code execution as a + high-risk workspace capability. +- Worktree create/delete verifies registered roots before and immediately before mutation, handles + partial directories/locks, and never deletes a path it cannot prove it created/owns. +- Remote fetch/push and credential management are out of scope unless separately promoted; no + launch flow should accidentally contact a Git remote. + +### Messaging and task mutation semantics + +- SendMessage uses a client-generated messageId/idempotency key. Retrying after timeout cannot append + a duplicate inbox entry. +- Distinguish durable inbox persistence from live runtime delivery. A persisted message with failed + provider delivery is not reported as wholly failed or silently retried without policy. +- Pagination uses opaque stable cursors and deterministic tie-breaking, not only timestamps. Head + refresh cannot invalidate or duplicate an already loaded older page. +- Task mutations carry expected task/team revision. StartTask is an idempotent compound command over + status/work interval/owner notification and reports partial notification outcome explicitly. +- Relationship/comment/review mutations use operation IDs so watcher echoes and retries do not + duplicate history events. +- Agent-side task/inbox edits that race browser commands produce a new revision and conflict/retry + path; no last-writer-wins overwrite of unrelated unknown fields. + +### Logs and diagnostics + +- Browser queries are paginated/bounded by bytes, lines, time range, and source generation. +- Redaction happens in the server projection before serialization. Replace registered absolute roots + with workspace labels/relative paths and remove credentials, auth headers, environment secrets, + pairing/session/CSRF tokens, and provider payload fields classified sensitive. Post-v1 terminal + grants join the same redaction class when that capability exists. +- diagnosticId is opaque and authorization-scoped. It is not a filesystem path and cannot be used + to request arbitrary artifact files. +- Full local artifact packs may remain operator-only on disk with mode/retention policy; the web UI + receives an allowlisted summary/tail unless an explicit secure export capability is enabled. +- Log source generation prevents a stale detail request from showing content for a replaced file. + Truncation/rotation is a normal typed result, not a parser error. + +## Provider and runtime strategy + +### Provider capability response + +For each provider/backend: + +- installed/resolvable; +- authenticated/unknown/unavailable; +- supported execution modes; +- supported composite lane topology modes and lead/member constraints; +- planned ProcessExecutionUnit shape and safe credential-isolation class + (`dedicated_execution_unit` or `shared_execution_unit`), never secret names/values; +- supported models and effort levels; +- launch readiness; +- log/recovery capability; hosted terminal capability is absent in v1; +- typed remediation. + +### Provider-specific details remain behind adapters + +Provider adapters may know: + +- CLI arguments; +- environment variables; +- model naming; +- auth probing; +- bootstrap proof; +- delivery journals; +- runtime-specific recovery. + +Application use cases know only provider-neutral capability and lifecycle contracts. +The browser may pre-gate an unsupported topology from the capability response, but LaunchTeam always +re-runs the authoritative `team-runtime-lanes` planner and returns its typed rejection. UI/provider +catalog code never guesses support from individual provider availability alone. + +### Agent-specific JSON and protocol boundary + +Provider-neutral application contracts do not mean pretending every runtime has identical files. +Claude-compatible config/tasks/inboxes are an external collaboration protocol used by the CLI and +agents; OpenCode launch/evidence/delivery stores and other provider artifacts have different +schemas and recovery semantics. + +Before extraction, build a provider artifact inventory with: + +- file/path pattern and producer/consumer; +- read-only, append-only, or writable authority; +- schema/version and required CLI fields; +- unknown-field preservation behavior; +- watcher trigger and self-write suppression rule; +- maximum size/count and parse timeout; +- corruption/partial-write behavior; +- whether it is authoritative state, evidence, journal, cache, or diagnostic artifact. + +Rules: + +1. Browser and application use cases never parse provider JSON directly. +2. A provider adapter may expose a tagged provider-neutral projection plus a bounded typed + providerDetails extension when the UI genuinely needs provider-specific diagnostics/actions. +3. Claude task/config writes retain CLI-required defaults and unknown fields. A normalized domain + DTO is not serialized wholesale over a newer external document. +4. App-owned OpenCode/versioned stores keep their existing generation/checksum/manifest guarantees; + migration must reuse them rather than flattening them into generic unversioned JSON. +5. Malformed one-item evidence cannot crash a team list. Critical launch state corruption blocks + mutation with a diagnostic; derived log/cache corruption degrades only that projection. +6. Watchers debounce stable file identity/generation, tolerate atomic rename, and bound repeated + parse failures. They do not emit a domain revision until a complete validated projection exists. +7. Every provider gets golden fixtures for current, legacy, future-unknown-field, truncated, + oversized, and partially written artifacts. +8. Direct runtime writes that cannot participate in expectedRevision are reconciled as external + mutations with a new server revision; conflicting browser edits receive stale_revision and must + refetch/reapply explicitly. +9. Claude-compatible task/inbox/config observations use ADR-24 ExternalFileActor and remain team- + scoped unless a catalogued provider artifact proves RunId/generation. Claimed member/owner/from + fields are content, not actor authentication. +10. A new launch cannot overlap an unclosed fileWriterEpoch. Stop/recovery must prove process + quiescence, drain the watcher watermark and commit a stable rescan before accepting the next run. +11. Provider adapters expose run-scoped facts only through a narrow VerifiedRunEvidence port; there is + no universal ProviderJson object or switch in team-lifecycle/application code. + +### Runtime liveness and recovery state model + +The current red tests expose an ambiguity that hosted code must not copy: historical bootstrap +confirmation is not current process liveness. Define one provider-neutral state projection: + +| State | Meaning | Allowed next states | +| ------------------------ | ----------------------------------------------------------------------- | ---------------------------------------------------- | +| absent | no configured/current run | provisioning | +| provisioning | accepted run is preparing/spawning/verifying | ready, degraded, failed, cancelling | +| ready | required runtime members have fresh verified liveness | degraded, stopping, disconnected | +| degraded | run exists but some required capability/member is unhealthy | ready, stopping, disconnected, failed | +| cancelling/stopping | terminal command claimed for the current generation | cancelled/stopped, failed | +| disconnected | persisted/historical run exists but current liveness is absent or stale | recovering, stopped, failed | +| recovering | coordinator is verifying/adopting instance-owned runtime evidence | ready, degraded, disconnected, failed | +| failed/cancelled/stopped | terminal for that run generation | no regression; a new launch creates a new generation | + +Rules: + +- `historicalBootstrapConfirmed` is diagnostic history only and never implies alive=true; +- liveness requires provider-specific fresh evidence plus verified process/session identity; +- PID existence alone is insufficient; PID start identity and ownership must match; +- evidence has explicit observedAt/freshUntil/source/generation and a documented precedence order; +- startup adoption is a bounded recovering transition. Unknown/unowned processes are observed or + reported, never killed/adopted by guess; +- v1 graceful-shutdown default is `stop-owned`: stop only verified instance-owned process trees, + persist terminal/recovery facts, flush journals, and leave no orphan. `preserve-for-adoption` is a + separate advertised deployment capability allowed only when the runtime backend proves an + external lifetime, stable ownership identity and bounded restart adoption; it is not inferred from + PID/tmux existence. It is unavailable in the default same-UID pairing-file profile; advertising it + additionally requires OS isolation proving the preserved runtime cannot read controller pairing/ + session/coordination state; +- cleanup of bootstrap/launch state is an idempotent state-machine action with durable completion, + not a timing-dependent test side effect; +- snapshot, progress event, and process-exit event are projections of the same canonical transition; +- list/detail/alive APIs cannot disagree on the current canonical generation. + +Add a transition-table test per provider plus restart/crash tests. Resolve the existing failing +liveness/cleanup expectations from evidence and this state model before using them as parity +fixtures; do not simply change tests to whichever boolean the current implementation returns. + +### Hosted provider test matrix + +- homogeneous Anthropic; +- homogeneous Codex; +- homogeneous Gemini; +- homogeneous OpenCode; +- mixed-provider team; +- missing runtime; +- missing auth; +- unsupported backend; +- malformed capability response; +- process timeout; +- partial launch; +- restart/adoption; +- OpenCode secondary lane recovery. + +## Implementation phases + +### How an AI team executes a phase + +The detailed task lists below are the architecture/evidence checklist. The +[execution router](./hosted-web-phases/README.md) selects the only executable phase, and its controller +packet owns the current DAG. A lane packet owns one worker's exact reads, writable paths, evidence IDs, +checks and handoff. An orchestrator never assigns an entire phase or this whole document to one vague +worker prompt. + +Later phase sections remain non-executable until the predecessor freeze materializes a packet under the +[packet standard](./hosted-web-phases/PACKET_STANDARD.md). This is intentional: Phase 0 evidence may +invalidate current assumptions about contracts, native primitives, provider boundaries or estimate +buckets. + +Every work package moves through the same states: + +1. `blocked`: its entry gate or upstream contract is not complete; +2. `ready`: owner, allowed paths, public contracts, fixtures and verification commands are recorded; +3. `characterizing`: current behavior and negative control are captured before behavior changes; +4. `implementing`: one bounded production seam plus its focused tests is changed; +5. `integrating`: self-review, scope check, targeted gates and conflict/adoption review run; +6. `verified`: required evidence is attached and the package's exit condition passes. + +A worker task must name exactly one package ID, owned paths/symbols, immutable upstream contract IDs, +expected artifacts, tests and forbidden adjacent work. Shared composition, global renderer/store, +RouteCatalog, migrations and build entrypoints always have a serialized integration owner. A package +cannot be called complete because code exists; its consumer must exercise the new seam and its old +authority/bypass must be removed or explicitly quarantined. + +### Phase 0 work packages: make the base and unknowns trustworthy + +Execution packet: [Hosted Web Phase 0 JIT Execution Packet](./hosted-web-phase-0-execution-packet.md). +It is the normative worker DAG/ownership/checklist for Phase 0; this document remains the architecture +and exit-gate authority. Worker-specific entrypoints are registered in the +[execution router](./hosted-web-phases/README.md); workers read one lane packet rather than loading this +entire plan as their prompt. + +- **0A - Base and accounting:** fetch/pin the exact base SHA, create the implementation branch, commit + this plan, classify baseline CI and create the unique-bucket estimate/salvage ledgers. +- **0B - Inventories:** generate API/action parity, renderer callsite, state-family, writer-authority, + provider/runtime-ingress, environment/credential and artifact inventories from the pinned code. +- **0C - Feasibility proofs:** independently prove ADR-16 lease, ADR-28 workspace guard, ADR-31 process + anchor, ADR-32 SQLite backup, ADR-33 snapshot/event handoff, ADR-34 effect recovery and ADR-7 auth/ + proxy schedules in final-shape fixtures. +- **0D - Freeze decisions:** turn successful evidence into versioned contracts/ADRs/readiness gates; + failed evidence narrows capability scope or reopens the specific ADR before feature implementation. +- **Order:** 0A first; 0B streams may run in parallel; 0C spikes may run in parallel only after their + environment assumptions are pinned; 0D is serialized integration. +- **Concrete result:** a reproducible branch, trustworthy baseline, checked-in inventories and runnable + negative/positive spikes. No hosted product mutation is enabled yet. + +### Phase 1 work packages: create one contract system, not another mega-API + +- **1A - Contract conventions:** add opaque IDs, revisions/cursors, RequestContext, AppError categories + and feature-local DTO/schema conventions; do not predeclare every future use case. +- **1B - Capability and routes:** implement feature-owned RouteDescriptor/CapabilityDescriptor sources + and narrow renderer facets; hosted never implements ElectronAPI. +- **1C - Conformance gates:** add import/dependency, no-stub, route/client/schema, IPC/HTTP outcome and + capability-mount negative fixtures. +- **1D - First proof:** define one read-only team-lifecycle query and drive the same use case through + test, IPC and Fastify adapters without transport types entering application code. +- **Order:** 1A before 1B/1D; 1B and 1C can proceed in parallel against frozen conventions; 1D proves + the conventions before Phase 2 adds more contracts. +- **Concrete result:** one small shared kernel and repeatable feature template with no business god- + facade, Electron dependency or fake browser implementation. + +### Phase 2 work packages: establish stable identity and safe read-only truth + +- **2A - Team/member identity:** implement TeamId, LegacyTeamKey, MemberId, roster generations, + adoption/ambiguity rules, migrations and tombstones around existing files. +- **2B - Workspace identity:** implement WorkspaceRegistration, stable WorkspaceId, boot-scoped + mountGeneration, overlap/permission probes and opaque browser workspace selection. +- **2C - Read repositories/use cases:** wrap current parsers/services with team list/detail/runtime- + status read ports and compose versioned browser-safe projections. +- **2D - Desktop delegation and shadow parity:** delegate existing IPC reads to the same use cases, + compare legacy/new projections and migrate renderer reads without enabling mutations. +- **Order:** 2A/2B schemas and recovery rules first; 2C depends on both identities; 2D begins after a + stable projection exists. Fixture building can run parallel to repository adapters. +- **Concrete result:** browser and desktop can list/select/inspect the same teams using opaque stable + IDs, while corrupt/ambiguous/rebound state remains read-only with typed diagnostics. + +### Phase 3 work packages: make external workflows crash-aware and local writes conflict-safe + +- **3A - Ownership catalog:** classify every config/task/inbox/review operation as app-exclusive, + cooperative, provider-mediated, quiescent-only or unavailable; record unknown-field semantics. +- **3B - Command/effect substrate:** keep simple ledger/revision handling for Tier A local mutations; + implement versioned descriptors, fingerprints, outbox, recovery evidence and operator-required + ambiguity only for Tier B external workflows. +- **3C - Compatibility repositories:** add bounded mutation coordinators for each approved file family, + watcher self-write attribution and hostile external-writer fixtures without a universal repository. +- **3D - Event and recovery foundation:** implement snapshot/event handoff, replay cursors, + append-before-wakeup delivery and the stopped-stack SQLite backup boundary proven in Phase 0. +- **Order:** 3A and the 3B contract precede mutating adapters; separate file-family adapters may then + run in parallel; 3D integration is serialized around journal/transaction boundaries. +- **Concrete result:** Tier A writes conflict safely and Tier B workflows recover without duplicating + effects or overwriting an uncoordinated provider writer. Product workflows are still added later. + +### Phase 4 work packages: own runtime execution and lifecycle in one place + +- **4A - Immutable runtime plan:** map roster/provider/lane facts to one CompositeRuntimePlan and two + real backend families: provisioning CLI for Anthropic/Codex/Gemini and OpenCode adapter execution. +- **4B - Machine ingress:** separate `/api/runtime/v1`, lane/run credentials, replay fences and ADR-30 + controller-owned relay from operator/browser routes. +- **4C - Process ownership:** integrate ADR-31 spawn intent, anchor handshake, pidfd/subreaper drain and + unclassified-residual behavior behind ProcessSupervisorPort. +- **4D - Lifecycle commands:** implement prepare/launch/progress/cancel/stop/recover, one currentRunRef, + legacy-drain cutover and provider-specific degraded states through team-lifecycle use cases. +- **Order:** 4A contract first; fake provider fixtures, 4B and 4C can proceed in parallel; 4D composes + them only after ingress and process evidence are stable. +- **Concrete result:** deterministic fake providers and all supported provider topologies launch and + stop through shared application use cases with durable state and zero raw PID/cwd/browser authority. + +### Phase 5 work packages: produce the real hosted artifact + +- **5A - Application composition:** reduce standalone.ts to config plus lifecycle wiring, compose + feature facades/RouteCatalog/readiness and keep business sequencing outside HttpServer. +- **5B - Build graph:** emit the server, renderer, internal-storage worker and required Node-ABI/native + helper artifacts; reject Electron imports, empty stubs and missing/wrong-hash artifacts. +- **5C - Container topology:** build the non-root image, ADR-16 launcher/init path, explicit volumes, + private app/runtime networking, health endpoints and bounded shutdown. +- **5D - Artifact/readiness probes:** run SQLite, ADR-28/31, provider/helper and competing-container + probes inside the final image; prove terminal artifacts/routes are absent from v1. +- **Order:** 5A and build-graph audit can run in parallel against frozen facades; 5B precedes 5C final + image; 5D gates route admission and documentation. +- **Concrete result:** one clean-machine production artifact serves the real renderer and canonical + APIs, but non-loopback mutations remain disabled until Phase 6 security passes. + +### Phase 6 work packages: close the remote-control security boundary + +- **6A - Durable operator authority:** implement pairing challenge, device family, short session, + auth keyring, renew/rotation/revocation/reset and crash recovery. +- **6B - HTTP boundary:** enforce explicit PUBLIC_ORIGIN, exact proxy CIDRs, secure cookies, Origin, + CSRF, body/rate/concurrency limits, no-store/redaction and browser/runtime route separation. +- **6C - Workspace/effect authorization:** issue operation-scoped WorkspaceAccessGrant values, enforce + ADR-28 in file/Git/provider-spawn adapters and reject raw paths/rebinding/unsupported mounts. +- **6D - Adversarial matrix:** test cookie/parser abuse, CSRF, proxy spoofing, credential replay, + cross-lane authority, secret canaries, traversal/symlink/mount races and residual-runtime pairing. +- **Order:** pure auth/security state machines and negative tests can run parallel; public route + registration waits for 6A-6C and one serialized security review. +- **Concrete result:** the Phase 5 artifact is a safe single-operator remote-control deployment, with + authenticated HTTP/SSE and scoped machine ingress; v1 still has no terminal/WS surface. + +### Phase 7 work packages: deliver the first complete browser lifecycle + +- **7A - Hosted clients:** build narrow team-read/lifecycle/provisioning facets over canonical HTTP/SSE + contracts with cancellation, safe errors and command receipts. +- **7B - Renderer composition:** introduce team-console, feature reconcilers and capability-first + mounting; migrate the minimum TeamList/TeamDetail controls without a second canonical store. +- **7C - Vertical server flow:** compose workspace list, team list/detail, draft/create, prepare, + launch/progress/status and stop through existing Phase 2-6 use cases. +- **7D - Real-browser proof:** run login -> select workspace -> create -> fake launch -> SSE progress -> + reload/reconnect -> stop -> container restart using only a new sandbox project. +- **Order:** 7A/7B can parallelize against frozen contracts while 7C composes handlers; 7D starts only + after the built artifact passes security and process gates. +- **Concrete result:** the first production-usable web UI slice. It is a milestone, not broad parity. + +### Phase 8 work packages: add tasks, Kanban and messaging safely + +- **8A - Task board:** implement core task create/read/update/status/assignment/Kanban revisions + through team-task-board repositories and pure reducers. Relationships/comments remain expansion. +- **8B - Messaging:** implement paginated inbox/history, send identity, persistence versus live delivery + outcomes and OpenCode delivery status through team-messaging. +- **8C - External reconciliation:** connect fileWriterEpoch, watcher watermarks, provider-mediated/ + quiescent mutation rules and replay journal to tasks/messages without falsely attributing writes. +- **8D - UI/realtime/E2E:** add feature clients/components, snapshot-first SSE reconciliation and + hostile-writer/reconnect/restart browser suites. +- **Order:** 8A and 8B can run in parallel; 8C owns shared watcher/journal integration and is serialized; + 8D integrates only public feature entrypoints. +- **Concrete result:** commands from the web and agent-written JSON converge without lost updates, + duplicate messages or stale-run resurrection. + +### Phase 9 work packages: close Core release diagnostics and approvals + +- **9A - Logs/diagnostics:** bounded member/task/exact logs, activity and redacted failure diagnostics. +- **9B - Minimum approvals:** when a provider can block for operator input, expose pending projection, + atomic allow/deny/timeout, audit and multi-tab/reload behavior. +- **9C - Core closure:** regenerate the action/capability ledger, remove every Core stub/bypass and + prove each advertised control against semantic conformance plus browser evidence. +- **Deferred expansion:** review/attachments, rich task collaboration, member recovery, destructive + restore/repair and cross-team administration keep their existing code but are not composed here. +- **Order:** 9A and 9B are feature-owned and may run in parallel after their shared contracts are + frozen; `team-console`, RouteCatalog and Core closure remain serialized; 9C is last. +- **Concrete result:** every mounted Core v1 control is operable, with no method-name-only parity claim. + +### Phase 10 work packages: turn feature-complete into releasable + +- **10A - Operations:** structured redacted logs, metrics, readiness lattice, diagnostic IDs, retention + and reference-scale latency/memory/event-loop budgets. +- **10B - Lifecycle hardening:** graceful admission/drain/flush/exit, repeated SIGTERM, non-root/read- + only filesystem, resource limits, private networks and stop-then-start replacement. +- **10C - Compatibility/offline recovery:** the single ADR-23 hosted schema contract, forward + migrations, ADR-32 stopped-stack archive, ADR-26 empty-volume restore and future-schema refusal. +- **10D - Release evidence:** full real-browser parity suites, deterministic provider matrix, desktop + regressions, load/chaos, optional sandbox live-provider canaries and operator runbooks. +- **Order:** observability, compatibility fixtures and test harnesses may run parallel; rollout/image/ + migration integration is serialized. No feature scope is added in this phase. +- **Concrete result:** a versioned production image that can start, operate, stop and recover through + the documented offline path with all v1 Definition-of-Done evidence attached. + +### Post-v1 terminal work + +No executable terminal packet is retained here. A future terminal plan is independently estimated and +reviewed after v1; it never blocks the hosted-web release. +Historical phase estimates below describe touched implementation/test surface and intentionally overlap: +later phases revisit contracts, adapters, and fixtures introduced earlier. They must not be +summed mechanically. The 24k-40k range is full-expansion context, not a Core v1 or remaining-work +commitment. Re-baseline from the live integrated head. Hosted terminal has no retained v1 estimate +or executable packet. + +### Phase 0: stabilize baseline and freeze decisions + +Estimated change: 4,000-6,500 lines. +Complexity: 9/10. Risk: 8/10. + +Tasks: + +1. Fetch the remote target base, record its exact SHA, and create a clean implementation branch from + that SHA only after this plan is accepted. Do not merge the closed PR ancestry. +2. Run the base branch's required checks and classify pre-existing failures before production edits. +3. Preserve characterization tests for current Electron behavior. +4. Add this plan, architecture decision records, ownership matrix, and salvage ledger template. +5. Freeze single-tenant v1 semantics. +6. Generate ADR-19 parity-ledger records from the pinned AST for all 86 TeamsAPI, 20 ReviewAPI and + 3 CrossTeamAPI members; assign replacement kind, owner/action/use-case IDs and semantic/test + obligations rather than accepting method-name presence. +7. Freeze `/api/hosted/v1` operator and `/api/runtime/v1` machine trust surfaces, namespace rules and + safe error/ack envelopes; do not freeze every feature DTO prematurely. +8. Add architecture fitness tests for dependency direction, public entrypoints, direct Electron + bypass, route/capability coverage, repository writes, process ownership, and browser stubs. +9. Generate a renderer action inventory mapping every visible team control and store call to its + capability, use case, transport adapter, authorization policy, and required test. Check in the + AST scanner plus explicit annotations for dynamic dispatch so future controls cannot bypass it. +10. Reproduce the closed PR's TeamProvisioning liveness/cleanup, HttpServer warning, route mismatch, + static Docker, and CodeQL rate-limit findings against the clean base. Record whether each is a + base defect, rejected PR design, or required new guardrail; do not copy the failing implementation. +11. Inventory every producer/caller of target-base OpenCode runtime-control routes and classify + bootstrap, delivery, task, heartbeat and permission direction/authority/idempotency before + designing canonical ingress DTOs. +12. Characterize the actual deterministic provisioning CLI and OpenCode adapter paths for identical + plan/preflight/launch/progress/cancel/stop/recover outcomes; record executable, argv/env, + bootstrap files, cleanup and persisted evidence without logging secrets. +13. Freeze the v1 hosting support envelope: one Linux host and one deployment-root writer, supported + local filesystem, volume provisioning, mount policy and fail-closed behavior for unsupported + kernel/path operations. Network-shared and multi-host writable state is explicitly rejected. +14. Implement the ADR-16 feasibility spike in the final image before any application feature code. + Build the versioned instance-lock launcher, provision the root-owned anchor plus runtime-owned + state child, and start two containers/Compose projects/manual invocations on the same volume. + Prove one reaches Node and every loser performs zero state/listener/process effect. Exercise + launcher/controller SIGSTOP/SIGKILL, anchor unlink/rename/recreate attempts, unsupported mounts, + duplicate-FD close order and clean handoff. Scan every fake provider/Git/helper descendant's + `/proc//fd` and fail if the lease inode escapes the controller lifecycle. +15. Inventory the canonical standalone bundle graph and v1 runtime artifacts. Prove which Electron + imports and required `.node` addons are currently stubbed, how the internal-storage worker URL is + emitted, which Node ABI each required SQLite addon uses, and which controller/MCP/provider artifacts + the Docker image actually copies. Terminal-only addons/daemons are explicitly outside the v1 image + manifest. Freeze ADR-17 artifact IDs and smoke probes before composing a feature that needs them. +16. Check in the small ADR-23 hosted schema contract plus ordered migration IDs/checksums and a + negative fixture for unknown future state. Keep legacy desktop/provider import separate and do not + build per-family read/write ranges or downgrade classifications before a second public format. +17. Implement the minimal ADR-28 guard feasibility spike before application feature code: compile it + in the target Linux image, probe `openat2`/`statx`/seccomp/filesystem behavior, enter a verified cwd + for the provider/process anchor, and run only the descriptor-bound read/list/atomic-replace and + approved-exec verbs required by enabled v1 consumers. Prove the adapters have no raw-path fallback. +18. Add the adversarial race harness with marker-owned roots: parent/final symlink swaps, root rename, + bind-mount replacement, stale mountGeneration and repeated provider-process cwd attempts. Demonstrate + the current Node path-string approach fails the negative control and the guard produces zero + outside-root effect. +19. Characterize every Git verb needed by team worktrees/review. Freeze the allowlisted argv/config/ + environment policy and prove `worktree add` cannot execute a fixture `post-checkout` hook, + fsmonitor, pager, external diff/textconv, credential helper or remote network operation. +20. Produce the ADR-29 writer matrix from actual callsites and sanitized provider fixtures: for each + config/task/inbox/review/launch/backup operation record provider/version, possible active writer, + proven lock/command acknowledgement, direct/quiescent/provider-mediated mode and UI capability. + A source-code lock used only by this app is not evidence that Claude/CLI cooperates. +21. Produce ADR-30 environment provenance from the real provisioning/MCP/OpenCode/Codex/Gemini paths. + Build allowlist-first fake launches for each backend, prove required key names and reject every + controller secret canary in env/argv/settings/MCP/log/artifact output. Characterize target-base + mixed-provider ProcessExecutionUnits, persist their minimum CredentialExposureSets, and prove no + unit receives an out-of-set credential or the canonical runtime-ingress bearer. +22. Implement the minimal ADR-31 anchor spike in the final image. Exercise control-pipe EOF, parent + SIGKILL, pidfd signal, subreaper double-fork, TERM-ignore/KILL, output flood, anchor crash and rapid + PID/PGID churn. A marker-owned unrelated process must survive every test. +23. Characterize the existing backup code only enough to prevent it being advertised as full hosted + recovery. Build ADR-32's stopped-stack one-shot archive/restore tool, immutable checksum manifest, + empty-volume guard, SQLite integrity verification and authority rotation test. Document separate + Keycloak/PostgreSQL and workspace-repository backup procedures. +24. Build an ADR-33 deterministic snapshot/event scheduler. Pause a mutation before/after journal + commit and every snapshot, response, wake-up-listener, replay-query and SSE-attach boundary; prove + that SQLite-transaction/lower-C0 plus listen-before-query converges with duplicates but no gap. + Expired/foreign cursors must produce `resync_required` and a bounded full bootstrap, without a + subscription locator or durable browser retention lease. +25. Build the ADR-7 auth/proxy state-machine harness against the exact target-base Fastify/Compose + path. Freeze session/device idle/absolute/renewal/grace limits; test pairing consume, normal restart, + expired access renew, lost rotation response, simultaneous tabs, replay-family revoke and host reset + with a fake residual runtime. Verify the current stable Fastify-5 cookie plugin, malformed/duplicate/ + oversized cookies, auth.keyring exclusive create/reopen/missing/corrupt/reset/restore behavior and + crash recovery at every AuthResetIntent transition. Put an HTTPS edge in front of a private app + listener and prove + PUBLIC_ORIGIN/exact-CIDR handling rejects wildcard/nth-hop trust, forwarded-header spoof, direct + HTTP and another port on the same host. +26. Classify Core mutations as Tier A local transactions or Tier B external workflows. Generate + ADR-34 `CommandDescriptor`/`EffectDescriptor` catalogs, fingerprints and evidence only for Tier B. + Classify each external filesystem/provider/process/message effect from real adapters as + operation-ID idempotent, uniquely reconcilable, compensatable or non-reconcilable; an unproven + Tier B effect defaults to non-reconcilable/operator-required, never generic retry. +27. Create the estimate ledger by unique feature/package/test/tooling bucket. Record reuse, new net + lines, deleted legacy lines, shared-file overlap and confidence separately; do not count a contract, + fixture or composition edit once per phase. Re-estimate after the parity/action inventory and again + after the Core lifecycle E2E. A bucket variance over 20% requires an explicit scope/design review + before adding capacity; do not compare live remaining work with the old fresh-branch total. + +Exit gate: + +- all product decisions required for the first vertical slice are explicit; +- current desktop behavior is characterized; +- required CI is green on the exact chosen base or every pre-existing failure has an owner and an + accepted isolation strategy before feature code begins; +- architecture tests fail on small deliberate negative fixtures and pass on the initial skeleton; +- every runtime-control producer/verb has an explicit machine-ingress or outbound-adapter direction; +- the parity ledger has exactly one disposition for every extracted legacy member and visible hosted + action, and deliberate signature/action negative fixtures fail the gate; +- both real execution topologies have characterization fixtures and no invented universal + orchestrator assumption remains; +- the ADR-16 launcher owns a kernel lock on a stable root-owned inode in the final volume topology; + two real containers cannot both reach Node, anchor replacement is impossible for the runtime UID, + and the lease descriptor is absent from all non-controller descendants; +- the built ADR-28 guard passes its final-image race/exec/Git negative suite; if it does not, all + dependent capabilities remain out of the release matrix and the architecture decision is reopened + before Phase 1 rather than replaced by Node-only checks; +- the hosted artifact inventory has no required module whose only current implementation is an empty + build stub or an Electron-ABI binary; +- every discovered app-owned state family has an explicit read/write compatibility range and + migration owner; unknown/future state blocks before migration; +- every externally writable file operation has one ADR-29 class and required active-run semantics; + no advertised Core action depends on generic merge/retry against an uncoordinated writer; +- every hosted child environment key has ADR-30 provenance; controller/out-of-exposure-set canaries + are absent from every provider tree/artifact, and the per-lane relay works without bearer-in-env/ + file fallback; +- the ADR-31 anchor passes final-image ownership/drain/PID-reuse tests; missing pidfd/subreaper/control + evidence disables hosted launch instead of selecting Node `kill(pid)`; +- the stopped-stack ADR-32 tool creates and verifies one immutable app-volume archive, refuses a live + stack/non-empty restore target and documents separate Keycloak/PostgreSQL/workspace recovery; +- exhaustive ADR-33 schedules prove no snapshot/cursor gap, including the deliberate negative control; +- every Tier B workflow has one stable ADR-34 fingerprint and every external step has one proven + recovery class; old/new descriptor golden vectors, changed-intent key reuse and ambiguous-effect + crash fixtures fail closed before implementation may advertise retry safety; +- ADR-7 schedules prove an operator is neither locked out by ordinary restart/renewal races nor given + a plaintext recovery credential beside a live/unclassified runtime; the real HTTPS proxy matrix has + no header-derived authority path; +- the estimate ledger has no duplicate phase counting and records explicit confidence/contingency per + unique bucket; hosted terminal contributes zero v1 implementation or packaging lines; +- no new browser stub is added without a capability classification. + +### Phase 1: single-source contracts and conformance + +Estimated change: 900-1,600 lines. +Complexity: 7/10. Risk: 6/10. + +Tasks: + +1. Define contract conventions plus the first team-lifecycle read DTOs/routes/errors/events; do not + predesign all parity DTOs before their owning vertical slices. +2. Keep the tiny shared kernel to opaque IDs, request context, revisions/cursors and safe AppError + categories. Feature-specific errors remain feature-owned. +3. Add runtime parsers for the first read/meta/capability contracts with explicit unknown-field and + version behavior. +4. Implement ADR-15 RouteDescriptor/RouteCatalog metadata + assertions and migrate the first + lifecycle read/meta routes. Do not introduce codegen/decorators or predeclare all later routes. +5. Add client-route versus server-route versus authorization-policy parity tests. +6. Cross-check ADR-19 ledger references against action IDs, RouteCatalog, IPC channels, public feature + entrypoints and test metadata. Prove direct, decomposed and desktop-only mappings with negative + fixtures; do not generate a mega client from the ledger. +7. Add an IPC-versus-HTTP semantic conformance harness and prove it with one read use case before + expanding the API. +8. Remove IpcError/raw message matching from the migrated read/store policy only; ratchet the rest. +9. Add ADR-20 renderer gates: forbid direct window.electronAPI/global ElectronAPI/transport access in + migrated team features, traverse the hosted renderer import graph for desktop-only entrypoints, + and inventory global listener registration. Exceptions live only in exact migration adapters with + owner/removal phase. +10. Add a capability/UI conformance test: supported controls have implemented handlers; unsupported + controls are hidden/disabled with a reason before interaction, never after a thrown call. +11. Strengthen dependency tests in both directions: contracts/domain/application ports cannot import + @main or Node infrastructure; hosted composition may import explicit adapters but cannot hide + business logic behind an unrestricted @main/application/hosted god facade. +12. Capture target-base HttpAPIClient/direct-write/direct-Electron violations in the ratchet and add + a negative type fixture proving no hosted facet can implement ElectronAPI. + +Exit gate: + +- RouteCatalog and the separate capability/action ledger cross-reference owner, auth policy and E2E + status for every required action without merging route metadata and product state; +- ADR-19 parity records cross-reference those stable IDs and fail on legacy AST signature drift, + missing semantic obligations or a hosted-visible desktop-only action; +- the first read route/client/parser exists end-to-end through the conformance harness; +- every current renderer TeamsAPI call is classified and every direct bypass is failing the gate; +- new contracts import no Electron, Fastify, React, Zustand, fs, path, or @main; +- no all-parity mega DTO/interface is introduced ahead of its owning slices. + +### Phase 2: identity substrate and externally read-only team lifecycle + +Estimated change: 2,600-4,400 lines. +Complexity: 9/10. Risk: 9/10. + +First use cases: + +- ListTeams; +- GetTeamLifecycleSnapshot; +- GetRuntimeStateProjection; +- ListAliveTeamProjections; +- CreateTeamDraft and DeleteTeamDraft through desktop/test adapters only; hosted remains read-only. + +Tasks: + +1. Introduce immutable RuntimeInstanceContext at composition boundaries and stop adding new mutable + root/path globals; legacy global reads remain ratcheted until their adapters migrate. +2. Extend internal-storage with TeamIdentity records/tombstones and create `workspace-registry` with + a read-only startup manifest adapter, registrationKey-stable opaque IDs, registration revision, + ADR-25 boot-scoped WorkspaceMountBinding/mountGeneration and operation-specific authorization + queries. Add immutable LegacyTeamKey reservation/tombstones and + a bounded cross-root/case-fold collision scan; unsafe or ambiguous legacy directories stay + read-only rather than being renamed or merged. +3. Before publishing any anchor, inventory and characterize every team-root create/remove/move/restore/ + backup path. Introduce one identity-aware TeamDirectoryLifecycleAdapter; route deterministic + `createTeamConfig`/draft creation, explicit `deleteDraft` and permanent delete through durable + identity cleanup/tombstone intents. Change provisioning failure for a committed draft/team to + remove only RunId-owned attempt artifacts and retain identity/retry metadata. Upgrade + TeamBackupService compatibility manifest so its legacy safety copy explicitly preserves + team.identity.json and maps, but does not equate, canonical TeamId with legacy + identityId/\_backupIdentityId evidence. This remains `legacy_unverified`, not ADR-32 v2 recovery; + it preserves the anchor and durable draft record even before config.json is provider-ready. The + current `isConfigReady` gate may still limit legacy/provider file copying but cannot omit canonical + identity. Replace same-name resurrection with an explicit + LegacyTeamKey tombstone/conflict path; a new team must reserve a different key. Add a CI ratchet + forbidding raw recursive removal of an anchored team root outside this adapter. Publication remains + disabled until these regression tests pass for both async and shutdown/sync backup paths. +4. Implement the dedicated write-once TeamIdentityFileStore and adopt legacy teams without rewriting + CLI-owned config or volatile team.meta/members.meta: prepared SQLite adoption intent, exclusive + team.identity.json publish, then committed index/checksum. Persist directory fingerprint and + binding evidence in the intent so recovery distinguishes retry, import and tampering without a + name-based guess. Resolve legacy cwd/projectPath against exactly one enabled + WorkspaceRegistration and persist initial TeamWorkspaceBinding generation; zero/ambiguous match + is unbound/read-only, and conflicting config/team.meta evidence is mismatch/read-only. The + identity anchor is the only new Phase 2 file emitted into a legacy team directory; coordination + records remain in internal-storage/backup state. Unwritable, duplicate, + corrupt, future, missing-after-commit or file/index-mismatched identities yield an explicit + blocked/read-only state, not fake parity or automatic ID replacement. +5. Create the minimal `team-lifecycle` contracts/domain/application/adapters needed for ListTeams, + GetTeamLifecycleSnapshot, runtime read projections, CreateTeamDraft and DeleteTeamDraft. Draft + creation extends the ADR-6 intent across verified team.meta/members.meta projections and commits + one draftGeneration/checksum set; partial projections recover before visibility. Draft commands + are wired only to desktop/test adapters in this phase and cannot bypass the identity protocol. +6. Add an Electron compatibility adapter over TeamDataService/current runtime reads and a separate + hosted context-bound read repository reusing pure parsers. Preserve the round2 provisioning + decomposition; do not instantiate global-root TeamDataService in hosted composition. +7. Make existing IPC read handlers delegate to the extracted use cases and keep legacy teamName DTO + mapping inside the IPC compatibility adapter. +8. Add canonical hosted read adapters using teamId/workspaceId only. They remain loopback/test-only + until hosted authentication passes. +9. Shadow-compare old and new projections over current/legacy/corrupt fixtures without dual writes. +10. Remove route-level config.json existence checks, cache invalidation and runtime overlays from the + new read adapter; those semantics belong in the use case/adapters. + +Exit gate: + +- IPC and test HTTP adapters call the same read use cases; +- no use case imports @main, Electron, Fastify, child_process, fs, or path; +- anchored team/workspace IDs survive restart, rename/display-name changes and legacy adoption; +- CreateTeamDraft reserves TeamId+LegacyTeamKey atomically; browser traffic uses only returned TeamId, + and deleted/case-colliding/unsafe keys cannot be silently reused, renamed or cross-attached; +- no identity file is published until legacy create failure, draft/permanent delete, backup/restore/ + prune and same-name resurrection paths preserve or durably tombstone it; architecture tests reject + bypassing recursive team-root deletion; +- an accepted draft has one TeamId across prepare/launch failure and retry; provisioning cleanup is + attempt-scoped, while only pre-draft failure or explicit DeleteTeamDraft can remove its root; +- crash before identity publish, after publish/before commit and after commit repairs or blocks + according to the ADR-6 matrix; prepared-without-file, file-without-row, row-without-file, + checksum mismatch, duplicate/corrupt IDs and identity_unanchored teams expose no hosted mutation + capability until the specified recovery/repair completes; +- zero/ambiguous workspace adoption and later external config path drift produce unbound/mismatch, + never an implicit rebind or spawn cwd; +- WorkspaceId survives a full container restart by registrationKey while mountGeneration advances; + prior-boot grants/refs are rejected and a changed declared root is not mistaken for remount; +- no canonical browser read contract carries raw teamName/projectPath; +- desktop list/detail/runtime projection regressions remain green; +- desktop createConfig/deleteDraft delegate to the identity-aware draft use cases with legacy DTOs; +- hosted mutation capabilities remain absent. + +### Phase 3: tiered mutation and external-writer compatibility + +Estimated change: 5,000-8,500 lines. +Complexity: 9/10. Risk: 9/10. + +Tasks: + +1. Freeze the persisted-file/provider-artifact ownership catalog and golden fixture corpus. +2. Keep Tier A writes transactional/revision-checked with optional application-ledger command lookup. + For Tier B implement prepared -> running -> committed/recovering/failed/operator_required, + lease-fenced recovery ownership, outbox commit and status lookup in internal-storage. Implement + ADR-23 read-only schema preflight and idempotent migration journal before any migration/recovery + writer opens; unknown or non-drainable records fail closed. +3. Add feature-keyed coordinators and compatibility repositories preserving Claude/provider layouts + and unknown fields. Do not expose a global filesystem repository. +4. Serialize ConfigManager writes and expose flush/failure state. +5. Add intent recovery for multi-file invariants and parent-directory durability where supported. +6. Add read-only compatibility scan, free-space/permission/filesystem capability probe and + desktop/foreign app-writer detection. Phase 3 may open a writer only after the composition proves + it was launched by ADR-16 with the expected held descriptor, root-owned stable anchor and matching + deployment/state binding; there is no application-created lease path or stale-marker takeover. +7. Implement the bounded external-writer observation algorithm: watch-before-scan barrier, + stat/read/stat stability, checksum-based self-write suppression, overflow/dirty scopes, scoped + periodic rescan and shutdown handoff. Persist ADR-24 observation sequence/watermark, + ExternalFileActor attribution and per-TeamId fileWriterEpoch; generic file events never inherit + currentRunRef implicitly. +8. Implement ADR-32's stopped-stack backup tool for the single app-owned deployment volume. It takes + the ADR-16 lease, verifies a cleanly closed database or uses the supported SQLite backup API, + inventories required identity/state files, and publishes a checksum-bound immutable archive with + its ready marker last. Restore requires a stopped stack and empty destination, validates the whole + archive, and never performs a partial/mtime merge. Keep any existing `coordination_backup` internal; + do not present it as deployment disaster recovery. +9. Keep new internal-storage coordination contracts/core free of @main types and add transactional + migrations for app-owned tables. Existing main-layer legacy journal adapters may keep deliberate + @main dependencies until their owning feature migrates; do not broaden this phase into rewriting + unrelated storage adapters. Hosted has no silent critical JSON fallback. +10. Prove transaction/conflict behavior at every Tier A boundary and crash recovery at every Tier B + boundary, including config replacement, launch-state/summary disagreement and external writers. +11. Keep ADR-34 descriptor registries and versioned HMAC fingerprints for Tier B workflows. Persist + an ordered effect plan/state/evidence version and permit automatic retry/compensation only through + its declared proof. Tier A needs no fake effect plan; no Tier B adapter may return a generic + `retryable` flag without descriptor evidence. + +Exit gate: + +- hosted tests can run two isolated RuntimeInstanceContext fixtures in one test process; +- no hosted repository resolves roots from mutable globals; +- legacy fixture round trips retain unknown fields; +- concurrent writes cannot regress to an older snapshot; +- missed/overflowed notifications, atomic replace and partial JSON writes converge through scoped + rescan without emitting a corrupt revision or recursively scanning unrelated roots; +- every timed-out mutation is queryable as + prepared/running/committed/recovering/failed/operator_required; +- same key/same normalized intent converges across reload and compatible binary upgrade, changed intent + is rejected, and every `attempting` external effect either proves deduplication/absence/compensation + or remains operator-required without a second effect; +- two final-image compositions against one provisioned root prove one kernel owner and zero loser-side + Node startup/migration/write/recovery; pausing the winner or deleting diagnostic metadata cannot + permit takeover, while clean full-container exit permits exactly one successor; +- stopped-stack app-volume archives verify checksums and SQLite integrity, reject non-empty targets, + and never claim atomic recovery with Keycloak/PostgreSQL or workspace repositories; +- no process/runtime launch is enabled yet. + +### Phase 4: team-runtime-control feature and lifecycle coordination + +Estimated change: 4,200-7,000 lines. +Complexity: 9/10. Risk: 9/10. + +Tasks: + +1. Create `team-runtime-control` around the existing TeamRuntimeAdapterRegistry, split provisioning + runtime modules, existing runtime-control service and team-runtime-lanes; do not duplicate + runtime-provider-management. + First wrap the current provisioning execution as one compatibility backend, then move provider + branches behind the target registry one at a time. Never run old and new launch planners in + parallel for the same command. + The v1 end state is exactly ProvisioningCliExecutionBackend for Anthropic/Codex/Gemini and + OpenCodeExecutionBackend for OpenCode. The former initially delegates to the target-base + deterministic provisioning flow. Replacing that flow or executable is out of scope unless a new + ADR, characterization suite and rollback path are accepted before implementation continues. +2. Define the feature-owned ProcessSupervisorPort, then ratchet direct spawn/signal callsites into it. + Desktop reuses vetted target-base spawnCli/killProcessTree/Job Object helpers in its compatibility + adapter. Hosted must use ADR-31 anchor/control pipes and may not route through the weaker Node PID + adapter. Implement ADR-22 intent, handshake, ownership record and unclassified-residual admission + block before enabling any hosted runtime. +3. Move provider selection/preflight/environment/model/auth interpretation behind provider-specific + adapters while consuming installation/settings state from runtime-provider-management. +4. Introduce TeamRoster persistence/adoption before canonical planning: reconcile config/members.meta + into MemberId/LegacyMemberKey/memberRevision with case/auto-suffix ambiguity blocking. Persist the + ADR-18 CompositeRuntimePlan with exact rosterGeneration/member mapping before spawn and execute the + exact planner result. Preserve primary-first/turn-complete/side-lane ordering, stable lane IDs, + per-lane credentials/evidence and partial outcome aggregation; never re-plan an accepted generation + during recovery. +5. Build OpenCode adapter composition without Electron app globals and remove the standalone-only + denial/WeakSet boundary. +6. Split existing runtime-control into canonical machine ingress commands and an OpenCode legacy + compatibility mapper. Remove authoritative cwd/teamName/expectedMembers/previousLaunchState from + canonical commands; resolve them from current server-owned run/workspace state. +7. Add persisted runtime-ingress idempotency/replay state and per-lane credential lifecycle behind an + internal adapter. Implement ADR-30 RuntimeIngressRelayPort and fixed derived lane scope; network + registration waits for Phase 5/6. +8. Update desktop and hosted runtime launch-plan builders to target canonical machine ingress. Hosted + gives the canonical credential only to the controller-owned relay through an inherited pipe; + provider adapters consume a narrow local relay descriptor. Keep the legacy route behind one explicit + loopback-only flag until provider clients and desktop regression tests migrate. +9. Extend Phase 2 `team-lifecycle` draft commands with PrepareProvisioning, LaunchTeam, + GetProvisioningStatus, CancelProvisioning and StopTeam over the Phase 3 mutation protocol. + Prepare remains a non-run preflight; accepted Launch atomically creates LifecycleRun plus immutable + plan, advances TeamLifecycle.currentRunRef and opens the next fileWriterEpoch only after ADR-24 + quiescence. Every filesystem/process/provider step consumes its persisted ADR-34 EffectDescriptor; + no generic workflow retry may repeat a launch, delivery or stop after ambiguous evidence. No + command mutates a terminal older run. +10. Make existing IPC mutation handlers delegate to those commands; raw cwd/teamName remain only in + legacy input/output adapters and are resolved before application invocation. +11. Extract lifecycle coordination from desktop startup wiring without undoing the target-base round2 + service/module split. +12. Run the same recovery, health, prompt-delivery watchdog, process polling, work-sync replay/scan, + task-comment journal initialization, backup, event fanout, and stale-GC flow in hosted composition. +13. Track instance-owned process identities and never clean unrelated or merely observed PIDs. +14. Make startup rollback and shutdown idempotent. +15. Represent each background service as a lifecycle component with start/readiness/stop ownership; + remove fire-and-forget startup work that can outlive a failed composition. +16. Implement the canonical liveness/recovery transition table and evidence precedence, separating + historical bootstrap confirmation from current alive state. +17. Keep RunRecoveryWorkflow RunId-scoped and DeletionSaga TeamId-scoped; add architecture tests that + neither can directly write the other's identity, draft, backup, runtime or archive repositories. +18. Implement the ADR-18 per-team legacy_drain -> canonical cutover fence. Characterize exactly which + old status/cancel/stop paths drain one unambiguous active generation; forbid new launch/rebind/ + topology edits until terminal cleanup, and block ambiguous legacy run selection. +19. Make process execution consume a typed `WorkspaceExecutionGrant`, not a cwd string. Desktop may + adapt that grant to its characterized local path behavior; hosted registration remains disabled + until Phase 6 binds it to ADR-28 + ADR-31. No application use case or provider adapter may downcast + the grant to an arbitrary path. +20. Introduce `HostedChildEnvironmentPolicy` as a provider-owned projection into the launch plan. It + starts empty, resolves explicit provider SecretRefs at the last responsible adapter, hashes key + provenance into each ProcessExecutionUnit/CredentialExposureSet and forbids hosted execution from + spreading process/shell env or widening an accepted exposure set. + +Exit gate: + +- every provider reports an explicit capability/readiness state; +- one launch command selects exactly one execution backend; no dual planner/spawn path exists; +- an active legacy generation can only drain through its old adapter; canonical launch cannot race it, + and ambiguous legacy candidates never become currentRunRef by newest-file heuristics; +- every accepted generation has one immutable CompositeRuntimePlan; current topologies preserve + planner rejection, primary/turn-complete/side-lane order and partial/cancel/recovery semantics; +- every canonical lane/member resolves one TeamRoster MemberId+memberRevision; ambiguous legacy names + block launch/member mutation instead of being merged, dropped or rebound by string comparison; +- execution and ingress registries agree on provider IDs/verbs or readiness fails for that provider; +- fake adapters can drive all lifecycle states deterministically; +- desktop create/prepare/launch/progress/cancel/stop reaches the new use cases with unchanged UX; +- canonical runtime ingress rejects raw host authority and persists idempotent acknowledgements; +- list/detail/alive/progress agree on one current run generation and liveness projection; +- hosted restart recovery/classification and stop-owned cleanup work without Electron; live + preserve-for-adoption remains absent unless its stronger isolation gate passes; +- each fake/provider lane traverses its own ADR-30 relay, and changing body TeamId/RunId/LaneId cannot + change derived ingress authority; +- normal stop and controller death drain through ADR-31 status/control evidence; broken/ambiguous + anchors block admission and require container replacement without signalling an observed PID; +- shutdown stops only instance-owned processes. + +### Phase 5: real hosted server composition and packaging + +Estimated change: 3,200-5,500 lines. +Complexity: 9/10. Risk: 9/10. + +Tasks: + +1. Turn target-base `standalone.ts` into a thin entrypoint over an app-level hosted composition that + assembles feature facades/adapters explicitly. +2. Refactor `HttpServer` so listener/static mechanics are separate from CORS/session/rate/admission + policy and route assembly while preserving one Fastify instance. +3. Serve renderer and `/api/hosted/v1` from that existing app while retaining only explicitly + allowlisted legacy non-team routes needed by the standalone session viewer. +4. Implement ADR-21 readiness lattice and route admission; register public live/serve-ready probes, + authenticated capability/meta revisions and private redacted readiness diagnostics. +5. Add capability endpoint. +6. Update the existing `standalone:build` and Docker target; do not port the closed PR's static-only + server or introduce a second hosted artifact. +7. Add persistent app state, Claude/runtime state, registered workspace, and secret mounts with + explicit read/write policy instead of the current blanket read-only example. +8. Add startup migration/compatibility gate. +9. Replace canonical-build Electron and catch-all native stubs with ADR-17 fail-closed dependency + rules. Emit the internal-storage worker as a production artifact, resolve its hosted SQLite driver + to the Node ABI, stage/rebuild required provider helpers, and smoke every artifact inside + the final image; do not rely on dist-electron paths. +10. Add bundle/artifact negative gates: an Electron deep import, missing worker, wrong-ABI SQLite, + unstaged addon or empty native stub must fail build/readiness before route admission. +11. Publish an image capability manifest showing which provider/native binaries are actually present. +12. Wire team routes from feature facades. `HttpServices.teamApis/teamDataApi` may be used only as a + temporary compatibility adapter, not as the final hosted composition contract. +13. Register `/api/runtime/v1` through a separate machine-ingress adapter/policy. It remains + unavailable unless the lane/run credential store and replay fence are ready. Keep legacy OpenCode + runtime routes loopback-only and outside the browser route/capability manifest. +14. Productionize ADR-28 as its own build-stage artifact: pin compiler/base-image inputs, emit build + hash/protocol version, strip/copy only the executable, run it as the non-root runtime UID and make + missing/wrong-version/wrong-architecture/blocked-syscall failures explicit readiness reasons. +15. Bundle no compiler or writable helper source in the final image. Run file and provider-exec guard + probes against the actual mounted test workspace after privileges and seccomp are final, not only + in the builder stage. +16. Build/package ADR-31 as a distinct protocol/hash/readiness artifact sharing only audited low-level + source with ADR-28. Run its pidfd/subreaper/control-pipe/drain probe after final PID namespace, init, + UID, seccomp and stop-grace settings are applied. +17. Package the ADR-30 per-lane relay as controller-owned code with no public listener. Artifact and + import scans fail if the hosted relay bootstrap accepts a bearer from env/file/argv or if provider + launch code reaches the canonical runtime bearer value. +18. Package ADR-16 `agent-teams-instance-lock` as a distinct pinned protocol/hash artifact and make it + the mandatory production command between init and Node. Ship a separate one-shot volume-init + profile/job that creates the root-owned deployment parent/anchor and runtime-owned state child; + the main app container remains non-root and cannot self-repair unsafe ownership. Probe the actual + local volume across two containers, preserve the lock across launcher -> Node exec/lifecycle, and + fail the image if any child artifact inherits the reserved descriptor. +19. Add a negative image/import gate proving hosted v1 does not stage, launch or register terminal- + platform daemon/gateway/SDK artifacts or terminal routes. Desktop packaging remains unchanged. + Post-v1 work package T1 owns its own compatibility manifest and image delta. + +Health endpoints expose only status/reason codes required by the orchestrator, never roots, +provider credentials, team names, or exception text. Metrics/diagnostics bind privately and require +operator authorization when exposed beyond the container network. + +Exit gate: + +- built production artifact returns both renderer and real hosted health/API; +- Docker returns the canonical API from the same Fastify process; +- clean machine/container startup requires no source checkout; +- final image starts only through ADR-16, rejects a competing container before Node, starts the emitted + internal-storage worker, executes a SQLite write/read/reopen probe, loads each required native/helper + artifact, passes ADR-28 and ADR-31 final-topology probes, proves the ADR-30 relay/environment and + lease-FD negative scans and contains no reachable Electron/empty-native stub; +- until Phase 6 security passes, non-loopback team mutations remain unregistered and the image is + test-only, not advertised as production-ready. + +### Phase 6: authentication, authorization, and workspace isolation + +Estimated change: 3,200-5,500 lines. +Complexity: 9/10. Risk: 10/10. + +Tasks: + +1. Create `hosted-access` public contracts/core/adapters and implement PairingChallenge, + OperatorDeviceGrant and OperatorSession repositories/migrations with keyed hashes, + family/generation, server-side idle/absolute/renewal expiry, revocation and restore exclusion. Add + the fsync'd auth.keyring lifecycle and pin the then-current reviewed Fastify-5-compatible + `@fastify/cookie`; no stateless signed-cookie authority. Core imports no Fastify/fs/team-runtime + internals and consumes only public residual-drain evidence. Keep OIDC as a later issuer, not a + second partially implemented login mode. +2. Implement initial pairing and host-controlled `pairingResetGeneration`: startup-only generation + consume, grant/session revocation, mutation admission close, ADR-31 runtime drain proof, atomic + challenge file issue/consume/expiry/retry and zero plaintext when residual state is ambiguous. +3. Implement production cookies and auth flows: path-scoped durable device grant, short host session, + renew/rotation predecessor grace and replay-family revocation, logout versus Forget this device, + multi-tab/response-loss recovery and ordinary backend-restart continuity. +4. Add derived session-bound CSRF bootstrap/verification and strict configured Origin/authority + validation before parsing/idempotency/commands. +5. Remove wildcard credentials behavior in production. +6. Rate-limit pairing/renew/session lookup and destructive endpoints with global plus trusted-client + buckets that do not accept spoofed forwarded IP. +7. Apply the browser session/data-redaction boundary to every retained browser route, including + allowlisted legacy non-team session/project reads, in loopback and hosted modes. `127.0.0.1`, a + `?port=` parameter, or CORS is not authentication against a malicious website/DNS-rebinding flow. +8. Implement opaque per-lane/run runtime ingress credential issue/hash/verify/rotate/revoke + lifecycle, verb scopes, constant-time comparison, request size/rate limits and persistent replay + fencing. +9. Implement ADR-30 relay issuance/bootstrap/rotation/revocation. Pass the canonical bearer only over + the inherited relay pipe, never provider env/file/argv/settings. Build every hosted child env from + its provider allowlist and SecretRefs; apply deny-in-depth/redaction and provenance checks. +10. Wire hosted manifest/mount adapters into `workspace-registry` and enforce its authorization query + for every workspace-bound operation. +11. Enforce opaque resource references and mint an operation-specific ADR-25 + WorkspaceAccessGrant. Every v1 registered-workspace read/write/list/review/Git/spawn adapter + must consume that grant through the exact ADR-28 verb; no adapter may unwrap a host path and call + Node fs/child_process directly. Keep an operation + unadvertised when its final-image guard probe cannot close the relevant race. +12. Block hosted HTTP changes to CLAUDE_ROOT and arbitrary project paths. +13. Add outbound provider URL/SSRF policy. +14. Centralize redacted safe errors and audit events. +15. Require explicit normalized HTTPS PUBLIC_ORIGIN. Trust Forwarded/X-Forwarded-\* only from exact + proxy CIDRs, reject ambiguous chains and never derive authority from them. Replace direct public + HTTP Compose with a private app listener plus TLS edge; label loopback demo as non-production. +16. Ensure auth/API/bootstrap responses are no-store, hashed static assets alone are immutable, and + caches/service workers cannot retain authenticated state. Rotate CSRF with the session. +17. Define expiry/recovery UX: accepted runtimes remain server-owned, mutations freeze, device renew + obtains fresh session/CSRF/meta/snapshot, and only missing/revoked device authority enters the + host-reset pairing instructions. 401/SSE close never implies team deletion/runtime stop. +18. Enforce the trusted-process profile: pairing material is created only after residual-runtime + classification/stop, runtime children receive no browser/controller secret, capability/meta names + the isolation level, and preserve-for-adoption stays unavailable without separate OS isolation. + +Exit gate: + +- unauthenticated HTTP/SSE fail closed, and v1 registers no WS upgrade route; +- cross-origin mutation fails; +- pairing creates one durable device family and short session; ordinary server/container restart + preserves valid browser authority without writing a new pairing token; +- idle/absolute session expiry renews from the device grant with fresh cookie/CSRF/meta/snapshot; + concurrent tabs and lost rotation responses converge within bounded predecessor grace, while replay + after grace revokes the family; +- lost/revoked device authority can recover only through a newer host-controlled reset generation, + and no challenge exists until every v1 runtime is drained or the container is replaced; +- browser never receives server credential or authoritative host path; +- browser session cookies cannot authorize runtime-ingress routes; +- missing/forged/revoked/stale-generation/wrong-run/wrong-lane runtime credentials and conflicting + idempotency reuse produce zero provider/team mutation and zero outbox event; +- a valid runtime credential cannot approve its own tool request, change policy, control another + lane, launch/stop/delete or register workspace; +- provider processes receive no canonical runtime bearer or controller/browser secret in env, argv, + generated settings/MCP config, stdout/stderr or artifacts; secret-canary scans and environment-key + snapshots prove this for every backend; +- each relay derives one immutable lane scope and ignores/rejects body authority. Under + `trusted_process`, tests/docs do not claim the local relay is secret from a malicious same-UID sibling; +- canonical runtime ingress cannot supply authoritative cwd/teamName/member topology/launch state; +- invalid workspace produces zero spawn attempts; +- external config projectPath/cwd drift cannot rebind TeamId/WorkspaceId or change spawn/file/Git + authority; it produces workspace_binding_mismatch and zero affected mutation; +- duplicate/overlapping writable roots, same-key path rebinding, missing mounts and manifest schema + mismatch keep mutation readiness false without changing existing WorkspaceIds; +- traversal, symlink, UNC/drive/case edge tests pass; +- an active symlink/rename/bind-mount adversary cannot cause an outside-root read/write/cwd, and + missing/blocked/wrong-version ADR-28 guard produces zero workspace effect and a precise capability + downgrade rather than a path-string fallback; +- allowlisted Git worktree/review operations cannot execute fixture hooks, helpers, pagers, + fsmonitor, credential prompts or network remotes; +- a residual live/unknown runtime blocks plaintext pairing creation; the default deployment reports + trusted_process rather than claiming hostile-runtime containment. +- production pairing/renew/SSE succeeds only through the configured HTTPS PUBLIC_ORIGIN and trusted + proxy CIDRs; spoofed/multiple forwarded headers, direct HTTP and a sibling port/authority fail before + auth lookup. The production Compose exposes only its TLS edge. + +### Phase 7: first vertical browser lifecycle + +Estimated change: 2,500-4,000 lines. +Complexity: 8/10. Risk: 8/10. + +Flow: + + login + -> workspace list + -> team list + -> team detail + -> create draft + -> prepare + -> launch fake runtime + -> poll/stream provisioning + -> runtime state + -> stop + +Tasks: + +1. Implement real hosted HTTP adapters and DTO projections for the flow. +2. Add idempotency and revision behavior. +3. Wire browser transport credentials/session behavior. +4. Implement ADR-27 server-owned recent-command projection and actor-scoped status resolution. On an + ambiguous timeout, the UI refreshes this projection; it persists and automatically replays neither + the mutation body nor its idempotency key. +5. Make each repository-owned fake runtime lane receive its real lane/run-scoped ingress credential + through the launch adapter and call real `/api/runtime/v1` bootstrap/heartbeat/delivery endpoints; + no test-only callback backdoor. +6. Split renderer teamRead/teamLifecycle/provisioning facets and create the capability-first hosted + renderer composition that registers only their owned listeners/effects. +7. Remove the blanket TeamListView Electron gate while keeping desktop-only editor/chooser/shortcut + subtrees unmounted and unreachable from the hosted route chunk. +8. Gate every action by server capability. +9. Replace throw/no-op/fake values for supported lifecycle methods. +10. Add meaningful degraded/error UI. +11. Introduce TeamTransportReconciler for scope/cursor/routing and route IPC, HTTP, SSE and fallback + polling through it into feature-owned reconcilers. +12. Preserve request scopes, context/team generations, optimistic-run replacement, terminal + tombstones, and structural-sharing behavior with the existing characterization suites. +13. Replace raw projectPath browser cache keys and auto-selection with workspaceId/repositoryId + projections while preserving desktop legacy mapping in its adapter. +14. Add localized login/capability/degraded/reconnect/error states and keyboard/focus/ARIA coverage; + no required action may depend on an Electron-only shortcut. +15. Replace first-flow direct platform calls with WorkspaceRegistry selection, browser-safe external + navigation, Blob/File handling, organization facet gating and opaque process controls. + +Exit gate: + +- real built browser performs the full flow against a real server listener; +- network log contains no unexpected 404, 401, or 500; +- late responses/events from a previous context/run cannot change the visible team; +- no supported lifecycle action uses a browser-mode stub; +- unavailable desktop subtrees/effects/listeners are not mounted or reachable from the hosted team + route chunk, and every rendered control uses a supported typed facet/action; +- fake runtime bootstrap/heartbeat/delivery traverses authenticated machine ingress and legacy + `/api/teams/:teamName/opencode/runtime/*` is unused by hosted E2E. +- refresh/direct SPA navigation serves the app shell and restores a safe selected view without raw + host data in the URL. + +### Phase 8: tasks, Kanban, messages, and external JSON reconciliation + +Estimated change: 2,800-4,800 lines. +Complexity: 9/10. Risk: 8/10. + +Tasks: + +1. Create `team-task-board` and `team-messaging` around current task/Kanban/inbox writers and runtime + delivery APIs; reuse member-work-sync and agent-attachments through public facades. +2. Implement task create/read/update/status/owner/relationships. +3. Implement Kanban ordering with revision checks. +4. Implement paginated message/inbox read and send. +5. Add provider delivery status for OpenCode. +6. Emit domain events from application mutations. +7. Reconcile agent-written filesystem changes into domain events. +8. Add per-team event revisions and replay journal. +9. Implement snapshot-first SSE client reconciliation. +10. Preserve serialized message head/older-page fetching and one required follow-up refresh. +11. Make watcher self-write suppression operation-aware and verify an immediately racing external + JSON observation is not mislabeled as the app's own checksum. This test does not authorize + uncoordinated direct mutation or claim recovery of overwritten bytes. +12. Enforce ADR-24 attribution: team-scoped Claude-compatible changes never gain current RunId/member + identity, while catalogued OpenCode run artifacts must pass VerifiedRunEvidence validation. +13. Test event, poll, and command response permutations against the same pure reducers. +14. Enforce ADR-29 per operation. App-exclusive and proven cooperative mutations use their declared + protocol; uncoordinated active-run changes become provider-mediated workflows with observed + outcomes or quiescent-only controls. Do not preserve the desktop illusion of immediate success. +15. Add the hostile fake writer at pre-read, post-intent, pre-publish, post-publish and watcher- + overflow boundaries. Prove direct unsafe mutation is denied and no stale request is replayed + automatically after quiescence. + +Exit gate: + +- admitted task/message changes commit exactly once; provider-mediated changes report success only + after observation; uncoordinated active direct mutation is visibly unavailable rather than risking + a lost update; +- reconnect does not lose or duplicate visible transitions; +- multi-file task relationships remain symmetric after failure/restart. +- old writer epoch notifications cannot appear as new-run activity, and relaunch remains blocked until + previous process/watcher quiescence is durably closed. + +### Deferred post-v1 work package: hosted terminal + +Hosted terminal is not estimated, packetized or pre-implemented in the v1 plan. When reprioritized, it +starts with a fresh threat-model and dependency review from the then-current desktop terminal and +runtime-platform sources. It must not reuse this v1 umbrella branch or weaken the released HTTP/SSE, +workspace, auth or process boundaries. + +### Phase 9: Core diagnostics/approvals and deferred parity inventory + +The historical 2,800-5,000 line estimate covered full parity and is not a Core v1 remaining-work +estimate. +Complexity: 9/10. Risk: 9/10. + +Core v1 tasks: + +- bounded logs/activity by extending member-log-stream and current exact-log readers; +- failure diagnostics; +- minimum tool approval snapshot/events/idempotent allow/deny/timeout when provider execution can + block, with server-persisted policy; +- compose the existing running-teams projection rather than rebuilding dashboard runtime state. + +Deferred hosted expansion preserves but does not compose review/apply, attachments, rich task +collaboration, live member recovery, delete/restore, identity-repair UI, and cross-team operations. + +Every promoted capability receives: + +- application port/use case; +- HTTP adapter; +- renderer facet; +- security policy; +- focused tests; +- browser E2E assertion. + +Exit gate: + +- no required team capability remains a stub; +- reload/multi-tab approval handling cannot lose, duplicate, or double-answer a request; +- every included web team screen is fully operable or deliberately capability-gated at control level; +- desktop-only exceptions are absent or clearly explained before interaction; +- raw paths/provider secrets are not exposed. + +### Phase 10: production hardening and rollout + +Estimated change: 2,700-4,800 lines. +Complexity: 8/10. Risk: 8/10. + +Tasks: + +1. Structured logs with requestId/runId/teamId correlation. +2. Safe error diagnostic IDs and systematic redaction. +3. Metrics for HTTP, SSE, watchers, queues, provisioning, and owned processes. +4. Complete ADR-21 readiness dimensions, RouteDescriptor admission and safe reason/revision events. +5. Graceful shutdown state machine: + - close mutation/runtime admission while ADR-16 remains held; + - readiness false; + - stop new mutations; + - drain HTTP/SSE; + - apply the explicit stop-owned default or validated preserve-for-adoption capability; + - flush journals; + - exit Node; the instance-lock launcher releases only as the controller/container lifecycle ends; + - exit before deadline. Never unlink or replace the lease anchor as shutdown signaling. +6. Non-root application container user plus an explicit privileged one-shot volume-init step. The main + container cannot create/chown/repair the ADR-16 deployment parent or anchor. +7. Read-only root filesystem, tmpfs, cap_drop, no-new-privileges. +8. Resource limits and stop grace period. +9. Private app/runner network and public edge only. +10. Generate/verify ADR-23's small hosted schema contract and publish the forward-migration, + future-version refusal and offline backup/restore runbook. +11. Feature flag and emergency hosted read-only mode. +12. Keep metric labels low-cardinality; team/task/run/request identifiers belong in sampled traces or + structured logs, not Prometheus labels. +13. Cover writer lease, schema scan, workspace registry, journal recovery, server listeners, + lifecycle components and provider backends. Prove one failed dimension cannot + incorrectly enable or disable unrelated routes; optional provider failure never makes login/read + UI unready. +14. Add bounded log rotation/retention and audit retention/export policy. +15. Complete ADR-32's stopped-stack one-shot app-volume archive and ADR-26 empty-volume restore. + Verify manifest/checksums/SQLite integrity, reject a running controller, partial archive or + non-empty target, rotate boot/event/session/runtime authority and document separate + Keycloak/PostgreSQL and workspace backup procedures. +16. Freeze a reference-scale fixture and latency/memory/event-loop budgets for cold team list, + detail, task/message pagination, launch progress, and reconnect replay. Run a bounded load test + with multiple browser tabs and a slow client. +17. Remove synchronous filesystem/process-table work from HTTP/event hot paths or isolate it behind + bounded workers; propagate cancellation and cap concurrency/queues. +18. Run built-artifact N -> N+1, interrupted forward-migration resume and unknown-future-version + refusal against copied production-shape state. Do not claim automatic downgrade support. +19. Prove ADR-33 handoff under reference-scale concurrent mutations, watcher overflow, retention + advancement, slow snapshot construction, response loss and SSE reconnect. No event may disappear; + a stale/foreign cursor must trigger bounded full resynchronization. +20. Prove rolling replacement is stop-then-start for each deployment root: a candidate stays blocked + before Node while the old controller is paused/draining, then acquires only after every old lock-FD + duplicate is closed. Document distinct operator recovery for a wedged old container; never delete + the anchor, edit diagnostic metadata or enable overlap as a rollout shortcut. +21. Verify the v1 production image, RouteCatalog, capability manifest, renderer chunks, migrations and + startup contain no hosted-terminal route/artifact/dependency. This is an absence gate, not a stub. + +Exit gate: + +- clean Docker restart retains valid state; +- two-container rolling replacement never overlaps controllers: candidate remains pre-Node until the + old launcher and Node have both released their duplicated lock description; +- hosted terminal is `not_offered`: no route, daemon, socket, store or UI effect exists in v1; +- repeated SIGTERM is safe; +- partial startup failure rolls back; +- production logs/API contain no secrets or host paths; +- rollback to the previous image and state schema is documented and tested. +- the exact previous built image either starts in a proven compatible mode or refuses before write; + source-level assumptions and feature flags are not accepted as rollback evidence. + +## Critical path and parallel dependencies + +The critical path is: + + Phase 0 decisions + -> Phase 1 contracts + -> Phase 2 identities + read-only lifecycle + -> Phase 3 tiered mutation/event protocol + -> Phase 4 runtime control + lifecycle commands + -> Phase 5 hardened existing standalone composition + -> Phase 6 auth/workspace security + -> Phase 7 first browser lifecycle + -> Phase 8 tasks/messages/events + -> Phase 9 Core diagnostics + minimum approvals + -> Phase 10 release hardening + +Post-v1 T1 terminal is deliberately outside this chain. Do not use spare capacity to start it while a +v1 critical-path or release-gate task remains; preserving the architecture seam is sufficient. + +Safe parallel work: + +- Phase 3 provider/file fixture inventory can begin during Phase 2, but mutation code waits for + stable identities and the ownership catalog. +- Phase 4 fake provider executables can be prepared beside Phase 3 repositories, but lifecycle + commands wait for the recovery protocol. +- Phase 5 Docker asset/build audit can begin early; team route wiring waits for feature facades. +- Phase 6 security tests can be written before implementation, but mutations stay disabled. +- Renderer facet scaffolding can proceed after Phase 1; state authority migration waits for Phase 2 + read projections and transport/feature reconciler fixtures. +- E2E harness scaffolding can begin early, but it must target the real composition from Phase 5. + +Unsafe parallel work: + +- client and server inventing DTOs independently; +- multiple workers editing HttpAPIClient, TeamListView, or teams IPC without file ownership; +- enabling browser mutations before auth/workspace policy; +- building terminal protocol independently from terminal-workspace; +- migrating persistence while provider/lifecycle authority is still ambiguous. + +## Real end-to-end verification design + +### Level 1: deterministic PR E2E + +Must use: + +- built production renderer; +- built production server or Docker image; +- an ephemeral HTTPS reverse-proxy edge in front of the private app, using the production forwarded- + header allowlist/origin policy. Playwright may trust the harness certificate, but the application + still receives an https canonical origin and uses the real Secure `__Host-` cookie path; +- real TCP listener; +- real browser automation; +- real HTTP/SSE network; +- real temporary filesystem; +- newly created sandbox git workspace; +- temporary CLAUDE_ROOT/app data; +- deterministic fake runtime executable below the real adapter/process-supervisor boundary. + +Application/unit tests may use an in-memory fake adapter. The release browser E2E must exercise the +real composition, process ownership, stdout/stderr parsing, filesystem reconciliation, cancellation, +and shutdown through a repository-owned fake executable. Scenario inputs are test-harness-owned and +allow success, delayed bootstrap, partial member failure, approval request, external task/inbox +write, malformed output, child-process tree, ignored TERM, and crash. The browser cannot choose an +arbitrary scenario executable or path in production contracts. + +The harness creates its own temporary state/workspace root with a unique run marker and refuses +launch, attachment, review or cleanup when canonical paths are outside that root or the +marker/runId does not match. It never accepts an existing repository path from environment defaults. +Cleanup deletes only marker-owned resources and reports leftovers instead of broad process/path +cleanup. This guard is tested with a known non-sandbox path and must fail before spawn/read/write. + +Browser automation is not currently a root project dependency. Before adding a CI runner, +verify the latest stable Playwright version and pin it through pnpm. Local in-app browser tools +may assist debugging, but the release gate must be repository-owned, reproducible, and runnable +headlessly in CI. + +Must not use: + +- fake fetch at the browser/server boundary; +- MockEventSource; +- real user projects; +- real provider agents; +- shared ~/.claude state. +- an insecure-cookie production switch or browser-side injection of session cookies/tokens. + +Required flow: + +1. start deployment; +2. login; +3. enumerate registered sandbox workspace; +4. create team; +5. launch fake provider; +6. observe provisioning via SSE; +7. create/update task; +8. write a simulated agent-side JSON change; +9. observe reconciliation; +10. send/receive message; +11. disconnect/reconnect SSE; +12. reload browser; +13. stop team; +14. restart the complete production container lifecycle, not only the Node process; +15. verify persisted/reconciled state and zero orphan processes under the required stop-owned policy. + +If preserve-for-adoption is advertised by a deployment backend, a separate gated suite restarts the +backend while a fake run is active and proves stable identity adoption. It is not part of the common +path and cannot silently fall back to PID/tmux guessing. + +That lifecycle flow is necessary but not sufficient for release. The table below is the combined +Core-plus-expansion E2E inventory. Core v1 gates only the scope-lock workflow: lifecycle, workspace +admission/effects, Tier B command recovery, runtime/process ownership, core task board, messages, +realtime, external attribution/writer coordination, runtime ingress, minimum approvals, +logs/diagnostics, backup replacement, and capability UX. Other rows become release gates only when +their capability is promoted. + +| Suite | Required proof | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Team lifecycle | create/configure/prepare/launch/cancel/relaunch/stop, failure diagnostics, stable TeamId across failed-run retry and explicit draft deletion | +| Workspace lifecycle | stable WorkspaceId with fresh mountGeneration after full container restart, stale file/review refs rejected, same-boot remount disables effects and changed registration root refuses startup mutation | +| Workspace effects | built ADR-28 guard performs bounded read/write/review/Git/provider operations; concurrent parent/symlink/root/bind-mount swaps yield zero outside-marker effect; missing/blocked guard removes capabilities without fallback | +| Command recovery | ADR-27 server-owned recent/non-terminal projection, timeout before commandId, reload/logout/re-login resolution, stable workflowRef, later workflow failure, mismatched-body conflict and no duplicate effect | +| Composite runtime | all five current lane modes, planner rejection, primary-before-side-lane gate, duplicate turn-complete, partial failure, cancel/restart at each gate | +| Process ownership | ADR-31 anchor ready/control EOF/parent death/main exit/double-fork/TERM/grace/KILL/drained evidence, pidfd PID-reuse refusal, unclassified residual block and hard whole-container replacement with zero surviving fake-runtime tree | +| Task board | create/edit/assign/start/status/Kanban order; rich collaboration/delete/restore when promoted | +| Messages | send, pagination, pending reply, provider delivery result, external inbox write | +| Members | MemberId-based add/replace/remove/restore/role/restart/skip, stale rosterGeneration, memberRevision/lane-attempt fencing, ambiguous legacy names and historical owner/log projection | +| Realtime | ADR-33 lower-C0/same-transaction handoff, listener-before-query, duplicate/gap detection, retention expiry, reconnect, server restart and full-bootstrap resynchronization | +| External file attribution | team-scoped Claude write, forged run/member claim, verified OpenCode run evidence, stop-write-drain-relaunch watermark and deletion-fence conflict | +| Writer coordination | app-exclusive/cooperative/uncoordinated matrix, hostile concurrent writer at every boundary, active direct-mutation denial, provider-mediated observed outcome, quiescent revalidation and no stale auto-replay | +| Runtime ingress | real fake-runtime callbacks through one ADR-30 relay per lane, bearer absent from provider tree, scope/rotation/revocation, replay/conflict, wrong body run/lane/provider and raw-authority rejection | +| Approvals | minimum prompt/allow/deny/timeout/reload/two-tab safety; richer policy UX when promoted | +| Logs/review | bounded member/task/exact logs in Core; source generation and review read/apply/error when promoted | +| Attachments | expansion: upload/download/delete, limits, MIME mismatch, no host-path exposure | +| Destructive | expansion: soft delete/restore/permanent delete, processRef kill, idempotent retry | +| Identity recovery | expansion: maintenance-mode evidence, expected-hash conflict, same-ID republish, duplicate/import refusal, backup-before-repair and restart recovery | +| Backup replacement | stopped-stack ADR-32 immutable app-volume archive, running-controller/partial/non-empty refusal, manifest/checksum/SQLite integrity, clean-target ADR-26 restore and authority rotation | +| Capability UX | each advertised capability works; each unavailable control is pre-gated | + +Do not force every assertion into one fragile mega-test. Each suite creates only new sandbox data, +uses unique team/workspace IDs, and performs narrow cleanup. A shared harness may reuse the built +image, but tests cannot depend on execution order or state left by another suite. + +The harness records a redacted route/transport coverage ledger. Release fails on any team/review/ +browser request to legacy `/api/teams`, any runtime process request outside +`/api/runtime/v1`, any capability marked required without at least one successful E2E action, any +unexpected browser console/page error, or any unclassified 4xx/5xx. This proves the new path is used, +not merely present beside the old one. + +V1 additionally fails if a hosted terminal control, WebSocket route, daemon artifact or capability is +present. Any later terminal release defines its own independent gates. + +Each failed CI run retains a redacted evidence bundle containing browser trace/screenshots, +requestId/diagnosticId, server logs, fake-runtime transcript, event cursor history, state manifest, +and owned-process leak report. HAR/body capture must redact cookies, CSRF, tickets, provider +credentials, prompts/tool inputs classified sensitive, and file contents. + +### Level 2: gated live provider smoke + +Run manually or nightly, not on every PR. + +- always create a brand-new sandbox/test project; +- one narrow team per provider; +- Claude, Codex, OpenCode, and Gemini where supported; +- create -> launch -> ready -> task -> message -> stop; +- capture redacted artifact pack; +- cleanup only smoke-owned teams/processes; +- never touch real user projects. + +### Level 3: desktop regression + +Electron remains a first-class transport. + +- IPC characterization tests; +- create/launch/progress/tasks/messages/stop; +- provider-specific diagnostics; +- existing safe launch matrix; +- packaging checks on supported platforms. + +## Test matrix + +| Layer | Required tests | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Domain | independent TeamLifecycle/LifecycleRun state machines, IDs/currentRunRef generations, terminal-run immutability, revisions and capability rules | +| Application | use cases with fake ports, immediate vs workflow-starting command semantics, stable workflowRef idempotency and cancellation | +| Cross-feature workflows | commit/effect ordering, explicit partial outcomes, compensation/recovery and zero swallowed visible failures | +| Contracts | routes, parsers, DTO compatibility, safe errors | +| Parity traceability | AST member/signature/action inventory, direct/decomposed/desktop-only mappings, stable ID references, missing/duplicate/drift negative fixtures | +| IPC adapter | parity with existing desktop semantics | +| HTTP adapter | real Fastify inject with real application facade | +| Client/server conformance | every client route registered and shape-compatible | +| Persistence | legacy fixtures, future fields, corrupt files, migrations | +| State compatibility | artifact/schema contract checks, future-version refusal, supported forward migration, checksum drift, interrupted resume and offline pre-migration backup | +| Instance lease | final-image shared local volume across two containers/Compose projects/manual starts; pre-Node loser, root/anchor ownership, symlink/unlink/rename/recreate refusal, launcher/controller STOP/KILL, duplicate-FD close order, clean handoff, unsupported NFS/CIFS refusal and descendant `/proc/*/fd` leak scan | +| Disaster recovery | stopped-stack app-volume archive/restore, lease ownership, checksum/SQLite integrity, disk-full/interruption, ready-marker-last publication, empty-target enforcement, preserved IDs, rotated credentials/mounts and explicit refusal of partial/live/fork restore | +| Identity lifecycle | atomic TeamId+LegacyTeamKey reservation, unsafe/case-fold/cross-root collision and no-reuse tests, publication gate over every legacy destructive path, durable-draft TeamId across failed run/retry, pre-commit draft cleanup vs explicit DeleteTeamDraft, legacy backup-ID mapping/rotation, prepared intent without file, published file without committed row, committed row without file, checksum/ID mismatch, external delete/change, exclusive publish crash repair, launch meta rewrites, display rename/soft-delete/restore, permanent tombstone, duplicate restore/import, async+sync backup manifest disagreement and downgrade refusal | +| Roster identity | config/members.meta adoption, case-fold/auto-suffix ambiguity, stable MemberId remove/restore/replace, memberRevision/lane-attempt fencing, historical task owner/inbox/log mapping and expected rosterGeneration conflicts | +| Concurrency | parallel config/task/inbox mutations, stale revisions, ADR-16 two-container kernel exclusion/clean handoff/path-replacement/FD-leak cases, ADR-29 writer-class admission and hostile external replacement | +| Command/effect recovery | ADR-34 fingerprint golden vectors and retained-version compatibility; same/different intent key reuse; concurrent claim; crash before/after attempting/effect/evidence/commit/compensation; false-equal/stale evidence; non-reconcilable operator-required and zero duplicate run/message/task/file effects | +| Process ownership | anchor/control/status protocol, crash before/after spawn/ownership commit, nonce mismatch, pidfd PID reuse, subreaper/double-fork, TERM/KILL/drained, anchor crash and full-container replacement | +| Child environment | provider/backend/execution-unit allowlists, key provenance, mixed-unit exposure-set snapshot, controller/out-of-set canaries absent from env/argv/settings/MCP/logs/artifacts, no post-accept widening and no canonical bearer fallback | +| Runtime relay | inherited-FD bearer bootstrap, fixed derived lane scope, local endpoint rotation, body-authority mismatch, relay crash/restart/revocation and explicit same-UID non-isolation claim | +| Workspace guard | final-image `openat2`/`statx`/seccomp/version probes; parent/final symlink, rename, bind-mount and stale-generation race loops; bounded file verbs; atomic replace/fsync; verified provider-process cwd; zero raw-path fallback | +| Git execution | fixed subcommand/argv policy, neutral HOME/system/global config, hook/fsmonitor/pager/diff/textconv/credential-helper suppression, no remote network, worktree add/remove recovery and malicious repository fixtures | +| Runtime | fake provider success/failure/timeout plus all current composite lane modes/order/partial/cancel/restart semantics | +| Runtime cutover | no-active direct canonical transition, one-active legacy_drain status/stop/cancel, blocked relaunch/rebind/topology edit, ambiguous/multiple legacy candidates, crash during cutover handoff and one-way canonical fence | +| Events | ADR-33 exhaustive same-transaction/lower-C0 snapshot handoff schedules, duplicate, gap, replay, foreign/old/ahead epoch cursor, retention crossing during snapshot, slow client, response loss, SSE attach/reconnect, resync and restart/journal restore | +| External attribution | Claude task/inbox write without RunId stays ExternalFileActor, forged member/run fields, OpenCode VerifiedRunEvidence, watcher watermark drain, old/new fileWriterEpoch and relaunch/deletion fences | +| Transport reconciler | deployment/boot/selection scope, binding/roster/fileWriter generations, single currentRunRef, cancellation, cursor duplicate/gap/schema, topic routing, reconnect/poll scheduling; no entity semantics | +| Feature reconcilers | lifecycle thin/full/run tombstones, task revisions, message head/older-page serialization, approval dedupe, poll/event/response permutations | +| Renderer reachability | hosted import graph excludes Electron/desktop entrypoints; capability permutations mount zero unavailable effects/listeners and every rendered control exercises a real facet | +| Post-v1 T1 terminal | two-plane grant/regrant state machine, hosted-safe method matrix, schema/resource budgets, GuardedShellLaunchSpec inherited-FD/exec evidence, raw-output/media denial, bounded projection and cancel/full-resnapshot backpressure, boot-scoped daemon/socket/store ownership, persistence-required mode, sanitized daemon/shell env, portable-pty close-all/drain evidence and container fallback; excluded from v1 | +| Browser | Core lifecycle/tasks/messages/diagnostics/approval suites using the real network; expansion suites only when promoted | +| Browser command recovery | server-owned recent/non-terminal projection, timeout before/after acceptance, reload/re-login/multi-tab merge, actor/deployment isolation and no browser receipt/key/body persistence | +| Hosted artifact | emitted worker URL, Node-ABI SQLite reopen, ADR-28/31 native manifests/probes, ADR-30 relay/env scan and Electron/native-stub negative bundle fixtures | +| Docker | non-root, HTTPS edge/Secure cookie, private app/runtime routes, health, persistence, SIGTERM, private ports | +| Readiness/admission | each dimension fails/recovers independently, handler not invoked on denial, read-only/drain exceptions, revision events and no unrelated outage | +| Hosted access | pure PairingChallenge/DeviceGrant/Session/AuthResetIntent transition tables, SQLite/keyring crash at every cross-store step, keyring failure, rotation grace/family bounds and public residual-drain-port behavior; no Fastify/fs/runtime internals in core | +| Security | ADR-7 real cookie/network pair/device/session/reset lifecycle, restart continuity, idle/absolute/renewal expiry, multi-tab/lost-response rotation, replay-family revoke, CSRF-before-claim, fixed PUBLIC_ORIGIN/proxy spoof/direct-HTTP/sibling-port denial, opaque refs, descriptor-bound traversal/symlink/mount races, Git helper execution, SSRF and limits | +| Workspace mount lifecycle | stable registrationKey/WorkspaceId across container restart, new mountGeneration, stale grant/ref/plan rejection, same-boot root swap/disappearance, permission downgrade and changed declared-root refusal | +| Desktop | IPC and Electron regression | +| Live provider | opt-in sandbox-only provider matrix | + +## Failure and chaos cases + +Mandatory cases: + +- malformed and oversized JSON; +- corrupt critical state; +- unknown future schema; +- disk full/read-only disk; +- permission denied; +- crash between paired task relationship writes; +- parallel mutation race; +- provider auth disappears mid-launch; +- provider process exits before bootstrap; +- primary lane fails before side-lane admission; +- duplicate primary turn-complete attempts to enqueue the same side lane twice; +- one side lane fails/stops while others become ready; +- config and members.meta disagree on case/removedAt or contain CLI auto-suffixed aliases; roster + adoption blocks instead of silently merging/dropping MemberIds; +- an older memberRevision/lane attempt emits logs/messages after replace/restore/restart and cannot + become the current member runtime identity; +- config changes after CompositeRuntimePlan commit but before restart recovery; +- rollout sees one active legacy run, then relaunch/rebind is attempted before legacy_drain completes; +- rollout sees multiple/newest-looking legacy run candidates and refuses to choose by mtime; +- forged/revoked/wrong-generation runtime ingress token; +- duplicate and conflicting runtime ingress idempotency keys; +- runtime callback attempts to override cwd/team/member topology; +- provider environment fixture tries to inherit pairing/session/CSRF/runtime bearer, a SecretRef + outside its ProcessExecutionUnit exposure set, `NODE_OPTIONS`, loader injection and credential-helper + variables; provenance admission or secret-canary scan rejects launch before provider exec; +- one lane calls another lane's local relay or changes body TeamId/RunId/LaneId. Under the declared + trusted profile the endpoint is not claimed secret, but the relay still cannot expand its fixed scope; +- relay crashes before/after forwarding and rotates while a duplicate callback arrives; durable server + claim/replay state prevents a duplicate semantic effect; +- ADR-31 anchor PID is rapidly recycled by an unrelated marker process; control/pidfd targeting never + signals the marker; +- controller crashes after anchor spawn but before ProcessOwnershipRecord commit; pipe EOF drains the + owned group or leaves explicit unclassified evidence requiring container replacement; +- provider double-forks, ignores TERM, floods output, exits before descendants, or anchor crashes + during escalation; only a typed `drained` result reopens launch/pairing readiness; +- spawnNonce/runtime handshake conflicts with persisted run/lane or executable/start-token evidence; +- Node hot-restart is attempted inside the production container while an owned child remains and is + rejected in favor of whole-container lifecycle; +- backend killed during provisioning; +- backend restart with active persisted run; +- dropped SSE connection; +- cursor older than replay buffer; +- duplicate event; +- command response arriving before/after its corresponding event; +- old poll response arriving after a newer SSE event; +- thin snapshot arriving after a full snapshot; +- delete/stop followed by late watcher and runtime events; +- old provider writes task/inbox during stop, and its watcher notification is delivered only after a + relaunch attempt; quiescence closes/commits it before the next fileWriterEpoch or blocks launch; +- a continuous external writer crosses every capture/rescan attempt and launch returns bounded + external_writer_busy without creating RunId/currentRunRef; +- an uncoordinated active writer races a browser task/config/review replacement; direct mutation is + denied before effect, while provider-mediated delivery cannot report success until the expected + newer semantic projection is observed; +- a quiescent-only command waits through stop/watermark/rescan, then sourceGeneration changes before + confirmation; the stale body is not replayed and the user must rebase/confirm; +- Claude-compatible JSON claims the current run/member without verifiable provider evidence and stays + ExternalFileActor rather than gaining audit/runtime authority; +- stale RunRecoveryWorkflow resumes after a newer currentRunRef or DeletionSaga and is denied from + deleting team identity/draft/backups or newer-run artifacts; +- two browser tabs answering one approval; +- browser reload while an approval or provisioning command is pending; +- access session expires after command acceptance; device renewal rotates session/CSRF, then the same + OperatorId resolves the pending key without replaying the command; +- backend/container restarts with a valid device/session family and writes no new pairing file; +- two tabs renew one device generation concurrently, the first response is lost, predecessor grace + permits bounded recovery and a post-grace replay revokes the family; +- all device cookies are lost while runtimes are active; browser cannot mint a challenge, while a + newer host reset generation drains/replaces the runtime boundary before plaintext appears; +- spoofed/multiple Forwarded/X-Forwarded values, direct production HTTP, unexpected Host/Origin and a + sibling service on another port cannot use or renew hosted authority; +- browser times out before commandId, reloads, then resolves the accepted operation from the + server-owned recent-command projection without persisting or replaying its prompt/body; +- crash leaves a runtime alive while startup attempts to create new pairing material; +- slow SSE consumer; + +- reverse proxy idle timeout; +- port collision; +- canonical hosted bundle reaches an Electron import or catches a required `.node` addon with a stub; +- internal-storage worker URL is missing after bundling or SQLite addon has the wrong ABI; +- desktop and hosted controller attempt to own the same app/team root; +- ADR-28 negative control swaps a parent/final symlink between Node validation and raw path-string + spawn/read and demonstrates the old approach is exploitable inside the marker harness; +- the guard race loop swaps parent, final symlink, registered root and bind mount during file read, + atomic replace, Git and provider spawn; every outside-marker effect remains zero; +- `openat2` is blocked by seccomp, guard protocol/build version is wrong, `statx` mount identity is + unavailable or the filesystem fails its atomicity probe; only dependent capabilities go unavailable + and no Node fallback executes; +- malicious repository config installs post-checkout hook, fsmonitor, pager, external diff/textconv, + credential helper and remote URL; allowlisted hosted Git operations execute none of them; +- full container restart remounts the same registrationKey/path with different dev/inode: WorkspaceId + stays stable, mountGeneration advances, and every prior grant/ref/plan is stale; +- a non-terminal workflow references the previous mountGeneration after restart and cannot resume an + effect until explicit recovery/replan; +- external agent rewrites config projectPath/cwd to another registered or unregistered workspace; +- mounted workspace disappears; +- two final-image controllers/Compose projects race for one provisioned deployment volume and only one + reaches Node; +- the ADR-16 winner is SIGSTOPed while diagnostic heartbeat/metadata becomes stale or disappears; the + contender still cannot acquire the kernel-held inode; +- runtime UID attempts to unlink/rename/recreate the root-owned anchor, launcher or Node closes one + duplicate FD, and a child tries to inherit another; ownership survives until the complete admitted + controller lifecycle exits and no child can prolong it; +- command crashes before effect, during effect, after verification and after outbox commit; +- journal restore rotates event epoch while a browser reconnects with an old cursor; +- migration interrupted and resumed; +- previous image encounters a future state family or non-terminal record it cannot drain and refuses + before migration/write instead of trusting a disabled feature flag; +- identity adoption crashes before file publish and after publish/before SQLite commit; +- a valid identity file exists without an intent/index and is staged as import instead of silently + attached by directory name; +- a committed identity row loses its file, or file/index checksums disagree, and mutation stays + blocked until explicit audited repair; +- identity file deletion/change races watcher overflow and a controller restart; +- legacy TeamBackupService rotates identityId for same-name resurrection while canonical TeamId remains + distinct, and old/new backup evidence cannot cross-attach teams; +- a deleted LegacyTeamKey is requested for a new draft, or legacy team/task/backup roots contain + case-fold/unsafe/cross-root collisions; creation/adoption blocks without auto-renaming while explicit + restore is allowed only for the original soft-deleted TeamId and never after permanent tombstone; +- async backup, shutdown backup and permanent delete interleave at every ordering boundary without + restoring or erasing an anchored/tombstoned identity; +- the stopped-stack ADR-32 tool refuses an active controller, loses power or disk space during archive + creation, observes checksum/SQLite corruption, crashes before the ready marker, and preserves the + prior known-good archive; restore refuses non-empty destinations and incomplete archives; +- ADR-33 mutation commits before/after lower-cursor capture, each projection read, response + serialization and SSE attach; response loss/reconnect and retention expiry yield duplicate replay or + resync, never an event absent from both snapshot and replay; +- team.meta launch rewrite occurs after identity adoption; +- draft creation crashes before/after identity commit, and a later provisioning failure/retry keeps + the committed TeamId while cleaning only RunId-owned artifacts; +- explicit DeleteTeamDraft crashes before/after identity tombstone; +- backup restore introduces a duplicate team.identity.json; +- replace_deployment restore is interrupted before completion; the empty destination is recreated and + the immutable archive is retried before normal startup can expose pairing or mutation; +- restore targets a non-empty root or requests an unsupported fork while the copied source deployment + might still run; operation refuses instead of duplicating deployment identity silently; +- SIGTERM delivered multiple times; +- partial startup failure after some services started. +- provider-only readiness failure while login/team reads remain available; +- mutation readiness failure while stop/status/recovery drain routes remain available; + +## Docker and hosting topology + +Recommended production topology: + + Internet + -> Caddy TLS edge (Core v1 reference profile) + -> private hosted app network + -> isolated runner/process boundary + -> mounted state and registered workspaces + +Nginx and Traefik may be compatible later, but they are not additional Core v1 release/E2E +matrices. Keycloak is likewise an external optional OIDC service, not part of the reference profile. + +Edge requirements: + +- TLS and HSTS; +- one explicit ADR-7 PUBLIC_ORIGIN on a dedicated hostname; the edge overwrites forwarding headers + and the app trusts only configured edge CIDRs, never all/nth-hop proxies; +- v1 pairing/device/session flow; optional OIDC grant issuer only when deliberately enabled later; +- request/body limits; +- SSE buffering disabled; +- long SSE idle timeout; +- only edge port publicly exposed; +- edge forwards renderer, `/api/hosted/v1`, public liveness and only the + explicitly reviewed legacy non-team routes required by the retained standalone session UI. Those + routes receive the same browser session/data-redaction policy and are listed in RouteCatalog. + It denies `/api/runtime/v1`, private readiness/metrics and legacy `/api/teams/*`; owned runtimes + reach machine ingress through loopback/private app networking. +- CSP, frame-ancestors denial, nosniff, strict Referrer-Policy, and minimal Permissions-Policy; +- trusted proxy configuration that cannot be spoofed from the public interface. +- no direct public app-port mapping in the production profile; HTTP 3456 remains an explicitly + insecure loopback demo only and cannot report productionAuth readiness. + +Hosted app requirements: + +- private bind; +- exactly one admitted writer per deployment root. `replicas: 1` is defense in depth; ADR-16 must + reject a second container/manual start sharing the volume before Node or readiness exists; +- one pre-provisioned local deployment volume layout: root-owned non-runtime-writable parent and + `instance.lock`, plus a runtime-UID-writable `state/` child. App and lock anchor cannot be supplied as + unrelated mounts/registrations, and the main container cannot initialize or chown them itself; +- durable hashed ADR-7 device/session state, short access authority, host-reset recovery and no + plaintext challenge while a v1 runtime remains live or unclassified; +- canonical API and renderer; +- machine ingress remains token-authenticated even on the private network and is not included in + browser CORS/cookie/capability policy; +- health/readiness; +- no arbitrary process path or workspace path from browser; +- Linux kernel 5.6+ plus a final-runtime seccomp profile that permits required ADR-28 syscalls. The + exact kernel/seccomp/filesystem is accepted by executable probes, not by version string alone; +- the runtime image contains the pinned `agent-teams-workspace-guard` protocol/build hash but no + compiler toolchain, and dependent routes remain unregistered until its mounted-workspace probe passes; +- the image also contains the separately versioned ADR-31 process anchor; pidfd/subreaper/control-pipe + probes pass under the final init/PID namespace/seccomp/UID configuration; +- init starts the separately versioned ADR-16 launcher before Node. The launcher and Node retain + duplicates of one kernel-locked open-file description; diagnostic metadata/heartbeat never controls + ownership, and the reserved descriptor is absent from every provider/relay/Git/helper child; +- canonical runtime ingress is reachable only from controller-owned ADR-30 relays on private local + transport. Provider processes receive no server bearer and the public edge cannot reach relays; +- no hosted-terminal WebSocket route, daemon/gateway port, socket/store volume or terminal-platform + artifact exists in the v1 image; +- no Docker socket, host PID namespace, privileged mode, or broad home-directory mount; +- init/signal forwarding and a shutdown deadline compatible with provider/process grace periods. + +Runner requirements: + +- non-root; +- one minimal init -> ADR-16 instance-lock launcher -> controller lifecycle; production restart + replaces the whole container and does not hot-restart Node while provider children remain; +- provider adapters; +- owned PID tracking; +- bounded stdout/stderr; +- ADR-30 allowlist-first provider environments and SecretRef resolution; no ambient process/login-shell + spread in hosted spawn code; +- ADR-31 anchor per provider run, with status/control pipes and whole-container fallback on ambiguous + drain. The minimal container init remains the final reaper, not a replacement lane supervisor; +- no host mounts outside registered state/workspace/state/credential needs. Under the accepted + `trusted_process` profile this container mount boundary, not ADR-28 alone, limits a provider; +- explicit UID/GID ownership and startup permission probe; never solve mount errors by running root; +- secret mounts read-only and excluded from child environments unless the selected provider adapter + explicitly requests the named credential material. + +Volume model: + +- /data/agent-teams: root-owned deployment parent, not writable by the runtime UID; +- /data/agent-teams/instance.lock: stable root-owned ADR-16 anchor, never runtime-created/replaced; +- /data/agent-teams/state: runtime-UID-owned app persistent state bound to that anchor/deployment; +- /data/claude: tenant runtime/CLI-compatible state; +- /workspaces/: registered project mounts with explicit read-only/read-write policy; +- /run/secrets or equivalent: provider credentials; +- temporary output on bounded tmpfs. + +## Migration and rollback + +### Compatibility rollout + +1. Read-only compatibility scan. +2. Shadow projection parity between legacy and new application snapshots. +3. Hosted feature flag disabled by default. +4. Enable list/read only. +5. Enable draft/create. +6. Enable fake-runtime launch in non-production. +7. Enable provider-specific launch canary. +8. Enable task/message mutations. +9. Enable remaining high-risk v1 capabilities separately. Hosted terminal is not a v1 rollout state. + +Each capability advances through a checked-in cutover state: + +| State | Meaning | +| --------------------- | --------------------------------------------------------------------- | +| inventoried | owner/current callsites/security/test obligations known; not routable | +| shadow-read | new projection compared with legacy; no UI authority | +| desktop-delegated | IPC uses the new application use case; legacy DTO adapter remains | +| hosted-read | authenticated browser query enabled; no mutation | +| fake-runtime mutation | deterministic sandbox-only command path enabled | +| provider canary | one explicitly supported provider/backend, operator opt-in | +| required | advertised by default and covered by release E2E | + +Activation is a server-side manifest/config decision, not a renderer flag. A capability cannot move +forward unless its route, authorization policy, application use case, persistence/recovery behavior, +renderer facet/control, observability and negative/E2E tests all have the same manifest status. +Rollback moves admission backward while keeping read/stop/recovery surfaces available for already +accepted operations. + +### Rollback controls + +- feature flag per capability; +- hosted read-only emergency mode; +- retain legacy IPC adapter; +- disabling launch/create/task mutation never disables status, stop, recovery, token revocation or + command-status lookup for work accepted by the newer version; +- rollback preflight refuses image downgrade while an incompatible mutation saga/migration is + prepared or recovering. The operator must drain/stop or complete recovery first; +- image rollback is stop-then-start under the same ADR-16 anchor. A previous image may wait pre-Node + for the current container to release, but no rollout script deletes/recreates the anchor, edits + lease diagnostics or mounts the same `state/` behind a different lock registration; +- active run-ingress credentials are revoked or remain verifiable by the compatible drain + path before process replacement; rollback cannot strand unauthenticated live children; +- ADR-7 auth schema/policy is part of the hosted schema contract. An image that cannot validate + device/session/reset generations must revoke them and require controlled re-pair after drain, or + refuse; it may never reopen wildcard-CORS/unauthenticated hosted routes as a downgrade fallback; +- no irreversible CLI-owned schema rewrite; +- versioned app-owned migration journal; +- a verified ADR-32 stopped-stack app-volume archive before an incompatible app-owned migration; +- restore validates the immutable manifest, checksums and SQLite integrity, requires an empty target + and cannot silently fork a copied deployment or reuse backed-up sessions/tickets; +- expand/contract migrations for app-owned tables: add/read-both/write-new before removing old + fields; no destructive schema cleanup in the capability-enabling PR; +- ADR-23 hosted schema version plus forward-migration and unknown-future refusal tests. Rollback means + stopped-stack restoration of the verified pre-migrate app archive, not live per-family downgrade; +- exclude ephemeral sockets/PIDs/temp, sessions/tickets and provider secrets by default; any later + sensitive inclusion needs explicit encryption/key-rotation/access/restore semantics and new threat + review, not an undocumented manifest flag; +- image rollback plus compatible state version; +- downgrade is refused if the target image cannot preserve team.identity.json through create + failure, delete, trash/restore and backup/restore, even if it can ignore the file during reads; +- fail closed on future critical schema. + +## Parallel execution model + +Target: six productive workers when host admission allows. + +Next worker launch profile requested by the user: + +- model/profile: 5.6 sol; +- effort: xhigh; +- fast mode: enabled. + +Do not keep six workers busy by creating overlapping work. +Each worker owns a bounded worktree and a non-overlapping surface. + +### Recommended workstreams + +| Worker | Ownership | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| A | shared-kernel minimum, public-contract conventions, RouteCatalog, architecture fitness gates | +| B | `team-lifecycle` application/domain plus legacy compatibility adapters | +| C | `workspace-registry`, browser/session security adapters and path/lease negative tests | +| D | `team-console`, TeamTransportReconciler and renderer composition; each feature worker owns its entity reducer/reconciler and public renderer facet | +| E | `team-runtime-control`: execution/ingress domain, lane/run credential/replay store, process supervision and liveness/recovery | +| F | persistence compatibility, command/intent recovery, event journal and external-writer reconciliation | + +The integration owner serially owns app-level hosted composition, shared route assembly, build/Docker +and adoption; these are convergence points, not another worker free-for-all. As prerequisites close, +A rotates to the real-network E2E/chaos harness, B to task-board, C to workspace/auth security E2E, +D to messaging UI/reconciliation, E to approvals/provider conformance, and F to review/attachment +persistence. Rotation occurs only with a written handoff and clean ownership boundary. Six workers +are a throughput target, not permission to modify six coupled surfaces before their contracts exist. + +### Integration rules + +- One integration owner. +- Small PR/commit slices with explicit dependency order. +- No worker edits shared mega-files without ownership coordination. +- Contract changes land before dependent client/server work. +- Every adopted slice includes tests and diff-check. +- Dirty outputs are reviewed before new capacity work. +- No raw merge of worker branches without targeted gates. +- A worker cannot introduce a cross-feature contract unilaterally; the owner and integration owner + approve it before dependent work starts. +- Shared-kernel and app-composition files have serialized ownership even while feature work is parallel. + +## Branch, PR, and commit strategy + +No implementation branch is created until the user accepts this plan. + +Recommended first branch name: `refactor/hosted-web-feature-boundaries`. At creation time it starts +from the then-current fetched SHA of `refactor/team-provisioning-round2-reapply`; the SHA is recorded +in the first commit/PR description. The closed `refactor/hosted-web-runtime-boundary` branch is never +merged into it. + +Do not expand the existing umbrella into an unreviewable full-parity PR. Deliver sequential, +feature-flagged Core slices into the target base when repository policy permits. Incomplete hosted +mutation capability stays unadvertised and off by default, so architecture and compatibility slices +can land without exposing a false product. +If integration policy requires an umbrella branch, worker PRs target the umbrella and a continuously +updated draft PR shows the aggregate diff, but each adopted commit still has its own review evidence. + +Suggested slice sequence: + +1. docs/architecture and capability matrix; +2. clean-base CI classification and independently valid baseline/security fixes; +3. feature skeletons, small shared kernel, contracts, AppError, and architecture fitness tests; +4. runtime/identity context, `workspace-registry`, and read-only `team-lifecycle` with desktop + compatibility plus hosted context-bound adapters; +5. Tier A transaction/revision handling, Tier B durable effect recovery, and external-writer + compatibility; +6. `team-runtime-control` execution + machine ingress, scoped credentials/replay fencing, liveness + state model and lifecycle mutation commands; +7. app-level hosted composition and existing standalone Docker target; +8. browser session + machine-ingress auth, workspace policy and security negative gates; +9. `team-console` reconciler and browser lifecycle vertical slice; +10. `team-task-board`, `team-messaging`, and their external-write reconciliation; +11. bounded diagnostics plus the minimum provider-blocking approval flow; +12. production hardening and Core real E2E gate. + +Review, attachments, rich task collaboration, live member recovery, destructive restore/repair and +cross-team administration use later promotion PRs. Their existing desktop/shared code is preserved. + +Any future hosted terminal starts from a fresh plan and its own branch/PR chain. This plan does not +reserve its protocol, dependency, packaging or rollout design. + +Each slice is independently reviewable and revertible. Use conventional commits. Do not combine a +contract change, broad mechanical move, and behavior change in one commit. A slice PR includes: + +- scope/ownership and explicitly excluded behavior; +- ADR/capability changes; +- migration and rollback effect; +- focused tests plus architecture/conformance results; +- desktop impact and hosted capability state; +- E2E evidence level honestly labeled fixture, adapter, process, browser, or container. + +## Verification commands and gates + +Exact commands may be split by phase, but final readiness requires: + +- pnpm typecheck:workspace; +- pnpm lint:fast during iteration and pnpm lint:fast:files -- for narrow worker gates; +- full pnpm lint for architecture-sensitive final gate; +- focused Vitest suites for each changed layer; +- pnpm test:workspace:ci; +- exactly one canonical renderer+API build: target-base `pnpm standalone:build`, or + `pnpm hosted:build` only after an atomic rename where it replaces the same entrypoint/artifact and + `standalone:build` is at most a temporary alias tested for equivalence; +- Docker image build; +- client/server route conformance; +- deterministic browser E2E; +- Docker restart/persistence E2E; +- security negative suite; +- desktop regression gate; +- git diff --check; +- secret/path leakage scan. + +The relevant phases must expose stable repository scripts, not CI-only shell fragments, for at least: + +- `pnpm hosted:parity:verify`: AST/signature/action extraction and parity-ledger cross-check against + capability/route/use-case/test IDs; +- `pnpm hosted:artifact:verify`: bundle import scan, emitted-worker existence, Node-ABI SQLite + write/read/reopen and required artifact manifest/hash/load probes inside the final image; +- `pnpm hosted:workspace-guard:verify`: build/protocol/hash check plus final-image syscall/seccomp/ + filesystem, bounded-file, process/PTY cwd, Git hardening and adversarial race tests; +- `pnpm hosted:process-anchor:verify`: final-image anchor protocol/hash, pidfd/subreaper, control EOF, + TERM/KILL/drain, double-fork, output-pressure and PID/PGID-reuse marker tests; +- `pnpm hosted:child-env:verify`: provider/backend environment-provenance ledger, ADR-30 relay bootstrap, + ProcessExecutionUnit exposure-set enforcement and secret-canary scan over env/argv/settings/MCP/ + log/artifact surfaces; +- `pnpm hosted:state-compat:verify`: generate/check ADR-23 manifest, scan migration ownership and run + built N/N+1 compatibility-or-refuse fixtures; +- `pnpm test:hosted:e2e`: HTTPS-edge browser parity suites against built artifacts; +- `pnpm test:hosted:security`: browser/runtime trust separation and negative path/auth/limit cases; +- `pnpm test:hosted:chaos`: lease, crash-point, watcher loss/overflow, composite-lane and restart cases. + +Names may follow an existing repository convention discovered in Phase 0, but there must be one +canonical script per gate and CI/local use the same entrypoint. Manual curl, source-mode dev server, +in-app browser debugging or a successful TypeScript build cannot substitute for these gates. + +Do not treat a mocked transport fixture as final E2E evidence. + +## Risk register + +| Risk | Current score | Mitigation | +| ------------------------------------------------------------------ | ------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Client/server contract drift | 10/10 | single contract owner and route conformance | +| Red/ambiguous baseline | 9/10 | exact-base reproduction and green Phase 0 gate | +| Arbitrary host path/runtime launch | 10/10 | opaque refs, workspace registry, fd/stat identity and fail-closed symlink-race policy | +| Node path check passes but later file/spawn cwd escapes | 10/10 | ADR-28 descriptor-bound guard, no hosted path-string fallback, final-image active-race proof | +| Repository-controlled Git helper/hook executes | 10/10 | fixed Git verbs/argv, neutral config/env, disabled hooks/helpers/network and malicious-repo tests | +| Guard unavailable or blocked only after deployment | 9/10 | versioned artifact plus kernel/seccomp/filesystem probes in Phase 0, build and mounted-runtime readiness | +| Stable WorkspaceId coupled to ephemeral mount fingerprint | 10/10 | ADR-25 registration/mount split, boot mountGeneration, stale grant/plan rejection and operator trust boundary | +| Team/workspace identity drift or rebinding | 10/10 | atomic TeamId+LegacyTeamKey reservation/no-reuse, publication gate over destructive/backup paths, replicated file+SQLite protocol, reconciliation matrix, versioned binding and tombstones | +| Member-name reuse/alias cross-attachment | 10/10 | TeamRoster MemberId, immutable LegacyMemberKey, roster/member revisions, plan-bound lane attempts and ambiguous legacy-name block | +| Browser auth/CSRF failure | 10/10 | ADR-7 durable device grant plus short session, derived CSRF, server expiry/revocation and real HTTPS browser E2E | +| One-time pairing is consumed but later re-auth has no credential | 10/10 | persistent hashed device family, ordinary restart continuity and host-controlled reset only after v1 runtime drain | +| Device rotation response loss or multi-tab race locks operator out | 9/10 | bounded predecessor grace/family size, coalesced renew/bootstrap retry, replay-after-grace revocation and deterministic schedules | +| Missing/rotated auth hash key silently accepts or orphans sessions | 10/10 | fsync'd versioned keyring, ordinary-restart reuse, fail-closed mismatch, host-reset-only rotation and restore revocation | +| Forwarded Host/proto spoof changes cookie/origin authority | 10/10 | explicit HTTPS PUBLIC_ORIGIN, exact proxy CIDRs, edge header overwrite, ambiguous/direct HTTP refusal and dedicated hostname | +| Runtime ingress impersonation/replay | 10/10 | disjoint route/auth surface, lane/run-scoped credentials, server-resolved identity, persistent replay fence | +| Canonical runtime bearer leaks through child env/config | 10/10 | ADR-30 controller-owned relay, inherited-FD bootstrap, allowlist-first environment and secret-canary artifact scan | +| Ambient/controller or out-of-set provider secrets cross units | 10/10 | ADR-18 exposure sets, per-key provenance, SecretRefs, deny-in-depth injection variables and mixed-unit negative fixtures | +| Same-UID runtime mistaken for a security sandbox | 10/10 | explicit trusted_process profile, relay non-isolation disclosure, pairing-after-residual-runtime gate and separate-UID/container profile deferred | +| Provider divergence | 9/10 | authoritative adapter registry and capability matrix | +| Composite lane reorder/duplicate/partial-state loss | 10/10 | immutable plan snapshot, characterized gate order, per-lane fencing and aggregate recovery | +| Legacy/canonical dual runtime authority | 10/10 | per-TeamId one-way legacy_drain -> canonical fence; old adapter is status/stop/cancel only and ambiguous candidates block | +| Desktop regression | 8/10 | characterization and IPC parity tests | +| Green build with fake Electron/native implementation | 10/10 | no canonical stubs, emitted worker, ABI/artifact smoke and negative bundle gates | +| Global readiness hides usable recovery or enables unsafe mutation | 9/10 | ADR-21 lattice, per-route admission and independent failure tests | +| JSON compatibility/data loss | 9/10 | ownership catalog, unknown fields, backup, migration journal | +| False rollback across incompatible state | 10/10 | ADR-23 single hosted schema contract, future-version refusal, journaled forward migrations and required offline backup | +| Backup silently clones deployment identity | 10/10 | ADR-26 explicit offline empty-target replacement, identity preservation, authority rotation and fork refusal | +| Live or partial copy is mistaken for a recovery point | 10/10 | ADR-32 stopped-stack whole-volume archive, checksum/SQLite verification, ready-marker-last publication and partial/live refusal | +| App backup is mistaken for workspace/identity-provider backup | 9/10 | explicit separate Keycloak/PostgreSQL/workspace procedures and no atomic cross-system recovery claim | +| Hosted split-brain writer | 10/10 | ADR-16 root-owned stable inode plus kernel-held dual launcher/Node FD, pre-Node loser exit, no TTL/PID/metadata takeover and two-container shared-volume proof | +| Lease path replaced while old inode remains locked | 10/10 | runtime UID cannot write deployment parent/anchor; descriptor-open verification plus unlink/rename/recreate adversarial tests | +| Lease FD leaks into a child and prevents clean handoff | 9/10 | reserved descriptor policy, explicit child stdio closure, `/proc//fd` inode canaries and container-replacement test | +| Desktop and hosted share one writable root | 10/10 | dedicated state, explicit offline handoff/import, foreign-writer readiness failure | +| Concurrent external-writer corruption | 9/10 | ADR-29 admission, provider-mediated/quiescent paths, intent journal and observation reconciliation | +| Impossible lossless CAS is assumed for provider JSON | 10/10 | ADR-29 writer classes; direct active mutation only for proven cooperative writers, otherwise provider-mediated or quiescent-only | +| Old/anonymous JSON write attributed to current run | 10/10 | ADR-24 ExternalFileActor, verified provider evidence only, watcher watermark and fileWriterEpoch quiescence before relaunch/delete | +| Idempotency fingerprint changes across schema/release | 10/10 | ADR-34 explicit intent projection, schema/fingerprint/key versions, HMAC golden vectors, retained-version comparison and ADR-23 startup block | +| Ambiguous command retry after partial effect | 10/10 | ADR-34 per-effect recovery class/evidence state, immediate/workflow acceptance split, stable workflowRef and non-reconcilable operator_required | +| Reload loses commandId after the server accepted a command | 8/10 | ADR-27 server-owned recent-command projection, durable status lookup and no automatic body replay | +| Cross-feature secondary effect swallowed | 9/10 | one coordinator, persisted delivery/saga outcome and partial-outcome E2E | +| Lost realtime updates | 8/10 | durable epoch cursor, revisioned journal, replay, reconciliation | +| Snapshot returns a cursor newer than the represented state | 10/10 | ADR-33 same-transaction cursor or lower C0 captured before external projection, duplicate-tolerant reducers and exhaustive boundary scheduling | +| Stale renderer state resurrects old run | 9/10 | generation/run tombstones and reconciler characterization | +| Hidden desktop effect mounts in hosted UI | 9/10 | capability-first composition, hosted import graph and listener/control mount conformance | +| Approval double-answer/policy reset | 10/10 | server authority, idempotent claim, durable policy, audit | +| Orphan/reused-PID processes | 10/10 | ADR-22/31 durable intent, anchor/subreaper/control pipe/pidfd/drained evidence, unclassified block and whole-container boundary | +| Process anchor mistaken for hostile-process containment | 9/10 | trusted-process claim, escape fixture becomes unclassified/container-replace, cgroup/separate-container isolation deferred | +| Scope explosion | 8/10 | required/deferred capability matrix and vertical slices | +| False green E2E | 10/10 | real browser/server/container gate | +| False method-name parity | 10/10 | AST/signature ledger plus semantic obligations and required-action E2E references | + +## Definition of done + +The hosted release is done only when every item below is true. + +### Architecture + +- [ ] Core application imports no Electron/Fastify/fs/path/child_process/@main. +- [ ] Every mutation, route, event, repository write, and renderer command has one owning feature. +- [ ] Production cross-feature imports use public entrypoints; deep imports and cycles fail CI. +- [ ] No replacement `team-application`, `runtime-core`, global store, or composition facade becomes + a god-module. +- [ ] IPC and HTTP call the same use cases. +- [ ] Browser API is capability-segregated. +- [ ] ADR-19 ledger maps every pinned TeamsAPI/ReviewAPI/CrossTeamAPI member and visible hosted action + to one semantic disposition; AST/signature drift, missing obligations and duplicate ownership fail CI. +- [ ] TeamTransportReconciler is the only transport-envelope entry for IPC/HTTP/event/poll; it owns + no feature entities/revision semantics, which remain in feature reconcilers. +- [ ] Renderer has one canonical currentRunRef; provisioning/runtime/lane projections cannot keep or + select independent current run IDs, and binding/roster/fileWriter generations fence stale updates. +- [ ] `team-console` owns orchestration only and does not duplicate feature domain projections. +- [ ] Renderer team features have no direct window.electronAPI.teams bypass. +- [ ] Hosted renderer import graph and mounted tree contain no direct Electron/preload/mega-client or + desktop-only entrypoint; unavailable feature listeners/effects register zero work. +- [ ] Application/store errors do not depend on IpcError or raw transport message strings. +- [ ] No supported method throws browser-unavailable. +- [ ] No supported method silently no-ops or fabricates empty success. +- [ ] runtime-core is no longer a concrete main-service aggregator. +- [ ] Electron, hosted Node, and deterministic test compositions select adapters only; business + sequencing remains in feature application use cases/sagas. +- [ ] Start/review/member/approval/delete/attachment workflows use their declared coordinator and + expose persisted partial/delivery/recovery outcomes; no user-visible secondary failure is swallowed. +- [ ] DeletionSaga is TeamId-scoped and RunRecoveryWorkflow is RunId-scoped; shared scheduling + primitives contain no business compensation logic and architecture tests prevent cross-ownership. +- [ ] Canonical hosted build fails on Electron/preload imports and missing native artifacts; no + reachable empty Electron or `.node` stub implements a production capability. +- [ ] Application/provider/review use cases receive opaque workspace grants, never cwd paths; + hosted adapters consume them only through ADR-28 while desktop compatibility remains explicit. +- [ ] `team-runtime-control` owns ProcessSupervisorPort/RuntimeIngressRelayPort semantics; hosted + ADR-30/31 and desktop adapters publish distinct capability evidence instead of pretending weaker + process implementations are substitutable. + +### Production topology + +- [ ] Default hosted Docker image runs renderer plus real API. +- [ ] Closed-PR static-only hosted target is absent from production build/docs/tests; any unrelated + developer UI preview cannot satisfy hosted readiness. +- [ ] Health/live and health/ready work. +- [ ] ADR-21 serve/auth/read/mutation/runtime-ingress/recovery-point dimensions gate only + their declared routes; read-only and drain/recovery behavior remains accessible under tested + degraded states. +- [ ] Main container runs non-root with explicit persistent mounts; a separate one-shot init step has + provisioned the root-owned ADR-16 anchor/state layout and the app cannot repair unsafe ownership. +- [ ] Final image starts Node only through the pinned ADR-16 launcher. Launcher and Node share one + locked open-file description, release it only after admission/drain/flush/exit ordering, and + expose no lock FD to provider/relay/Git/helper descendants. +- [ ] Final runtime image carries the pinned ADR-28 guard, passes its actual mounted-workspace probe + under production UID/seccomp and contains no compiler/build toolchain. +- [ ] Final image carries separately versioned ADR-31 anchor and ADR-30 relay artifacts. Their probes + run under final init/PID namespace/UID/seccomp/mount settings before launch readiness. +- [ ] Final v1 image and startup graph contain no hosted terminal-platform daemon/N-API/SDK artifact, + terminal migration/store/socket, WS route or browser terminal chunk. +- [ ] Only TLS edge is public. +- [ ] Deployment declares one replica, while two actual final-image containers/Compose projects against + the same provisioned local volume independently prove kernel exclusion: the loser fails before + Node, migration, listener, recovery, watcher or spawn. Paused winner, deleted diagnostics and + same-name path recreation do not permit takeover; complete old-container exit does. +- [ ] Desktop and hosted modes cannot concurrently mutate the same app/team root; offline handoff is + documented and tested. + +### Security + +- [ ] Remote deployment requires secure authentication. +- [ ] ADR-7 initial pairing creates one durable device family plus short session; ordinary restart + preserves valid authority without plaintext re-pairing, while restore revokes copied auth. +- [ ] Session fixation, idle/absolute/renewal expiry, logout/forget-device, rotation predecessor grace, + response-loss/multi-tab convergence, replay-family revoke, CSRF-before-command-claim and exact + Origin checks pass. +- [ ] Lost device authority can recover only through a strictly newer host manifest reset generation; + reset revokes prior families and emits no challenge until v1 runtimes are drained or the + container is replaced. +- [ ] Auth keyring is exclusively created, mode-checked, fsync'd, absent from backup/children/logs and + stable across ordinary restart. Missing/corrupt/mismatched keyring with live auth rows fails + closed until host reset; AuthResetIntent crash tests prove DB authority is revoked before key + activation and plaintext challenge is last. The reviewed Fastify cookie parser rejects malformed/ + duplicate/oversized authority cookies before lookup. +- [ ] Production uses explicit HTTPS PUBLIC_ORIGIN, dedicated hostname and exact proxy CIDRs. Spoofed/ + multiple forwarding headers, direct HTTP, unexpected Host/Origin and sibling-port requests cannot + authenticate, renew or open SSE; v1 exposes no WS upgrade route and only the TLS edge is public. +- [ ] SSE is authenticated. +- [ ] Browser sessions and lane/run-scoped runtime ingress credentials are non-substitutable and authorize + disjoint route sets. +- [ ] Runtime ingress credentials are hashed, scoped, rotated/revoked, replay-fenced and absent from + browser responses, URLs, logs, diagnostics and persisted team/provider config. +- [ ] Canonical runtime bearers are absent from provider env/argv/settings/MCP files and reach only + controller-owned per-lane relays through inherited bootstrap FDs; every emitted child key has + checked-in provenance. +- [ ] Controller-secret and out-of-CredentialExposureSet canaries are absent from provider descendants + and retained artifacts. Mixed units expose only their persisted minimum provider-secret union; + dedicated units receive no unrelated provider credential. +- [ ] Default capability/meta reports `runtimeIsolation: trusted_process`; no live/unclassified + runtime exists when plaintext pairing material is present, and tests/docs never claim OS sandboxing. +- [ ] Documentation states that local relays, env filtering and process anchors prevent accidental/ + stale cross-scope effects but do not isolate a malicious same-UID runtime from controller memory, + sibling local endpoints or mounted files. +- [ ] Browser never sees server secrets. +- [ ] Browser never supplies an authoritative host path. +- [ ] Browser never supplies a raw PID; hosted terminal controls/contracts/routes are absent in v1. +- [ ] Opaque resource references and workspace traversal/symlink/rename-race tests pass; unsupported + filesystem operations are absent from capabilities rather than guarded only by `realpath()`, and + every reference/grant is fenced by current workspace mountGeneration. +- [ ] File/review/Git/provider-spawn operations pass active parent/symlink/root/bind-mount races + with zero outside-marker effect; blocked/missing/stale guard produces zero effect and no fallback. +- [ ] Hosted Git worktree/review verbs cannot execute repository hooks, pager, fsmonitor, external + diff/textconv, credential helper or remote network action. +- [ ] Provider credentials and private payloads are redacted. +- [ ] Tool approval policy and decisions are server-authoritative, audited, and multi-tab safe. +- [ ] Identity repair routes are absent outside explicit maintenance mode; inside it, normal + mutation/runtime admission is closed and expected-evidence/backup/idempotency/audit tests pass. + +### Runtime and persistence + +- [ ] All providers expose explicit capabilities. +- [ ] Every accepted run persists one immutable CompositeRuntimePlan and all current lane topologies, + rejection/order/partial/cancel/restart cases pass without re-planning or duplicate side lanes. +- [ ] Every actual provider process maps to one immutable ProcessExecutionUnit with environment-policy + hash and CredentialExposureSet. Capability/meta distinguishes shared versus dedicated execution- + unit credential isolation, and recovery cannot widen or remap a set. +- [ ] Runtime cutover is one-way per TeamId: legacy active generations expose only drain controls, + canonical launch/rebind/topology edits wait for verified legacy-run cleanup, and ambiguous candidates + never become currentRunRef. +- [ ] TeamLifecycle and LifecycleRun remain separate: drafts/preflight have no fabricated RunId, + accepted launch advances exactly one currentRunRef, completed/cancelled/stopped runs never reopen, and stale prior-run + events cannot change current team state. +- [ ] Hosted image executes its emitted internal-storage worker with Node-ABI SQLite and smoke-loads + every required native/controller/MCP/provider artifact before readiness. +- [ ] Hosted lifecycle recovery matches desktop semantics where required. +- [ ] One runtime root has one hosted controller writer; external agent writers are reconciled. +- [ ] ADR-16 ownership cannot be lost/reacquired inside a live controller. Mutation/recovery/process + state advances only while InstanceLeaseGuard holds its inherited FD; heartbeat/metadata never + steals or grants ownership, and release occurs only with complete controller lifecycle exit. +- [ ] Every Tier A mutation validates authority, uses a transaction/atomic write plus revision guard, + and returns a typed conflict/result. Tier B external workflows additionally have versioned + ADR-34 descriptors, normalized intent projection, fingerprint compatibility and effect evidence. +- [ ] Every external step persists one ADR-34 recovery class, state and evidence schema. Ambiguous + partial effects remain recovering/operator_required and are never reported as a retryable clean + failure; automatic retry/commit occurs only after descriptor-required deduplication or absence + proof, and compensation is feature-owned/idempotent rather than generic snapshot rollback. +- [ ] Workflow-starting commands commit one stable accepted workflowRef before external effects; + command committed is never displayed as workflow completed, and timeout/retry/later workflow + failure cannot create a second run/saga. +- [ ] Stable OperatorId idempotency survives session renewal, device-family rotation and host re-pair; + browser and runtime-ingress key scopes cannot collide or duplicate an accepted effect. +- [ ] ADR-27 recovery is server owned and resolves only for the matching authenticated + actor/action/deployment. Browser storage contains no pending locator, command body, prompt, + path, secret, idempotency key, or replayable receipt. +- [ ] Config writes are ordered/flushable and aggregate journals recover paired mutations. +- [ ] Provider artifact inventory and current/legacy/future/corrupt golden fixtures pass. +- [ ] Legacy CLI-owned files retain unknown fields. +- [ ] Team IDs use the ADR-6 replicated team.identity.json + committed SQLite row/checksum protocol + and WorkspaceIds use immutable registrationKey; volatile team.meta/members.meta never carry + identity authority. Every file/intent/index combination follows the tested recovery matrix, while + duplicate, missing, corrupt, unanchored or rebound identities fail mutation readiness without + silent reassignment, republish or name-based adoption. +- [ ] Create accepts requestedTeamKey only as validated creation input, reserves it atomically with + TeamId, returns TeamId for all later browser actions, and rejects unsafe/case-fold/cross-root/ + tombstoned collisions; display rename never changes filesystem identity. +- [ ] Anchor publication is impossible until provisioning cleanup, draft/permanent deletion, + backup/restore/prune, shutdown backup and same-name resurrection pass the identity-preservation/ + tombstone gate; legacy `_backupIdentityId` remains correlation evidence and never authorization. +- [ ] TeamWorkspaceBinding is versioned/server-owned; mutable config path drift yields mismatch and + cannot change runtime/file/Git authority without an explicit authorized rebind command. +- [ ] WorkspaceId remains stable by registrationKey while every boot/remount creates a new + mountGeneration; stale file/review grants and runtime plans fail, same-boot root changes + close admission, and the product does not claim cross-boot volume identity it cannot prove. +- [ ] TeamRoster is versioned and MemberId-authoritative; remove/restore/replace preserve explicit + logical identity, legacy case/auto-suffix ambiguity blocks mutation, and stale memberRevision/lane + evidence cannot attach to the current member. +- [ ] Corrupt/future state fails according to the catalog. +- [ ] Built artifact carries the verified ADR-23 artifact/schema contract; startup refuses unknown + future state, verifies ordered migration checksums and resumes or refuses interrupted forward + migration without claiming automatic downgrade. +- [ ] ADR-26 offline replacement restore preserves logical deployment/entity/audit identities but + rotates boot/event/session/runtime authority and mount generations; live, partial, fork and + non-empty-target activation attempts are refused. +- [ ] ADR-32 stopped-stack backup creates and verifies one immutable whole app-volume archive with its + ready marker last. Restore validates checksums and SQLite integrity into an empty target; it never + claims atomicity with Keycloak/PostgreSQL or workspace repositories. +- [ ] ADR-29 classifies every externally writable operation. App-exclusive/cooperative paths prove + exactly-once committed semantics; uncoordinated active direct writes are impossible, and only + provider-mediated observed outcomes or revision-rechecked quiescent mutations are advertised. +- [ ] Generic Claude-compatible file writes remain team-scoped ExternalFileActor events; only + provider-verified artifacts gain RunId/member attribution, and fileWriterEpoch quiescence prevents + queued old-run observations from appearing as current-run activity. +- [ ] Watch-before-scan, notification overflow/loss, partial write, atomic rename, scoped rescan and + shutdown dirty-scope recovery tests pass without broad recursive scans. +- [ ] ADR-33 snapshot responses return a cursor represented by the same SQLite transaction or a lower + C0 captured before external projection. Every event committed during projection is replayed; + duplicate/stale events converge by revision/generation, retention loss causes resync and the + deterministic boundary scheduler finds no lost-update execution. +- [ ] Machine runtime callbacks cannot override workspace/team/member/run authority and stale or + conflicting callbacks create no mutation/event. +- [ ] Each provider spawn has intent plus ADR-31 anchor ready/handshake/ownership evidence. Normal stop, + controller EOF and TERM/KILL escalation reopen readiness only after typed `drained`; broken or + escaped ownership becomes unclassified and requires whole-container replacement. +- [ ] No hosted path signals a persisted PID/PGID. PID churn fixtures prove marker-owned unrelated + processes survive while pidfd/anchor/control semantics drain the owned tree. +- [ ] Hosted v1 contains no terminal capability, route, renderer effect, daemon artifact, migration, + socket or store. Desktop terminal IPC behavior remains covered by its existing regression gate. +- [ ] Shutdown affects only instance-owned anchors and the final container lifecycle proves + zero surviving fake-runtime processes. + +### User workflow + +- [ ] Browser login works. +- [ ] Team list/detail works. +- [ ] Create/prepare/launch/progress/stop works; an accepted draft retains one TeamId and retryable + configuration across failed provisioning, while explicit draft deletion is recoverable/idempotent. +- [ ] Tasks/Kanban/messages work. +- [ ] Logs/activity/failure diagnostics work. +- [ ] Minimum provider-blocking tool approval flow works safely where advertised. +- [ ] Initial roster configuration works; deferred member recovery, review, attachments, rich task + collaboration, destructive restore/repair and cross-team controls are not mounted. +- [ ] Reload/reconnect/restart preserves correct state. +- [ ] Logout, forget-device and host reset work through the real browser/host boundary. +- [ ] Deferred actions are visibly unavailable before click. +- [ ] Required web flows pass keyboard/focus/accessibility checks and all new user text is localized. + +### E2E evidence + +- [ ] Built production browser and real server run together. +- [ ] Browser E2E uses an HTTPS edge, explicit PUBLIC_ORIGIN, exact trusted-proxy CIDRs and production + `__Host-`/`__Secure-` cookies; it does not inject auth state or enable insecure cookies. +- [ ] Pair -> device -> session, ordinary container restart, access renewal, two-tab/lost-response + rotation, replay revoke, forget-device and host reset-after-runtime-drain all pass through the + real browser/network/storage boundary. +- [ ] No fake fetch/EventSource at the tested boundary. +- [ ] New sandbox project and temporary runtime state are used. +- [ ] Fake runtime produces deterministic lifecycle evidence. +- [ ] Docker restart and SIGTERM cases pass. +- [ ] Competing-container, lock-anchor replacement, launcher/controller crash/stop, lock-FD leakage, + clean handoff, command crash-point and event-epoch restore cases pass. +- [ ] Hosted-terminal absence gate passes for browser chunks, RouteCatalog, capability manifest, image, + migrations and startup processes. +- [ ] No unexpected 404/401/500 in the browser flow. +- [ ] Desktop regression gate passes. +- [ ] Optional live provider smoke uses only fresh sandbox projects. +- [ ] Every ledger action in required rollout state references passing semantic conformance and real + browser E2E evidence; method-name/200-only coverage is rejected. + +## Immediate tasks after plan acceptance + +Do these before adding any hosted endpoint or production implementation from the closed PR: + +1. Fetch and pin the then-current remote target-base SHA; create + `refactor/hosted-web-feature-boundaries` from it with no ancestry from PR #250. +2. Run and classify the clean-base CI/test/security baseline. +3. Check in the capability/action ownership matrix, ADRs, AST-backed parity ledger/scanner, + evidence-gate tables, ADR-23 state compatibility source, and salvage ledger. +4. Add architecture fitness tests and negative fixtures before broad feature code. +5. Close ADR-16 first in the target image: build the instance-lock launcher and provisioned-volume + fixture, then prove two containers, path replacement attempts, lifecycle crash/stop and descendant + FD scans. Close ADR-28 next: build the guard, prove process/PTY cwd and bounded file primitives, + run the active-race negative control, and freeze Git execution policy. In the same evidence pass, + freeze ADR-29 writer classes and active-run semantics from real provider callsites/fixtures; prove + ADR-30 environment/relay secrecy-from-inheritance and ADR-31 anchor drain/PID-reuse behavior. Do + not expand dependent application work if any gate fails. +6. Define the small shared kernel and first read contracts under their owning features. +7. Preserve the existing RuntimeInstanceContext, identity stores, TeamWorkspaceBinding, roster + primitives and read-only `workspace-registry`. Hosted startup only scans/classifies legacy state; + automatic adoption/repair remains disabled and later import is explicit/offline. +8. Extract ListTeams/GetTeamLifecycleSnapshot/runtime read projections into `team-lifecycle` with + unchanged IPC mapping to legacy getData. +9. Keep Tier A local mutations transaction/revision based and apply ADR-34 descriptor/effect recovery + only to Tier B external workflows. Preserve the existing command ledger and bounded + watch-before-scan reconciliation; unknown Tier B effects default to operator-required, never retry. +10. Extend the Phase 2 draft lifecycle with Prepare/Launch/ProvisioningStatus/Cancel/Stop and establish + `team-runtime-control` as the only team process-execution authority, split canonical machine + ingress from browser control, and reuse runtime-provider-management/team-runtime-lanes/current + runtime-control as compatibility inputs. Persist and execute one immutable CompositeRuntimePlan + per accepted generation before altering provider internals; keep TeamLifecycle.currentRunRef and + immutable completed LifecycleRun records as separate aggregates. Hosted execution uses ADR-30 + per-lane relays/allowlist-first environments and ADR-31 process anchors, never PID or bearer-env + compatibility fallbacks. +11. Refactor existing standalone into the app-level hosted composition and harden its Docker target: + no production Electron/native stubs, emitted internal-storage worker, Node-ABI SQLite and + required artifact smoke probes. +12. Implement ADR-7 pairing/device/session/reset plus explicit HTTPS PUBLIC_ORIGIN/proxy enforcement, + workspace security and per-lane/run machine-ingress credentials/replay fencing before enabling + mutations. Ordinary restart/renew must not lock out the operator or create plaintext pairing beside + a live runtime. +13. Build `team-console` and wire login -> list -> detail -> create -> launch -> progress -> stop + through TeamTransportReconciler and feature-owned reconcilers. +14. Only after that compose Core tasks/messages/events, diagnostics and minimum approvals. Full + parity and hosted terminal remain outside Core v1. + +## Final assessment + +The original clean-branch readiness and salvage statements are historical. PR #252 now contains +substantial identity, lifecycle/runtime, command-ledger, task-board, hosted-access and related feature +work. Preserve those seams and re-baseline from its integrated head; do not recreate the branch or +use this plan's old fresh-branch percentages. + +The accepted release is the Core v1 workflow in `hosted-web-core-v1-scope-lock.md`. The old +24k-40k figure describes a fresh full-parity expansion and is not a remaining-work estimate. +Hosted terminal remains separately planned and estimated only if later promoted. + +Current ratings: + +- confidence in this audit: 9/10; +- confidence in the pre-Phase-0 line estimate: 7/10; +- implementation complexity: 8/10; +- initial clean-branch bug/security risk: 8/10; +- expected risk after all required gates: 4/10. + +This is not an overnight patch if quality is the requirement. +It is a staged architecture migration with a clear first vertical slice. +The fastest safe route is to build that slice through shared use cases, not to add +another layer of aliases and mocks around the existing Electron-shaped API. diff --git a/docs/hosted-web-phase-0-execution-packet.md b/docs/hosted-web-phase-0-execution-packet.md new file mode 100644 index 0000000000..37b5cc09b2 --- /dev/null +++ b/docs/hosted-web-phase-0-execution-packet.md @@ -0,0 +1,714 @@ +# Hosted Web Phase 0: Just-in-Time Execution Packet + +## Status and authority + +- Status: historical Phase 0 packet; for the live phase status see + [docs/hosted-web-phases/EXECUTION_INDEX.json](hosted-web-phases/EXECUTION_INDEX.json) + (single source of truth) — the line below reflects the state at authoring time +- Status at authoring time: ready for execution after explicit implementation start +- Packet revision: `phase-00-r2` +- Scope: Phase 0 only +- Parent plan: [Hosted Web Runtime: End-to-End Completion Plan](./hosted-web-e2e-completion-plan.md) +- Execution router: [Hosted Web Execution Router](./hosted-web-phases/README.md) +- Packet standard: [Hosted Web Execution Packet Standard](./hosted-web-phases/PACKET_STANDARD.md) +- Canonical repository: `777genius/agent-teams-ai` +- Base branch: `refactor/team-provisioning-round2-reapply` +- Implementation branch: `refactor/hosted-web-feature-boundaries` +- Current observed base SHA: `67548009b4d811d24ce3cb3ee0aed591e4922730` +- Pinning rule: fetch and record the remote SHA again immediately before branch creation +- Estimated Phase 0 diff: 4,000-6,500 changed lines, including executable spikes and tests +- Complexity: 9/10 +- Initial Phase 0 risk: 8/10 +- Hosted terminal: excluded; only a v1 absence assertion is allowed + +This packet is the normative execution order for Phase 0. The parent plan remains authoritative for +architecture, threat model, ADR semantics and later phases. If they conflict, stop and reconcile the +documents before code changes. + +## Phase objective + +Turn the moving, partially red and Electron-coupled baseline into a pinned, measured and evidence- +backed starting point for Phase 1. + +Phase 0 does not implement hosted team product behavior. It must answer, with checked-in evidence: + +1. Which exact base and current failures are being inherited? +2. Which renderer actions and API members require replacement or deliberate exclusion? +3. Which provider/runtime paths, state files, external writers and native artifacts exist? +4. Can the proposed lease, workspace guard, process ownership, backup, event handoff, command recovery + and authentication boundaries work in the target production topology? +5. Which ADRs are closed, narrowed or reopened before feature implementation? +6. Is the 28k-45k v1 estimate still credible when counted by unique packages and actions? + +## Explicit non-goals + +- No hosted create/launch/task/message/review route is enabled. +- No renderer team screen is migrated. +- No broad service extraction from `teams.ts`, TeamDataService, teamSlice or TeamDetailView occurs. +- No provider is launched against a real user project. +- No terminal daemon, gateway, WebSocket route, store, migration or artifact is added. +- No production schema migration is applied to user state. +- No dependency is installed merely to make a spike convenient. +- No refactor is accepted because it looks cleaner without closing a Phase 0 evidence question. + +## Definition of Ready + +The controller may start 0A only when all conditions below are true: + +- explicit user authorization to begin implementation has been received; +- canonical remote resolves to `https://github.com/777genius/agent-teams-ai.git`; +- broker tools and project-scoped controller manifest are available on the chosen execution host; +- the controller has an isolated project root plus empty/reserved integration and worktree roots owned + only by this project; creation of the integration worktree is part of 0A; +- no worker will use the user's current dirty local `dev` checkout; +- the target host has enough capacity for the admitted worker pool; +- all test launches use new sandbox/test state and workspaces; +- secrets can be passed only through approved runtime mechanisms and never printed into evidence. + +If broker-only enforcement is unavailable, stop. Do not fall back to raw tmux, raw registry writes, +untracked workers or `danger_full_access` orchestration. + +Role separation is strict: the host operator creates the isolated project/controller manifest; the +broker-only controller schedules and observes jobs; child workers run repository commands only inside +their owned worktrees; the integration owner adopts through broker integration attempts. The controller +itself is not a raw shell/git writer. + +## Repository and worktree bootstrap: 0A + +0A is serialized and owned by the integration controller. No child implementation worker starts +before 0A.1-0A.4 are complete. + +### 0A.1 - Fetch and pin + +Run inside a broker-owned 0A baseline job or approved host-operator preflight, not from the broker-only +controller and not from the user's dirty checkout: + +```bash +git fetch origin refactor/team-provisioning-round2-reapply +git rev-parse origin/refactor/team-provisioning-round2-reapply +git remote get-url origin +``` + +Record: + +- `baseBranch`; +- `baseSha`; +- fetch timestamp; +- remote URL; +- Node, pnpm, OS, kernel and architecture; +- controller manifest ID and project slug; +- lockfile hash. + +The branch name may move later; Phase 0 stays on the recorded SHA. A later remote update triggers an +explicit rebase/impact decision, never an automatic mid-phase rebase. +If the broker cannot fetch/resolve and record the remote SHA, stop instead of performing an unmanaged +host-side worktree fallback. + +### 0A.2 - Create isolated integration state + +Use project-scoped broker lifecycle to create: + +- one integration worktree at the pinned SHA; +- branch `refactor/hosted-web-feature-boundaries`; +- one registry namespace containing only this project's jobs; +- one integration-attempt namespace. + +The integration worktree is the only writer for shared manifests, package scripts, global docs index, +RouteCatalog prototypes and final Phase 0 decision records. + +Before any child worktree exists, adopt the reviewed plan bundle into the implementation branch through +the policy integration lifecycle. The bundle contains the parent plan, execution router, packet +standard, active Phase 0 controller packet and all six lane packets. Record `planBundleCommit`, packet +revision and file hashes. A controller-side `/control/plans` copy is an operator audit artifact, not a +substitute for in-worktree packet files. + +If the plan bundle cannot be adopted cleanly on the pinned base, stop with `packet_stale`; do not let +workers read an out-of-worktree path or use a plan commit based on different ancestry without review. + +### 0A.3 - Materialize dependencies without changing them + +Use the pinned lockfile: + +```bash +pnpm install --frozen-lockfile +``` + +If installation changes `pnpm-lock.yaml`, stop and classify the environment mismatch. Do not update a +dependency in Phase 0 unless the parent plan explicitly requires version research and a separate +reviewed dependency decision. + +### 0A.4 - Baseline gate + +Run and capture exit code, duration and final 20 lines for each command: + +```bash +pnpm typecheck:workspace 2>&1 | tail -20 +pnpm lint:fast 2>&1 | tail -20 +pnpm test:workspace:ci 2>&1 | tail -20 +pnpm standalone:build 2>&1 | tail -20 +pnpm check:ci 2>&1 | tail -20 +``` + +The broad gate is diagnostic at this point. A pre-existing failure does not authorize changing its +test. Classify every failure as: + +- `base_blocker`: prevents any safe Phase 0 evidence; +- `base_owned_fix`: independently correct and suitable for a narrow prerequisite PR; +- `isolated_known_failure`: does not contaminate the relevant evidence and has a named owner; +- `environment_failure`: host/toolchain problem, not repository behavior; +- `unknown`: blocks dependent worker admission until resolved. + +After classification, adopt the 0A base/baseline evidence through the integration lifecycle and record +the resulting immutable `phaseStartSha`. It must descend from both `baseSha` and `planBundleCommit`. + +### 0A.5 - Materialize lane worktrees + +Only after 0A.4 passes, create six dedicated controller-owned lane branches/worktrees from the exact +`phaseStartSha`. Never check out the integration branch itself in a child worktree. Verify in each lane: + +- `HEAD == phaseStartSha` before its first lane commit; +- packet revision `phase-00-r2` and the assigned lane packet are present inside the worktree; +- writable paths do not overlap another live lane; +- frozen install succeeds without changing the lockfile; +- the rendered prompt names the in-worktree lane packet and worktree-local handoff path. + +The generic capacity controller remains `dryRun=true` and no capacity timer is enabled until this gate +passes. Enabling refill before 0A.5 would start evidence workers against an unpinned or packet-less base. + +### 0A outputs + +The controller owns these future-branch paths: + +```text +docs/research/hosted-web/phase-0/index.md +docs/research/hosted-web/phase-0/base.json +docs/research/hosted-web/phase-0/baseline.md +docs/research/hosted-web/phase-0/lane-ledger.json +docs/research/hosted-web/phase-0/estimate-ledger.md +docs/research/hosted-web/phase-0/salvage-ledger.md +docs/research/hosted-web/phase-0/decision-register.md +``` + +`base.json` records `baseSha`, `planBundleCommit`, `phaseStartSha`, packet revision, plan/packet hashes, +toolchain envelope and inherited-failure ledger reference. These three SHAs must not be collapsed into +one field: they answer different ancestry and reproducibility questions. + +0A passes only when the base is immutable for the phase, the baseline is reproducible, every failure +has one classification, `phaseStartSha` contains the reviewed plan/evidence bundle, all lane worktrees +start from that SHA and no worker depends on an `unknown` failure. + +## Execution DAG + +```mermaid +flowchart TD + A1["0A1 Fetch and pin"] --> A2["0A2 Integration and plan bundle"] + A2 --> A3["0A3 Frozen dependencies"] + A3 --> A4["0A4 Baseline classification"] + A4 --> A5["0A5 Lane worktrees from phaseStartSha"] + A5 --> W1["W1 Parity and renderer inventory"] + A5 --> W2["W2 Provider and runtime-ingress inventory"] + A5 --> W3["W3 State, writer and backup evidence"] + A5 --> W4["W4 Lease, guard and process-anchor spikes"] + A5 --> W5["W5 Snapshot, event and command recovery"] + A5 --> W6["W6 Auth, proxy and artifact boundary"] + W1 --> R12["Cross-review W1/W2"] + W2 --> R12 + W3 --> R35["Cross-review W3/W5"] + W5 --> R35 + W4 --> R46["Cross-review W4/W6"] + W6 --> R46 + R12 --> D1["0D1 Adoption and conflict audit"] + R35 --> D1 + R46 --> D1 + D1 --> D2["0D2 Freeze decisions and estimates"] + D2 --> D3["0D3 Phase 0 full gate"] + D3 --> D4["0D4 Create Phase 1 JIT packet"] +``` + +0B is the inventory portion of each W lane. 0C is its executable evidence or negative control. Each +worker owns both for one bounded concern so research cannot be declared complete without a reproducible +artifact. + +## Worker pool and orchestration policy + +- Target: six fresh, useful child workers while host load/admission is safe. +- Controller/integration owner is not counted as a child worker. +- Requested worker profile: configured fast GPT-5.6 profile with xhigh reasoning. The controller must + verify the exact broker-supported model/profile identifiers rather than inventing flags. +- Every child is `isolated_workspace_write` in exactly one worktree. +- Every child is controller-owned and visible in project status/events. +- Controller checks progress at least every ten minutes while work is active. +- If host load exceeds the accepted threshold, do not add processes merely to preserve worker count. +- A finished lane moves to its assigned review or estimate reconciliation; it does not start surprise + implementation work from later phases. +- A stale/no-output lane is first inspected for blocker, overlap or oversized scope, then split or + redirected through broker tools. It is not silently replaced by an invisible worker. +- The target of six applies only while six unique ready lane/review slots exist. The controller never + creates duplicate evidence work merely to preserve a process count, and serialized 0A/0D gates may + run below six. + +The current hosted-controller policy uses load `<=16` as the admission ceiling unless the host runbook +or current controller manifest defines a stricter value. + +### Capacity epochs and unique lane slots + +Phase 0 capacity advances through three explicit epochs: + +1. `evidence`: one live slot for each of W1-W6; +2. `cross_review`: one reciprocal review slot per original lane after paired handoffs exist; +3. `integration`: serialized correction, adoption and freeze owned by the integration controller. + +The controller maintains `docs/research/hosted-web/phase-0/lane-ledger.json`. Each slot records lane ID, +epoch, attempt, job/worktree IDs, `phaseStartSha`, packet revision, owned evidence IDs, state, last useful +progress, handoff hash and superseded job ID. Slot states are `unstarted`, `active`, `handoff`, `review`, +`correction`, `adopted`, `blocked`, `failed` or `superseded`. + +Static capacity templates are a catalog, not a refill queue. Automatic refill is allowed only for a +staged request that names an existing ready slot and either has no prior attempt or records the salvage/ +supersession decision for a stale or failed attempt. If the generic capacity controller cannot enforce +that identity, keep its producer target at zero and let the broker-only controller launch the exact six +declared jobs. Never turn `desiredWorkers=6` on against reusable W1-W6 templates without lane-ledger +deduplication. + +After an evidence handoff, the slot transitions to reciprocal review; it is not refilled as another +producer. After all reviews, capacity may fall below six because 0D is intentionally serialized. + +## Ownership rules + +All workers may read the pinned repository. Writes are exclusive: + +| Owner | Writable surface | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Controller | phase index, base/baseline, estimate/salvage ledgers, decision register, shared package scripts, final integration fixes | +| W1 | `docs/research/hosted-web/phase-0/parity-renderer/**`, `scripts/hosted-web/phase-0/parity-renderer/**`, matching tests | +| W2 | `docs/research/hosted-web/phase-0/provider-runtime/**`, `scripts/hosted-web/phase-0/provider-runtime/**`, matching fixtures/tests | +| W3 | `docs/research/hosted-web/phase-0/state-writers/**`, `scripts/hosted-web/phase-0/state-writers/**`, matching fixtures/tests | +| W4 | `docs/research/hosted-web/phase-0/host-primitives/**`, `scripts/hosted-web/phase-0/host-primitives/**`, matching fixtures/tests | +| W5 | `docs/research/hosted-web/phase-0/recovery-events/**`, `scripts/hosted-web/phase-0/recovery-events/**`, matching fixtures/tests | +| W6 | `docs/research/hosted-web/phase-0/auth-artifacts/**`, `scripts/hosted-web/phase-0/auth-artifacts/**`, matching fixtures/tests | + +Matching tests live below: + +```text +test/architecture/hosted-web/phase-0//** +``` + +Workers do not edit `package.json`, `pnpm-lock.yaml`, shared architecture docs, production source, +Docker entrypoints or another lane's files. If a runnable alias is useful, the worker documents the +exact command and the controller adds the shared package script after adoption review. + +### Worker lane entrypoints + +The sections below remain the controller's compact mission and acceptance registry. Each child worker +receives the corresponding lane packet in full; it does not receive the entire parent plan as a prompt. + +| Lane | Autonomous packet | Evidence concern | +| ---- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| W1 | [`w1-parity-renderer.md`](./hosted-web-phases/phase-00/lanes/w1-parity-renderer.md) | API/action parity and renderer reachability | +| W2 | [`w2-provider-runtime.md`](./hosted-web-phases/phase-00/lanes/w2-provider-runtime.md) | provider topology, runtime ingress and environment provenance | +| W3 | [`w3-state-writers-backup.md`](./hosted-web-phases/phase-00/lanes/w3-state-writers-backup.md) | state authority, external writers and safe backup | +| W4 | [`w4-lease-guard-process.md`](./hosted-web-phases/phase-00/lanes/w4-lease-guard-process.md) | Linux lease, descriptor guard and process ownership | +| W5 | [`w5-events-commands-recovery.md`](./hosted-web-phases/phase-00/lanes/w5-events-commands-recovery.md) | snapshot/event and command/effect recovery | +| W6 | [`w6-auth-proxy-artifacts.md`](./hosted-web-phases/phase-00/lanes/w6-auth-proxy-artifacts.md) | auth, proxy/origin and standalone artifact truth | + +## W1 - Parity and renderer reachability + +### Mission + +Produce the exact API/action ledger that prevents method-name parity and hidden Electron-only effects. + +### Required reads + +Read TeamsAPI/ReviewAPI/CrossTeamAPI in `src/shared/types/api.ts`, teamSlice/store event wiring, +TeamList/TeamDetail/CreateTeam and child controls, HttpAPIClient/renderer composition, preload/IPC +channels and their characterization tests. + +### Deliverables + +- `api-parity-ledger.json`, `renderer-action-inventory.json`, `legacy-bypass-inventory.json`; +- `selection-reconciliation-invariants.md` and `estimate-input.json`; +- deterministic `scan-api-and-actions.ts` plus its negative/positive fixture test. + +Every ledger row contains stable ID, legacy symbol/signature, renderer callers, owning feature, +`direct | decomposed | desktop-only | deferred`, security class, required semantic evidence and target +phase/work package. + +### Acceptance + +- Counts match the pinned AST, not historical numbers. A difference from 86/20/3 is explained. +- Every visible team control maps to one action or deliberate absence-before-mount. +- Dynamic dispatch has an explicit annotation/fixture; it is not omitted because static scanning is hard. +- Direct `window.electronAPI.teams`, global mega-client and fabricated-success browser paths are listed. +- Selection, thin/full snapshots, tombstones, pagination and event/poll races are captured as invariants. +- Scanner failure on a deliberately missing/duplicate action fixture is tested. + +Do not migrate renderer code, create client facets or treat optional chaining/method existence as +capability proof. + +## W2 - Provider, runtime ingress and environment truth + +### Mission + +Describe the actual execution topologies and every machine callback/credential/environment boundary. + +### Required reads + +Read deterministic provisioning/TeamProvisioning modules, TeamRuntimeAdapterRegistry and lane planner, +OpenCode runtime/delivery routes, provider-management/profile features, environment/settings/MCP/ +binary resolution and relevant provider/fake-runtime tests. + +### Deliverables + +- execution topology, credential exposure and fake-runtime fixture matrices; +- `runtime-ingress-inventory.json`, `environment-provenance.json`, `estimate-input.json`; +- deterministic `scan-runtime-surfaces.ts` plus its fixture test. + +### Acceptance + +- Distinguish four provider identities from two execution backend families. +- Every bootstrap/delivery/task/heartbeat/permission operation has direction, caller, authority, + idempotency, body IDs, persisted evidence and current route. +- Every provider child environment key has provenance and required/optional/forbidden classification. +- Browser session and runtime-ingress authority are proven disjoint in the proposed mapping. +- Anthropic/Codex/Gemini compatibility assumptions and OpenCode-specific differences are explicit. +- No secret value, auth payload or private provider payload appears in evidence. + +Do not invent a universal provider interface, rewrite adapters, execute a live provider or use an +existing project. + +## W3 - State families, external writers and backup truth + +### Mission + +Catalog durable authority and prove the minimum safe SQLite backup primitive without pretending all +provider-owned files are transactional. + +### Required reads + +Read ConfigManager, TeamDataService, task/Kanban/inbox/review writers and watchers, OpenCode stores/ +journals, TeamBackupService, internal-storage/SQLite driver and corruption/concurrency tests. + +### Deliverables + +- state-family, writer-coordination, unknown-field/schema and backup-behavior catalogs; +- `sqlite-online-backup-spike.md`, `estimate-input.json`; +- runnable SQLite backup spike plus its fault-injected test. + +### Acceptance + +- Every state family has owner, writer set, schema/version, atomicity, corruption policy and backup role. +- Each required mutation is app-exclusive, cooperative, provider-mediated, quiescent-only or unavailable. +- Negative fixtures show an app-only in-process lock does not coordinate an external writer. +- SQLite spike uses the supported Online Backup API, runs with active WAL, reopens independently and + fails rather than raw-copying DB/WAL/SHM on BUSY/corruption. +- No user `~/.claude` data is read or written; all fixtures are temporary and marker-owned. + +Do not add a universal repository, migrate provider JSON into SQLite or modify production backup +behavior. + +## W4 - Host lease, workspace guard and process ownership + +### Mission + +Run the Linux feasibility work that TypeScript mocks cannot prove: one writer, descriptor-bound +workspace effects and owned process-tree drain. + +### Required reads + +Read standalone/Docker/init, process spawn/kill paths, workspace/Git/provider-spawn adapters, final +seccomp/user/mount topology and ADR-16/28/31. + +### Deliverables + +- target-host envelope, lease/guard/anchor result reports, native artifact proposal and estimate input; +- isolated instance-lock, workspace-guard and process-anchor spike directories; +- marker-owned container/native fixture tests for all three primitives. + +### Acceptance + +- Two containers/manual starts on one supported local volume prove only one reaches Node/effect code. +- Paused/killed owners, duplicate FD close ordering and anchor path replacement cannot create overlap. +- `openat2`/`statx`/seccomp/filesystem probes run inside the final-shape Linux container. +- Parent/final symlink, root rename, bind-mount and stale-generation races produce zero outside-marker + file/Git/provider-spawn effects; raw Node path checks provide the failing negative control. +- Process anchor proves spawn nonce, ready evidence, pidfd/subreaper ownership, double-fork reap, + TERM/KILL escalation, controller EOF and typed drained/unclassified outcomes. +- `/proc//fd` scan proves lease/control descriptors do not leak into fake provider/Git/helpers. + +If the active worker host is not the target Linux topology, local source work may proceed, but the lane +cannot become `verified` until its container probes run on an admitted target host. + +Do not touch unrelated processes, accept NFS/CIFS without explicit support, or productionize spike +artifacts before 0D. + +## W5 - Snapshot/event and command/effect recovery + +### Mission + +Prove that snapshot plus replay cannot lose a concurrent mutation and that transport retries cannot +repeat an ambiguous external effect. + +### Required reads + +Read event broadcast/renderer EventSource, snapshots/watchers, provisioning idempotency/OpenCode +delivery journals, reconciliation tests and ADR-27/33/34. + +### Deliverables + +- event/cursor inventory and snapshot-handoff scheduler report; +- command catalog, effect matrix, fingerprint golden vectors and estimate input; +- runnable schedule/catalog generators and their deterministic tests. + +### Acceptance + +- Deterministic scheduler pauses before/after read, commit, cursor, serialization, listener and replay. +- Negative controls reproduce the cursor-after-read and query-then-listen lost-event schedules. +- Accepted design converges with duplicates but no gap for SQLite and external-file projection cases. +- Every required mutation has one versioned normalized intent/fingerprint and effect recovery class. +- Same idempotency key with changed intent conflicts; non-reconcilable ambiguity becomes + `operator_required`, never an automatic body replay. +- Golden vectors cover schema/fingerprint/key version changes and retained comparison behavior. + +Do not introduce event sourcing, claim exactly-once without evidence or persist sensitive command +bodies in receipts/fingerprints. + +## W6 - Browser authentication, proxy contract and artifact truth + +### Mission + +Prove restart-safe single-operator authentication and the exact standalone/bundle boundary before any +remote mutation route is enabled. + +### Required reads + +Read standalone/HttpServer/routes/Docker, renderer boot and CORS behavior, internal-storage/build/native +stubs, ADR-7/17 and official package metadata for version-dependent facts. + +### Deliverables + +- auth transition, proxy/origin threat and cookie-version evidence; +- artifact inventory, ABI/stub report, terminal-absence report and estimate input; +- standalone artifact scanner plus auth/proxy/artifact negative tests. + +### Acceptance + +- Pairing -> durable device family -> short session -> renew/rotate/revoke/reset schedules cover restart, + expiry, response loss, two tabs, replay after grace and keyring failure. +- PUBLIC_ORIGIN and exact trusted-proxy CIDRs are the only browser authority source; direct HTTP, + forwarded-header spoof, wildcard CORS and sibling authority fail before body/idempotency work. +- No stateless signed cookie becomes session authority and no plaintext recovery credential is durable. +- Artifact report proves current standalone/static/API topology, emitted worker needs, Node/Electron ABI + splits and every empty/missing stub risk. +- V1 terminal daemon/gateway/SDK/routes/migrations are absent from the proposed hosted artifact. + +Do not enable auth/change CORS, install the cookie dependency before 0D or expose sensitive values. + +## Worker prompt contract + +The controller renders each worker prompt from this template: + +```text +You own Phase 0 package only. +Base SHA/worktree: / +Phase start SHA / plan bundle commit: / +Parent plan: docs/hosted-web-e2e-completion-plan.md +Execution packet: docs/hosted-web-phase-0-execution-packet.md +Lane packet: docs/hosted-web-phases/phase-00/lanes/.md +Packet revision: phase-00-r2 +Writable paths: +Read-only source surfaces: +Deliverables and acceptance: +Forbidden scope: +Characterize before changing. Use only new sandbox/temp fixtures. Do not print secrets. +Do not edit shared files, package.json, lockfile, production source or another lane. +Return status, evidence, diff stat, checks, assumptions and ADR recommendation. Never claim verified +when a required target-host probe did not run. +``` + +The controller injects immutable runtime facts and the handoff path defined by the packet standard. +Worker prompts do not contain broad instructions such as “improve architecture”, “finish Phase 0” or +“help another lane”. Cross-lane questions go through the controller. A stale revision, missing heading +or ownership conflict returns `packet_stale` or `packet_conflict` before edits. + +## Progress monitoring and intervention + +Every check records only project state: host admission, controller/timer health, fresh worker count, +last useful output, lane state/next evidence, overlap/dirty risk, review/adoption queue, red prerequisite +and estimate burn by unique bucket rather than raw output lines. Fresh worker count is reconciled against +unique active slots in `lane-ledger.json`; duplicate jobs never increase useful capacity. + +Intervene when: + +- no useful output for two intervals or a worker edits outside ownership; +- lanes define overlapping authority/evidence or replace a spike with prose/mocks; +- later-phase implementation or a real project/provider/secret enters the task; +- output exceeds budget without new required risk; +- a negative control is weakened instead of understood. + +Preferred interventions are: narrow the question, split a fixture from implementation, hand a shared +decision to the controller, redirect a finished worker to its paired review, or mark a genuine blocker. + +## Review pairing + +Review begins after both paired lanes have a self-reviewed output: + +| Reviewer pair | Review focus | +| ---------------------------- | ---------------------------------------------------------------------- | +| W1 reviews W2; W2 reviews W1 | renderer actions agree with provider/runtime capability and direction | +| W3 reviews W5; W5 reviews W3 | command/event recovery matches actual writer/storage atomicity | +| W4 reviews W6; W6 reviews W4 | host primitives agree with auth/pairing/artifact/container assumptions | + +Reviewers do not edit the other worker's branch. They produce findings with evidence path, severity, +required correction and affected ADR/decision. The owner corrects the output or records a contested +decision for 0D. + +Required self-review before review handoff: + +```bash +git status --short +git diff --stat +git diff --check +pnpm lint:fast:files -- 2>&1 | tail -20 +pnpm exec vitest run 2>&1 | tail -20 +``` + +Documentation-only lanes still run formatting/checks and validate JSON/schema fixtures. Native/container +lanes attach the exact build/run command, final image identity and marker-owned cleanup evidence. + +## Diff and commit budget + +- Inventory/scanner lane: target 400-800 changed lines. +- Executable spike plus tests: target 700-1,300 changed lines. +- Any single adoption above 1,500 changed lines must be split or explicitly approved by the controller. +- Commit only deterministic evidence; summarize/hash large raw artifacts outside Git. +- One lane should normally produce one to three conventional commits. + +Example commit subjects: + +```text +docs(hosted-web): inventory phase zero runtime surfaces +test(hosted-web): add snapshot handoff negative schedules +build(hosted-web): add workspace guard feasibility harness +``` + +Do not commit unrelated baseline fixes with evidence work. A necessary base fix receives its own narrow +prerequisite commit/PR and the baseline is rerun before dependent adoption. + +## Integration and adoption order: 0D.1 + +Integration uses the project policy lifecycle, not raw worker-branch merges: + +1. Verify worker status, clean scope, self-review, targeted checks, `git diff --check` and no secrets. +2. Open one integration attempt at the current integration-head SHA. +3. Adopt deterministic scanners and fixture schemas first. +4. Adopt W1/W2 inventories and reconcile capability/provider terminology. +5. Adopt W3/W5 state, writer, event and recovery evidence together. +6. Adopt W4/W6 host/auth/artifact evidence only after their container assumptions agree. +7. Controller regenerates shared estimate/salvage ledgers from all lane `estimate-input.json` files. +8. Resolve contested findings in the decision register; do not hide disagreement in prose. +9. Run targeted combined checks, then the Phase 0 broad gate. +10. Commit/push only after the integration attempt is green and reviewed. + +An integration conflict in a shared source path is evidence that ownership was violated. Do not solve +it by accepting both implementations. Select one authority or return the package to its owner. + +## Shared evidence schemas + +| Record | Required fields | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Decision | decisionId, question, sourceEvidence, options, outcome, confidence, affected capabilities/ADRs, owner, `accepted \| narrowed \| reopened \| blocked` | +| Estimate | bucketId, packages, production/test/deleted lines, excluded generated/vendor lines, overlap, confidence, assumptions, evidenceRefs | +| Baseline failure | command, exitCode, classification, first-known-bad evidence, affected packages, owner, isolation/fix and rerun evidence | + +Machine-readable files use versioned JSON schemas, deterministic ordering and stable IDs. Markdown is a +review projection, not the only authority for parity or estimate calculations. + +## Phase 0 combined verification: 0D.3 + +Required narrow gates: + +- every scanner has positive and deliberate negative fixtures; +- every JSON evidence file validates against its checked-in schema; +- every spike has exact environment, command, positive result and failing negative control; +- every state/writer/provider/action row has one disposition and owner; +- no lane writes outside its owned paths; +- no evidence contains secrets, raw auth payloads or real-project paths; +- terminal implementation/artifacts remain absent. + +Required repository gates after adoption: + +```bash +pnpm typecheck:workspace 2>&1 | tail -20 +pnpm lint:fast 2>&1 | tail -20 +pnpm test:workspace:ci 2>&1 | tail -20 +pnpm standalone:build 2>&1 | tail -20 +pnpm check:ci 2>&1 | tail -20 +git diff --check +``` + +Do not run `pnpm lint:fix`. `pnpm check:ci` includes the full root/MCP lint gates; `lint:fast` remains +the iteration preflight, not final evidence. + +## Stop and fail-closed conditions + +Stop dependent work immediately when: + +- canonical remote/base SHA cannot be verified; +- baseline has an unknown failure affecting the evidence surface; +- broker/controller ownership or isolated worktrees cannot be enforced; +- host load is above admission policy; +- a target Linux syscall/filesystem/process primitive cannot be proven; +- a proposed solution requires a real user project, live user team or unrestricted host cleanup; +- state authority/writer coordination cannot be classified for a required mutation; +- a secret may have entered Git, logs or artifacts; +- the estimate grows outside 28k-45k or a unique bucket varies over 20% without scope review; +- an ADR negative control succeeds unexpectedly or its positive proof is non-reproducible; +- Phase 0 needs a hosted terminal implementation to pass. + +A failed feasibility spike does not mean “try a weaker fallback”. It produces one of: + +- narrow/remove the dependent v1 capability; +- change the supported deployment envelope; +- reopen the ADR with explicitly scored alternatives; +- block Phase 1 until the external condition changes. + +## Phase 0 freeze: 0D.2 + +The controller may freeze Phase 0 only when the decision register records outcomes for: + +- exact v1 scope and capability exclusions; +- contract/route/facet conventions; +- TeamId/WorkspaceId/MemberId identity authority; +- provider execution topology and runtime-ingress direction; +- state ownership and external-writer classes; +- child environment and credential exposure policy; +- instance lease, workspace guard and process-anchor feasibility; +- snapshot/event handoff and command/effect recovery; +- auth/device/session/reset and proxy/origin contract; +- artifact/build/SQLite worker and backup feasibility; +- terminal absence from v1; +- updated estimate and Phase 1 prerequisites. + +Each outcome is `accepted`, `narrowed`, `reopened` or `blocked`; “TBD” is not a frozen state. + +## Definition of Done and Phase 1 handoff + +Phase 0 is complete only when: + +- 0A base/baseline evidence is reproducible from the pinned SHA; +- every lane starts at the recorded `phaseStartSha`, which contains `planBundleCommit` and reviewed 0A + evidence; +- all six lanes are reviewed and adopted or explicitly rejected with a decision; +- every parent-plan Phase 0 exit gate has an evidence reference; +- native/container claims ran in the supported target topology; +- estimate/salvage/parity/state/provider/artifact ledgers are generated and validated; +- required broad gates are green or only accepted isolated base failures remain; +- no hosted product mutation or terminal implementation was enabled; +- integration worktree is clean after the conventional Phase 0 commit sequence; +- the controller writes a concise Phase 0 completion report with residual risks; +- a new Phase 1 JIT execution packet is generated from the frozen decisions and current integration SHA. + +Phase 1 workers must not be prestarted. Their owned paths, contract IDs and tasks depend on the Phase 0 +freeze, so creating them earlier would turn just-in-time planning back into speculative architecture. +The packet is materialized through the execution router and packet standard; a free-form continuation +prompt is not an acceptable Phase 1 packet. diff --git a/docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md b/docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md new file mode 100644 index 0000000000..8e71c17a72 --- /dev/null +++ b/docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md @@ -0,0 +1,61 @@ +# Hosted-web evidence lifecycle + +## One catalog, explicit authority + +Every evidence record names one stable evidence ID and records its repository-relative path, phase, lane, +authority class, producer, producer base SHA, content SHA-256, regeneration command, review +disposition, and supersession links. A file's directory or modification time never implies authority. + +A supersession link transfers authority; it is not merely a provenance link. Therefore every row +named by `supersededBy` must itself be `canonical` with an `approved` or +`approved-with-conditions` disposition, and only such a row may carry a non-empty `supersedes` +list. Raw, generated-but-unadopted, historical, rejected, and already-superseded rows cannot receive +authority through a supersession link. The catalog fails closed instead of laundering authority +through one of those classes. + +Authority classes are deliberately disjoint: + +| Authority | Meaning | Current decision authority | Retention | +| ------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `canonical` | Reviewed artifact adopted by the controller for its evidence ID. | Yes, unless a later canonical row explicitly supersedes it. | Retain permanently with its catalog row and hash. Never rewrite in place. | +| `raw` | Immutable observation, capture, log, or source input. | No; it supports a reviewed conclusion. | Retain unchanged for the lifetime of every derived artifact and the release audit. No automatic deletion. | +| `generated` | Deterministic derivative reproducible by the recorded command. | Only if its review disposition separately permits adoption. | Retain every reviewed or referenced version. Unreferenced rebuilds may be cleaned only by a separately approved retention process, never by these tools. | +| `historical` | Former context retained for traceability but not current authority. | No. | Retain permanently. Do not move it merely to express this class; the catalog is authoritative. | +| `rejected` | Reviewed candidate explicitly found unsuitable. | No. It must not be revived without a new evidence ID or reviewed revision. | Retain permanently with rejection disposition and review evidence. | +| `superseded` | Former authority replaced by the artifact named in `supersededBy`. | No. | Retain permanently with an unbroken forward and reverse supersession link. | + +`historical` means context aged out of current decision-making. `rejected` means review made an +adverse decision. `superseded` means a named replacement took authority. These terms are not +interchangeable. + +## Review dispositions + +Every row records one of `pending`, `approved`, `approved-with-conditions`, `rejected`, `superseded`, +or `not-required`. Canonical evidence must be `approved` or `approved-with-conditions`. Rejected and +superseded authority classes require their matching dispositions. A superseded row must name exactly +one forward replacement; the replacement must list the old evidence ID in `supersedes` and must be +canonical with an accepted disposition. Missing targets, non-reciprocal links, cycles, and +non-authoritative targets invalidate the entire catalog. + +Raw observations may use `not-required` because review applies to the conclusion drawn from them, not +to whether the bytes were observed. Generated artifacts require a non-empty exact regeneration command. +Other classes use `null` only when regeneration is impossible or inapplicable. + +## Evidence maintenance boundary + +Existing evidence and its recorded hashes, dispositions, and historical regeneration commands remain +frozen. Historical commands are provenance only, not executable instructions. New evidence must be +created at a new exact path and reviewed through the current controller and lane packets. The product +repository does not provide a hosted-worker evidence-catalog generator or orchestration validator. + +## Correction and supersession + +1. Preserve the old bytes and row. +2. Produce a new artifact at a new exact path and give it a distinct evidence ID. +3. Record its producer, base SHA, hash, regeneration command, and review disposition. +4. After adoption, classify the old row as `superseded`, set its disposition to `superseded`, set + `supersededBy` to the new ID, and add the old ID to the new row's `supersedes` list. +5. Review the complete evidence record before using the replacement as an input to a packet or worker. + +Evidence maintenance is non-destructive. It never deletes, moves, truncates, or rewrites archived +evidence. diff --git a/docs/hosted-web-phases/EXECUTION_INDEX.json b/docs/hosted-web-phases/EXECUTION_INDEX.json new file mode 100644 index 0000000000..4191635b55 --- /dev/null +++ b/docs/hosted-web-phases/EXECUTION_INDEX.json @@ -0,0 +1,112 @@ +{ + "schemaVersion": 3, + "packetRevision": "phase-03-actual-owner-closure-r2", + "currentPhase": "phase-03", + "currentNode": "P3.A.PRODUCT_BINDING", + "status": "active-product-binding-closure", + "terminalState": "HOLD", + "authority": { + "repository": "777genius/agent-teams-ai", + "pullRequestNumber": 252, + "phaseAuthority": "worker-launch.phaseStartSha", + "orchestratorRepository": "777genius/agent_teams_orchestrator", + "orchestratorPullRequestNumber": 44, + "openCodeRepository": "777genius/opencode-anomaly", + "openCodeFunctionalPullRequestNumber": 1, + "openCodeArtifactPullRequestNumber": 2, + "successorLaunchAuthorizedNow": false, + "productionActivationAuthorized": false + }, + "activeLane": { + "id": "p3-a-product-binding", + "node": "P3.A.PRODUCT_BINDING", + "packet": "docs/hosted-web-phases/phase-03/lanes/p3-a-product-binding.md", + "reviewKind": "implementation", + "capacity": 1, + "zeroCodeResultAllowed": false, + "ownedPaths": [ + "src/main/standalone.ts", + "src/main/composition/hosted/hostedLifecycleOrchestratorReadiness.ts", + "src/main/composition/hosted/createHostedApprovalProductionComposition.ts", + "src/main/services/team/provisioning/HostedApprovalRuntimeAdmissionPublisher.ts", + "src/main/services/team/provisioning/HostedApprovalRuntimeLifecycleCoordinator.ts", + "src/main/services/team/provisioning/HostedApprovalRuntimeProductionComposition.ts", + "src/main/services/team/provisioning/HostedApprovalRuntimeActivationEnvelope.ts", + "src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeAdmissionPublisher.test.ts", + "src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeLifecycleCoordinator.test.ts", + "test/features/team-approvals/hosted/HostedApprovalRuntimeActivation.test.ts", + "test/main/composition/hosted/hostedApprovalProductionActivation.test.ts", + "test/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.test.ts", + "test/architecture/hosted-web/approval-production-unmounted.test.ts", + "docs/hosted-approval-activation-v1-golden.json" + ], + "requiredChecks": [ + "pnpm exec vitest run test/features/team-approvals/hosted/HostedApprovalRuntimeActivation.test.ts test/main/composition/hosted/hostedApprovalProductionActivation.test.ts test/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.test.ts src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeAdmissionPublisher.test.ts src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeLifecycleCoordinator.test.ts test/architecture/hosted-web/approval-production-unmounted.test.ts", + "pnpm typecheck", + "pnpm lint:fast:files -- ", + "pnpm exec prettier --check ", + "git diff --check" + ] + }, + "nodes": [ + { + "id": "P3.A.PRODUCT_BINDING", + "kind": "bounded-product-binding-audit-and-closure", + "dependsOn": ["PR252.CORE_HEAD.GREEN"], + "capacity": 1, + "terminalState": "HOLD" + }, + { + "id": "P3.RA.PRODUCT_REVIEW", + "kind": "independent-product-architecture-security-review", + "dependsOn": ["P3.A.PRODUCT_BINDING"], + "writeOrRepairAuthority": false, + "requiredDisposition": "ACCEPT-with-P0-P1-P2-0-0-0", + "terminalState": "HOLD" + }, + { + "id": "P3.B.ORCHESTRATOR_INTEGRATION", + "kind": "independent-review-and-integration-of-orchestrator-pr44", + "dependsOn": ["P3.RA.PRODUCT_REVIEW"], + "repository": "777genius/agent_teams_orchestrator", + "terminalState": "HOLD" + }, + { + "id": "P3.C.NO_FAKE_E2E", + "kind": "cross-repository-sandbox-actual-owner-e2e", + "dependsOn": ["P3.B.ORCHESTRATOR_INTEGRATION"], + "packetMaterialized": false, + "productionActivationAuthorized": false, + "terminalState": "HOLD" + }, + { + "id": "P3.F.COORDINATED_ACTIVATION", + "kind": "exact-head-product-orchestrator-manifest-activation", + "dependsOn": ["P3.C.NO_FAKE_E2E"], + "packetMaterialized": false, + "requiredDisposition": "ACCEPT-with-P0-P1-P2-0-0-0", + "terminalState": "HOLD" + } + ], + "invariants": [ + "Launcher-signed v4 admission is the only authority that may mount approval routes.", + "Provisioning and restart_required generations keep product approval routes unmounted.", + "No scalar lifecycle coordinator value or owner-writable file may become signed-v4 authority.", + "Product, orchestrator and artifact production gates remain false before accepted no-fake E2E evidence.", + "All launch, runtime and browser proof uses only a fresh marker-owned sandbox project.", + "The OpenCode downstream remains a bounded upstream patch queue, not an independently evolving product fork.", + "Product is the logical authenticated activation-v1 producer; orchestrator owns durable persistence and atomic delegate activation." + ], + "forbidden": [ + "real user projects or shared user runtime state", + "production gate activation before P3.F", + "new lifecycle authority or broad coordinator platform", + "legacy v2/v3 approval route mounting", + "unverified OpenCode repin or mutable artifact", + "broad parity, terminal, attachments, review, member recovery, or destructive recovery scope", + "dependency updates", + "successor launch from worker output" + ], + "nextAction": "RUN_P3_A_PRODUCT_ACTIVATION_FROM_EXACT_PHASE_SHA", + "launchSuccessor": false +} diff --git a/docs/hosted-web-phases/ORCHESTRATION_GUARDS.md b/docs/hosted-web-phases/ORCHESTRATION_GUARDS.md new file mode 100644 index 0000000000..f49705967c --- /dev/null +++ b/docs/hosted-web-phases/ORCHESTRATION_GUARDS.md @@ -0,0 +1,9 @@ +# Hosted-worker orchestration responsibility boundary + +Hosted-worker admission, work identity, retries, registry state, and process launch are owned by +`hosting/subscription-runtime`, not by the Agent Teams product repository. Hosting controllers must +use subscription-runtime's builtin `worker-start-v1` boundary. + +This repository retains product architecture, controller and lane packets, the execution DAG, and +frozen evidence. It does not implement, generate, validate, test, or execute hosted-worker +orchestration. diff --git a/docs/hosted-web-phases/PACKET_STANDARD.md b/docs/hosted-web-phases/PACKET_STANDARD.md new file mode 100644 index 0000000000..11d5b3f1c8 --- /dev/null +++ b/docs/hosted-web-phases/PACKET_STANDARD.md @@ -0,0 +1,158 @@ +# Hosted Web Execution Packet Standard + +## Why packets exist + +A packet is a bounded execution contract, not a summary of the master plan. It must let one controller +or worker act autonomously without inventing scope, ownership, acceptance criteria or recovery rules. + +## Phase controller packet + +Each phase controller packet contains: + +1. `Status and authority`: phase, revision, predecessor evidence, active/blocked state. +2. `Outcome`: one measurable phase result. +3. `Inputs`: immutable base SHA, plan revision, ADR set and inherited failure ledger. +4. `Non-goals`: later-phase and explicitly deferred behavior. +5. `Definition of Ready`: authorization, host admission and evidence prerequisites. +6. `DAG`: serial bootstrap, parallel lanes, reviews, adoption and freeze. +7. `Ownership`: exact exclusive writable paths and shared integration-only paths. +8. `Lane registry`: packet path, dependencies, evidence IDs and estimate bucket per lane. +9. `Capacity epochs`: unique lane slots, replacement/supersession and legal review transitions. +10. `Monitoring`: freshness, useful progress, overlap, debt and intervention thresholds. +11. `Integration`: review pairs, adoption order, required checks and rejection conditions. +12. `Definition of Done`: artifacts, proof level, open-risk budget and next-phase inputs. + +The controller packet owns orchestration semantics. It never gives a controller raw shell, raw tmux, +raw Git writer or direct registry rights. + +## Lane packet + +Each lane packet contains: + +- stable lane and evidence IDs; +- packet revision and parent phase; +- one mission and explicit non-goals; +- exact required reads, with master-plan headings rather than the entire document; +- exclusive writable paths and prohibited shared paths; +- deliverables with machine-readable schemas where applicable; +- acceptance and negative-control requirements; +- targeted checks; +- stop conditions; +- a structured handoff contract. + +One worker receives one lane packet. Cross-lane work is requested through the controller and results in +a new reviewed packet revision or a separate lane; it is never accepted through an informal prompt. + +## Immutable runtime facts + +The controller injects these values when rendering a worker prompt: + +```text +projectId +controllerJobId +phaseId +laneId +packetRevision +parentPlanCommit +baseSha +planBundleCommit +phaseStartSha +sourceWorktree +writablePaths +requiredCheckCommands +handoffPath +``` + +Packet files do not hardcode a moving branch head as immutable truth. A displayed SHA is an observed +snapshot; the controller's 0A/base record is authoritative once a phase starts. A child lane starts only +from `phaseStartSha`, which contains the reviewed packet bundle and serial bootstrap evidence. + +## Evidence identity + +Every deliverable has a stable evidence ID such as `P0.W3.STATE_FAMILY_CATALOG`. The ID survives file +renames and retries and appears in: + +- the lane packet; +- the produced evidence index; +- review findings; +- the integration attempt; +- the phase decision register and estimate reconciliation. + +An evidence ID has one integration owner. Two workers may independently review or falsify evidence, +but they may not both publish competing canonical rows without a controller resolution. + +## Proof levels + +| Level | Meaning | May close acceptance? | +| ----------------------- | --------------------------------------------------------------- | ------------------------------- | +| `source_observed` | Relevant production source was traced | no | +| `fixture_characterized` | Deterministic positive and negative fixtures reproduce behavior | only characterization gates | +| `target_verified` | Required test ran in the declared final-shape topology | yes | +| `live_sandbox_verified` | Provider/runtime smoke ran only in a new test project | only when the phase requires it | +| `unverified` | Claim is inferred or required environment was unavailable | no | + +Tests on real user projects are forbidden regardless of proof level. + +## Required handoff + +Workers write a worktree-local `.codex-handoff/-.json` with this shape: + +```json +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "laneId": "w1", + "packetRevision": "phase-00-r2", + "baseSha": "", + "status": "verified | characterized | blocked | failed", + "evidence": [ + { + "id": "P0.W1.API_PARITY_LEDGER", + "path": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "proofLevel": "fixture_characterized" + } + ], + "changedPaths": [], + "checks": [{ "command": "", "exitCode": 0 }], + "unverifiedClaims": [], + "blockers": [], + "adrRecommendations": [], + "estimateBuckets": [], + "nextAction": "review" +} +``` + +The handoff never contains secrets, auth payloads, raw provider payloads or sensitive command bodies. +The controller rejects a handoff with an unknown evidence ID, stale packet revision, different base SHA, +out-of-scope changed path or a `verified` claim whose required check is absent. + +## Blocker protocol + +When blocked, a worker stops changing code and returns: + +- blocker class: `packet_conflict`, `packet_stale`, `base_failure`, `environment`, `scope_overlap`, + `security`, `missing_evidence` or `design_falsified`; +- smallest reproducer or source reference; +- affected evidence IDs; +- whether unrelated lane work can continue; +- one recommended controller action. + +The worker does not silently reinterpret the packet or widen its writable paths. + +## Packet revision rules + +A packet revision changes when ownership, deliverables, acceptance, required ADRs, integration order or +proof topology changes. Typographical edits may keep the revision. + +An active packet revision is immutable for an existing worker. The controller either lets the worker +finish under that revision or stops it and issues a new worktree/job with an explicit salvage decision. + +## Just-in-time rule + +Future phase packets are materialized only after predecessor freeze. Before that, the master plan's +phase sections describe intent and dependencies but are not executable worker instructions. + +This avoids two failure modes: + +- false precision: later packets encode assumptions Phase 0 is meant to test; +- distributed drift: the same ADR or contract is copied into many files and later diverges. diff --git a/docs/hosted-web-phases/README.md b/docs/hosted-web-phases/README.md new file mode 100644 index 0000000000..dd4430962e --- /dev/null +++ b/docs/hosted-web-phases/README.md @@ -0,0 +1,33 @@ +# Hosted-web execution packets + +Current authority is [Phase 03 actual-owner closure](phase-03/README.md), revision +`phase-03-actual-owner-closure-r2`. Start with [START_HERE.md](START_HERE.md) and use +[EXECUTION_INDEX.json](EXECUTION_INDEX.json) as the machine-readable source of truth. + +## Current route + +Phase 03 closes one remaining Hosted Core v1 boundary without broadening scope: + +1. add the independently demonstrated missing activation-v1 producer between orchestrator + `owner_ready` and authenticated final `ready` without adding another lifecycle authority; +2. independently review and integrate the two-phase actual-owner startup change from orchestrator + PR #44; +3. run one new sandbox-only no-fake approval flow across the exact product, orchestrator, and pinned + OpenCode artifact revisions; and +4. change production eligibility only as one coordinated activation after that evidence passes. + +The current authorized node is only `P3.A.PRODUCT_BINDING`. It must not add another lifecycle +authority, treat the scalar compatibility coordinator as signed-v4 truth, launch a real project, or +enable a production gate. The completed r1 audit established that zero code cannot complete this +node; r2 authorizes only the bounded activation-v1 paths listed in the execution index. + +## Authority and evidence + +Every worker starts from the exact immutable phase SHA injected by `ProjectScopedControl`, edits only +the lane packet's explicit paths, runs the declared checks, self-reviews, and returns `HOLD`. +Successor nodes require a new controller decision; worker output alone never authorizes integration, +E2E, or gate activation. + +The runtime owns execution primitives only. The controller owns DAG admission, dependencies, review, +drift invalidation, integration, and promotion. Historical Phase 01/02 packets remain preserved but +are not current launch authority. diff --git a/docs/hosted-web-phases/START_HERE.md b/docs/hosted-web-phases/START_HERE.md new file mode 100644 index 0000000000..c6e49acbe4 --- /dev/null +++ b/docs/hosted-web-phases/START_HERE.md @@ -0,0 +1,59 @@ +# Start here: Hosted Core v1 actual-owner closure + +- Revision: `phase-03-actual-owner-closure-r2` +- Current node: `P3.A.PRODUCT_BINDING` +- Current authority: exact phase SHA supplied by `ProjectScopedControl` +- Terminal state: `HOLD` + +The live-head synchronization route is complete and historical. Phase 03 is the bounded closure for +the signed-v4 approval actual-owner path. It does not authorize broad parity, a second lifecycle +platform, real-project runtime testing, or production activation. + +## Mandatory read order + +Every actor reads these items completely and in this order: + +1. repository [AGENTS.md](../../AGENTS.md); +2. this file; +3. [EVIDENCE_LIFECYCLE.md](EVIDENCE_LIFECYCLE.md); +4. [hosted-web packet README](README.md); +5. [EXECUTION_INDEX.json](EXECUTION_INDEX.json); +6. [Phase 03 navigation record](phase-03/README.md); +7. [Phase 03 controller packet](phase-03/controller-packet.md); +8. [Phase 03 execution DAG](phase-03/execution-dag.md); +9. [P3.A product binding lane](phase-03/lanes/p3-a-product-binding.md); +10. repository [CLAUDE.md](../../CLAUDE.md); +11. [critical guardrails](../../AGENT_CRITICAL_GUARDRAILS.md); +12. [feature architecture standard](../FEATURE_ARCHITECTURE_STANDARD.md); +13. [packet standard](PACKET_STANDARD.md); +14. [orchestration responsibility boundary](ORCHESTRATION_GUARDS.md); +15. [Core v1 scope lock](../hosted-web-core-v1-scope-lock.md), especially approval actual-owner + admission and production activation; +16. [OpenCode downstream policy](../hosted-opencode-downstream-policy.md); and +17. the immutable worker-launch contract injected for this exact phase SHA. + +Stop on any revision, repository, phase SHA, packet, scope, dependency, signed-v4 authority, or +sandbox mismatch. Return `HOLD`; do not repair authority informally. + +## Route + +The r1 audit proved that signed-v4 consumption alone cannot advance the real process from +`owner_ready` to active approval delivery. `P3.A` therefore implements the smallest production +activation-v1 seam within its expanded exact ownership: product authenticates the activation over +lifecycle-control IPC, orchestrator durably persists it and activates only the exact delegate, and +product mounts routes only after authenticated `ready`. A fresh reviewer must accept the exact +result with P0/P1/P2 `0/0/0` before integration. + +After the orchestrator change is independently accepted and integrated, the controller may +materialize the E2E packet. The E2E uses only a newly created marker-owned sandbox project and exact +pinned artifacts. It proves request, durable pending state, authenticated browser decision, owner +delivery, reconciliation, restart, stale authority, replacement, ambiguity, isolation, and cleanup. +Only its accepted exact-head evidence can unlock coordinated activation. + +## Safety + +Never use a real user project, shared user runtime state, real provider agent, product terminal, or +ambient home data. `P3.A` performs no launch or smoke flow at all. A later E2E node may launch only +inside its declared fresh sandbox. Do not enable any production eligibility flag, repin OpenCode from +unverified bytes, add dependencies, or expand deferred Hosted parity. Runtime primitives do not +choose the DAG. diff --git a/docs/hosted-web-phases/_templates/phase-controller-packet.md b/docs/hosted-web-phases/_templates/phase-controller-packet.md new file mode 100644 index 0000000000..9bf286e400 --- /dev/null +++ b/docs/hosted-web-phases/_templates/phase-controller-packet.md @@ -0,0 +1,71 @@ +# Phase : + +## Status and authority + +- Status: `draft | ready | active | blocked | complete | superseded` +- Packet revision: `` +- Parent plan commit: `` +- Predecessor integration commit: `` +- Predecessor evidence index SHA-256: `` +- Plan bundle commit: `` +- Phase start SHA: `` +- Required ADR IDs: `` +- Explicit authorization: `` + +## Outcome + +State one measurable phase result. Do not combine discovery, unrelated cleanup and later product scope. + +## Inputs and inherited failures + +- immutable base/predecessor SHA; +- frozen contracts and evidence IDs; +- accepted inherited failure IDs; +- reopened decisions and blocking unknowns. + +## Non-goals + +List later-phase behavior, deferred terminal scope and adjacent refactors that workers must not start. + +## Definition of Ready + +- [ ] predecessor freeze is complete; +- [ ] packet/evidence revisions match; +- [ ] host admission and project debt permit work; +- [ ] every lane has exclusive ownership; +- [ ] required test topology exists; +- [ ] rollback or feature gate is defined for behavior changes. +- [ ] child worktrees will be created only after the plan bundle and serial bootstrap evidence are in + `phaseStartSha`. + +## DAG and lane registry + +| Lane | Packet | Dependencies | Evidence IDs | Estimate bucket | Review pair | +| -------- | -------- | ------------ | ------------ | ----------------- | ----------------- | +| `` | `` | `` | `` | `` | `` | + +Describe serial bootstrap, legal parallelism, integration order and freeze. One shared writer surface has +one integration owner. + +Define unique lane slots and capacity epochs. A replacement attempt must name the slot and superseded +job; static templates must not create duplicate completed evidence work. + +## Monitoring and intervention + +Define freshness interval, useful-progress evidence, scope-overlap response, host load limit, debt +backpressure, stale-worker salvage and the conditions that stop new admission. + +## Integration gate + +For every lane require scope review, deterministic tests, negative controls, `git diff --check`, secret +scan, evidence-ID reconciliation and a clean integration attempt. State rejection and retry behavior. + +## Definition of Done + +- all evidence IDs are reviewed and adopted, rejected or explicitly deferred; +- required proof levels are met in the named topology; +- inherited/new failures are classified; +- bypass/old authority removal is proven where the phase changes behavior; +- rollback gate is exercised where required; +- decision and estimate registers are frozen; +- the next packet is materialized from this phase's evidence, not from the old master-plan assumptions. diff --git a/docs/hosted-web-phases/_templates/worker-lane-packet.md b/docs/hosted-web-phases/_templates/worker-lane-packet.md new file mode 100644 index 0000000000..085a59a169 --- /dev/null +++ b/docs/hosted-web-phases/_templates/worker-lane-packet.md @@ -0,0 +1,58 @@ +# Phase : + +- Packet revision: `` +- Evidence owner: `` +- Depends on: `` +- Result states: `verified | characterized | blocked | failed` + +## Mission + +One bounded result that this worker can prove independently. + +## Required reads + +- phase controller packet sections: ``; +- master-plan headings/ADR IDs: ``; +- source entrypoints and characterization tests: ``. + +Do not instruct the worker to read the entire master plan unless the lane is an architecture-wide audit. + +## Writable paths + +- `` +- worktree-local `.codex-handoff/-.json` + +List shared and production paths that remain read-only. An overlap is `packet_conflict`, not permission +to coordinate informal concurrent edits. + +## Evidence + +- ``: `` +- ``: `` +- ``: `` + +## Acceptance + +State observable success, required negative controls, proof topology, compatibility conditions and the +claim that must remain `unverified` when the environment is unavailable. + +## Forbidden scope + +Name later-phase behavior, broad cleanup, dependency changes, real-project testing and authority changes. + +## Checks + +- deterministic focused test command; +- `pnpm lint:fast:files -- ` when applicable; +- `git diff --check`; +- lane-specific schema/generator/negative-control checks. + +## Stop conditions + +Stop on stale revision/base, source-plan mismatch, unsafe secret/user-project access, ownership overlap, +unclassified inherited failure or falsified required architecture. Return the standard blocker record. + +## Handoff + +Write the schema from `PACKET_STANDARD.md`. Include exact commands and exit codes, evidence proof levels, +unverified claims, ADR recommendation, estimate buckets and the smallest safe next controller action. diff --git a/docs/hosted-web-phases/phase-00/lanes/w1-parity-renderer.md b/docs/hosted-web-phases/phase-00/lanes/w1-parity-renderer.md new file mode 100644 index 0000000000..e764f6358c --- /dev/null +++ b/docs/hosted-web-phases/phase-00/lanes/w1-parity-renderer.md @@ -0,0 +1,49 @@ +# Phase 0 W1: Parity and Renderer Reachability + +- Packet revision: `phase-00-r2` +- Evidence owner: W1 +- Depends on: completed 0A base record and baseline classification +- Result states: `verified | characterized | blocked | failed` + +## Mission + +Produce the exact API/action ledger that prevents method-name parity and hidden Electron-only effects. + +## Read set + +Read the Phase 0 packet sections `Ownership rules`, `W1`, `Worker prompt contract`, `Shared evidence +schemas` and `Stop and fail-closed conditions`. From the master plan read only `Capability-segregated +renderer API`, `ADR-19`, `ADR-20`, `Capability and parity matrix`, `Renderer state, authority, and +migration invariants` and referenced source/tests. + +## Writable paths + +- `docs/research/hosted-web/phase-0/parity-renderer/**` +- `scripts/hosted-web/phase-0/parity-renderer/**` +- `test/architecture/hosted-web/phase-0/parity-renderer/**` +- worktree-local `.codex-handoff/phase-00-w1.json` + +Do not edit production source, shared architecture docs, package files or another lane. + +## Evidence + +- `P0.W1.API_PARITY_LEDGER`: `api-parity-ledger.json` +- `P0.W1.RENDERER_ACTIONS`: `renderer-action-inventory.json` +- `P0.W1.LEGACY_BYPASSES`: `legacy-bypass-inventory.json` +- `P0.W1.SELECTION_INVARIANTS`: `selection-reconciliation-invariants.md` +- `P0.W1.SCANNER`: deterministic scanner plus positive and negative fixture tests +- `P0.W1.ESTIMATE`: `estimate-input.json` + +## Acceptance + +Counts come from the pinned AST; every visible control maps to one action or deliberate absence before +mount; dynamic dispatch is annotated and fixture-backed; hidden Electron/global-client/fabricated-success +paths are listed; selection, snapshot, tombstone, pagination and event/poll races are explicit; removing +or duplicating a required action makes the scanner test fail. + +Do not migrate renderer code or create client facets in Phase 0. + +## Handoff + +Run targeted scanner tests, lint changed TypeScript files and `git diff --check`. Return the standard +handoff with ledger counts, unexplained dynamic sites, proof levels and estimate buckets. diff --git a/docs/hosted-web-phases/phase-00/lanes/w2-provider-runtime.md b/docs/hosted-web-phases/phase-00/lanes/w2-provider-runtime.md new file mode 100644 index 0000000000..f11298b91a --- /dev/null +++ b/docs/hosted-web-phases/phase-00/lanes/w2-provider-runtime.md @@ -0,0 +1,45 @@ +# Phase 0 W2: Provider and Runtime Ingress + +- Packet revision: `phase-00-r2` +- Evidence owner: W2 +- Depends on: completed 0A base record and baseline classification + +## Mission + +Describe real execution topologies and every machine callback, credential and environment boundary. + +## Read set + +Read the Phase 0 packet sections `Ownership rules`, `W2`, shared schemas and stop conditions. From the +master plan read `ADR-14`, `ADR-18`, `Provider and runtime strategy`, `Agent-specific JSON and protocol +boundary`, `Runtime liveness and recovery state model` and `Hosted provider test matrix`. + +## Writable paths + +- `docs/research/hosted-web/phase-0/provider-runtime/**` +- `scripts/hosted-web/phase-0/provider-runtime/**` +- `test/architecture/hosted-web/phase-0/provider-runtime/**` +- worktree-local `.codex-handoff/phase-00-w2.json` + +## Evidence + +- `P0.W2.EXECUTION_TOPOLOGY` +- `P0.W2.RUNTIME_INGRESS_INVENTORY` +- `P0.W2.ENVIRONMENT_PROVENANCE` +- `P0.W2.CREDENTIAL_EXPOSURE_MATRIX` +- `P0.W2.RUNTIME_SCANNER` +- `P0.W2.ESTIMATE` + +## Acceptance + +Separate provider identity from execution backend; map every bootstrap, delivery, task, heartbeat and +permission operation by direction, authority, idempotency and persisted evidence; classify every child +environment key; prove browser and runtime-ingress authority are disjoint; record Claude/Codex/Gemini +compatibility assumptions and OpenCode differences without exposing secret values. + +Do not invent a universal provider interface, launch a provider or use an existing user project. + +## Handoff + +Run scanner fixtures, lint changed TypeScript files and `git diff --check`. Mark topology claims that +remain source-observed rather than target-verified. diff --git a/docs/hosted-web-phases/phase-00/lanes/w3-state-writers-backup.md b/docs/hosted-web-phases/phase-00/lanes/w3-state-writers-backup.md new file mode 100644 index 0000000000..099465b7e8 --- /dev/null +++ b/docs/hosted-web-phases/phase-00/lanes/w3-state-writers-backup.md @@ -0,0 +1,46 @@ +# Phase 0 W3: State, External Writers and Backup + +- Packet revision: `phase-00-r2` +- Evidence owner: W3 +- Depends on: completed 0A base record and baseline classification + +## Mission + +Catalog durable authority and prove the minimum safe SQLite backup primitive without pretending that +provider-owned JSON files are transactional. + +## Read set + +Read the Phase 0 W3, ownership, schema and stop sections. From the master plan read `Data ownership +catalog required before writes`, `Mutation commit protocol`, `ADR-23`, `ADR-24`, `ADR-29`, `ADR-32` +and `External-writer observation algorithm`. + +## Writable paths + +- `docs/research/hosted-web/phase-0/state-writers/**` +- `scripts/hosted-web/phase-0/state-writers/**` +- `test/architecture/hosted-web/phase-0/state-writers/**` +- worktree-local `.codex-handoff/phase-00-w3.json` + +## Evidence + +- `P0.W3.STATE_FAMILY_CATALOG` +- `P0.W3.WRITER_COORDINATION` +- `P0.W3.SCHEMA_UNKNOWN_FIELDS` +- `P0.W3.BACKUP_BEHAVIOR` +- `P0.W3.SQLITE_ONLINE_BACKUP_SPIKE` +- `P0.W3.ESTIMATE` + +## Acceptance + +Every state family names its authority, writers, schema, atomicity, corruption policy and backup role; +each mutation gets an honest coordination class; a negative fixture disproves app-only locking against +an external writer; SQLite backup runs under WAL, reopens independently and fails safely on BUSY or +corruption instead of raw-copying DB/WAL/SHM. + +Use only temporary marker-owned fixtures. Do not read or write user `~/.claude` state. + +## Handoff + +Return state-family counts, unresolved writer identities, backup fault results, proof levels, exact +checks and ADR recommendations through `.codex-handoff/phase-00-w3.json`. diff --git a/docs/hosted-web-phases/phase-00/lanes/w4-lease-guard-process.md b/docs/hosted-web-phases/phase-00/lanes/w4-lease-guard-process.md new file mode 100644 index 0000000000..099a13540f --- /dev/null +++ b/docs/hosted-web-phases/phase-00/lanes/w4-lease-guard-process.md @@ -0,0 +1,47 @@ +# Phase 0 W4: Lease, Workspace Guard and Process Ownership + +- Packet revision: `phase-00-r2` +- Evidence owner: W4 +- Depends on: completed 0A and access to an admitted final-shape Linux test topology + +## Mission + +Run Linux feasibility work that TypeScript mocks cannot prove: one writer, descriptor-bound workspace +effects and owned process-tree drain. + +## Read set + +Read the Phase 0 W4, ownership and stop sections. From the master plan read `ADR-16`, `ADR-28`, +`Descriptor-bound PTY launch handoff`, `ADR-31`, `Docker and hosting topology` and relevant process, +workspace, Git and provider-spawn source/tests. + +## Writable paths + +- `docs/research/hosted-web/phase-0/host-primitives/**` +- `scripts/hosted-web/phase-0/host-primitives/**` +- `test/architecture/hosted-web/phase-0/host-primitives/**` +- worktree-local `.codex-handoff/phase-00-w4.json` + +## Evidence + +- `P0.W4.TARGET_HOST_ENVELOPE` +- `P0.W4.INSTANCE_LEASE_SPIKE` +- `P0.W4.WORKSPACE_GUARD_SPIKE` +- `P0.W4.PROCESS_ANCHOR_SPIKE` +- `P0.W4.NATIVE_ARTIFACT_PROPOSAL` +- `P0.W4.ESTIMATE` + +## Acceptance + +Prove mutual exclusion across two starts and owner failure; test descriptor close and path replacement; +run openat2/statx/seccomp/filesystem probes in the final-shape container; produce zero outside-marker +effects for symlink, rename, bind-mount and generation races; prove nonce/ready/pidfd/subreaper process +ownership, escalation and typed drain outcomes; prove control descriptors do not leak to children. + +If the topology is unavailable, return `characterized`, never `verified`. Never touch unrelated host +processes or productionize spike artifacts. + +## Handoff + +Include host/kernel/filesystem envelope, negative controls, process cleanup evidence and every unverified +topology assumption. diff --git a/docs/hosted-web-phases/phase-00/lanes/w5-events-commands-recovery.md b/docs/hosted-web-phases/phase-00/lanes/w5-events-commands-recovery.md new file mode 100644 index 0000000000..48e95cc698 --- /dev/null +++ b/docs/hosted-web-phases/phase-00/lanes/w5-events-commands-recovery.md @@ -0,0 +1,46 @@ +# Phase 0 W5: Events, Commands and Recovery + +- Packet revision: `phase-00-r2` +- Evidence owner: W5 +- Depends on: completed 0A base record and baseline classification + +## Mission + +Prove snapshot plus replay cannot lose a concurrent mutation and transport retries cannot repeat an +ambiguous external effect. + +## Read set + +Read the Phase 0 W5, ownership, shared-schema and stop sections. From the master plan read `ADR-27`, +`ADR-33`, `ADR-34`, `Realtime model`, `Reconciliation`, `Renderer state, authority, and migration +invariants` and provider delivery/idempotency journals. + +## Writable paths + +- `docs/research/hosted-web/phase-0/recovery-events/**` +- `scripts/hosted-web/phase-0/recovery-events/**` +- `test/architecture/hosted-web/phase-0/recovery-events/**` +- worktree-local `.codex-handoff/phase-00-w5.json` + +## Evidence + +- `P0.W5.EVENT_CURSOR_INVENTORY` +- `P0.W5.SNAPSHOT_HANDOFF_SCHEDULER` +- `P0.W5.COMMAND_CATALOG` +- `P0.W5.EFFECT_RECOVERY_MATRIX` +- `P0.W5.FINGERPRINT_GOLDENS` +- `P0.W5.ESTIMATE` + +## Acceptance + +Exercise pauses around read, commit, cursor, serialization, listener and replay; reproduce known lost- +event schedules as negative controls; converge with duplicates but no gaps; assign every required +mutation a normalized intent and recovery class; reject key reuse with changed intent; map ambiguous +non-reconcilable effects to `operator_required`; retain versioned fingerprint golden vectors. + +Do not introduce event sourcing, claim exactly-once or persist sensitive command bodies. + +## Handoff + +Return explored schedules, counterexamples, convergence proof, ambiguous effect list, checks and proof +levels in the standard handoff. diff --git a/docs/hosted-web-phases/phase-00/lanes/w6-auth-proxy-artifacts.md b/docs/hosted-web-phases/phase-00/lanes/w6-auth-proxy-artifacts.md new file mode 100644 index 0000000000..21b231b185 --- /dev/null +++ b/docs/hosted-web-phases/phase-00/lanes/w6-auth-proxy-artifacts.md @@ -0,0 +1,48 @@ +# Phase 0 W6: Authentication, Proxy and Artifact Truth + +- Packet revision: `phase-00-r2` +- Evidence owner: W6 +- Depends on: completed 0A base record and baseline classification + +## Mission + +Prove restart-safe single-operator authentication and the exact standalone/bundle boundary before any +remote mutation route is enabled. + +## Read set + +Read the Phase 0 W6, ownership, schema and stop sections. From the master plan read `ADR-7`, `ADR-14`, +`ADR-17`, `HTTP contract rules`, `Docker and hosting topology`, `Migration and rollback` and current +standalone/build/internal-storage/CORS source and tests. + +## Writable paths + +- `docs/research/hosted-web/phase-0/auth-artifacts/**` +- `scripts/hosted-web/phase-0/auth-artifacts/**` +- `test/architecture/hosted-web/phase-0/auth-artifacts/**` +- worktree-local `.codex-handoff/phase-00-w6.json` + +## Evidence + +- `P0.W6.AUTH_TRANSITIONS` +- `P0.W6.PROXY_ORIGIN_THREAT_MATRIX` +- `P0.W6.COOKIE_VERSION_EVIDENCE` +- `P0.W6.ARTIFACT_INVENTORY` +- `P0.W6.ABI_STUB_REPORT` +- `P0.W6.TERMINAL_ABSENCE_REPORT` +- `P0.W6.ESTIMATE` + +## Acceptance + +Cover pairing, durable device family, session rotation, revoke/reset and response-loss schedules across +restart, expiry, two tabs and keyring failure; reject direct HTTP, forwarded-header spoof, wildcard CORS +and sibling authority before body handling; keep session authority server-side; prove emitted artifact, +worker needs, ABI splits and empty/missing stubs; prove terminal daemon/gateway/routes/migrations absent +from v1. + +Do not enable auth/CORS, install dependencies or expose recovery credentials and secret values. + +## Handoff + +Return transition coverage, rejected proxy cases, artifact gaps, dependency facts with source/version, +checks, proof levels and unresolved deployment assumptions. diff --git a/docs/hosted-web-phases/phase-01/README.md b/docs/hosted-web-phases/phase-01/README.md new file mode 100644 index 0000000000..163c4d38e2 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/README.md @@ -0,0 +1,26 @@ +# Hosted Web Phase 1 navigation record + +Phase 1 is complete. This directory remains the navigation boundary for the current PR #252 sync +gate; it does not reopen a Phase 1 product node. + +Current packet authority is `pr252-live-head-sync-router-v2`. It supersedes every durable PR head or +base pin, fixed conflict list, old-job continuation, and dirty-worktree reuse contract. + +## Current route + +Read the [controller packet](controller-packet.md), [execution DAG](execution-dag.md), and +[conflict-resolution lane](lanes/pr252-base-conflict-resolution.md). + +At atomic prepare/start, the broker records the current PR head as +`attempt.canonicalHeadSha` and resolves the current base once as `attempt.resolvedBaseSha`. The +first value is the attempt's immutable materialization source, ordered first parent, and expected old +head; the second is its immutable ordered second parent. + +One producer resolves only the actual conflict paths and preserves both parent behaviors. After +focused tests, mechanical gates, self-review, direct controller rerun, and one independent combined +semantic review, exact `ACCEPT` with P0/P1/P2 `0/0/0` permits the broker to construct and push the +true two-parent merge and prove the exact GitHub head/base pair non-conflicting. + +Any later head or base drift invalidates only the attempt. The same router may admit a fresh atomic +attempt after the old one is terminal. The router author launches no worker or successor and ends +`HOLD`. diff --git a/docs/hosted-web-phases/phase-01/architecture-and-contracts.md b/docs/hosted-web-phases/phase-01/architecture-and-contracts.md new file mode 100644 index 0000000000..29e67e6f22 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/architecture-and-contracts.md @@ -0,0 +1,172 @@ +# Proposed architecture and contracts + +Status: design proposal only. Every identifier and path is proposed until serial bootstrap. + +## Feature ownership + +| Concern | Proposed owner | Explicitly not owned | +| ------------------------------------------------------------------------------ | ---------------------------- | --------------------------------------------------------------------------------------- | +| Opaque ID/context/revision/error primitives | tiny shared contract kernel | feature DTOs, transport status, persistence, auth policy | +| Team summary schema/parser/query/read port | `team-lifecycle` | tasks, messages, runtime control, workspace authorization, raw provider/filesystem data | +| Feature route descriptor and browser-safe route constant | `team-lifecycle` | global route registration or auth implementation | +| Feature capability/action descriptor | `team-lifecycle` | runtime resource allowance and UI state | +| RouteCatalog collection/assertions | hosted app composition | feature business rules or generic request dispatch | +| Semantic conformance harness, IPC/HTTP-shaped adapters, and ADR-19/20 ratchets | architecture-test tooling | production adapters, preload/global APIs, registration, or canonical product state | +| Global ratchet files | serialized integration owner | concurrent producer edits or any transport registration | + +Only folders required by the first slice are created. `team-lifecycle` does not absorb tasks, +messaging, approvals, review, runtime control, workspace registry, or console composition. + +## Candidate small contract kernel + +Proposed exports are branded string types plus constructors/parsers, not classes or a universal +envelope: + +- `ActorId`, `SessionId`, `DeploymentId`, `BootId`, `RequestId`, and test-only fixture `TeamId`; +- `QueryContext` containing actor/session, deployment/boot, request, authorized scope, + deadline/cancellation; +- opaque `Revision` and `Cursor` values; +- safe `AppErrorCode` categories: `invalid_request`, `unauthenticated`, `forbidden`, `not_found`, + `conflict`, `unsupported`, `unavailable`, `cancelled`, and `internal`; +- `SafeAppError` with code, stable safe reason, optional diagnostic ID and bounded retry hint. + +The kernel must not export `ApiResponse`, `Repository`, `PlatformAdapter`, a route descriptor, a +capability descriptor, feature error codes, HTTP status, IPC result types, or provider data. Adding a +sixth primitive family requires a usage in the accepted first slice and bootstrap review. + +## Candidate first vertical slice + +Proposed application query: `ListTeamLifecycleSummaries(query, context)`. It is a conformance +specimen, not a production-registered feature in Phase 1. + +Proposed request: + +```ts +type ListTeamLifecycleSummariesQuery = { + limit: number; // integer, 1..200; defaulted by each input adapter to 50 + cursor?: Cursor; +}; +``` + +Proposed immutable result: + +```ts +type TeamLifecycleSummaryPage = { + schemaVersion: 1; + snapshotRevision: Revision; + items: readonly TeamLifecycleSummary[]; + nextCursor: Cursor | null; +}; + +type TeamLifecycleSummary = { + teamId: TeamId; + displayName: string; + lifecycle: 'draft' | 'ready' | 'running' | 'degraded' | 'stopped' | 'deleted'; + revision: Revision; +}; +``` + +This is deliberately smaller than legacy `TeamSummary`: no `teamName`, project/host path, session ID, +member/task payload, provider status, launch diagnostics, or mutable entity escapes. Phase 1 uses only +synthetic test `TeamId` values. Phase 2 decides how canonical identities and legacy projections feed +the port. + +Proposed consumer-owned port: + +```ts +interface TeamLifecycleSummaryReader { + listPage(input: { + scope: AuthorizedScope; + limit: number; + cursor?: Cursor; + signal: AbortSignal; + }): Promise; +} +``` + +It is cohesive around one read consistency model. There is no generic repository, save/delete method, +filesystem object, Fastify request, Electron event, or transport error in the port. + +## Version, revision, and cursor semantics + +- `schemaVersion` is an integer major version. Same-version response parsers ignore additive unknown + fields after validating known fields; they never preserve them into domain/application objects. +- Unsupported major versions and unknown enum members fail with `unsupported`/`schema_version` rather + than fabricating empty data. Input objects reject unknown fields. +- Revisions are opaque server-issued tokens. Clients compare equality only and must not parse, + increment, sort, or use display names as cache keys. +- Cursors are opaque, scope/query/snapshot-bound, integrity-checked adapter tokens. A cursor used by a + different actor scope, query shape, boot, or expired snapshot returns `invalid_request` with safe + reason `invalid_cursor`; it never falls back to page one. +- Page ordering is total and deterministic within `snapshotRevision`: normalized display name, then + opaque team ID as tie-breaker. A next page must use the same snapshot or return `conflict` with safe + reason `snapshot_changed`. +- Empty success is distinct from unavailable/forbidden. No adapter converts failure to `[]`. + +## Auth and safe error semantics + +The application receives only a validated `QueryContext`; it never reads cookies, Electron event +objects, headers, IPs, or global auth state. + +| Application outcome | Test HTTP-shaped mapping | Test IPC-shaped mapping | +| ------------------- | ---------------------------------------------------------- | --------------------------- | +| success | `200` plus page | typed success plus page | +| `invalid_request` | `400` | typed failure | +| `unauthenticated` | `401`, no body parsing/use-case call | test admission failure | +| `forbidden` | `403`, no reader call | typed failure | +| `not_found` | not applicable to list; fixture rejects accidental mapping | not applicable to list | +| `conflict` | `409` | typed failure | +| `unsupported` | `422` | typed failure | +| `unavailable` | `503` plus bounded retry hint | typed retryable failure | +| `cancelled` | transport cancellation; no internal detail | typed cancellation | +| unexpected fault | `500` safe envelope with diagnostic ID | typed safe internal failure | + +HTTP browser policy is proposed as `browser.session.read`; it is fixture metadata only in Phase 1. +The HTTP-shaped adapter receives a fake authenticated browser session; the IPC-shaped adapter receives +a fake local principal. Neither derives identity from a production event or is present in production +registration. Safe responses and logs must never contain cookie/token values, raw provider output, +command bodies, host paths, stack traces, or legacy `teamName` when it could reveal a path convention. + +## Route and capability separation + +Proposed conformance-fixture IDs (not production constants or advertised capabilities): + +- route: `team-lifecycle.list-summaries.v1`; +- method/path: `GET /api/v1/team-lifecycle/teams`; +- IPC-shaped operation: `teamLifecycle:listSummaries`; +- specimen facet: `teamRead`; +- action/capability: `team.lifecycle.list`; +- request/response schemas: `team-lifecycle.list.request.v1` and + `team-lifecycle.list.response.v1`. + +The conformance route descriptor owns method/path, trust kind, auth-policy ID, readiness requirement, +schemas, handler reference, `testOnly: true`, and optional specimen-capability reference. The separate +capability descriptor owns facet/action support and feature owner but fixes production support to +absent. Dynamic resource allowance is absent from Phase 1 and cannot be inferred from fixture route +presence. RouteCatalog assertions operate on immutable fixture descriptors only; production catalogs +must reject `testOnly` descriptors. The parity ledger cross-references the proposed sources but is +neither a runtime manifest nor a generated client. + +## Phase 1 filesystem boundary + +The application port accepts values only; it has no path/root/config parameter. Its sole Phase 1 +implementation is an in-memory deterministic reader under the test tree. No production or test module +in this specimen may import filesystem APIs, resolve ambient roots, create watchers, repair files, or +perform cleanup. `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1` scans imports and port/constructor signatures +and includes a deliberately path-taking reader that must fail with the stable diagnostic +`phase1-filesystem-adapter-forbidden`. + +The audited `P1.NEG.TEST_ROOT_ESCAPE` control is explicitly deferred to Phase 2's first +filesystem-backed output adapter. That later packet must create fresh temporary project and runtime +roots with an ownership marker, reject unmarked, pre-existing, ambient, home-scoped, and +symlink-escaped roots before access, and limit cleanup to marker-verified fixture-owned paths. This +deferral reopens immediately if any filesystem-backed or path-taking adapter enters Phase 1. + +## Dependency direction + +`contracts <- isolated test input adapters`, `domain <- application`, application owns the read port, +the test-only in-memory reader implements it, and only the conformance harness wires them. No Phase 1 +preload or renderer adapter exists. Only root or documented layer entrypoints are public. Core cannot +import `@main`, Electron, Fastify, React, Zustand, `fs`, `path`, `child_process`, provider SDKs, or +transport types. Future hosted composition may import explicit production adapters only after a later +phase supplies identity, authorization, and registration. diff --git a/docs/hosted-web-phases/phase-01/conformance-and-tests.md b/docs/hosted-web-phases/phase-01/conformance-and-tests.md new file mode 100644 index 0000000000..5b29d2fad9 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/conformance-and-tests.md @@ -0,0 +1,190 @@ +# Proposed conformance and architecture gates + +Status: blocked proposal. IDs, fixtures, commands, and thresholds are proposed until serial bootstrap. + +## One semantic vector, three transports + +The harness constructs one `ListTeamLifecycleSummaries` use case around a deterministic in-memory +reader and clock. It calls it directly, through an IPC-shaped adapter with a fake invoke +event/principal binder, and through Fastify injection with a fake authenticated session binder. Both +transport-shaped adapters, their identifiers, and their composition live under +`test/features/team-lifecycle/conformance/`; production code cannot import the test tree. There is no +IPC registration, preload/global exposure, renderer client, HTTP registration, or production +composition change. The harness compares normalized application outcomes, not byte-identical +transport envelopes. + +| Obligation | Direct/test | IPC | HTTP | +| ----------------------------- | ------------------------------- | --------------------------- | -------------------------------------- | +| query defaults/validation | normalized query | wire parser then same query | query parser then same query | +| actor/scope/deadline/cancel | fixed `QueryContext` | derived local principal | derived test browser session | +| success page/revisions/cursor | exact semantic page | typed IPC success | `200` decoded page | +| invalid cursor/schema | `invalid_request`/`unsupported` | typed safe failure | `400`/`422` safe envelope | +| unauthenticated/forbidden | policy outcome | admission/typed failure | `401`/`403`, use-case spy count zero | +| unavailable/internal | safe application outcome | typed retry/internal | `503`/`500`, redacted diagnostic | +| cancellation | cancelled outcome | cancelled result | aborted injection/no post-cancel write | + +Conformance fails on changed item order, missing revision, fabricated empty success, raw error message, +different retryability, use-case invocation after admission denial, or transport objects reaching core. + +## Complete audited outcome oracle + +The normalized oracle is either +`{ kind: 'success', page, warnings: [] }` or +`{ kind: 'failure', code, reason, retryable, diagnosticPresent }`. Phase 1 permits no success +warnings, no partial item page, and no adapter metadata beyond the allowlisted transport status, +request-correlation presence, and admission-before-use-case count. All three paths must produce the +same normalized value and ordered items; adapters invoke the use case exactly once after successful +admission and zero times after denied admission. + +Every state named by the independent audit is classified below. “Not applicable” is an asserted +semantic decision with a negative fixture and a later owner, not an omitted vector. + +| Audited state | Phase 1 applicability | Normalized oracle / assertion | Carry-forward owner | +| ------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | +| success | Applicable | Non-empty page, exact `schemaVersion`, snapshot/item revisions, deterministic order, opaque cursor, and empty warnings. | Phase 1 | +| empty | Applicable | Successful empty page with snapshot revision, `nextCursor: null`, and empty warnings; never fabricated from a failure. | Phase 1 | +| not-found | Not applicable | A collection list has no requested resource whose absence can be `not_found`; a fixture injecting that result must fail as an unhandled outcome. | Phase 2 first point/resource read | +| draft | Applicable as data, not a top-level outcome | A draft item remains an ordered item with lifecycle `draft`; it cannot become empty, not-found, or a warning. | Phase 1 | +| provisioning | Not applicable | The specimen contract has no provisioning workflow/state and must reject an unknown `provisioning` lifecycle rather than coerce it. | Phase 2 real lifecycle projection | +| corrupt | Applicable as failure | `internal/corrupt_source`, no items or raw parser/path detail, non-retryable, diagnostic present; identical safe mapping through all adapters. | Phase 1 | +| partial | Applicable as rejected partial read | `unavailable/partial_source`, retryable only when the reader marks retry safe, no partial items, and no warning-only success. | Phase 1 | +| unavailable | Applicable | `unavailable/source_unavailable` with bounded retry hint; never `[]`. | Phase 1 | +| stale | Applicable | Foreign/expired cursor is `invalid_request/invalid_cursor`; changed snapshot is `conflict/snapshot_changed`; neither restarts pagination. | Phase 1 | +| unexpected | Applicable | `internal/unexpected`, redacted diagnostic present, no raw message/stack/path, non-retryable. | Phase 1 | + +Ordering is normalized display name followed by opaque ID, byte-for-byte stable within the snapshot. +The warning allowlist is empty for this specimen. Resource and snapshot revisions remain explicit and +equality-only. Adapter-only metadata is compared separately and may not alter the oracle. The fixture +manifest must contain one positive/applicable vector or one fail-closed non-applicability vector for +every row. + +## Route, capability, and parity assertions + +- Route IDs and method/path pairs are unique; referenced handler, schema, auth policy, trust kind, + readiness dimension, owner, and test metadata exist. +- Browser routes may reference browser capabilities; runtime/private/health routes may not become + browser facets. +- Capability/action IDs are unique and feature-owned. Supported means an implemented semantic path, + not a throwing/no-op stub. Resource allowance remains a separate future projection. +- Browser client constants and parsers reference the feature contract route/schema IDs. No handwritten + alternate path string is allowed in the migrated proof. +- ADR-19 records retain pinned legacy signature hashes and independently reference owner, disposition, + action, route/channel, public feature entrypoint, and semantic test. Missing or stale references fail. +- Direct, decomposed, and desktop-only negative fixtures prove each mapping class. The ledger is never + imported by production runtime or used to generate a mega client. + +## Negative architecture gates + +Each gate includes one passing fixture and one deliberately failing fixture with an exact diagnostic: + +1. Core/contracts cannot import Electron, Fastify, React, Zustand, Node built-ins, `@main`, renderer, + preload, or infrastructure paths. +2. External feature code imports only documented team-lifecycle root/layer entrypoints; deep imports + and cycles fail. +3. Hosted composition cannot import an unrestricted `@main/application/hosted` facade or business + service locator. +4. Migrated renderer code cannot reach `window.electronAPI`, global `ElectronAPI`, generic HTTP client, + or transport singleton; only the narrow `teamRead` facet is legal. +5. The hosted renderer import graph cannot reach desktop-only modules, and listener registration is + inventoried with cleanup ownership. +6. No hosted facet is structurally assignable to `ElectronAPI`; no all-parity DTO/interface or + `TeamsAPI` implementation may be added. +7. Unsupported UI controls are absent/disabled with a safe reason before interaction; a throwing or + silent stub fails. Phase 1 supplies fixture components only, not product UI migration. +8. Production composition, IPC registration, preload/global API, renderer API, or HTTP registration + importing either test-only transport adapter/composition fails with + `phase1-test-adapter-production-import`; the positive production graph contains neither identifier. +9. Route-level filesystem checks, cache invalidation, runtime overlays, authorization decisions, or + pagination logic in input adapters fail source assertions. +10. Manual error-string matching (`IpcError`, raw `.message`) in the new slice fails; the untouched + legacy surface remains a counted ratchet baseline. +11. Any Phase 1 port or adapter taking a path/root, importing filesystem/path APIs, using ambient root + lookup, watchers, repair, or cleanup fails with `phase1-filesystem-adapter-forbidden`. + +## Named negative-control inventory + +Serial bootstrap must freeze exact paths, mutations, positive neighbors, stable diagnostics, owners, +and commands for every row. A category label without a deliberate failing mutation is not evidence. + +| Fixture ID | Phase 1 disposition | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `P1.NEG.IMPORT_FORBIDDEN` | Required: forbidden core/framework/import fixture. | +| `P1.NEG.HOSTED_ELECTRON_API` | Required: hosted facet structural alias/implementation fails. | +| `P1.NEG.LEGACY_GOD_DTO` | Required: legacy aggregate, raw name, and path fields fail. | +| `P1.NEG.ROUTE_DRIFT` | Required: duplicate/missing ID and schema/handler/policy/test drift fail. | +| `P1.NEG.PARITY_DRIFT` | Required: legacy signature and cross-reference omission fail. | +| `P1.NEG.SEMANTIC_OUTCOME` | Required: every applicable and non-applicable row above is mutation-tested. | +| `P1.NEG.CORE_SIDE_EFFECT` | Required: watcher, repair, process, notification, runtime overlay, and transport logger reachability fail. | +| `P1.NEG.SCHEMA_VERSION` | Required: malformed/missing/future/incompatible version and unknown-field rules fail safely. | +| `P1.NEG.ID_REVISION_CURSOR` | Required: cross-kind/raw/stale/foreign/mismatched values fail. | +| `P1.NEG.CAPABILITY_MOUNT` | Required: production support remains absent and unsupported listeners/controls fail. | +| `P1.NEG.RATCHET_REGRESSION` | Required: path/symbol debt increase or expired quarantine fails. | +| `P1.NEG.PRODUCTION_ADAPTER_MOUNT` | Required: production import or mount of IPC-shaped or HTTP-shaped adapters fails. | +| `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1` | Required: any path-taking or filesystem-backed reader/adapter fails before execution. | +| `P1.NEG.TEST_ROOT_ESCAPE` | Explicitly deferred to Phase 2's first filesystem adapter; marked-root admission and cleanup controls are mandatory there. Deferral is valid only while the preceding no-filesystem gate passes. | +| `P1.NEG.PATH_SECRET_LEAK` | Required: raw host paths, auth/provider payloads, command bodies, and canaries fail. | +| `P1.NEG.PERFORMANCE_DEGRADATION` | Required: limits, duplicate calls, ignored cancellation, cache/revision, fallback/source, or telemetry loss fail. | + +## Ratchet semantics + +Serial bootstrap records counts and hashes for current TeamsAPI members, renderer callers/bypasses, +direct Electron accesses, global listeners, HTTP route strings, and forbidden-import exceptions. +Integration permits counts only to decrease, or to change through an exact reviewed replacement row. +New exceptions require owner, reason, removal phase, source span, and packet revision. A file rename +cannot evade the content-based scan. Existing debt outside the first slice is quarantined, not declared +fixed. + +## Proposed checks + +Bootstrap must resolve commands against current scripts before freezing them. Candidate commands: + +```bash +pnpm exec vitest run test/features/team-lifecycle test/architecture/hosted-web/phase-1 +pnpm lint:fast:files -- +pnpm typecheck +pnpm exec prettier --check docs/hosted-web-phases/phase-01 .codex-handoff/.json +git diff --check +git status --short +``` + +Additional deterministic scanners must run parity-reference, renderer-boundary, feature-dependency, +production adapter import/mount, Phase 1 no-filesystem-adapter, secret, and absolute-path checks. The +integration report records commands, exit +codes, versions, elapsed time, exact base/result SHA, inherited failures, and fixture hashes. Full +`pnpm lint` is an integration gate when architecture-sensitive configs change; `lint:fast` alone never +replaces typecheck or the full required gate. + +## Secret and path scan policy + +Scan changed and untracked files for private keys, bearer/cookie/token values, provider auth payloads, +home directories, `/Users/`, non-fixture `/home/`, Windows user roots, task-local `/tmp` paths, real +project names, and raw command/runtime bodies. Allowed examples are fixed placeholders documented in +the fixture manifest. Any match is reviewed; a zero-match grep alone is not proof if binary/untracked +files were excluded. + +## Performance and determinism gates + +- Bound `limit` to 200 and cursor/token sizes to bootstrap-approved byte maxima before repository work. +- Measure parser and adapter overhead separately from the reader on fixed 1, 50, and 200 item fixtures. +- Proposed acceptance: median direct parser plus use-case overhead under 5 ms for 200 items, transport + adapter overhead under 10 ms locally, and no more than 10% regression against the frozen baseline; + bootstrap must replace noisy limits with host-qualified evidence if needed. +- Response size is bounded and excludes legacy payload fields; the 200-item vector must remain below + the bootstrap-recorded byte budget. +- Cursor parsing is constant-space and bounded-time; malformed input cannot trigger repeated decode or + unbounded allocation. +- Tests use fixed time/IDs/order and run twice with identical normalized output. No wall-clock sleeps, + filesystem/path API, ambient or temporary root, network, filesystem watcher, provider process, or + shared cache is allowed. + +## Dependency and cache policy + +Phase 1 proposes no new package, code generator, decorator, reflection layer, OpenAPI dependency, or +lockfile edit. Use TypeScript, Fastify injection, Vitest, and existing lint tooling. A missing primitive +is implemented narrowly or triggers a reviewed packet revision; a producer cannot install it. + +RouteCatalog is frozen at composition and not a mutable cache. Parser results are not globally cached. +If test/build caching is used, keys include predecessor SHA, packet revision, lockfile hash, Node/pnpm +versions, scanner source hashes, and fixture manifest hash. Evidence/handoffs are never restored from a +cache without rerunning integrity and negative controls. Display name, legacy team name, cursor, raw +path, or auth token is never an application cache key. diff --git a/docs/hosted-web-phases/phase-01/controller-packet.md b/docs/hosted-web-phases/phase-01/controller-packet.md new file mode 100644 index 0000000000..79616492e1 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/controller-packet.md @@ -0,0 +1,151 @@ +# PR #252 live-head sync controller packet + +## Status and authority + +- Phase/node: `phase-02` / `PR252.LATEST_BASE_SYNC` +- Revision: `pr252-live-head-sync-router-v2` +- Repository/PR: `777genius/agent-teams-ai#252` +- Admission and integration owner: `ProjectScopedControl` +- Attempt resolver and promotion actor: broker +- Runtime role: execution primitives only +- Terminal state: `HOLD` + +This packet contains no author-time PR head or base SHA. It supersedes every durable source/base pin, +fixed conflict-path assumption, old-job continuation, and dirty-worktree reuse contract. The router +author edits only the seven declared paths, launches nothing, and ends `HOLD`. + +## Outcome + +The route admits at most one attempt, directly reruns its mechanical gates, obtains one independent +combined semantic review, and—only after exact acceptance—constructs, pushes, and proves a reviewed +true two-parent sync merge. Successful proof releases this gate but launches no successor. + +## Immutable attempt contract + +The runtime-owned `pr252.latest-base-binding/v1` contract is created before worker start and contains: + +```text +attempt.id +attempt.canonicalHeadSha +attempt.resolvedBaseSha +attempt.materializationSourceSha = attempt.canonicalHeadSha +attempt.orderedParentShas = [attempt.canonicalHeadSha, attempt.resolvedBaseSha] +attempt.expectedOldHeadSha = attempt.canonicalHeadSha +attempt.conflictPaths +attempt.focusedTestCommands +attempt.resolvedAt +``` + +Both SHA fields must identify exact full commit objects. Every field is immutable after start. +`attempt.conflictPaths` is the complete sorted distinct conflict set from the ordered mechanical +merge, and every focused command is deterministic and repository-local. Results and reviews remain +runtime-owned; no repository handoff manifest or hash ledger is created. + +## Atomic prepare/start + +`ProjectScopedControl` admits one atomic transition. During it the broker: + +1. verifies the router, canonical repository/PR, profile, scope, and empty attempt capacity; +2. resolves the live PR head and records it as `attempt.canonicalHeadSha`; +3. resolves the live PR base exactly once and records it as `attempt.resolvedBaseSha`; +4. proves both values identify exact commit objects; +5. materializes from `attempt.canonicalHeadSha`; +6. mechanically applies `attempt.resolvedBaseSha` as ordered second parent; +7. records the actual conflict paths and controller-selected focused tests; and +8. freezes the complete contract before starting one producer. + +These steps either complete together or start no worker. The head cannot be substituted by the router +authoring commit, a branch name, a prior observation, another worker, or an abbreviated object ID. +The base cannot be re-resolved within the attempt. + +## Product producer + +The producer may edit only `attempt.conflictPaths`. Mechanically merged non-conflict bytes are +immutable. For every conflict, the producer preserves behavior from both +`attempt.canonicalHeadSha` and `attempt.resolvedBaseSha`; it may not select a whole side, weaken a +guard, hide a failure, skip a test, add a compatibility fallback, or perform unrelated cleanup. + +If both behaviors cannot be preserved within exact scope, the producer records a blocker and returns +`HOLD`. + +### Required gates + +Before its result, the producer runs and records: + +1. complete attempt-contract, commit-object, source, ordered-parent, and expected-old-head checks; +2. fresh live-head/base comparisons without rebinding; +3. exact conflict-only diff and non-conflict byte-equality proof; +4. zero unmerged index entries and zero unresolved conflict markers; +5. every command in `attempt.focusedTestCommands`, covering both parent behaviors; +6. `pnpm typecheck`; +7. `pnpm lint:fast:files -- ` when non-empty; +8. `pnpm exec prettier --check `; +9. `git diff --check`; and +10. exact diff classification for binaries, symlinks, secrets, credentials, auth/provider payloads, + private or real-project paths, destructive behavior, and unresolved placeholders. + +The producer then self-reviews semantics, test adequacy, architecture, security, scope, and all gate +results. Its immutable runtime result is bound to the attempt and resolved tree SHA and ends `HOLD`. +It does not stage, commit, launch review, or authorize integration. + +## Direct controller mechanical rerun + +After producer `HOLD`, the controller first compares the live head and base to the immutable attempt +values. It then freshly materializes the producer tree and directly reruns the complete mechanical +gate set. Producer evidence is not a substitute. There is no mechanical-review worker. + +Any mismatch or failure invalidates promotion eligibility and ends the attempt `HOLD`. + +## Independent combined semantic review + +Only after the controller pass and another head/base equality check may exactly one fresh independent +reviewer start. The reviewer is independent of the router author, producer, prior invalidated attempt +actors, and broker and has no edit, repair, rebind, commit, merge, push, or retry authority. + +The reviewer examines both parents, every resolution, the complete tree, focused-test adequacy, +architecture, security, trust boundaries, data exposure, producer self-review, and controller +evidence. Only `ACCEPT` with P0/P1/P2 `0/0/0`, bound to the same attempt/base/head/tree, creates a +promotion edge. Every other result returns `HOLD` with no integration. + +## Drift + +The controller compares the live PR head and base: + +1. before direct mechanical rerun; +2. before reviewer admission; +3. immediately before merge construction and push; and +4. during post-push GitHub proof. + +Any inequality invalidates only that attempt and all of its results. After it is terminal, the same +packet may admit a fresh atomic attempt. Drift never mutates an attempt, revives an old source pin, +reuses a worker, or requires a docs revision. + +## True-merge promotion and proof + +After exact semantic acceptance, the broker rechecks the immutable bindings, then creates one merge +commit whose: + +```text +parents[0] = attempt.canonicalHeadSha +parents[1] = attempt.resolvedBaseSha +tree = attempt.acceptedReviewedTreeSha +``` + +The broker proves the commit has exactly those two parents and that tree. It pushes with +`attempt.canonicalHeadSha` as the expected old PR head, proves the remote and GitHub PR head equal +the merge commit, proves the GitHub base still equals `attempt.resolvedBaseSha`, and waits for a +resolved non-conflicting mergeability result for that exact pair. + +One-parent, squash, patch-only, reversed-parent, octopus, whole-side, tree-mismatched, force-substituted, +unreviewed, or `UNKNOWN`/`CONFLICTING` output is not success. + +## Ownership and stop policy + +The router owns exactly the seven paths listed in `EXECUTION_INDEX.json`. No product, test, Phase 2 +packet, evidence, runtime, config, dependency, lockfile, or handoff path is writable to the router. + +Stop on any authority, attempt, head, base, source, parent-order, conflict-path, byte, command, test, +review, tree, push, remote, or GitHub-proof mismatch. Nothing authorizes real-project access, team +launch/provisioning, product terminal/smoke/provider/auth flow, raw lifecycle calls, other +repositories, broad docs work, dependency changes, Fast mode, or automatic successor launch. End +`HOLD`. diff --git a/docs/hosted-web-phases/phase-01/execution-dag.md b/docs/hosted-web-phases/phase-01/execution-dag.md new file mode 100644 index 0000000000..17938d8204 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/execution-dag.md @@ -0,0 +1,64 @@ +# PR #252 live-head sync execution DAG + +Revision: `pr252-live-head-sync-router-v2`. Terminal state: `HOLD`. + +No observed PR head or base is packet authority. Those values become attempt authority only through +the atomic broker binding. + +## Ordered DAG + +```text +PR252.ROUTER.ACTIVE + -> PR252.BINDING.ATOMIC + broker resolves live head -> attempt.canonicalHeadSha + broker resolves live base once -> attempt.resolvedBaseSha + source = attempt.canonicalHeadSha + parents = [attempt.canonicalHeadSha, attempt.resolvedBaseSha] + expected old head = attempt.canonicalHeadSha + record actual conflicts and focused tests before worker start + -> PR252.SYNC.PRODUCER (capacity 1) + resolve actual conflicts only; preserve both parent behaviors + focused tests + all mechanical gates + self-review + -> HOLD + -> PR252.SYNC.CONTROLLER_MECHANICAL + compare live head/base; controller reruns every mechanical gate + -> PR252.SYNC.SEMANTIC_REVIEW (one fresh independent reviewer) + combined integration + architecture + security + semantic decision + -> HOLD + ACCEPT with P0/P1/P2 = 0/0/0 + -> PR252.SYNC.BROKER_PROMOTION_PROOF + compare live head/base again + true merge with exact ordered parents and reviewed tree + push with expected-old-head protection + prove remote/GitHub head, base, and non-conflicting state + -> PR252.LATEST_BASE_SYNC RELEASED + -> HOLD; launch no successor + REJECT or nonzero finding -> no promotion -> HOLD +``` + +At any later comparison point: + +```text +live head != attempt.canonicalHeadSha OR live base != attempt.resolvedBaseSha + -> invalidate only the bound attempt and all attempt results + -> wait until that attempt is terminal + -> optionally admit one fresh atomic attempt under the same router +``` + +## Invariants + +- The broker resolves and records both live values during atomic prepare/start. +- The base is resolved once; neither binding can change within the attempt. +- `attempt.canonicalHeadSha` is source, ordered first parent, and expected old head. +- `attempt.resolvedBaseSha` is ordered second parent. +- Producer writable scope equals `attempt.conflictPaths`; non-conflict bytes are immutable. +- The controller, not a review worker, reruns all mechanical gates. +- One independent reviewer covers integration, architecture, security, and semantics. +- Only exact `ACCEPT 0/0/0` permits promotion. +- Promotion is a true two-parent merge of the exact reviewed tree. +- Push proof binds the merge, remote/GitHub head, GitHub base, and non-conflicting result to the same + attempt. +- Runtime primitives do not select or advance the DAG. + +Every actor ends `HOLD`. No real-project, team launch, product terminal/smoke, provider/auth, raw +lifecycle, other-repository, broad-docs, dependency-update, or Fast activity is authorized. diff --git a/docs/hosted-web-phases/phase-01/execution-packet-templates.md b/docs/hosted-web-phases/phase-01/execution-packet-templates.md new file mode 100644 index 0000000000..959f7cd3ce --- /dev/null +++ b/docs/hosted-web-phases/phase-01/execution-packet-templates.md @@ -0,0 +1,116 @@ +# Proposed execution packet templates + +Status: templates only. They must not be rendered until all Ready gates pass and serial bootstrap +replaces every proposal token with reviewed exact values. Producer target remains zero. + +## Serial bootstrap record + +```yaml +phaseId: phase-01 +packetRevision: +parentPlanCommit: +predecessorSha: +predecessorEvidenceIndexSha256: +planBundleCommit: +authorizationRef: +phaseStartSha: +inheritedFailureLedger: +resolvedIds: +resolvedPathManifest: +fixtureManifest: +gapDispositionRegister: +phase1NoFilesystemAdapterGate: +deferredTestRootEscape: +baseline: +``` + +Abort bootstrap if any value is absent, any proposed path overlaps, a dependency change appears, the +first slice requires production identity/auth or production IPC/HTTP mounting, a filesystem/path-taking +adapter appears, any `P1-GAP-001..010` disposition disappears, or current source contradicts the plan. + +## Controller packet materialization checklist + +- Status/authority: exact frozen values, active router state, ADR set, authorization. +- Outcome/non-goals: copy semantics from this bundle without broadening. +- Inputs/failures: integrated Phase 0 decisions and exact inherited fingerprints. +- Ready: every gate checked with evidence link. +- DAG/ownership: final IDs, exact files (no unresolved glob), one writer, estimate bucket, and exact + `1B + 1C -> R1 -> 1D -> R2 -> I` dependency chain. +- Monitoring/capacity: unique slots, replacement/salvage, ten-minute useful-progress checks. +- Integration: adoption order, shared writer, commands, rollback, reject conditions. +- Done: evidence IDs, proof topology, risk budget, Phase 2 outputs. + +## Worker lane packet template + +```markdown +# Phase 1 : + +- Packet revision: +- Phase start SHA: +- Depends on: +- Result states: verified | characterized | blocked | failed + +## Mission + + + +## Required reads + +- AGENTS.md, CLAUDE.md, AGENT_CRITICAL_GUARDRAILS.md +- controller headings: +- parent-plan headings/ADRs: +- source/tests: + +## Writable paths + +- +- .codex-handoff/phase-01-.json + +Everything else, especially shared-writer files, package/lock/config files, and real projects, is +read-only. + +## Deliverables and evidence + +- : +- +- + +## Acceptance + + + +## Checks + +- +- pnpm lint:fast:files -- +- git diff --check +- + +## Stop conditions + +Stop on stale base/revision, overlap, source contradiction, dependency change, unsafe evidence, +production IPC/HTTP adapter exposure, filesystem-backed Phase 1 work, unclassified failure, or +falsified architecture. Return the standard +blocker record; do not widen scope. + +## Handoff + +Write the PACKET_STANDARD schema with exact commands/exit codes, evidence proof levels, changed paths, +unverified claims, blockers, estimate, and smallest next controller action. +``` + +## Reviewer packet template + +A reviewer receives read-only producer paths and one exclusive review file. It must rerun deliberate +negative fixtures, compare source to packet, list every evidence ID as accept/reject/rework, verify +scope and redaction, and avoid repairing producer code. Reviewer handoff includes falsifiers attempted, +commands/exit codes, findings with owner/severity/reproducer, and integration recommendation. + +## Integration packet template + +The integration packet names one writer for the exact shared files, immutable adoption commits/order, +accepted review IDs, baseline fingerprints, complete command list, rollback procedure, and freeze +artifacts. It forbids opportunistic producer repair and later-phase work. Any changed contract, +ownership, proof topology, or dependency produces a reviewed packet revision rather than an informal +exception. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-1d-additive-response-remediation.md b/docs/hosted-web-phases/phase-01/lanes/p1-1d-additive-response-remediation.md new file mode 100644 index 0000000000..cc46e3454b --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-1d-additive-response-remediation.md @@ -0,0 +1,374 @@ +# P1.1D shadowed-map implementation remediation lane + +> **Post-freeze amendment (2026-07-16).** The wire request no longer embeds a query context: +> `ListTeamLifecycleRequest` is `{ schemaVersion, cursor, expectedRevision }` and the host passes +> the `QueryContext` to `execute(request, context)` separately. Criteria below that require +> nested query-context request objects describe the reviewed pre-fix snapshot. + +## Current r4 producer contract + +- Packet revision: `phase-01-p1-1d-shadowed-map-remediation-r4` +- Mode/capacity: implementation remediation / exactly one producer +- Original product `baseSha`: `1b37afb02bec25a1f08432d733595b553101ecab` +- `canonicalSha`, `phaseStartSha`, `planBundleCommit`, and worktree `HEAD`: + `3405da177b040c65caad10ef2df4d4f4338feed0` +- Runtime carrier: `1f9c6a2a28e5540c61d1395bc51a34a7c0db31855bae575abc9582f839118b49` +- Semantic reconstruction: `fa46617652b072e887563f5a751f7bd0260e0e1d4fb96b628badea91ea7ae9d6` + +Read the exact mandatory sources below in order through item 38, using the current r4 controller and +lane values; the absent candidate reads and ledger reads in items 39-42 are superseded by this +materialization contract. Do not glob or inspect siblings. The runtime must materialize reviewed +output `693d79c9314c46b9ac0ae13c8c62cb7951461fb7d335ec426119fc8a86a23c91` as `output.patch`, verified +by `inputPatchHash` `521d8bab2ed7bc4334b38a5786dd5685f5e4f033c3962cab566f9ab3b60d0000`. + +Canonical HEAD contains none of the candidate files. Materialize the complete exact nine-path carrier +listed below. Preserve these six paths byte-for-byte from reviewed output: the four product entrypoint +and application files other than `team-lifecycle-read.ts`, `ListTeamLifecycle.test.ts`, and +`team-lifecycle-read-boundaries.test.ts`. Semantically edit only: + +1. `src/features/team-lifecycle/contracts/team-lifecycle-read.ts` +2. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts` +3. `.codex-handoff/phase-01-p1-1d.json` (regenerated) + +For `items`, capture a validated length before traversal, allocate a trusted fresh plain array, own +each output index, reject every sparse input, read every input index exactly once, and call `parseItem` +for every element. Never dispatch input-owned `map`, iterator, constructor, or species behavior. +Perform deterministic ordering and freeze only on trusted arrays. Preserve strict request rejection, +same-version additive response validation/discard, safe projections, all ten fixture outcomes, +transport neutrality, source-call behavior, original negative diagnostics, and public boundaries. + +Run every focused test, lint, typecheck-classification, Prettier-check, diff, ownership, provenance, +hash, and safety gate already listed below. Typecheck may report only the exact seven unchanged Phase 0 +diagnostics in the same three files. Regenerate the handoff with the r4 packet identity, exact nine +changed paths, six byte-preservation proofs, three semantic-edit hashes, command results, and +`nextAction: "review"`. + +Admission is `codex_goal_project_refill_worker`, outer `workerRole: producer`, `serial-builtin` +internal `kind: worker-launch`, `format: 1`, `reviewKind: implementation`, and the reviewed snapshot +patch as `inputPatchHash`. After producer completion, require a fresh independent exact-read reviewer. +Do not integrate, commit, push, launch a reviewer/controller, start P1.R2/P1.I/P1.F/Phase 2+, or touch +the exact five PR conflict files. End `HOLD`. + +Reviewer r2 binding evidence is strict result +`b8dca625e5eedfc457fd9908a7c0f41489db1dc784c98b52598a1e26504dc895`, audited attempt +`p1-1d-review-v17-r2-formal-reject`, with product P1-001 and process-only fail-closed P1-002. Broad r3 +output `5e1f1bcb6bfc076d59346b0fddc97db271800af4a9e17e85c604de0f2d046822`, patch +`8f74ea9cf5b3e187a75a36c0e4e90378752e52d5e1b06893d961811c54ab5dcf`, audited attempt +`p1-1d-shadowed-map-router-v17-r3-scope-reject` is rejected evidence and must never be copied/applied. + +## Retained rejected r2 review packet + +The review-only text below is preserved as prior provenance and is non-executable. Its focused checks, +exact scope lists, safety rules, and blocked successors remain binding where compatible with r4. + +## Authority and provenance + +- Project: `agent-teams-hosted-web-refactor` +- Phase/node: `phase-01` / `P1.1D-additive-response-remediation` +- Lane ID: `p1-1d-additive-response-remediation-review` +- Packet revision: `phase-01-p1-1d-additive-response-review-r2` +- Mode: review-only +- Immutable product candidate `baseSha`: `1b37afb02bec25a1f08432d733595b553101ecab` +- Reviewer `canonicalSha`, `phaseStartSha`, `planBundleCommit`, and worktree `HEAD`: + `bbfd2551baaa904061e705511f07716e0f6db17d` +- Superseded producer revision: `phase-01-p1-1d-additive-response-remediation-r1` +- Reviewer configuration: reasoning effort `xhigh`, service tier `default` +- Capacity: exactly one fresh independent reviewer; no producer, retry, refill, integration, or later + work +- Required terminal disposition: explicit `ACCEPT` or `REJECT` + +This packet becomes executable only after its exact seven-path correction router is policy-integrated +and the same durable controller has atomically adopted this review-only scope while remaining exactly +`live=true`. This docs job authorizes but does not launch the reviewer. + +## Binding input roles + +| Input | SHA-256 | Binding role | +| ---------------------------------------- | ------------------------------------------------------------------ | --------------------------------------- | +| Runtime nine-path handoff carrier | `1f9c6a2a28e5540c61d1395bc51a34a7c0db31855bae575abc9582f839118b49` | Complete immutable candidate carrier | +| Final eight-path semantic reconstruction | `fa46617652b072e887563f5a751f7bd0260e0e1d4fb96b628badea91ea7ae9d6` | Five product plus three test paths | +| Reviewed-workspace snapshot | `521d8bab2ed7bc4334b38a5786dd5685f5e4f033c3962cab566f9ab3b60d0000` | Prior rejection-ledger consumption only | +| Prior independent-review strict result | `29ad2243be1a1e0c7aa95cb1a32ae32b8f15db8ebe1a260cd41dd85d2c079934` | Binding `REJECT` P1 record | + +Never substitute one role for another. In particular, the reviewed-workspace snapshot does not name +candidate bytes and must not be used as the carrier, handoff, patch, or semantic reconstruction hash. + +## Binding prior rejection and correction + +The prior independent reviewer returned formal `REJECT` P1. Preserve that disposition exactly; it is +not `ACCEPT` and this packet does not reinterpret it. + +The sole finding was an incorrect external review instruction that asserted transient interim hash +`7672e922` was the final handoff hash. The assertion was external to the candidate. The final candidate +does not contain or claim `7672e922`. Correct fresh-review input names the nine-path carrier and +eight-path semantic reconstruction separately, as listed above. + +Both producer output and prior reviewer output have formal rejected integration-ledger records. +Admission reports no blocking output debt. Ledger closure permits this one fresh review only and is not +evidence of product acceptance or integration authority. + +The predecessor docs-router output is also immutable rejected evidence: reviewed output +`1ad2849056be658ab629b9810914ace7eab3287745ecb39c1d76ac1c124d0eb7`, patch SHA-256 +`657c1c5ff6421f6b206ef14509586d09fad72e8c511efe1a6f9bf6b8dce5f577`. This packet reproduces its +useful review-only transition with the two admission defects corrected. It does not modify, revive, or +integrate that output. + +## Mission + +Independently review the existing immutable P1.1D additive-response remediation candidate. Rerun all +bound checks without changing any candidate, handoff, documentation, research, runtime, configuration, +package, lockfile, or conflict-resolution path. Return one explicit `ACCEPT` or `REJECT` result with +P0/P1/P2 findings and exact hash observations. + +No producer retry/refill, product rerun, product change, product integration, commit, push, P1.R2, +P1.I, P1.F, Phase 2+, or work in any of the five PR conflict files is authorized. + +## Exact ProjectScopedControl reviewer admission + +The hosting subscription runtime may admit work only through the existing ProjectScopedControl +operation and stable reviewer shape below. It introduces no separate tool or public contract: + +```text +operation: codex_goal_project_refill_worker +workerRole: reviewer +reasoningEffort: xhigh +serviceTier: default +preStartAdmission.mode: serial-builtin +preStartAdmission.contract.kind: worker-launch +preStartAdmission.contract.format: 1 +preStartAdmission.contract.canonicalSha: bbfd2551baaa904061e705511f07716e0f6db17d +preStartAdmission.contract.baseSha: 1b37afb02bec25a1f08432d733595b553101ecab +preStartAdmission.contract.phaseStartSha: bbfd2551baaa904061e705511f07716e0f6db17d +preStartAdmission.contract.packetRevision: phase-01-p1-1d-additive-response-review-r2 +preStartAdmission.contract.controllerPacket: docs/hosted-web-phases/phase-01/controller-packet.md +preStartAdmission.contract.lanePacket: docs/hosted-web-phases/phase-01/lanes/p1-1d-additive-response-remediation.md +preStartAdmission.contract.phaseId: phase-01 +preStartAdmission.contract.laneId: p1-1d-additive-response-remediation-review +preStartAdmission.contract.inputPatchHash: 1f9c6a2a28e5540c61d1395bc51a34a7c0db31855bae575abc9582f839118b49 +preStartAdmission.contract.reviewKind: review +``` + +The controller must also supply the stable contract's exact `ownedPaths`, `mandatoryDocs`, +`mandatoryScripts`, `mandatoryFixtures`, non-empty `requiredChecks`, and sandbox-only +`executionPolicy` from this packet. Reviewer `planBundleCommit` is separately bound to +`bbfd2551baaa904061e705511f07716e0f6db17d`; it is not invented as an unsupported internal contract +field. The serial controller state enforces exactly one reviewer. + +The semantic reconstruction, ledger snapshot, prior result, prior `REJECT`, and stale `7672e922` +classification remain review evidence with their roles from the table above. They are not substituted +for `inputPatchHash` or added as guessed contract fields. + +The reviewer must be a new identity and isolated review worktree, distinct from this router author, +all P1.1D producers, and the prior rejected reviewer. A reused identity/worktree, service tier other +than `default`, reasoning effort other than `xhigh`, second reviewer, stale packet, mixed hash role, or +non-live controller fails closed. + +## Exact mandatory reads + +Read in this order. Directory reads, globs, implicit siblings, recursive documentation/research reads, +and the whole master plan are not authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. `docs/hosted-web-phases/phase-01/lanes/p1-1d-additive-response-remediation.md` +8. `docs/hosted-web-phases/phase-01/lanes/p1-1d-team-lifecycle-read.md` +9. `CLAUDE.md` +10. `AGENT_CRITICAL_GUARDRAILS.md` +11. `src/features/CLAUDE.md` +12. `docs/hosted-web-phases/PACKET_STANDARD.md` +13. `docs/hosted-web-phases/phase-01/execution-dag.md` +14. `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +15. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +16. only the two headings `Phase 1 work packages: create one contract system, not another mega-API` + and `Phase 1: single-source contracts and conformance` in + `docs/hosted-web-e2e-completion-plan.md` +17. `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md` +18. `src/shared/contracts/hosted/index.ts` +19. `src/shared/contracts/hosted/app-error.ts` +20. `src/shared/contracts/hosted/identifiers.ts` +21. `src/shared/contracts/hosted/query-context.ts` +22. `src/shared/contracts/hosted/revision.ts` +23. `src/main/composition/hosted/routing/route-types.ts` +24. `test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts` +25. `test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts` +26. `scripts/hosted-web/phase-1/check-feature-dependencies.ts` +27. `test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts` +28. `test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json` +29. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json` +30. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json` +31. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json` +32. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json` +33. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json` +34. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json` +35. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json` +36. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json` +37. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json` +38. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json` +39. the immutable candidate's exact five product paths, in the order below +40. the immutable candidate's exact three test paths, in the order below +41. the immutable `.codex-handoff/phase-01-p1-1d.json` +42. the controller-supplied formal producer and reviewer rejected integration-ledger records, only + after verifying the ledger snapshot role and prior strict-result SHA-256 above + +Every input is read-only. The prior result and ledger records are rejection provenance, never a +substitute for independently reviewing the candidate. + +## Exact immutable review scope + +### Product paths: exactly five + +1. `src/features/team-lifecycle/contracts/team-lifecycle-read.ts` +2. `src/features/team-lifecycle/contracts/index.ts` +3. `src/features/team-lifecycle/core/application/ListTeamLifecycle.ts` +4. `src/features/team-lifecycle/core/application/index.ts` +5. `src/features/team-lifecycle/index.ts` + +### Test paths: exactly three + +1. `test/features/team-lifecycle/core/ListTeamLifecycle.test.ts` +2. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts` +3. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts` + +### Handoff path: exactly one + +1. `.codex-handoff/phase-01-p1-1d.json` + +The sets are disjoint: five product + three test + one handoff = nine candidate paths. The reviewer has +no repository writer authority. Formatting, generated output, review notes, evidence, or ledger +records must not be written to the worktree. + +## Required semantic review + +The reviewer must independently prove: + +1. top-level and nested query-context request objects remain strict and reject unknown own string and + symbol fields; +2. every supported same-version success, failure, and inapplicable response validates all known fields + before ignoring additive fields; +3. top-level responses, success `items[]`, and failure safe `error` objects return fresh frozen + known-field-only projections with additive own string and symbol fields absent; +4. unsupported versions, missing/invalid known fields, invalid discriminants/combinations, malformed + IDs/revisions/cursors, and unsafe error fields still reject when additive data is present; +5. no response returns or spreads an untrusted source object/array, mutates it, or preserves additive + state; +6. all ten immutable manifest scenarios preserve their required success/failure/inapplicable outcome, + ordering, cursor/revision treatment, safe errors, and deterministic application behavior; +7. the source port is injected and called exactly once for a valid request, with no filesystem, + runtime, adapter, transport, or production mount; and +8. the contract and public entrypoints remain narrow, browser-safe, path-free, secret-free, and free + of legacy aggregate, Electron, renderer, preload, `@main`, fixture, and research dependencies. + +The original negative IDs and exact diagnostics remain binding: + +| Negative ID | Exact diagnostic | Required neighbor | +| ------------------------------------- | ------------------------------------- | ---------------------------------------------------------- | +| `P1.NEG.SEMANTIC_OUTCOME` | `phase1-semantic-outcome-drift` | all ten actual outcomes match the immutable manifest | +| `P1.NEG.LEGACY_GOD_DTO` | `phase1-legacy-god-dto-forbidden` | only the narrow feature-owned contract is publicly exposed | +| `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1` | `phase1-filesystem-adapter-forbidden` | pure injected port and test-owned in-memory value pass | + +## Focused required checks + +Run every command independently from the immutable review worktree. Do not use a formatting writer or +any command that mutates candidate bytes: + +```bash +pnpm exec vitest run test/features/team-lifecycle/core/ListTeamLifecycle.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts +pnpm lint:fast:files -- src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/index.ts src/features/team-lifecycle/core/application/ListTeamLifecycle.ts src/features/team-lifecycle/core/application/index.ts src/features/team-lifecycle/index.ts test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts +pnpm typecheck +pnpm exec prettier --check src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/index.ts src/features/team-lifecycle/core/application/ListTeamLifecycle.ts src/features/team-lifecycle/core/application/index.ts src/features/team-lifecycle/index.ts test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts .codex-handoff/phase-01-p1-1d.json +git diff --check +git status --short +``` + +The three candidate test files and two accepted ratchet files must pass. Lint, Prettier, diff, +ownership, hash, and safety gates must be green. `pnpm typecheck` may exit 1 only for exactly the seven +unchanged inherited Phase 0 diagnostics accepted by P1.R1, in exactly these files: + +- `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts` +- `test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts` +- `test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts` + +Any new, removed, moved, or changed diagnostic or any candidate-path diagnostic is a finding. Do not +run an app, browser, Electron, server, IPC/HTTP smoke, real project, provider/runtime, or filesystem +integration check. + +## Hash, provenance, ownership, and safety review + +Before disposition, independently: + +1. prove immutable product candidate `baseSha` is exactly + `1b37afb02bec25a1f08432d733595b553101ecab`, while reviewer `canonicalSha`, `phaseStartSha`, + `planBundleCommit`, and worktree `HEAD` are exactly + `bbfd2551baaa904061e705511f07716e0f6db17d`; +2. prove the candidate set is exactly the nine paths above with nothing staged and no extra tracked or + untracked change; +3. verify the complete carrier as + `1f9c6a2a28e5540c61d1395bc51a34a7c0db31855bae575abc9582f839118b49` using its runtime carrier + procedure; +4. reconstruct the eight non-handoff paths using the handoff's canonical deterministic procedure and + require `fa46617652b072e887563f5a751f7bd0260e0e1d4fb96b628badea91ea7ae9d6`; +5. use `521d8bab2ed7bc4334b38a5786dd5685f5e4f033c3962cab566f9ab3b60d0000` only to consume the prior + rejection-ledger record; +6. verify strict result + `29ad2243be1a1e0c7aa95cb1a32ae32b8f15db8ebe1a260cd41dd85d2c079934` and preserve `REJECT`; +7. scan only the exact nine candidate paths and prove none contains or claims `7672e922`; +8. verify both formal rejected integration-ledger records and the no-blocking-output-debt admission; +9. scan all nine candidate paths for credentials, secrets, auth/provider payloads, private locations, + user directories, real-project paths, raw command/runtime bodies, and binary content, classifying + every match; and +10. compare status and hashes again after all checks to prove review caused no mutation. + +A mismatch is a finding or `REJECT`; it is never permission to edit, regenerate, restage, retry a +producer, or relabel a hash. + +## Strict result contract + +Return one runtime-owned structured result; write no repository evidence file. The result must include: + +1. reviewer identity, independence proof, `xhigh` reasoning effort, and `default` service tier; +2. packet revision, distinct product-base and reviewer canonical/start/plan/HEAD facts, and controller + binding; +3. explicit `disposition: "ACCEPT"` or `disposition: "REJECT"`; +4. P0/P1/P2 finding counts and complete findings; +5. each of the four SHA-256 values above with its exact role and independently observed result; +6. explicit confirmation that prior `REJECT` was preserved and not laundered into acceptance; +7. explicit confirmation that `7672e922` was external/transient only and absent as a candidate claim; +8. command exit codes, observed test counts, inherited-diagnostic classification, and negative/additive + matrix results; +9. exact nine-path scope, candidate immutability, ledger-record status, no-blocking-output-debt status, + and secret/private-path/binary classifications; and +10. `nextAction: "controller-hold"`. + +`ACCEPT` is legal only if every gate passes with no P0/P1 finding and the evidence is complete. +Otherwise return `REJECT` with the finding; blocked, incomplete, ambiguous, or missing output is not +acceptance. + +## Explicit stop conditions + +Stop without changing files if any input hash or role is stale, the prior disposition is not +`REJECT`, the candidate contains or claims `7672e922`, a ledger record is missing, admission reports +blocking output debt, the controller is not the same live durable identity, the review-only scope was +not atomically adopted, reviewer independence/configuration fails, a second reviewer exists, an extra +or staged path appears, or a required check cannot be run without mutation or expanded scope. + +Also stop on any secret/private/real-project value, binary, product edit, producer retry/refill, +integration, commit, push, P1.R2, P1.I, P1.F, Phase 2+, controller replacement, or work in any of the +five PR conflict files. Do not widen scope or repair an immutable input. + +## Completion and HOLD + +The review completes only with one explicit `ACCEPT` or `REJECT` result and no repository mutation. +Return it to the same durable controller and hold. This packet never authorizes product integration; +that remains blocked pending a later separately authorized router even if the fresh result is +`ACCEPT`. + +The correction-router author likewise ends on `HOLD` without launching the reviewer, staging, +committing, pushing, integrating, rerunning a producer, or starting any later or conflict-resolution +work. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-1d-team-lifecycle-read.md b/docs/hosted-web-phases/phase-01/lanes/p1-1d-team-lifecycle-read.md new file mode 100644 index 0000000000..d6e2d6178e --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-1d-team-lifecycle-read.md @@ -0,0 +1,346 @@ +# P1.1D transport-neutral team-lifecycle read/list lane + +## Authority and provenance + +- Phase/node: `phase-01` / `P1.1D` +- Lane ID: `p1-1d` +- Packet revision: `phase-01-p1-1d-team-lifecycle-read-r1` +- Canonical base: `759a5d4f45c2142485a0acc13760f3de4d0ff6ea` +- Accepted predecessor: formal P1.R1 `ACCEPT`, policy-integrated at the canonical base +- Formal reviewer: `agent-teams-hosted-web-refactor-p1-r1-review-v16-r1` +- Accepted review result: routes 16/16, conformance 13/13, P0/P1/P2 findings 0/0/0 +- Handoff path: `.codex-handoff/phase-01-p1-1d.json` +- Evidence IDs: + - `P1.1D.TEAM_LIFECYCLE_READ_CONTRACT` + - `P1.1D.TEAM_LIFECYCLE_READ_USE_CASE` + - `P1.1D.TEAM_LIFECYCLE_SEMANTIC_PROOF` + +This packet becomes executable only after the exact seven-path router containing it is +policy-integrated and its successor controller reports exactly `live=true`. It authorizes one serial +producer, not a review, integration, transport, mount, launch implementation, or successor. + +## Mission + +Implement the first narrow team-lifecycle read/list contract and application proof. Reuse the accepted +hosted shared kernel for opaque IDs, request/query context, revisions/cursors, and safe AppError +categories. Add runtime parsing with explicit version and unknown-field behavior. Drive one pure list +use case through test-owned in-memory values and prove deterministic semantic outcomes against the +accepted synthetic corpus. + +Close exactly the P1.1D-owned gaps left by P1.R1: + +1. prove `P1.NEG.SEMANTIC_OUTCOME` with a deliberate mismatched outcome and adjacent valid outcomes; +2. provide the narrow feature-owned DTO/use-case positive neighbor for + `P1.NEG.LEGACY_GOD_DTO`; and +3. provide the value-only application-port/in-memory-test positive neighbor for + `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1`. + +Do not implement a driving or driven production adapter. No behavior in this packet is mounted into +the app. + +## Exact mandatory reads + +Read in this order. Directory reads, globs, implicit siblings, recursive documentation/research reads, +and the whole master plan are not authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. `docs/hosted-web-phases/phase-01/lanes/p1-1d-team-lifecycle-read.md` +8. `CLAUDE.md` +9. `AGENT_CRITICAL_GUARDRAILS.md` +10. `src/features/CLAUDE.md` +11. `docs/hosted-web-phases/PACKET_STANDARD.md` +12. `docs/hosted-web-phases/phase-01/execution-dag.md` +13. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +14. only the two headings `Phase 1 work packages: create one contract system, not another mega-API` + and `Phase 1: single-source contracts and conformance` in + `docs/hosted-web-e2e-completion-plan.md` +15. `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md` +16. `src/shared/contracts/hosted/index.ts` +17. `src/shared/contracts/hosted/app-error.ts` +18. `src/shared/contracts/hosted/identifiers.ts` +19. `src/shared/contracts/hosted/query-context.ts` +20. `src/shared/contracts/hosted/revision.ts` +21. `src/main/composition/hosted/routing/route-types.ts` +22. `test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts` +23. `test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts` +24. `scripts/hosted-web/phase-1/check-feature-dependencies.ts` +25. `test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts` +26. `test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json` +27. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json` +28. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json` +29. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json` +30. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json` +31. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json` +32. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json` +33. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json` +34. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json` +35. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json` +36. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json` + +All mandatory inputs are read-only. The P1.R1 result is provenance and accepted disposition, not +permission to inspect sibling research evidence or rewrite a finding. + +## Dependencies and frozen facts + +The producer must preserve these dependency facts: + +- the accepted shared kernel comes from P1.1A and is reused rather than copied or widened; +- RouteDescriptor and capability assertions remain separate, frozen P1.1B inputs; +- the semantic harness, dependency scanner, and fixture corpus remain P1.1C test inputs, never product + dependencies; +- P1.R1 accepted the existing routes 16/16 and conformance 13/13 with no P0/P1/P2 finding; and +- IPC/HTTP parity, route registration, renderer consumption, production mounts, review, integration, + and later-phase behavior remain future work. + +If a required outcome cannot be proved without changing an accepted input or adding a dependency, +adapter, transport, fixture, or configuration path, stop with `packet_conflict`. + +## Exact exclusive writer authority + +### Product paths: exactly five + +1. `src/features/team-lifecycle/contracts/team-lifecycle-read.ts` +2. `src/features/team-lifecycle/contracts/index.ts` +3. `src/features/team-lifecycle/core/application/ListTeamLifecycle.ts` +4. `src/features/team-lifecycle/core/application/index.ts` +5. `src/features/team-lifecycle/index.ts` + +### Test paths: exactly three + +1. `test/features/team-lifecycle/core/ListTeamLifecycle.test.ts` +2. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts` +3. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts` + +### Handoff paths: exactly one + +1. `.codex-handoff/phase-01-p1-1d.json` + +The five-product, three-test, and one-handoff sets are mutually disjoint and contain exactly nine +paths. The producer may create their parent directories but may place no other file in them. Every +other tracked or untracked path is read-only. An extra path is a stop condition, not cleanup or repair +authority. + +In particular, do not modify shared contracts, RouteCatalog/capability files, semantic harness or +fixture inputs, dependency scanners, IPC/HTTP/preload/renderer code, filesystem/infrastructure or +composition code, package/lock/config files, existing handoffs, router docs, or research evidence. + +## Contract deliverable + +`contracts/team-lifecycle-read.ts` owns one versioned, browser-safe list contract and its runtime +parsers. It must: + +1. reuse, by import, the accepted opaque identifier, query/request context, revision/cursor, and + AppError-category primitives that apply; it must not duplicate their wire shapes; +2. define only the request, narrow list item, success result, and safe feature-local failure surface + needed by this one read; +3. treat input as `unknown` at the parsing boundary and make supported schema version and unknown-field + behavior explicit and deterministic; +4. reject malformed IDs, revisions/cursors, versions, item shapes, and semantic states with safe + structured errors rather than raw-message matching; +5. exclude filesystem paths, working directories, command/runtime bodies, auth/provider payloads, + secrets, Electron/renderer values, and task/member/message/session/provider aggregates; and +6. remain DTO/parser code only, with no store access, orchestration, side effect, environment lookup, + transport, test fixture, or framework dependency. + +The two contract entrypoints export only this supported narrow surface. No wildcard implementation +export and no mega `TeamsAPI`, `ElectronAPI`, all-parity, or legacy god DTO is permitted. + +## Application deliverable + +`core/application/ListTeamLifecycle.ts` owns one source port and one list use case. It must: + +1. accept and return contract values, invoke the injected source port exactly once per request, and + keep source/transport/runtime types outside the public application surface; +2. parse at the contract boundary and normalize failures to the accepted safe AppError categories; +3. preserve opaque identity and revision/cursor meaning without exposing a path or runtime handle; +4. return deterministic ordering and deterministic success/failure classification for identical + inputs; +5. have no Electron, Fastify, React, Zustand, Node filesystem/path/process, `@main`, adapter, + infrastructure, global state, clock, network, or side-effect dependency; and +6. be exported only through the two narrow application/root public entrypoints. + +The only source implementation in this node is a test-owned in-memory value in an owned test file. +Do not add a product in-memory adapter, mock transport, fake browser/server, filesystem adapter, IPC +handler, HTTP route/client, preload bridge, renderer hook/UI, route descriptor, or production +composition. + +## Exact positive semantics + +The owned tests must prove all of these behaviors against the immutable manifest and ten outcome +fixtures: + +1. a valid empty result parses and returns an empty list without inventing an error; +2. valid success, draft, partial, stale, and explicitly inapplicable states retain their fixture-defined + semantic classification and only the narrow safe fields; +3. corrupt, unavailable, and unexpected inputs become the fixture-defined safe structured failure + outcome without raw message matching or leaked input; +4. identical values produce identical ordering, cursor/revision treatment, and normalized outcome; +5. unsupported version, unknown-field behavior, malformed identifier, malformed revision/cursor, and + malformed item/state follow the contract's explicit accept/reject rules; +6. the use case calls a test-owned in-memory port, has no filesystem dependency, and passes the clean + value-only neighbor used by the accepted dependency scanner; and +7. a caller can use the narrow public feature entrypoint without importing an implementation folder, + legacy team aggregate, transport, Electron surface, or `@main` module. + +The fixture manifest is authoritative for which scenario is success, failure, or inapplicable. Tests +must not rewrite or reinterpret fixture bytes to make an assertion pass. + +## Exact negative and positive-neighbor semantics + +| Negative ID | Required rejection | Required P1.1D positive neighbor | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `P1.NEG.SEMANTIC_OUTCOME` | A deliberate mismatch of fixture-defined success/error/inapplicable semantics rejects with exact diagnostic `phase1-semantic-outcome-drift`. | Actual parsed/use-case outcomes for all ten immutable scenarios match the manifest and are accepted. | +| `P1.NEG.LEGACY_GOD_DTO` | The boundary test rejects an owned synthetic source string that adds a broad team/member/task/message/session/provider/runtime aggregate with exact inherited diagnostic `phase1-legacy-god-dto-forbidden`. | The new contract exposes only its request, narrow item/result, parser, and safe feature-local error surface through public entrypoints. | +| `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1` | The boundary test rejects an owned synthetic source string that imports or models a filesystem/path adapter with exact inherited diagnostic `phase1-filesystem-adapter-forbidden`. | The pure injected source port and test-owned in-memory value prove the list use case with no filesystem, path, process, adapter, or infrastructure dependency. | + +The negative strings live only inside the owned boundary test; do not create fixtures. A diagnostic +change, a non-rejecting negative, a false positive-neighbor claim, or weakened inherited scanner is a +failure. Do not claim IPC-versus-HTTP parity, production support, renderer behavior, a production +adapter, or all-feature conformance. + +## Architecture and boundary gates + +The three owned tests must additionally prove: + +1. all product imports follow `docs/FEATURE_ARCHITECTURE_STANDARD.md` and public entrypoint rules; +2. contracts import no Electron, Fastify, React, Zustand, Node built-in, `@main`, adapter, + infrastructure, renderer, preload, test, fixture, or research module; +3. core application imports only its contracts and permitted pure shared contracts, with no side + effect or process-owned dependency; +4. the fixture corpus and semantic harness are test-only inputs and never reachable from product + entrypoints; +5. there is no new `main/`, `preload/`, `renderer/`, `adapters/`, `infrastructure/`, route/catalog, + app-shell, or production registration path; and +6. the full owned product surface remains path-free, secret-free, transport-neutral, and browser-safe + without pretending to implement browser mode. + +Electron desktop remains the default real app target, but this packet runs no app or runtime. The +renderer is not involved. Transport and filesystem responsibilities remain outside this node. + +## Focused required checks + +Run each command independently from the bound source worktree and record its exact exit code and test +counts in the handoff: + +```bash +pnpm exec vitest run test/features/team-lifecycle/core/ListTeamLifecycle.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts +pnpm lint:fast:files -- src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/index.ts src/features/team-lifecycle/core/application/ListTeamLifecycle.ts src/features/team-lifecycle/core/application/index.ts src/features/team-lifecycle/index.ts test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts +pnpm typecheck +pnpm exec prettier --check src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/index.ts src/features/team-lifecycle/core/application/ListTeamLifecycle.ts src/features/team-lifecycle/core/application/index.ts src/features/team-lifecycle/index.ts test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts .codex-handoff/phase-01-p1-1d.json +git diff --check +git status --short +``` + +The three new focused test files and the two accepted focused ratchet files must pass. Lint, Prettier, +and diff must be green. `pnpm typecheck` may exit 1 only for exactly the seven unchanged inherited +Phase 0 diagnostics accepted by P1.R1 in these three files, with no diagnostic in a P1.1D-owned path: + +- `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts` +- `test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts` +- `test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts` + +Any new, moved, removed, or changed inherited diagnostic fails the gate. Do not run an app, browser, +Electron, HTTP server, IPC smoke, real project, provider/runtime, or filesystem integration check. + +## Safety, scope, and ownership gates + +Before handoff: + +1. prove `HEAD` and the runtime `baseSha`/`phaseStartSha` bindings match this packet; +2. prove the diff from `phaseStartSha` contains exactly the nine owned paths and no accepted-input + edit; +3. validate the handoff JSON, evidence IDs, packet revision, base/start provenance, changed-path list, + proof levels, negative matrix, commands, and next action; +4. compute and record SHA-256 for every non-handoff owned path and a deterministic patch hash in exact + owned-path order; +5. scan all nine owned paths for credentials, secrets, auth/provider payloads, private/home/real-project + paths, raw command/runtime bodies, and binary content, classifying every lexical match; and +6. require status to resolve to exactly the nine owned paths, with nothing staged. + +A secret, private path, binary, raw payload, staging, extra path, provenance mismatch, or unclassified +workspace change is a failure and stop condition. The producer may not clean, repair, stage, commit, +or push outside its contract. + +## Evidence and proof contract + +The handoff maps evidence as follows: + +| Evidence ID | Required owned evidence | Required proof level | +| ------------------------------------- | ------------------------------------------------------------- | -------------------- | +| `P1.1D.TEAM_LIFECYCLE_READ_CONTRACT` | the two contract files plus contract-focused test | `target_verified` | +| `P1.1D.TEAM_LIFECYCLE_READ_USE_CASE` | the use-case/application/root entrypoint files plus core test | `target_verified` | +| `P1.1D.TEAM_LIFECYCLE_SEMANTIC_PROOF` | the two architecture tests and recorded focused checks | `target_verified` | + +No evidence ID may name an immutable input as produced evidence. A missing owned path, non-green +required proof, or `fixture_characterized`/`unverified` proof level cannot close this lane. + +## Structured handoff contract + +Write only `.codex-handoff/phase-01-p1-1d.json`. It must be valid JSON and follow +`PACKET_STANDARD.md`, including: + +1. `schemaVersion: 1`, `phaseId: "phase-01"`, `laneId: "p1-1d"`, and packet revision + `phase-01-p1-1d-team-lifecycle-read-r1`; +2. runtime-bound `baseSha`, `planBundleCommit`, and `phaseStartSha`, with canonical base + `759a5d4f45c2142485a0acc13760f3de4d0ff6ea`; +3. status `verified` only when every gate passes; +4. exactly the three evidence IDs, their exact owned paths, and `target_verified` proof levels; +5. `changedPaths` containing exactly the five product, three test, and one handoff paths in packet + order, with no duplicate; +6. every required check command, exact exit code, and observed test count; +7. a negative-result matrix containing exactly the three rows above, exact diagnostics, and positive + neighbor results; +8. per-file SHA-256 values, deterministic patch hash, ownership and safety results, and P0/P1/P2 + findings or explicit zero counts; +9. unverified claims for IPC/HTTP parity and adapters, preload/renderer behavior, filesystem/runtime + integration, production mount, review/integration, Phase 1 completion, and Phase 2+; and +10. `nextAction: "review"`. + +The handoff contains no secret, auth/provider payload, raw runtime body, private path, or fake success +claim. Do not create a second handoff or evidence file. + +## Explicit stop conditions + +Stop changing files and return the smallest `PACKET_STANDARD.md` blocker record when any of these is +true: + +- the router is not policy-integrated, the successor controller is not `live=true`, or a runtime fact + is stale/mixed; +- the base is not `759a5d4f45c2142485a0acc13760f3de4d0ff6ea` or an accepted input differs; +- another worker exists, an owned path overlaps another lane, or an extra changed/untracked path + appears; +- implementation would require an IPC/HTTP/preload/renderer adapter, route/catalog edit, filesystem or + infrastructure adapter, production mount, package/config/fixture/shared-kernel change, or fake + browser; +- the narrow contract cannot express the manifest semantics without a legacy aggregate, path, secret, + transport type, raw error matching, or source/runtime payload; +- a required negative does not reject with its exact diagnostic or its positive neighbor fails; +- a required check fails, the inherited typecheck set drifts, evidence cannot reach + `target_verified`, or handoff provenance/hash/ownership is inconsistent; +- any real user project, provider/runtime, production data, credential, private path, or binary enters + the work; or +- review, integration, commit, push, P1.R2, P1.I, P1.F, or Phase 2+ activity begins. + +Use blocker class `packet_conflict`, `packet_stale`, `base_failure`, `environment`, `scope_overlap`, +`security`, `missing_evidence`, or `design_falsified` as applicable. Do not widen scope, silently +reinterpret semantics, weaken a ratchet, repair an immutable input, retry/refill, or continue on an +unrelated deliverable. + +## Completion and return + +Completion requires exactly nine owned changed paths, all three evidence IDs at `target_verified`, +every focused/quality/safety/ownership gate classified, the complete three-row negative matrix, and a +valid handoff with next action `review`. Return that handoff to the controller without staging, +committing, pushing, integrating, launching a reviewer/successor, or starting later work. + +P1.R2, integration/P1.I, P1.F, and Phase 2+ remain blocked after producer completion. Only a later +reviewed, policy-integrated docs-only router with its own successor controller `live=true` may advance +authority. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-f-freeze.md b/docs/hosted-web-phases/phase-01/lanes/p1-f-freeze.md new file mode 100644 index 0000000000..17111c33fe --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-f-freeze.md @@ -0,0 +1,445 @@ +# P1.F Phase 1 milestone-freeze lane + +## Authority and mission + +- Project: `agent-teams-hosted-web-refactor` +- Phase/node: `phase-01` / `P1.F` +- Lane ID: `p1-f-freeze` +- Lane packet revision: `phase-01-p1-f-freeze-r3` +- Router revision: `phase-01-p1-f-environment-router-r3` +- Router authoring base: `69c4219b7ce3c7ad99e469ecd537a42e4bb4d2b5` +- Accepted true-merge SHA: `20706bd067ce5ccbf13697700411904faa2a00c8` +- Accepted P1.I integration commit: `134f64f0c5c7bbbab0552eddf08df1508118f4bb` +- Canonical merge second parent: `6bf43f140878f8b79f7ee17349bd21b177df901d` +- Evidence IDs: `P1.F.FREEZE` and `P1.F.PHASE_EXIT` +- Required worker profile: `gpt-5.6-sol`, `xhigh`, `serviceTier: "default"`; Fast is prohibited +- Root role: sole orchestrator +- Durable controller: `controller-v17`, `HOLD`, observation-only +- Terminal state after every authorized attempt: `HOLD` + +P1.I received independent `ACCEPT` with P0/P1/P2 `0/0/0`. Attempt +`agent-teams-hosted-web-refactor-p1-i-integration-apply-v17-r2` integrated its exact five outputs in +`134f64f0c5c7bbbab0552eddf08df1508118f4bb^..134f64f0c5c7bbbab0552eddf08df1508118f4bb`. +Canonical `20706bd067ce5ccbf13697700411904faa2a00c8` is the accepted ordered two-parent PR #252 merge +where `20706bd067ce5ccbf13697700411904faa2a00c8^1` equals +`134f64f0c5c7bbbab0552eddf08df1508118f4bb` and the second parent is +`6bf43f140878f8b79f7ee17349bd21b177df901d`. The five P1.I output bytes are identical at the +integration commit and canonical merge. The second-parent-to-merge diff is accumulated current-base +history and never the exact P1.I proof. + +Immutable r1 patch `2f7338a1e7b41955d15106f5fb3994b17db6749158bde8134a0a8e23d2081615` was independently +`REJECT`ed with exactly one P1 finding for using the second-parent diff as the five-output P1.I proof. +This r2 lane preserves every other useful r1 requirement and corrects only that proof. + +The r2 patch `1b9d824436f076f751df91fe2d8abedb88995c5fe8a02f3fc0194921d669d5c1` received independent +`ACCEPT` with P0/P1/P2 `0/0/0`, and integration attempt `p1-f-router-r2-accepted-20260716` integrated +and pushed its exact seven paths as `69c4219b7ce3c7ad99e469ecd537a42e4bb4d2b5`. Three later P1.F +attempts started clean at that authority, wrote neither output, and ended `HOLD`: the network-disabled +worker could not query the remote and its sandbox could not spawn the normalizer child (`EPERM`). This +r3 lane changes only those environment-incompatible evidence routes. It does not relax a P1.F gate. + +This lane authorizes exactly one serial, fresh, independent milestone-freeze worker. It verifies the +integrated Phase 1 result at the current router authority, writes only two freeze records, returns an +explicit `ACCEPT` or `REJECT`, and ends `HOLD`. It does not repeat P1.I, change product/test/runtime +source, launch a reviewer, integrate output, or start Phase 2. + +This router author starts nothing. A new worker cannot start until the exact seven-path r3 router is +independently accepted, broker-integrated, pushed, and root binds the broker-returned pushed commit as +`postRouterIntegrationAuthoritySha` with both required immutable evidence inputs. + +## Exact mandatory reads + +Read in this order. Directory reads, globs, recursive research reads, rejected-job materialization, +implicit siblings, real-project reads, and the whole master plan are not authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. this lane packet +8. `CLAUDE.md` +9. `AGENT_CRITICAL_GUARDRAILS.md` +10. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +11. `docs/hosted-web-phases/PACKET_STANDARD.md` +12. `docs/hosted-web-phases/phase-01/README.md` +13. `docs/hosted-web-phases/phase-01/execution-dag.md` +14. `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +15. `docs/hosted-web-phases/phase-01/conformance-and-tests.md` +16. `docs/hosted-web-phases/phase-01/operations-and-risk.md` +17. `docs/hosted-web-phases/phase-01/packet-inputs.md` +18. historical `docs/hosted-web-phases/phase-01/lanes/p1-i-integration.md`, read-only +19. the exact 74 paths in `EXECUTION_INDEX.json.phase1FreezeManifest.paths`, in exact order + +Before reading the first manifest path, validate that the array contains exactly 74 distinct existing +paths and its UTF-8 compact JSON-array SHA-256 is +`0e8e2b82125eb3b8e559f9fa439e8942e0eea89d75da4cccc35d75099e868223`. Nothing nearby becomes an +input implicitly. + +## Independence and start gate + +The worker must be fresh and independent of: + +- this P1.F router author and its independent router reviewer; +- every P1.I producer, remediation producer, reviewer, and integration actor; +- `agent-teams-hosted-web-refactor-p1-i-integration-apply-v17-r2`; and +- every earlier Phase 1 producer and reciprocal reviewer. + +Root captures one immutable pre-start snapshot proving: + +1. this exact seven-path r3 router is independently accepted, broker-integrated, and pushed; +2. `postRouterIntegrationAuthoritySha` is the exact broker-returned pushed commit and equals local + `HEAD`, admission `expectedSourceCommit`, and all handoff authority fields; +3. the worktree is clean and a fresh immutable root/broker remote attestation proves the sole explicit + remote ref equals that SHA with exact command, exit `0`, one-ref output, timestamp, root/broker + provenance, and content hash; +4. `69c4219b...` is an ancestor of the pushed r3 router authority and the diff from it is exactly the + seven router-owned paths; `20706bd...` remains the accepted immutable true-merge provenance; +5. historical `p1-i-integration.md` and the five accepted P1.I outputs remain byte-identical; +6. both P1.F output paths are absent; +7. no P1.F worker, remediation worker, Phase 2 worker/router, unrelated worker, or successor controller + is active; +8. dependencies are broker-materialized offline and worker install/fetch/update is disabled; and +9. admission uses exactly the required default-only profile; and +10. root ran the exact normalizer command at the same clean pushed authority and the broker captured a + fresh immutable root-attested normalizer record containing command, timestamps, runner provenance, + normalizer exit `0`, compiler exit `2`, passing structured JSON, the exact seven inherited + diagnostics, zero resolved/unexpected diagnostics, no unparsed output, and capture hashes. + +Any mismatch ends `HOLD` without launch. Root uses this admission shape: + +```text +operation: codex_goal_project_refill_worker +workerRole: reviewer +reviewKind: review +node: P1.F +model: gpt-5.6-sol +reasoningEffort: xhigh +serviceTier: default +sourceRemote: origin +sourceBranch: refactor/hosted-web-feature-boundaries +expectedSourceCommit: +preStartAdmission.mode: serial-builtin +preStartAdmission.contract.kind: worker-launch +preStartAdmission.contract.format: 1 +preStartAdmission.contract.canonicalSha: +preStartAdmission.contract.baseSha: +preStartAdmission.contract.phaseStartSha: +preStartAdmission.contract.packetRevision: phase-01-p1-f-freeze-r3 +preStartAdmission.contract.controllerPacket: docs/hosted-web-phases/phase-01/controller-packet.md +preStartAdmission.contract.lanePacket: docs/hosted-web-phases/phase-01/lanes/p1-f-freeze.md +preStartAdmission.contract.phaseId: phase-01 +preStartAdmission.contract.laneId: p1-f-freeze +preStartAdmission.contract.inputPatchHash: null +preStartAdmission.contract.reviewKind: review +``` + +No fallback model, alternate tier, Fast mode, concurrent worker, retry, refill, worker-spawned +reviewer, moving source ref, or P1.I replay is authorized. The normalizer record is ineligible unless +the reviewer records that its sandbox could not spawn the exact command. Neither attestation grants +network access, bypasses a gate, or permits generic root substitution for local review. + +## Exact ownership + +The P1.F worker owns exactly these two paths, in writer order: + +1. `.codex-handoff/phase-01-p1-f.json` +2. `docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md` + +The exact 74 manifest paths are read-only. In particular, these five frozen P1.I outputs may not be +rewritten, reformatted, regenerated, repaired, copied over, or reintegrated: + +1. `.codex-handoff/phase-01-p1-i.json` +2. `docs/research/hosted-web/phase-1/decision-register.json` +3. `docs/research/hosted-web/phase-1/estimate-reconciliation.json` +4. `docs/research/hosted-web/phase-1/evidence-index.json` +5. `docs/research/hosted-web/phase-1/integration-report.json` + +There is no product, test, fixture, runtime, packet, dependency, config, package, lockfile, registry, +repository-temporary-output, compile-coherence, cleanup, P1.I, or third-path exception. + +## Canonical ancestry, remote, and merge proof + +The worker independently performs every bounded, read-only, sandbox-compatible repository observation +required here. No checkout, reset, rebase, fetch, stage, commit, merge, push, apply in the repository +worktree, repository-index mutation, or network enablement is authorized. + +Remote equality remains mandatory. Root runs exactly: + +```bash +git ls-remote origin refs/heads/refactor/hosted-web-feature-boundaries +``` + +The broker must capture after r3 integration/push the exact command, exit `0`, exact one-ref output, +remote/ref, observed SHA, clean local `HEAD`, broker-returned commit, ISO-8601 timestamp, root actor and +tool provenance, and content SHA-256. All SHA fields must equal +`postRouterIntegrationAuthoritySha`. The record must postdate the push and becomes stale after any +later remote, authority, or worktree change. This fresh immutable root/broker remote attestation is an +authoritative reviewer input. The reviewer validates every field and capture hash. Upstream-tracking +state, a moving branch, a copied summary, or an earlier attestation is not evidence. + +MUST NOT run `git ls-remote` from the restricted worker sandbox + +The reviewer independently proves all sandbox-compatible local facts: + +1. `HEAD` equals `postRouterIntegrationAuthoritySha` and descends from the r3 authoring base + `69c4219b7ce3c7ad99e469ecd537a42e4bb4d2b5`. +2. The worktree is clean; local `HEAD`, admission `expectedSourceCommit`, all authority fields, and the + inspected remote-attestation SHA are identical. +3. The exact path diff in + `134f64f0c5c7bbbab0552eddf08df1508118f4bb^..134f64f0c5c7bbbab0552eddf08df1508118f4bb` + is the five frozen P1.I outputs in writer order after deterministic path ordering. +4. Accepted true merge `20706bd067ce5ccbf13697700411904faa2a00c8` has exactly two parents in + order: first parent `134f64f0c5c7bbbab0552eddf08df1508118f4bb`, second parent + `6bf43f140878f8b79f7ee17349bd21b177df901d`. +5. `20706bd067ce5ccbf13697700411904faa2a00c8^1` equals + `134f64f0c5c7bbbab0552eddf08df1508118f4bb`. +6. Both parents are ancestors of `20706bd...`; the commit is not squash, one-parent, octopus, or + reversed-parent history. +7. Each of those five paths at `20706bd...` is byte-identical to the same path at `134f64f...`. +8. The second-parent-to-merge diff is accumulated current-base history. It is never used or reported + as the exact five-output P1.I integration proof. +9. The exact path diff from `69c4219b...` to `postRouterIntegrationAuthoritySha` is the seven router + paths in `EXECUTION_INDEX.json.routerExclusiveOwnership`; no P1.I or product/test path changed. + +Any ambiguity, stale or malformed remote attestation, extra parent/path, missing ancestry, remote +drift, or byte difference is a P0/P1 finding and forces `REJECT`/`HOLD`. The root/broker remote +attestation replaces only the sandbox-incompatible network observation, never a local independent +proof. + +## Exact 74-path and P1.I byte proof + +Resolve `freeze_paths` only from `EXECUTION_INDEX.json.phase1FreezeManifest.paths`. Require 74 paths, +74 distinct values, all existing regular UTF-8 text files, and no symlink or NUL byte. The first 69 +paths must exactly match the ordered `canonicalInputManifest.entries[].path` in the frozen P1.I +evidence index. Recompute every corresponding entry SHA-256 and require all 69 bytes to match the +recorded hashes. + +The last five paths must be the exact P1.I outputs in writer order proven by the integration range +above. Their required SHA-256 values at `134f64f...`, `20706bd...`, and the current worker authority +are: + +| Path | SHA-256 | +| --------------------------------------------------------------- | ------------------------------------------------------------------ | +| `.codex-handoff/phase-01-p1-i.json` | `be6ca8a01fba06871b9246ae2baaf230e7b95222bb0da3eec8548016c5639903` | +| `docs/research/hosted-web/phase-1/decision-register.json` | `1d275a95a189d7840a6d75591d90c138b0ec5399747db41794697de0cde32ba9` | +| `docs/research/hosted-web/phase-1/estimate-reconciliation.json` | `941c58195b9955b9807b896aedf7f46ea1a4ed455dc6713241ffefb074405328` | +| `docs/research/hosted-web/phase-1/evidence-index.json` | `07a17cb6674916f65713e337f15deeb3f5405d36fbcccbbcdada3b5895724590` | +| `docs/research/hosted-web/phase-1/integration-report.json` | `a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f` | + +Compute and record a fresh SHA-256 for every one of the 74 current files and a compact JSON digest of +the ordered `{path, sha256}` manifest. A hash mismatch or path-order drift forces `REJECT`. + +## Exact evidence and lifecycle proof + +The worker validates the frozen evidence index against `EVIDENCE_LIFECYCLE.md`. The exact 14 Phase 1 +acceptance evidence IDs, in order, are: + +1. `P1.S0.BASELINE` +2. `P1.S0.BOOTSTRAP` +3. `P1.1A.KERNEL` +4. `P1.1A.VERSION` +5. `P1.1B.ROUTES` +6. `P1.1B.CAPABILITIES` +7. `P1.1C.CONFORMANCE` +8. `P1.1C.RATCHETS` +9. `P1.R1.ARCH_REVIEW` +10. `P1.1D.TEAM_LIFECYCLE_READ_CONTRACT` +11. `P1.1D.TEAM_LIFECYCLE_READ_USE_CASE` +12. `P1.1D.TEAM_LIFECYCLE_SEMANTIC_PROOF` +13. `P1.R2.SEMANTIC_REVIEW` +14. `P1.NEG.RATCHET_REGRESSION` + +Require exactly these 14 IDs once each, with valid paths and current content hashes. Also require the +accepted lint-remediation provenance ID `P1.I.LINT.REMEDIATION` and both P1.I result IDs +`P1.I.INTEGRATION` and `P1.I.ROLLBACK`, producing exactly 17 distinct frozen catalog rows and no +unknown ID. Preserve the P1.I index bytes: the independent P1.I acceptance and integration records +are external adoption evidence and do not authorize rewriting `pending` fields inside that immutable +output. + +The two new P1.F artifacts record only `P1.F.FREEZE` and `P1.F.PHASE_EXIT`. They must not claim to +have mutated the frozen evidence index or superseded an existing evidence row. + +## Required quality and gate reruns + +Rerun and capture exact command, exit code, duration, tool version, and final result for every +sandbox-compatible command: + +```bash +pnpm exec vitest run test/features/team-lifecycle test/architecture/hosted-web/phase-1 +pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts +node scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.mjs --mode milestone +pnpm lint +pnpm exec prettier --check "${freeze_paths[@]}" +``` + +Required exact results: + +- Phase 1 plus team-lifecycle Vitest: 13/13 files and 60/60 tests; +- focused ratchet Vitest: 1/1 file and 3/3 tests; +- native TypeScript: seven exact inherited diagnostics, zero resolved drift, and zero unexpected; +- full lint: exit `0` with zero errors; and +- pinned Prettier: exit `0` with exactly 74 matched paths. + +The worker first runs the exact normalizer command locally. If it spawns, its local structured JSON is +the only admissible typecheck result. The capture must classify the normalizer process as `exited` with +exit `0`; the report must have `passed: true`, compiler `rawExitCode: 2`, +`observedDiagnosticCount: 7`, `normalizedInheritedCount: 7`, `resolvedInheritedCount: 0`, +`effectiveDiagnosticCount: 0`, empty unexpected/resolved/unparsed arrays, no signal or runner error, +and the exact file, code, line, column, and normalized message set in the checked-in baseline. + +If and only if the restricted sandbox cannot spawn that exact command, the worker records the local +command, attempted timestamp, process disposition, error code/message, and absence of a valid local +report. It may then use the fresh immutable root-attested normalizer input captured at the identical +worker authority. It independently inspects the exact command, root execution start/completion +timestamps, root/broker actor and tool provenance, clean authority binding, stdout/stderr and record +hashes, normalizer exit `0`, compiler exit `2`, all structured fields above, and all seven diagnostics. +A failed root command, stale authority, incomplete provenance, hash mismatch, different command, +changed diagnostic, unparsed output, or use without a local sandbox spawn failure fails the gate. + +This bounded exception is not a bypass, network enablement, or generic root substitution. The worker +must independently execute the two Vitest commands, full lint, exact-74 and exact-two Prettier, every +local Git/ancestry/diff/hash/link/scan proof, and every other sandbox-compatible check. After both P1.F +outputs are final, run a separate exact-two-path Prettier check over them; it does not widen or replace +the required exact-74 proof. + +Independently validate every one of the 14 gate IDs in `EXECUTION_INDEX.json.requiredGateIds`. A gate +passes only from its current rerun or exact declared structural proof; a P1.I record alone is not a +substitute for current evidence. + +## Rollback and current-base proof + +Reperform the exact 54-path scratch-only rollback proof using +`EXECUTION_INDEX.json.rollbackPayload.paths` in declared order. Require 54 distinct paths, all absent +at bootstrap `5f30df49e052d1cc1d0e7efd03aa105673b5b614`, and generate the binary/full-index delta from that +bootstrap to `20706bd...` for only those paths. + +In a newly created external scratch directory, never the repository or a real project: + +1. apply-check and apply the forward patch; +2. prove all 54 resulting files are byte-identical to `20706bd...`; +3. reverse-check and reverse-apply the same patch; and +4. prove all 54 paths are absent again. + +Record the ordered path-manifest hash, patch hash, all apply exit codes, 54/54 forward byte equality, +54/54 reverse absence, `scratchOnly: true`, and `workspaceApply: false`. Cleanup is limited to the +marker-owned scratch directory. Separately record the ordered-parent/current-base proof and the exact +P1.I integration-range proof above; the accumulated second-parent diff and rollback substitute for +neither. + +## JSON, hash, link, diff, and classified scans + +After the two outputs are final, define `candidate_paths` as the exact 74 manifest paths followed by +the exact two outputs. Require 76 distinct paths. Perform and record: + +1. JSON parse of every `.json` path, rejection of duplicate keys where the parser supports it, and + declared-schema/required-field validation for the five P1.I and two P1.F records. +2. SHA-256 verification of all 74 frozen inputs, both new outputs, the historical P1.I lane packet, + and the current controller/lane packet hashes recorded in the execution index. +3. Local Markdown-link resolution for every `.md` path; ignore only empty anchors and explicit URI + schemes. Every repository-relative target must exist. +4. Read-only diff/scope proof: no staged path, no tracked change, exactly the two P1.F outputs + untracked, exactly two status paths, and no product/test/P1.I byte change. +5. `git diff --check` success and a complete reread of both output diffs/content. +6. UTF-8, NUL, symlink, regular-file, and MIME classification over all 76 paths. +7. Classified scans over all 76 paths for secret/credential terms and values, auth/provider payloads, + private user/home paths, real-project names, task-temporary paths, raw command/runtime bodies, + binary content, and unresolved placeholders. +8. Schema, content-hash, timestamp/freshness, authority-binding, and provenance validation of the + immutable root/broker remote attestation and root-attested normalizer record; the latter is used as + gate evidence only after a recorded local sandbox spawn failure. + +The exact scan families include: + +```bash +rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' "${candidate_paths[@]}" +rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' "${candidate_paths[@]}" +rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\Users\\|real[-_ ]project)' "${candidate_paths[@]}" +``` + +Fixture canaries, repository-relative control paths, required profile metadata, hashes, scan-command +text, historical provenance, and explicit prohibited-action language are expected control text, but +every match must still be classified. Any real credential, auth/provider payload, private or real- +project path, raw sensitive body, binary, unexplained placeholder, or unclassified match fails. + +## Freeze report, handoff, and self-review + +`docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md` is the human-reviewable P1.F freeze record. +It contains: + +1. authority, inspected root/broker remote attestation, independently executed local ancestry, + exact-five P1.I integration range, ordered parents, first-parent equality, accumulated current-base + history classification, and exact-seven router diff proof; +2. immutable P1.I independent acceptance/integration provenance and all five byte hashes; +3. the exact 74-path manifest digest and 74 current content hashes; +4. all 14 Phase 1 evidence IDs, both P1.I evidence IDs, lint-remediation provenance, and lifecycle + validation; +5. all 14 gate IDs with exact current proof; +6. 60-test, three-ratchet-test, 7/0/0 typecheck, exact normalizer evidence source plus any local spawn + failure, lint, exact-74 and exact-two Prettier results; +7. exact-54 rollback and current-base proofs; +8. JSON/hash/link/diff/scope/text and classified scan results; +9. complete P0/P1/P2 findings with explicit `ACCEPT` or `REJECT` rationale; +10. explicit Phase 1 phase-exit conclusion and the unverified later-phase claims; and +11. `HOLD`, with Phase 2 blocked and no integration or successor claim. + +`.codex-handoff/phase-01-p1-f.json` follows `PACKET_STANDARD.md` and records: + +- schema, phase/node/lane, packet/router revision, all authority SHAs and ordered parents; +- status, explicit disposition, P0/P1/P2 finding counts, the two evidence IDs, and exact two + `changedPaths` in writer order; +- exact 74-path manifest count/digest, 74 content hashes, and five frozen P1.I output hashes; +- evidence/lifecycle and 14-gate summaries; +- every exact check, the remote-attestation inspection, the conditional normalizer-attestation + inspection and evidence source when eligible, any local spawn failure, rollback, merge, + JSON/hash/link/diff and scan result; +- complete self-review, unverified claims, blockers, and immutable findings; +- no claim of broker integration, pushed P1.F evidence, Phase 2 authority, or successor launch; and +- conditional next action plus terminal `HOLD`. + +Before returning, the worker rereads both complete outputs and all observed diffs. Self-review must +explicitly confirm independence, exact authority, complete remote-attestation inspection, conditional +normalizer-attestation inspection when eligible, the bounded normalizer rule, independent execution of +every sandbox-compatible local check, all 74 frozen bytes, all evidence/gates, all check counts, +rollback/current-base proof, scan classifications, exact-two ownership, no P1.I/product/test change, +no unsupported claim, and finding counts. + +## Explicit disposition and lifecycle boundary + +If every required proof passes and finding counts are exactly P0/P1/P2 `0/0/0`, return exactly: + +```text +P1_F_FREEZE_RESULT {"disposition":"ACCEPT","evidenceIds":["P1.F.FREEZE","P1.F.PHASE_EXIT"],"findingCounts":{"P0":0,"P1":0,"P2":0},"reviewedPathCount":74,"changedPathCount":2,"integrationPathCount":2,"nextAction":"broker-integrate-freeze-evidence","terminalState":"HOLD"} +``` + +Any substantiated finding returns `REJECT` with the same schema, nonzero finding counts, immutable +finding details in both outputs, and `nextAction: "bounded-two-path-remediation"`. Admission, +environment, authority, toolchain, or missing-result incidents that prevent a review return `HOLD` +without fabricating `REJECT`. + +On `ACCEPT`, root mechanically validates the strict result and may call `mark_reviewed`; only then may +the broker integrate and push exactly the two outputs in writer order. The worker has no integration +authority. On `REJECT`, root may not mark reviewed and no integration or Phase 2 router may follow. +Only a separately admitted remediation confined to the same two paths and immutable findings is +permitted, followed by fresh independent verification. + +Phase 2 remains blocked until P1.F `ACCEPT`. That acceptance is necessary but not sufficient: only +after accepted exact-two integration and a new exact pushed-authority/clean remote-equality +attestation may root separately commission a Phase 2 JIT docs router. That is not a Phase 2 worker +launch and grants no product authority. Phase 2 remains blocked until its separate router is authored, +independently accepted, integrated, pushed, and explicitly activated. + +## Stop conditions and HOLD + +Stop and end `HOLD` on authority/profile/independence drift, extra/missing path or parent, +stale/incomplete/mismatched attestation, remote mismatch, any sandbox remote query, normalizer root +substitution without a recorded sandbox spawn failure, P1.I byte drift, evidence or lifecycle mismatch, +any gate/test/typecheck/lint/format failure, rollback/current-base failure, +JSON/hash/link/diff/scope mismatch, unsafe or unclassified scan match, binary/symlink/NUL content, +false output field, incomplete self-review, early integration, or unsupported successor claim. + +No current action authorizes P1.I repetition, product/test/runtime edits, real-project access, +dependency install/fetch/update, registry writes, app/server/team/provider flows, stage, commit, merge, +push, raw Git integration, worker-spawned review, controller replacement, successor controller, Phase +2 router authoring, or Phase 2 work. The router author performs none of those actions and ends `HOLD`. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-i-integration.md b/docs/hosted-web-phases/phase-01/lanes/p1-i-integration.md new file mode 100644 index 0000000000..dc574c506d --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-i-integration.md @@ -0,0 +1,378 @@ +# P1.I format remediation and integration lane + +## Authority and provenance + +- Project: `agent-teams-hosted-web-refactor` +- Phase: `phase-01` +- Current node: `P1.I.FORMAT.REMEDIATION` +- Serial successor: `P1.I.INTEGRATION` +- Lane packet revision: `phase-01-p1-i-integration-r3` +- Router revision: `phase-01-p1-i-format-remediation-router-r1` +- Router `packetBaseSha`: `b482e816a90e9bb988a0797565241bae4d60b690` +- Remediation evidence ID: `P1.I.FORMAT.REMEDIATION` +- Downstream evidence IDs: `P1.I.INTEGRATION` and `P1.I.ROLLBACK` +- Profile for every producer and reviewer: `gpt-5.6-sol`, `xhigh`, + `serviceTier: "default"`; Fast is prohibited +- Root role: sole orchestrator +- Durable controller: `controller-v17`, `HOLD`, observation-only +- Terminal state after every authorized attempt: `HOLD` + +The canonical authority is clean and remote-equal. It contains the accepted lint remediation and the +existing 69 canonical P1.I inputs. Terminal job +`agent-teams-hosted-web-refactor-p1-i-integration-v17-r1` returned immutable `BLOCKED`/`HOLD`: + +- patch SHA-256: `d94f8dfa6548427e007402e8771c469c8e661cd64de3a8728dec042a509aebbe`; +- manifest SHA-256: + `1b88a6e8e53199f0b1905d4f4c194525bcb86db185f0e4748acf60f69bb78f94`; +- audited rejection ledger: present; +- gate result: 13 of 14 passed; and +- sole failure: exact 74-path Prettier reports + `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md` unformatted. + +The rejected patch and its blocked five outputs are provenance only. They must never be materialized, +applied, copied, repaired, salvaged, reviewed for acceptance, or integrated. This lane inserts one +exact formatting remediation before a fresh P1.I evidence-freeze producer. + +This router author starts nothing. No worker starts until the router has independent acceptance, +broker integration and push, and root has immutably bound the exact broker-returned pushed commit as +`postRouterIntegrationAuthoritySha`, proved a clean worktree, and attested equality to the sole result +of `git ls-remote origin refs/heads/refactor/hosted-web-feature-boundaries`. + +## Exact mandatory reads + +Read in this order. Directory reads, globs, recursive research reads, implicit siblings, rejected +patch materialization, and the whole master plan are not authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. `docs/hosted-web-phases/phase-01/lanes/p1-i-integration.md` +8. `CLAUDE.md` +9. `AGENT_CRITICAL_GUARDRAILS.md` +10. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +11. `docs/hosted-web-phases/PACKET_STANDARD.md` +12. `docs/hosted-web-phases/phase-01/README.md` +13. `docs/hosted-web-phases/phase-01/execution-dag.md` +14. `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +15. `docs/hosted-web-phases/phase-01/conformance-and-tests.md` +16. `docs/hosted-web-phases/phase-01/operations-and-risk.md` +17. `docs/hosted-web-phases/phase-01/packet-inputs.md` +18. the exact 68 paths in `EXECUTION_INDEX.json.phase1CanonicalInputs`, in exact group and path order +19. `.codex-handoff/phase-01-p1-i-lint-remediation.json`, the accepted 69th P1.I input + +The index must be expanded and validated as 68 distinct manifest paths and 69 distinct existing P1.I +inputs before reading the first manifest path. Nothing nearby becomes an input implicitly. The +format-remediation handoff created by this lane is not a 70th P1.I input. + +## P1.I.FORMAT.REMEDIATION start gate + +Root must capture one immutable pre-start snapshot proving: + +1. this exact seven-path router is independently accepted, broker-integrated, and pushed; +2. `postRouterIntegrationAuthoritySha` is the exact broker-returned pushed commit and equals local + `HEAD`, admission `expectedSourceCommit`, and the worker contract authority fields; +3. the worktree is clean and the explicit remote ref equals that SHA; +4. all existing 69 Phase 1/P1.I inputs are present at their canonical bytes from + `b482e816a90e9bb988a0797565241bae4d60b690`; +5. `.codex-handoff/phase-01-p1-i-format-remediation.json` and all five P1.I outputs are absent; +6. the immutable exact-74 finding is exactly the one unformatted Markdown path above; +7. the rejected patch and its five candidate outputs have not been materialized or applied; +8. no remediation producer/reviewer, P1.I producer/reviewer, P1.F, Phase 2+, unrelated product worker, + or successor controller is active; +9. dependencies are broker-materialized offline and worker install/fetch/update is disabled; and +10. admission uses the exact default-only profile. + +Any mismatch ends `HOLD` without launch. Root uses this admission shape: + +```text +operation: codex_goal_project_refill_worker +workerRole: producer +node: P1.I.FORMAT.REMEDIATION +model: gpt-5.6-sol +reasoningEffort: xhigh +serviceTier: default +sourceRemote: origin +sourceBranch: refactor/hosted-web-feature-boundaries +expectedSourceCommit: +preStartAdmission.mode: serial-builtin +preStartAdmission.contract.kind: worker-launch +preStartAdmission.contract.format: 1 +preStartAdmission.contract.canonicalSha: +preStartAdmission.contract.baseSha: +preStartAdmission.contract.phaseStartSha: +preStartAdmission.contract.packetRevision: phase-01-p1-i-integration-r3 +preStartAdmission.contract.controllerPacket: docs/hosted-web-phases/phase-01/controller-packet.md +preStartAdmission.contract.lanePacket: docs/hosted-web-phases/phase-01/lanes/p1-i-integration.md +preStartAdmission.contract.phaseId: phase-01 +preStartAdmission.contract.laneId: p1-i-format-remediation +preStartAdmission.contract.inputPatchHash: null +preStartAdmission.contract.reviewKind: implementation +``` + +No fallback model, tier substitution, Fast mode, concurrent worker, retry, refill, producer-side +reviewer launch, network query, moving source ref, or rejected patch/input binding is authorized. + +## Exact remediation ownership and edit + +The producer owns exactly these two paths, in this order: + +1. `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md` +2. `.codex-handoff/phase-01-p1-i-format-remediation.json` + +Before any write, record the canonical Markdown SHA-256 and semantic-token SHA-256 and compute the +expected formatted bytes with the repository-installed Prettier module using the Markdown filepath. +Then the only authorized repository writer command is: + +```bash +pnpm exec prettier --write docs/research/hosted-web/phase-1/reviews/routes-ratchets.md +``` + +The Markdown after that command must hash exactly to the precomputed expected formatter output. A +second formatter evaluation must be byte-identical. No other `--write`, format/fix command, editor, +patch, substitution, append, generated file, or cleanup is permitted. + +No word, link, heading, list marker, table value/alignment, inline-code token, fenced-code byte, HTML +token, identifier, command, SHA, disposition, finding, or successor statement may change. Only +repository-pinned Prettier formatting is legal. + +There is no product, test, fixture, P1.I output, dependency, config, lockfile, router, review, registry, +temporary repository output, or third-path exception. + +## Exact semantic-token proof + +The producer and reviewer use this algorithm over the canonical base bytes and candidate bytes: + +1. normalize CRLF/CR line endings to LF; +2. identify fenced code blocks opened by three or more backticks or tildes and retain each fence token + and every content line byte-for-byte; +3. identify Markdown table-delimiter rows only when every nonempty pipe-delimited cell matches + `^:?-{3,}:?$`; replace only each delimiter's hyphen run with `---` while retaining leading/trailing + alignment colons and pipe structure; +4. retain inline-code spans and HTML comment tokens byte-for-byte; +5. emit the remaining ordered non-whitespace tokens without changing punctuation or text; and +6. SHA-256 hash the UTF-8 JSON encoding of that ordered token array. + +Before and after token hashes must be equal. Fenced-code and inline-code token arrays must also be +equal independently. Table alignment-colon arrays must be equal. This is supplemental to exact +formatter derivation: the post-write raw SHA must equal the SHA of Prettier's output computed from the +canonical raw input before the write. + +The handoff records the exact algorithm version +`phase1-markdown-semantic-token-v1`, before/after raw hashes, before/after semantic-token hashes, +fenced/inline token hashes, table-alignment hashes, pinned Prettier version, expected format hash, and +post-write format hash. + +## Required remediation checks + +Validate pinned formatter derivation before and after the sole write. The producer may use a Node +process that imports the repository-installed `prettier` package to compute bytes/hashes in memory; +it may not write through the API. The CLI command above remains the only Markdown writer. + +After the handoff is final, run exact two-path Prettier: + +```bash +pnpm exec prettier --check \ + docs/research/hosted-web/phase-1/reviews/routes-ratchets.md \ + .codex-handoff/phase-01-p1-i-format-remediation.json +``` + +Acceptance is exit `0` with exactly two matched paths. Parse the handoff as JSON and recompute all +recorded hashes and token proofs. + +Read-only Git is authorized only for these provenance, diff, and scope observations; staging, +checkout, apply, commit, merge, push, reset, integration, rejected patch access, or index mutation is +forbidden: + +```bash +test "$(git rev-parse HEAD)" = "$postRouterIntegrationAuthoritySha" +git diff --check +git diff --cached --quiet +git diff --name-only "$postRouterIntegrationAuthoritySha" -- \ + docs/research/hosted-web/phase-1/reviews/routes-ratchets.md +git ls-files --others --exclude-standard +git status --short +``` + +The tracked diff must contain exactly the Markdown, the untracked set must contain only the handoff, +the staged set must be empty, and status must resolve to exactly two paths. The Markdown diff must be +exactly the pinned formatter output already proved from canonical input. + +Scan the exact same two paths and classify every match: + +```bash +remediation_paths=( + docs/research/hosted-web/phase-1/reviews/routes-ratchets.md + .codex-handoff/phase-01-p1-i-format-remediation.json +) +test "${#remediation_paths[@]}" -eq 2 +rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' "${remediation_paths[@]}" +rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' "${remediation_paths[@]}" +rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\Users\\|real[-_ ]project)' "${remediation_paths[@]}" +file --mime-type "${remediation_paths[@]}" +``` + +Also prove both paths are valid UTF-8 text with no NUL byte. Required model/profile metadata, +repository-relative paths, provenance hashes, scan-command text, and explicit prohibited-action +language are control text, but every match still requires classification. Any real secret, +auth/provider payload, private/real-project/task-temporary path, raw command/runtime body, binary, or +unclassified match fails. + +Do not run product tests, ESLint, typecheck, full lint, app/runtime flows, or a second writer. The +immutable terminal record already establishes that every non-format P1 gate passed; this lane changes +no product/test token and must not reinterpret those gates as remediation checks. + +## Remediation handoff and self-review + +`.codex-handoff/phase-01-p1-i-format-remediation.json` follows `PACKET_STANDARD.md` and records: + +1. schema, phase, node, lane, packet/router revision, evidence ID, and terminal `HOLD`; +2. `baseSha`, `canonicalSha`, `planBundleCommit`, `phaseStartSha`, and `headSha`, all equal to + `postRouterIntegrationAuthoritySha`; +3. the exact two `changedPaths` in writer order; +4. the immutable terminal job, `BLOCKED`/`HOLD`, rejected patch/manifest hashes, audited-ledger + presence, exact-74 failure, and never-integrate disposition without importing rejected bytes; +5. canonical Markdown raw SHA, expected pinned-Prettier output SHA, final Markdown SHA, pinned Prettier + version, and idempotence result; +6. exact semantic-token algorithm version and all equal before/after semantic/fenced/inline/table + hashes with semantic token change count zero; +7. exact two-path Prettier, JSON parse, diff, scope, scan, UTF-8/NUL and MIME results; +8. explicit self-review of formatter derivation, complete Markdown diff, token proof, hashes, writer + scope, handoff, classifications, and rejected-attempt quarantine; +9. no claim of independent acceptance, integration, P1.I completion, or successor authority; and +10. `nextAction: "independent-verification"` and `terminalState: "HOLD"`. + +Before returning, the producer rereads the complete Markdown diff and handoff. Any ambiguity, scope +expansion, missing classification, token/content change, hash/gate failure, or unsupported claim ends +`HOLD` with no self-repair outside the two paths. + +The strict producer result is: + +```text +P1_I_FORMAT_REMEDIATION_PRODUCER_RESULT {"status":"VERIFIED","evidenceId":"P1.I.FORMAT.REMEDIATION","changedPathCount":2,"semanticTokenChangeCount":0,"nextAction":"independent-verification","terminalState":"HOLD"} +``` + +`VERIFIED` is legal only when every gate passes. On failure, replace only `status` with `BLOCKED` or +`FAILED`. The result plus broker-captured immutable bytes/hashes for both paths is required; heartbeat, +PID, tmux, changed-file notice, or provider observation is insufficient. + +## Independent remediation review + +After producer termination and immutable two-path capture, root proves no remediation producer or +reviewer is active and prepares exactly one fresh independent reviewer: + +```text +operation: codex_goal_project_prepare_verifier +workerRole: reviewer +reviewScope: P1.I.FORMAT.REMEDIATION +model: gpt-5.6-sol +reasoningEffort: xhigh +serviceTier: default +sourceRemote: origin +sourceBranch: refactor/hosted-web-feature-boundaries +expectedSourceCommit: +inputPatchHash: +reviewKind: review +``` + +The reviewer is fresh and independent of the router author, format producer, terminal blocked-attempt +worker, and prior Phase 1 workers. It is read-only over the exact two candidate paths, broker-captured +canonical base bytes, and execution documents needed to evaluate them. It has no writer, repair, +lifecycle, integration, retry, refill, network, provider, runtime, agent-flow, registry, +rejected-patch, or real-project authority. + +It independently inspects the exact Markdown diff, proves pinned formatter derivation, reruns semantic +token/fenced/inline/table equivalence, validates every hash, runs exact two-path Prettier and scans, +validates the handoff and self-review, and returns exactly one immutable result: + +```text +P1_I_FORMAT_REMEDIATION_REVIEW_RESULT {"disposition":"ACCEPT","findingCounts":{"P0":0,"P1":0,"P2":0},"reviewedPathCount":2,"integrationPathCount":2,"semanticTokenChangeCount":0,"terminalState":"HOLD"} +``` + +`ACCEPT` requires complete proof and zero P0/P1/P2 findings. `REJECT` uses the same schema with +nonzero finding counts and immutable finding details. Admission, provider, environment, or +missing-result incidents remain `HOLD` and are not synthetic `REJECT`. + +On `ACCEPT`, root may mechanically call `mark_reviewed`; only then may the broker integrate and push +exactly the two paths in writer order. On `REJECT`, root may not mark reviewed, the broker may not +integrate, and P1.I may not start. The only permitted follow-up is a separately admitted bounded +remediation addressing immutable findings within these same two paths and the same default-only +profile. It grants no broad cleanup, unrelated edit, direct retry, or integration authority. + +## Direct P1.I.INTEGRATION continuation + +After accepted format-remediation integration, root resolves the exact broker-returned pushed commit +as `postFormatRemediationIntegrationAuthoritySha` and proves: + +1. clean worktree and exact equality to the explicit remote branch ref; +2. the integration changes exactly the two accepted remediation paths; +3. all integrated bytes/hashes equal the independently accepted immutable candidate; +4. all 68 manifest paths and the accepted lint-remediation handoff are present, making the unchanged + P1.I input set 69 distinct paths; +5. only `routes-ratchets.md` differs among those 69 inputs from + `b482e816a90e9bb988a0797565241bae4d60b690`, and it equals the accepted pinned-Prettier output; +6. the format-remediation handoff is present as provenance but excluded from the 69 P1.I inputs; +7. all five P1.I outputs are absent; +8. rejected patch/output bytes were never materialized, applied, copied, or selected for integration; + and +9. exact pinned Prettier over all 69 canonical inputs exits `0`. + +Those facts directly satisfy the prerequisite for one fresh five-output producer. No further docs +router is required. Root then admits one fresh `P1.I.INTEGRATION` producer at +`postFormatRemediationIntegrationAuthoritySha` using the same default-only profile and lane packet +revision. + +The P1.I producer retains exact output ownership: + +1. `.codex-handoff/phase-01-p1-i.json` +2. `docs/research/hosted-web/phase-1/decision-register.json` +3. `docs/research/hosted-web/phase-1/estimate-reconciliation.json` +4. `docs/research/hosted-web/phase-1/evidence-index.json` +5. `docs/research/hosted-web/phase-1/integration-report.json` + +Its read-only input set is the 68 paths in `phase1CanonicalInputs`, evaluated at accepted format +authority, followed by `.codex-handoff/phase-01-p1-i-lint-remediation.json`: 69 distinct inputs. The +format handoff is not an input. The rejected patch, manifest, and blocked outputs are not inputs or +salvage carriers. + +The fresh producer must generate all five output files anew from those canonical inputs. It must not +reuse, copy, compare for adoption, materialize, or integrate any blocked output. The existing 14 P1 +gate IDs remain mandatory. Exact checks are: + +- full Phase 1 plus team-lifecycle Vitest: 13/13 files and 60/60 tests; +- focused ratchet Vitest: 1/1 file and 3/3 tests; +- typecheck: seven inherited, zero owned, zero unexpected; +- full `pnpm lint`: exit `0`; +- Prettier and classified scans: exact 69 inputs plus five fresh outputs, 74 paths; +- scratch-only rollback/apply proof: the same exact 54 payload paths, from P1.S0 to + `postFormatRemediationIntegrationAuthoritySha`, with forward byte equality and reverse absence; +- diff/scope: exactly five untracked P1.I outputs with no staged or tracked change; and +- provenance, predecessors, tests, negatives, ratchet, security, decision, estimate, evidence + lifecycle, integration report, handoff, self-review, and terminal `HOLD` requirements. + +The fresh P1.I producer strict result is: + +```text +P1_I_PRODUCER_RESULT {"status":"VERIFIED","evidenceIds":["P1.I.INTEGRATION","P1.I.ROLLBACK"],"changedPathCount":5,"nextAction":"independent-verification","terminalState":"HOLD"} +``` + +After terminal immutable five-path output, root may start exactly one fresh independent P1.I +milestone reviewer. That reviewer is read-only over 69 inputs plus five outputs (74 paths), uses the +same default-only profile, and returns `ACCEPT` or `REJECT`. On `ACCEPT`, root may `mark_reviewed` and +the broker may integrate and push exactly the five P1.I outputs. On `REJECT`, no integration occurs. + +## Stop conditions and HOLD + +Stop and end `HOLD` on authority drift, profile mismatch, extra/missing path, writer command/target +beyond the exact pinned formatter, output not exactly derived from canonical input, any semantic-token +or content drift, hash mismatch, Prettier failure, staged path, scope/diff mismatch, unsafe or +unclassified scan match, binary output, false handoff field, incomplete self-review, early/concurrent +reviewer, integration before `ACCEPT` and `mark_reviewed`, rejected-byte materialization/use, or +unsupported successor claim. + +No current action authorizes product/test edits, fetch, install, app/server/runtime/team launch, +agent-flow tests, real-project access, registry writes, stage, commit, merge, push, raw Git integration, +lifecycle action, P1.F, Phase 2+, unrelated product work, controller replacement, or a successor +controller. The router author performs none of those actions and ends `HOLD`. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-r1-review.md b/docs/hosted-web-phases/phase-01/lanes/p1-r1-review.md new file mode 100644 index 0000000000..e1f0ec33e4 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-r1-review.md @@ -0,0 +1,260 @@ +# P1.R1 routes and ratchets formal review lane + +## Authority and provenance + +- Phase/node: `phase-01` / `P1.R1` +- Packet revision: `phase-01-p1-r1-review-r1` +- Canonical base: `6a9e9ab714359638fb93a6880855a53c9e8ef4be` +- Canonical tree: `22020029327465ed389cd4479db340082ae81601` +- Accepted producer commits: routes `74038b54eee23e93798b3aa5d11411d3f7e9adcf`; conformance + `6a9e9ab714359638fb93a6880855a53c9e8ef4be` +- Admission input: `02a6b3ac5ac2baaad55c413f8547252dddee4d41`, tree-identical to canonical +- Admission reviewer: `agent-teams-hosted-web-refactor-p1-s2-admission-review-v15-r2`, disposition + `ACCEPT` +- Evidence under review: `P1.1B.ROUTES`, `P1.1B.CAPABILITIES`, `P1.1C.CONFORMANCE`, + `P1.1C.RATCHETS` +- Result path: `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md` +- Result states: `ACCEPT | REJECT` + +This is one formal review, not another admission pass. It becomes executable only after the exact +seven-path router commit containing it is integrated and its successor controller reports +`live=true`. It does not launch a worker, revise accepted bytes, integrate its output, or authorize a +successor. + +## Independence gate + +The assigned reviewer identity, controller job, and source worktree must be different from: + +1. the P1.1B producer responsible for `74038b54eee23e93798b3aa5d11411d3f7e9adcf`; +2. the P1.1C producer responsible for `6a9e9ab714359638fb93a6880855a53c9e8ef4be`; and +3. admission reviewer `agent-teams-hosted-web-refactor-p1-s2-admission-review-v15-r2`. + +The reviewer must record all three exclusions and its own runtime identity, controller job, and +worktree in the result. Failure to prove independence is `REJECT`; substitution or reassignment is not +authorized by this packet. + +## Mission + +Review the exact canonical P1.S2 tree independently and return one formal disposition. Confirm that +RouteCatalog assertions and capability descriptors remain separate and non-production; that the +semantic harness, synthetic corpus, dependency/parity/renderer scanners, and ratchets enforce the +frozen architecture without implementing P1.1D; and that every handoff claim, negative diagnostic, +path count, hash, and inherited failure is truthful and mutually consistent. + +Do not repair a finding. Do not modify product, tests, scripts, fixtures, handoffs, router docs, or +existing research evidence. Do not add research, run a real project, or infer production readiness. + +## Exact mandatory reads + +Read in this order; directory reads, globs, implicit siblings, and recursive research reads are not +authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. `docs/hosted-web-phases/phase-01/lanes/p1-r1-review.md` +8. `CLAUDE.md` +9. `AGENT_CRITICAL_GUARDRAILS.md` +10. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +11. `docs/hosted-web-phases/PACKET_STANDARD.md` +12. `docs/hosted-web-phases/phase-01/execution-dag.md` +13. the exact 37 canonical input paths listed below, in listed order + +No other research path is a mandatory read. The result path is new output, not input. + +## Exact 37-path canonical review input + +P1.1B contributes exactly these 9 paths: + +1. `.codex-handoff/phase-01-p1-1b.json` +2. `src/main/composition/hosted/routing/RouteCatalog.ts` +3. `src/main/composition/hosted/routing/index.ts` +4. `src/main/composition/hosted/routing/route-types.ts` +5. `test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts` +6. `test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts` +7. `test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts` +8. `test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts` +9. `test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts` + +P1.1C contributes exactly these 28 paths: + +1. `.codex-handoff/phase-01-p1-1c.json` +2. `scripts/hosted-web/phase-1/check-feature-dependencies.ts` +3. `scripts/hosted-web/phase-1/check-parity-references.ts` +4. `scripts/hosted-web/phase-1/check-renderer-boundaries.ts` +5. `test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts` +6. `test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts` +7. `test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts` +8. `test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts` +9. `test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts` +10. `test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts` +11. `test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts` +12. `test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts` +13. `test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts` +14. `test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts` +15. `test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts` +16. `test/architecture/hosted-web/phase-1/parity/parity-references.test.ts` +17. `test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts` +18. `test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json` +19. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json` +20. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json` +21. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json` +22. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json` +23. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json` +24. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json` +25. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json` +26. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json` +27. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json` +28. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json` + +The sets must be disjoint, complete, and unchanged at canonical P1.S2. A 36th/38th path, overlap, +missing input, or content drift is `REJECT`. + +## Exact exclusive writer authority + +The reviewer owns exactly one path: + +- `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md` + +Everything else is read-only. The reviewer may create parent directories needed for that one file but +may not place any other file in them. Any other changed or untracked path is `REJECT` and a stop +condition, not permission to clean or repair it. + +## Architecture gate + +Review the 37 inputs against `docs/FEATURE_ARCHITECTURE_STANDARD.md` and require all of the following: + +1. RouteCatalog is a frozen assertion collection over immutable descriptors. It is not a dispatcher, + mutable cache, production route registry, generated client input, or source of business rules. +2. Capability/action assertions remain separate and feature-owned. `testOnly` routes cannot establish + or advertise production support. +3. The semantic harness and team-lifecycle corpus are deterministic, in-memory, path-free, and + transport-neutral. They do not implement the P1.1D list use case. +4. The dependency, parity, and renderer scanners remain test tooling, are content-sensitive where + frozen, and are not imported or mounted by production code. +5. No product IPC/HTTP/preload/renderer registration, filesystem adapter, dependency/config change, + legacy god DTO, secret/path-bearing contract, real-project access, or Phase 1 completion claim is + introduced. +6. The two P1.1D-owned positive neighbors and future P1.1D semantics remain absent and explicitly + unverified; their absence is not a P1.S2 defect. + +Run the two exact aggregate architecture commands and require the frozen counts: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/routes +pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance test/architecture/hosted-web/phase-1/dependencies test/architecture/hosted-web/phase-1/parity test/architecture/hosted-web/phase-1/renderer-boundaries +``` + +The first must pass 2 files and 16/16 tests; the second must pass 4 files and 13/13 tests. + +## Exact scope and provenance gate + +Run and record these commands with expanded runtime-bound `phaseStartSha` where shown: + +```bash +git merge-base --is-ancestor a0dc964e9a71b782b1bbad4769db62a691e50c97 74038b54eee23e93798b3aa5d11411d3f7e9adcf +git merge-base --is-ancestor 74038b54eee23e93798b3aa5d11411d3f7e9adcf 6a9e9ab714359638fb93a6880855a53c9e8ef4be +git diff --name-only a0dc964e9a71b782b1bbad4769db62a691e50c97..74038b54eee23e93798b3aa5d11411d3f7e9adcf +git diff --name-only 74038b54eee23e93798b3aa5d11411d3f7e9adcf..6a9e9ab714359638fb93a6880855a53c9e8ef4be +git diff --name-only a0dc964e9a71b782b1bbad4769db62a691e50c97..6a9e9ab714359638fb93a6880855a53c9e8ef4be +git diff --exit-code 02a6b3ac5ac2baaad55c413f8547252dddee4d41..6a9e9ab714359638fb93a6880855a53c9e8ef4be +git rev-parse 02a6b3ac5ac2baaad55c413f8547252dddee4d41^{tree} +git rev-parse 6a9e9ab714359638fb93a6880855a53c9e8ef4be^{tree} +git diff --name-only 6a9e9ab714359638fb93a6880855a53c9e8ef4be.. +git diff --exit-code -- . ':(exclude)docs/research/hosted-web/phase-1/reviews/routes-ratchets.md' +git status --short +``` + +Require 9 route paths, 28 conformance paths, exactly the listed 37-path union, no diff between admitted +and canonical input, tree `22020029327465ed389cd4479db340082ae81601` for both, exactly the seven +router paths between base and `phaseStartSha`, no non-owned worktree diff, and only the owned result in +status. Validate both handoff JSON files, every per-file SHA-256, both patch hashes, revisions, base, +plan bundle, evidence IDs, proof levels, commands, negative matrices, and unverified claims against +the canonical bytes. Any inconsistency is `REJECT`. + +## Exact focused gates + +Run all six commands independently and require the exact counts: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts +``` + +Required results are respectively 12/12, 4/4, 4/4, 3/3, 3/3, and 3/3 tests. + +## Exact negative gate + +The aggregate and focused commands must exercise and assert every frozen negative below. Inspect the +test and fixture pairs and record the exact diagnostic and positive-neighbor result in the review: + +| Negative ID | Required diagnostic | Required neighbor disposition | +| ------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------- | +| `P1.NEG.ROUTE_DRIFT` | `phase1-route-catalog-drift` | duplicate and missing-reference cases rejected; adjacent valid descriptors accepted | +| `P1.NEG.CAPABILITY_MOUNT` | `phase1-test-capability-production-mount` | production-support and production-mount cases rejected; test catalog accepted | +| `P1.NEG.CORE_SIDE_EFFECT` | `phase1-core-side-effect-forbidden` | verified | +| `P1.NEG.HOSTED_ELECTRON_API` | `phase1-hosted-electron-api-forbidden` | verified | +| `P1.NEG.IMPORT_FORBIDDEN` | `phase1-core-import-forbidden` | verified | +| `P1.NEG.LEGACY_GOD_DTO` | `phase1-legacy-god-dto-forbidden` | fixture/scanner half verified; P1.1D neighbor unverified | +| `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1` | `phase1-filesystem-adapter-forbidden` | fixture/scanner half verified; P1.1D neighbor unverified | +| `P1.NEG.PARITY_DRIFT` | `phase1-parity-reference-drift` | verified | +| `P1.NEG.PATH_SECRET_LEAK` | `phase1-path-secret-leak` | verified | +| `P1.NEG.PRODUCTION_ADAPTER_MOUNT` | `phase1-test-adapter-production-import` | verified on eight frozen production boundaries | +| `P1.NEG.RATCHET_REGRESSION` | `phase1-ratchet-regression` | verified | + +Do not create or run the absent P1.1D positive-neighbor path. Do not claim `P1.NEG.SEMANTIC_OUTCOME` +or future feature conformance. Missing or changed diagnostics, false positive-neighbor claims, or a +negative that no longer rejects is `REJECT`. + +## Exact quality and safety gates + +Run and record the following exact commands. The lint list is every TypeScript input; the Prettier +list is all 37 canonical inputs. + +```bash +pnpm lint:fast:files -- src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts +pnpm typecheck +pnpm exec prettier --check .codex-handoff/phase-01-p1-1b.json src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts .codex-handoff/phase-01-p1-1c.json scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json +git diff --check +``` + +Lint, Prettier, and diff must be green. `pnpm typecheck` may exit 1 only for exactly the unchanged seven +inherited Phase 0 diagnostics in these three files and with no P1.S2-owned diagnostic: + +- `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts` +- `test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts` +- `test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts` + +Scan every one of the 37 canonical inputs and the owned result for credentials, secrets, auth/provider +payloads, private/home/real-project paths, raw command/runtime bodies, and binary content. Record the +exact command, exit code, and manual classification of every lexical match. A leaked value, private +path, binary, new typecheck diagnostic, or changed inherited diagnostic is `REJECT`. + +## Disposition rule and result contract + +Return `ACCEPT` only if independence is proven and every architecture, provenance, 37-path scope, +handoff/hash, aggregate, focused, negative, quality, safety, and ownership requirement passes exactly. +Any failure is `REJECT`; there is no conditional acceptance and no repair authority. + +Write only `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md`. It must contain: + +1. `Disposition: ACCEPT` or `Disposition: REJECT` as the single formal result; +2. reviewer identity/job/worktree and the three independence exclusions; +3. canonical, producer, admission, tree, router `phaseStartSha`, and packet-revision provenance; +4. exact 9 + 28 = 37 scope accounting and tree-equivalence proof; +5. every command, exit code, observed test count, and typecheck diagnostic classification; +6. the complete 11-ID negative matrix and positive-neighbor dispositions; +7. handoff, patch/hash, architecture, safety, and ownership findings; +8. all P0/P1/P2 findings or an explicit zero count for each; and +9. an explicit statement that P1.1D, P1.R2, integration/P1.I, P1.F, and Phase 2+ remain blocked + pending formal `ACCEPT` integration and a later router. + +The only safe next action is to return the result to the controller for a later router decision. Do +not integrate or push it, launch a successor, start P1.1D, or expand research. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-r2-review.md b/docs/hosted-web-phases/phase-01/lanes/p1-r2-review.md new file mode 100644 index 0000000000..6c086dec06 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-r2-review.md @@ -0,0 +1,525 @@ +# P1.R2 list semantics formal review lane + +> **Post-freeze amendment (2026-07-16).** The wire request no longer embeds a query context: +> `ListTeamLifecycleRequest` is `{ schemaVersion, cursor, expectedRevision }` and the host passes +> the `QueryContext` to `execute(request, context)` separately. Review criteria below that require +> nested query-context request parsing describe the reviewed pre-fix snapshot. + +## Authority and provenance + +- Project: `agent-teams-hosted-web-refactor` +- Phase/node: `phase-01` / `P1.R2` +- Lane ID: `p1-r2` +- Packet revision: `phase-01-p1-r2-review-r1` +- Router revision: `phase-01-p1-r2-router-r1` +- Evidence ID: `P1.R2.SEMANTIC_REVIEW` +- Router remediation `packetBaseSha`: + `48d79e2b13e258fc82ad55723875f15d6e162872` (authoring base only) +- Formal-review `postIntegrationAuthoritySha`: intentionally unresolved until the broker returns and + pushes the exact accepted policy-integration commit; never hardcode it to `packetBaseSha` or a + guessed SHA +- Authority state required before admission: root-resolved, clean, and bound by immutable pre-start + attestation to the sole result of + `git ls-remote origin refs/heads/refactor/hosted-web-feature-boundaries`; upstream-tracking + assumptions are not evidence +- Reviewed product snapshot for all exact 32 unchanged inputs: + `666042037a9c91df572b1d8274bf6024f8d00f40` +- Reviewed product snapshot's accepted true-merge parents, in order: + 1. `c3135d40c6e70e4b2ddc905dc815407397197634` + 2. `3b48f9391b4bff1d82bc85ef01a2d5e0e5b50e95` +- Accepted predecessors: PR #252 conflict gate and P1.1D, both complete and accepted +- Capacity: exactly one fresh independent reviewer +- Reviewer profile: `gpt-5.6-sol`, `xhigh`, `serviceTier: "default"`; Fast is not authorized +- Dependencies: broker-materialized offline before admission; worker installation is forbidden +- Terminal result: explicit `ACCEPT` or `REJECT`, then `HOLD` + +This lane becomes executable only after the exact seven-path router containing it receives +independent acceptance, is policy-integrated, and is pushed. Root is the sole orchestrator and may +then resolve and attest `postIntegrationAuthoritySha` and start this one reviewer. The router author +starts none. + +`controller-v17` remains `HOLD` and observation-only. It has no launch, admission, integration, +restart, replacement, or successor-controller authority. No successor controller exists or is +authorized by this packet. + +## Independence gate + +The reviewer identity, controller job, and worktree must be distinct from: + +1. this P1.R2 router author; +2. every P1.1A or P1.1D producer, remediation worker, and reviewer; +3. every PR #252 conflict-resolution producer and reviewer; and +4. every prior Phase 1 formal reviewer. + +The reviewer records its own identity, job, worktree, model, effort, service tier, and all four +exclusions in both outputs. Reuse, overlap, a second reviewer, non-default service tier, Fast mode, +or inability to prove independence is an admission runtime incident and requires `HOLD` without a +review disposition; substitution is not authorized. No concurrent duplicate may be started. + +## Exact ProjectScopedControl reviewer admission + +Root must use the existing refill-worker operation for this clean formal review. `prepare_verifier` +is not authorized. Angle-bracketed authority values below are resolved runtime values, not packet +literals or permission to guess a SHA: + +```text +operation: codex_goal_project_refill_worker +workerRole: reviewer +reasoningEffort: xhigh +serviceTier: default +sourceRemote: origin +sourceBranch: refactor/hosted-web-feature-boundaries +expectedSourceCommit: +preStartAdmission.mode: serial-builtin +preStartAdmission.contract.kind: worker-launch +preStartAdmission.contract.format: 1 +preStartAdmission.contract.canonicalSha: +preStartAdmission.contract.baseSha: +preStartAdmission.contract.phaseStartSha: +preStartAdmission.contract.packetRevision: phase-01-p1-r2-review-r1 +preStartAdmission.contract.controllerPacket: docs/hosted-web-phases/phase-01/controller-packet.md +preStartAdmission.contract.lanePacket: docs/hosted-web-phases/phase-01/lanes/p1-r2-review.md +preStartAdmission.contract.phaseId: phase-01 +preStartAdmission.contract.laneId: p1-r2 +preStartAdmission.contract.inputPatchHash: null +preStartAdmission.contract.reviewKind: review +``` + +The resulting isolated review worktree must start at local `HEAD` equal to +`expectedSourceCommit`/`postIntegrationAuthoritySha`. Reviewer `planBundleCommit` and handoff +`baseSha`, `canonicalSha`, `planBundleCommit`, `phaseStartSha`, and `headSha` are separately bound to +that same resolved SHA. Root's immutable pre-start authority attestation must contain the exact +broker-returned pushed commit, remote name/ref, `git ls-remote` command, exit code, exact output, +equality result, clean-worktree result, and the `expectedSourceCommit` used for admission. + +The reviewer has no GitHub, network, fetch, or remote-query authority. It validates the immutable +broker/root attestation and local canonical `HEAD`. A missing or invalid authority attestation, +authority checkout/admission failure, or root remote-query/network failure is a runtime incident that +ends `HOLD` without a semantic disposition. It is never a review finding or synthetic `REJECT`. + +## Mission + +Independently review the exact unchanged shared hosted kernel and P1.1D team-lifecycle list surface +from reviewed product snapshot `666042037a9c91df572b1d8274bf6024f8d00f40` in the clean authority +worktree whose `HEAD` is the resolved `postIntegrationAuthoritySha`. +Return one formal determination of whether list semantics, authorization context, safe errors, +revisions/cursors, deterministic bounded parsing, kernel size, and public boundaries match accepted +Phase 1 policy at that reviewed product snapshot. + +This is a review, not a producer, repair, integration, or production-readiness pass. Do not modify a +reviewed product input or fix a finding. Do not add transport, filesystem, runtime, IPC, HTTP, preload, +renderer, production auth, provider, route, composition, dependency, or product behavior. Do not run +an app, runtime, team, server, provider check, browser check, filesystem integration, or real +project. Dependencies are already broker-materialized offline; do not install, fetch, update, or +repair them. + +## Exact mandatory reads + +Read in this order. Directory reads, globs, implicit siblings, and recursive research reads are not +authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. `docs/hosted-web-phases/phase-01/lanes/p1-r2-review.md` +8. `CLAUDE.md` +9. `AGENT_CRITICAL_GUARDRAILS.md` +10. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +11. `docs/hosted-web-phases/PACKET_STANDARD.md` +12. `docs/hosted-web-phases/phase-01/README.md` +13. `docs/hosted-web-phases/phase-01/execution-dag.md` +14. `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +15. `docs/hosted-web-phases/phase-01/lanes/p1-s1-foundations.md` +16. `docs/hosted-web-phases/phase-01/lanes/p1-1d-team-lifecycle-read.md` +17. `docs/hosted-web-phases/phase-01/lanes/p1-1d-additive-response-remediation.md` +18. the exact 32 reviewed product inputs below, in listed order + +The historical PR #252 lane remains frozen and is not a current review input. The two result paths +are new outputs, not inputs. Do not inspect other research or rejected artifacts. + +## Exact 32-path reviewed product input + +P1.1A contributes exactly these 12 paths: + +1. `.codex-handoff/phase-01-p1-1a.json` +2. `src/shared/contracts/hosted/app-error.ts` +3. `src/shared/contracts/hosted/identifiers.ts` +4. `src/shared/contracts/hosted/index.ts` +5. `src/shared/contracts/hosted/query-context.ts` +6. `src/shared/contracts/hosted/revision.ts` +7. `test/architecture/hosted-web/phase-1/contracts/app-error.test.ts` +8. `test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json` +9. `test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json` +10. `test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts` +11. `test/architecture/hosted-web/phase-1/contracts/query-context.test.ts` +12. `test/architecture/hosted-web/phase-1/contracts/revision.test.ts` + +P1.1D contributes exactly these 9 paths: + +1. `.codex-handoff/phase-01-p1-1d.json` +2. `src/features/team-lifecycle/contracts/team-lifecycle-read.ts` +3. `src/features/team-lifecycle/contracts/index.ts` +4. `src/features/team-lifecycle/core/application/ListTeamLifecycle.ts` +5. `src/features/team-lifecycle/core/application/index.ts` +6. `src/features/team-lifecycle/index.ts` +7. `test/features/team-lifecycle/core/ListTeamLifecycle.test.ts` +8. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts` +9. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts` + +The immutable semantic corpus contributes exactly these 11 paths: + +1. `test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json` +2. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json` +3. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json` +4. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json` +5. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json` +6. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json` +7. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json` +8. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json` +9. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json` +10. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json` +11. `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json` + +The 12 + 9 + 11 sets are disjoint and total exactly 32 paths. Every byte is read-only, bound to +`reviewedProductSnapshotSha` `666042037a9c91df572b1d8274bf6024f8d00f40`, and required to be +byte-identical at post-integration authority `HEAD`. A missing, additional, overlapping, or modified +reviewed product input requires `REJECT` after valid admission. + +## Exact exclusive reviewer writer authority + +The reviewer may create and write exactly these two paths, in this order: + +1. `.codex-handoff/phase-01-p1-r2.json` +2. `docs/research/hosted-web/phase-1/reviews/list-semantics.md` + +Everything else is read-only. The reviewer may create a missing parent directory only when required +for one of these paths and may create no sibling. Any other changed, untracked, staged, generated, +formatted, or temporary repository path is `REJECT`, not repair or cleanup authority. + +## Semantic, authorization, error, cursor, and bound review + +The reviewer must independently prove all of the following: + +1. Request parsing accepts only the exact versioned top-level and nested query-context fields, + including own string and symbol behavior, and validates actor/session/deployment/boot/request IDs, + `authorizedScope`, deadline, and cancellation without reading ambient or production auth state. +2. The use case invokes its injected value-only source exactly once for each valid request and zero + times for an invalid request. It has no filesystem, adapter, transport, runtime, provider, or + production mount. +3. Same-version success, failure, and inapplicable responses validate every known field before + discarding additive own string/symbol fields and return fresh frozen known-field-only projections. +4. The success parser captures and bounds the untrusted `items` length at 1,000, rejects sparse or + duplicate-ID input, reads each dense index exactly once, parses each element, uses a fresh plain + array, and never dispatches an input-owned map, iterator, constructor, or species behavior. +5. All ten manifest scenarios retain their accepted success/failure/inapplicable outcome, + deterministic ordering, revision/cursor values, safe fields, retryability, and empty-versus-error + distinction. The deliberate semantic mismatch still rejects with + `phase1-semantic-outcome-drift`. +6. Safe errors remain limited to the accepted application categories and bounded fields. Unsupported + versions, malformed known fields, source throws, and invalid source responses fail closed without + raw messages, stacks, auth/provider payloads, command bodies, or private paths. +7. Revisions and cursors remain opaque, kind-separated tokens. The contract never parses, increments, + sorts, derives, or uses them as display/cache keys, and never silently converts an invalid cursor + to page one. Production cursor integrity/scope/snapshot binding remains explicitly unverified + because Phase 1 has no production adapter. +8. Public entrypoints expose only the narrow team-lifecycle contract and use case. They do not expose + a legacy aggregate, universal envelope, transport status, route/capability metadata, provider + value, filesystem/path value, production identity, or implementation folder. +9. The shared kernel remains exactly five product files and five primitive families: opaque IDs, + query/authorization context, revision/cursor, safe application errors, and the public entrypoint. + The five files remain exactly 159 lines and 7,242 bytes at the reviewed product snapshot and + unchanged post-integration authority `HEAD`; the accepted P1.1A handoff remains 299 gross owned + lines. No sixth primitive family or unproved export is accepted. +10. `P1.NEG.SCHEMA_VERSION` still fails with + `phase1-schema-version-invalid-or-unsupported`, and its valid same-version neighbors pass. No + production auth, transport parity, adapter integrity, filesystem/runtime integration, production + mount, or full Phase 1 completion is claimed. + +## Exact focused command + +Run exactly this one focused test command from the authority review worktree at local `HEAD` equal to +`expectedSourceCommit`/`postIntegrationAuthoritySha`: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts test/features/team-lifecycle +``` + +It must exit 0 with exactly 5 test files and 14/14 tests passing. Do not split, widen, replace, or add +another test command. + +## Frozen typecheck baseline + +Run `pnpm typecheck`. It may exit 1 only for these exact seven inherited diagnostics: + +- `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts`: TS7016 at + 25:8; TS7031 at 66:31; TS18046 at 117:68; TS7031 at 413:48; TS7031 at 733:10; +- `test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts`: TS7016 at 12:8; + and +- `test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts`: TS2352 at + 162:44. + +Acceptance requires exactly `7 inherited / 0 owned / 0 unexpected`. A new, removed, moved, or +changed inherited diagnostic, or any diagnostic in either reviewer output or a reviewed P1 input, +requires `REJECT`. + +## Prettier, diff, and exact two-path scope + +After the broker returns and pushes the accepted router-integration commit, root must bind +`brokerReturnedAndPushedCommitSha` and `postIntegrationAuthoritySha` to that exact value, run this +remote equality check, and capture the resolved values, command, exit code, exact output, equality, +and clean-worktree result in the immutable pre-start authority attestation. The reviewer never reruns +it or substitutes an upstream-tracking ref: + +```bash +test -n "$brokerReturnedAndPushedCommitSha" +test "$postIntegrationAuthoritySha" = "$brokerReturnedAndPushedCommitSha" +remote_ref=refs/heads/refactor/hosted-web-feature-boundaries +remote_result=$(git ls-remote origin "$remote_ref") +test "$remote_result" = \ + "$(printf '%s\t%s' "$postIntegrationAuthoritySha" "$remote_ref")" +``` + +From the immutable attestation and admission contract, the reviewer binds the read-only shell values +`postIntegrationAuthoritySha` and `expectedSourceCommit` without querying the network. It then runs +these exact local authority, snapshot-topology, and 32-input byte-equality checks: + +```bash +test -n "$postIntegrationAuthoritySha" +test "$expectedSourceCommit" = "$postIntegrationAuthoritySha" +test "$(git rev-parse HEAD)" = "$postIntegrationAuthoritySha" +test "$(git rev-list --parents -n 1 666042037a9c91df572b1d8274bf6024f8d00f40)" = \ + "666042037a9c91df572b1d8274bf6024f8d00f40 c3135d40c6e70e4b2ddc905dc815407397197634 3b48f9391b4bff1d82bc85ef01a2d5e0e5b50e95" +node <<'NODE' +const assert = require('node:assert/strict') +const index = require('./docs/hosted-web-phases/EXECUTION_INDEX.json') +const snapshot = '666042037a9c91df572b1d8274bf6024f8d00f40' +const inputs = index.reviewCanonicalInputs +const paths = [...inputs.p11aPaths, ...inputs.p11dPaths, ...inputs.semanticCorpusPaths] +assert.equal(inputs.authorityShaBinding, 'postIntegrationAuthoritySha') +assert.equal(inputs.reviewedProductSnapshotSha, snapshot) +assert.equal(paths.length, 32) +assert.equal(new Set(paths).size, 32) +console.log('P1.R2 exact reviewed product input manifest: 32 disjoint paths') +NODE +mapfile -t review_input_paths < <(node -e \ + "const i=require('./docs/hosted-web-phases/EXECUTION_INDEX.json').reviewCanonicalInputs; console.log([...i.p11aPaths,...i.p11dPaths,...i.semanticCorpusPaths].join('\\n'))") +test "${#review_input_paths[@]}" -eq 32 +git diff --exit-code \ + 666042037a9c91df572b1d8274bf6024f8d00f40 \ + HEAD \ + -- "${review_input_paths[@]}" +git diff --exit-code \ + HEAD \ + -- "${review_input_paths[@]}" +``` + +Run this exact Prettier check after both outputs are complete: + +```bash +pnpm exec prettier --check \ + .codex-handoff/phase-01-p1-1a.json \ + src/shared/contracts/hosted/app-error.ts \ + src/shared/contracts/hosted/identifiers.ts \ + src/shared/contracts/hosted/index.ts \ + src/shared/contracts/hosted/query-context.ts \ + src/shared/contracts/hosted/revision.ts \ + test/architecture/hosted-web/phase-1/contracts/app-error.test.ts \ + test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json \ + test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json \ + test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts \ + test/architecture/hosted-web/phase-1/contracts/query-context.test.ts \ + test/architecture/hosted-web/phase-1/contracts/revision.test.ts \ + .codex-handoff/phase-01-p1-1d.json \ + src/features/team-lifecycle/contracts/team-lifecycle-read.ts \ + src/features/team-lifecycle/contracts/index.ts \ + src/features/team-lifecycle/core/application/ListTeamLifecycle.ts \ + src/features/team-lifecycle/core/application/index.ts \ + src/features/team-lifecycle/index.ts \ + test/features/team-lifecycle/core/ListTeamLifecycle.test.ts \ + test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts \ + test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts \ + test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json \ + .codex-handoff/phase-01-p1-r2.json \ + docs/research/hosted-web/phase-1/reviews/list-semantics.md +git diff --check +git diff --cached --quiet +git diff --exit-code +git status --short +``` + +Prettier and all three diff commands must be green. After both outputs exist, status must resolve to +exactly the two untracked writable paths above, in lexical Git status order, with no staged path. +Prove `expectedSourceCommit`, `HEAD`, base, canonical, plan bundle, and phase start remain the resolved +`postIntegrationAuthoritySha`; the immutable root attestation remains exact; and the reviewed product +snapshot and ordered merge parents remain exact. Do not run `git ls-remote`, fetch, or any GitHub or +network query in the reviewer worktree. + +## Secret, provider, and private-path scans + +Scan all 32 reviewed product inputs and both outputs. Use one exact path array so no untracked output +or reviewed product input is missed: + +```bash +review_scan_paths=( + .codex-handoff/phase-01-p1-1a.json + src/shared/contracts/hosted/app-error.ts + src/shared/contracts/hosted/identifiers.ts + src/shared/contracts/hosted/index.ts + src/shared/contracts/hosted/query-context.ts + src/shared/contracts/hosted/revision.ts + test/architecture/hosted-web/phase-1/contracts/app-error.test.ts + test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json + test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json + test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts + test/architecture/hosted-web/phase-1/contracts/query-context.test.ts + test/architecture/hosted-web/phase-1/contracts/revision.test.ts + .codex-handoff/phase-01-p1-1d.json + src/features/team-lifecycle/contracts/team-lifecycle-read.ts + src/features/team-lifecycle/contracts/index.ts + src/features/team-lifecycle/core/application/ListTeamLifecycle.ts + src/features/team-lifecycle/core/application/index.ts + src/features/team-lifecycle/index.ts + test/features/team-lifecycle/core/ListTeamLifecycle.test.ts + test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts + test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts + test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json + .codex-handoff/phase-01-p1-r2.json + docs/research/hosted-web/phase-1/reviews/list-semantics.md +) +rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer)' "${review_scan_paths[@]}" +rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' "${review_scan_paths[@]}" +rg -n '(/Users/|/home/|/root/|~/|[A-Za-z]:\\Users\\|real[-_ ]project)' "${review_scan_paths[@]}" +``` + +Record every command and exit code and manually classify every lexical match. Expected test terms, +safe error-category words, and reviewer launch metadata are not secret/provider payloads. Any real +credential, secret, auth/provider payload, raw provider value, private user path, or real-project path +requires `REJECT`. A zero-match claim without all 34 paths is invalid. + +## Disposition and evidence contract + +Write `docs/research/hosted-web/phase-1/reviews/list-semantics.md` as the canonical review result. It +must contain: + +1. exactly one formal `Disposition: ACCEPT` or `Disposition: REJECT`; +2. reviewer identity/job/worktree/profile and complete independence proof; +3. `packetBaseSha` as router-authoring provenance only; resolved `postIntegrationAuthoritySha`; + `expectedSourceCommit`/base/canonical/plan-bundle/start/`HEAD` equality; clean state; root's exact + immutable remote-ref equality attestation; separate `reviewedProductSnapshotSha`; and exact + ordered-parent provenance; +4. exact 12 + 9 + 11 = 32 reviewed product input accounting, authority/snapshot byte-equality proof, + and exact two-path output accounting; +5. findings for every semantic/auth/error/cursor/kernel-size requirement above; +6. the focused command, exact exit code, and observed file/test counts; +7. the complete seven-diagnostic typecheck classification with zero owned/unexpected; +8. Prettier, diff, status, secret/provider/private-path commands, exit codes, and match + classifications; +9. explicit P0/P1/P2 findings or zero counts for each; and +10. an explicit statement that P1.I, P1.F, Phase 2+, product workers, integration, production auth, + adapters, mounts, and successor controllers remain blocked or unverified as applicable. + +Write `.codex-handoff/phase-01-p1-r2.json` following `PACKET_STANDARD.md`. It must include: + +1. `schemaVersion: 1`, `phaseId: "phase-01"`, `laneId: "p1-r2"`, and packet revision + `phase-01-p1-r2-review-r1`; +2. `baseSha`, `canonicalSha`, `planBundleCommit`, `phaseStartSha`, and `headSha`, all the exact resolved + `postIntegrationAuthoritySha`, plus separate + `reviewedProductSnapshotSha: "666042037a9c91df572b1d8274bf6024f8d00f40"` for the unchanged + exact 32 product inputs; +3. status `verified` when the formal review completes with all required observations, whether its + disposition is `ACCEPT` or `REJECT`; use `failed` only when the review contract cannot complete; +4. exactly one evidence row for `P1.R2.SEMANTIC_REVIEW`, result path + `docs/research/hosted-web/phase-1/reviews/list-semantics.md`, and proof level + `target_verified` when the exact authority/snapshot target and required commands were fully observed; + otherwise record `unverified` without inventing proof; +5. `changedPaths` containing exactly the handoff path and result path, in writer-authority order; +6. every check command, exit code, observed count, typecheck classification, scan classification, + scope proof, and result-file SHA-256; +7. explicit disposition and P0/P1/P2 findings/counts consistent with the Markdown result; +8. unverified claims and blocked successors exactly as required above; and +9. `nextAction: "controller-hold"` and `terminalState: "HOLD"`. + +`ACCEPT` is legal only when every gate passes and P0/P1/P2 are `0/0/0`. There is no conditional +acceptance and no repair authority. A semantic, content, or review-gate finding—including incomplete +or ambiguous evidence content or scope/provenance drift—returns `REJECT` with the finding. Admission, +provider, or environment failure, including an unavailable required command, is a runtime incident +that returns no review disposition and ends `HOLD`. Absence of a strict terminal result is likewise +a runtime incident, not evidence and not a synthetic `REJECT`. +Missing or invalid authority attestation, authority checkout/admission failure, or root +remote-query/network failure is also a runtime incident under this rule. The reviewer must not turn +any of those failures into a semantic finding. + +## Strict completion and attempt lifecycle + +Root may declare this review complete only when the same admitted attempt has both: + +1. a strict terminal result carrying the formal `ACCEPT` or `REJECT`, and +2. broker-captured immutable output binding both exact result paths, whose bytes and hashes match + that terminal result. + +`changedFiles`, heartbeat, PID, tmux, and `providerObserved` state are insufficient individually and +together. They never establish termination, disposition, or immutable evidence. + +There is no concurrent duplicate, refill, or attempt after a semantic, content, or gate `REJECT`. +For an admission, provider, environment, or no-strict-result runtime incident, root may authorize at +most one exact corrected attempt. Before doing so, root must prove the affected attempt terminal or +prove no runner exists. The corrected attempt must preserve the exact assignment, resolved +`postIntegrationAuthoritySha`, immutable authority attestation, +reviewed product snapshot SHA, 32 unchanged inputs, two output paths, `gpt-5.6-sol` model, `xhigh` +effort, default service tier, no-Fast rule, commands, and independence requirements. + +## Accepted-result integration boundary + +For strict `ACCEPT` with P0/P1/P2 `0/0/0`, root mechanically verifies the strict result, immutable +output, bound evidence bytes and hashes, exact commands, findings, and two-path scope. Root then +invokes `mark_reviewed` and directs the broker to integrate and push exactly, and only: + +1. `.codex-handoff/phase-01-p1-r2.json` +2. `docs/research/hosted-web/phase-1/reviews/list-semantics.md` + +The reviewer and root do not stage, commit, integrate, or push. Broker integration adopts the +evidence but authorizes no successor. P1.I, P1.F, Phase 2+, and product workers remain blocked. Only +a later separately reviewed docs router may authorize P1.I; it reads the already integrated +evidence and must never integrate either P1.R2 evidence path again. + +## Stop and HOLD + +When a properly admitted reviewer reaches a strict terminal result, reviewed-product-snapshot, +ordered-parent, accepted-predecessor, input/output scope, semantic, +authorization, error, cursor, kernel-size, test-count, typecheck, Prettier, diff, scan, or evidence +content drift is a review-gate finding: record `REJECT` and stop `HOLD`. Do not repair or retry. + +Unresolved or mismatched post-integration authority, missing or invalid immutable authority +attestation, local authority checkout mismatch, reviewer admission/count/independence/profile failure, +provider failure, root remote-query/network failure, environment failure, or no strict terminal result +is a runtime incident: record controller `HOLD` without inventing `REJECT`. Do not run a concurrent +duplicate. At most one exact corrected attempt is possible under the terminal/no-runner rule above. + +Return the strict result, immutable output, and both bound result paths to root when they exist. +After any disposition, P1.I, P1.F, Phase 2+, and product workers remain blocked. Broker integration +of strict accepted evidence follows only the accepted-result boundary above; a later docs router +alone may authorize P1.I and never reintegrates the evidence. This packet authorizes no successor +controller. End `HOLD`. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-s0-serial-bootstrap.md b/docs/hosted-web-phases/phase-01/lanes/p1-s0-serial-bootstrap.md new file mode 100644 index 0000000000..71c64a2f90 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-s0-serial-bootstrap.md @@ -0,0 +1,95 @@ +# P1.S0 serial bootstrap lane + +## Authority + +- Lane: `P1.S0` +- Contract phase/lane: `phase-01` / `p1-s0` +- Controller: `docs/hosted-web-phases/phase-01/controller-packet.md` +- Worker-start revision: `phase-01-s0-bootstrap-r1` +- Status: the single current Phase 1 lane, admitted only through subscription-runtime's builtin + `worker-start-v1` boundary +- Scope: metadata and evidence serial bootstrap only +- Concurrency: one producer; no refill or successor provisioning + +This packet cannot broaden the accepted Phase 0 freeze. It cannot authorize product implementation or +advance the router. Completion returns evidence to the controller; `P1.S1` and all later subphases stay +blocked until a separate reviewed integration and explicit router update. + +The worker contract keeps canonical/base provenance at +`42ec333848e29e97c41699b9fed73ed199740e3f` and binds `phaseStartSha` to the isolated +`workspaceRoot` Git HEAD. `jobRoot` is a separate, non-overlapping runtime directory containing the +prompt. Repository reads and check working directories resolve below `workspaceRoot`; `sandboxRoot` +equals or is contained by that workspace and never overlaps `jobRoot`. + +## Required outcomes + +1. Bind the exact `phaseStartSha` and freeze one Phase 1 packet revision from the accepted Phase 0 + freeze. +2. Resolve proposed downstream identifiers, evidence owners, exact no-glob writer paths, synthetic + fixture paths, commands, and review pairings into deterministic metadata. Unresolved items remain + blocked; they are not guessed or silently widened. +3. Record reproducible baseline fingerprints for the checks that later packets will inherit, with + each failure classified as accepted inherited, newly introduced, or blocking. +4. Reconcile the proposed Phase 1 unique estimate allocation without reopening the accepted Phase 0 + estimate or target-image narrowing. +5. Produce a concise bootstrap report that maps every output to its source reference and states that + no product source or successor work was started. + +## Non-goals + +- No file beneath `src/` may be created or changed. +- No contract kernel, route, adapter, feature, fixture implementation, migration, provider runtime, + artifact composition, terminal behavior, or production registration may be implemented. +- No `P1.S1` or later worker contract, worktree, task, preload, or refill may be created or admitted. +- No accepted Phase 0 decision, evidence artifact, estimate, or preserved history may be rewritten, + regenerated, moved, or deleted. +- No real user project, credential, raw auth/runtime payload, private host path, or live team may be + used. + +## Exact owned paths + +The S0 contract may grant write access only to these new files: + +- `docs/research/hosted-web/phase-1/bootstrap/phase-start.json` +- `docs/research/hosted-web/phase-1/bootstrap/packet-revision.json` +- `docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json` +- `docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json` +- `docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json` +- `docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md` + +All other repository paths are read-only. The runtime `worker-start-v1` contract must repeat these +paths exactly; directory roots, globs, and implicit sibling authority are invalid. + +## Contract-listed references + +The worker reads the baseline, controller, and this lane first. Its validated contract may then list +only the exact references required from this bounded set: + +- `docs/hosted-web-phases/phase-01/packet-inputs.md` +- `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +- `docs/hosted-web-phases/phase-01/execution-dag.md` +- `docs/hosted-web-phases/phase-01/conformance-and-tests.md` +- `docs/hosted-web-phases/phase-01/operations-and-risk.md` +- `docs/hosted-web-phases/phase-01/execution-packet-templates.md` +- `docs/research/hosted-web/phase-0/freeze/current-canonical/README.md` +- `docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json` +- `docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json` +- `docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json` +- `docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json` + +Absence from the contract means do not read it. No directory, glob, or recursive evidence read is +permitted. + +## Required checks + +- Confirm subscription-runtime `worker-start-v1` admission before work. +- Run `node docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs`. +- Run `node docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs`. +- Parse every created JSON file and verify deterministic ordering, exact paths, unique IDs, ownership + disjointness, estimate arithmetic, and baseline fingerprints with contract-listed checks. +- Run targeted lint for every changed script or test, Prettier on the exact changed paths, + `git diff --check`, an owned-path scope scan, and bounded secret/private-path scans. + +Stop with a named blocker on any stale SHA, path overlap, unclassified failure, secret/private-path +finding, accepted-freeze mismatch, or attempt to write product source. Passing these checks does not +authorize `P1.S1`. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-s1-foundations.md b/docs/hosted-web-phases/phase-01/lanes/p1-s1-foundations.md new file mode 100644 index 0000000000..0f273593fa --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-s1-foundations.md @@ -0,0 +1,155 @@ +# P1.S1 foundations lane + +## Authority + +- Lane: `P1.S1` +- Frozen owner: `P1.1A` +- Contract phase/lane: `phase-01` / `p1-s1` +- Controller: `docs/hosted-web-phases/phase-01/controller-packet.md` +- Worker-start revision: `phase-01-s1-foundations-r1` +- Accepted predecessor: P1.S0 commit `6f1a87daa9a4bfdf5d754347d92f313f28d0f95d` +- Transition base: `f12a85af0fddadd06f69a27ef408d26bc27eb3fc` +- Historical S0 `phaseStartSha`: `5f30df49e052d1cc1d0e7efd03aa105673b5b614` +- Status: the single current Phase 1 lane, admitted only through subscription-runtime's builtin + `worker-start-v1` boundary +- Concurrency: one producer; no refill or successor provisioning + +The runtime contract binds `phaseStartSha` to the isolated `workspaceRoot` Git HEAD. That SHA must +contain this router transition, descend from the transition base, and preserve the exact six accepted +S0 evidence paths. It does not replace the historical S0 `phaseStartSha` above. + +## Mission + +Implement the frozen `P1.1A` minimal shared hosted contract kernel and its focused parser/version +tests. Produce evidence `P1.1A.KERNEL` and `P1.1A.VERSION` without creating a feature DTO, transport +contract, generic repository, universal response envelope, route or capability descriptor, or +production registration. + +Passing this lane returns evidence to the controller. It does not authorize `P1.S2`, `P1.1B`, +`P1.1C`, a successor worktree, a task refill, or any production transport work. + +## Exact required reads + +The `worker-start-v1` contract must list every path below exactly. Directory roots, globs, recursive +reads, and implicit sibling authority are invalid. + +### Mandatory baseline, in reading order + +- `AGENTS.md` +- `docs/hosted-web-phases/START_HERE.md` +- `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +- `docs/hosted-web-phases/README.md` +- `docs/hosted-web-phases/EXECUTION_INDEX.json` +- `docs/hosted-web-phases/phase-01/controller-packet.md` +- `docs/hosted-web-phases/phase-01/lanes/p1-s1-foundations.md` + +### Mandatory documents and configuration inputs + +- `CLAUDE.md` +- `AGENT_CRITICAL_GUARDRAILS.md` +- `docs/hosted-web-phases/phase-01/README.md` +- `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +- `docs/hosted-web-phases/phase-01/conformance-and-tests.md` +- `docs/research/hosted-web/phase-1/bootstrap/phase-start.json` +- `docs/research/hosted-web/phase-1/bootstrap/packet-revision.json` +- `docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json` +- `docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json` +- `docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json` +- `docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md` +- `package.json` +- `tsconfig.json` +- `vitest.config.ts` + +There are no mandatory scripts or fixtures and no authority to read preserved Phase 0 evidence. The +accepted S0 evidence is read-only input: do not regenerate, reformat, rewrite, move, or delete it. + +## Exact writable paths + +- `.codex-handoff/phase-01-p1-1a.json` +- `src/shared/contracts/hosted/app-error.ts` +- `src/shared/contracts/hosted/identifiers.ts` +- `src/shared/contracts/hosted/index.ts` +- `src/shared/contracts/hosted/query-context.ts` +- `src/shared/contracts/hosted/revision.ts` +- `test/architecture/hosted-web/phase-1/contracts/app-error.test.ts` +- `test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json` +- `test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json` +- `test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts` +- `test/architecture/hosted-web/phase-1/contracts/query-context.test.ts` +- `test/architecture/hosted-web/phase-1/contracts/revision.test.ts` + +Everything else is read-only. In particular, do not edit package or lock files, TypeScript or lint +configuration, any other `src/` file, production composition/registration, another lane's paths, or +the six accepted S0 evidence paths. No new dependency is authorized. + +## Frozen deliverables + +- `P1.1A.KERNEL`: small value-only primitives for opaque identifiers, query context, revision/cursor, + and safe application errors, exported through the exact shared-kernel entrypoint. +- `P1.1A.VERSION`: positive and deliberate negative tests for parsing, kind/scope separation, + revisions/cursors, safe errors, schema/version behavior, and cancellation/deadline context. +- Negative fixture: `P1.NEG.SCHEMA_VERSION`, with expected diagnostic + `phase1-schema-version-invalid-or-unsupported`. +- Unique estimate bucket: 180-300 gross changed lines. Stop for controller review before exceeding + 300 lines; do not shrink required behavior merely to meet the estimate. + +The kernel must not export a feature DTO, `ApiResponse`, `Repository`, `PlatformAdapter`, +route or capability metadata, HTTP status, IPC result, provider value, filesystem/path value, or +production identity claim. Fixture `TeamId` values are synthetic and test-only. + +## Acceptance + +1. Contracts import no Electron, Fastify, React, Zustand, Node infrastructure, `@main`, renderer, + preload, filesystem/path/process, provider SDK, or transport type. +2. Opaque identifier constructors reject empty, oversized, malformed, raw-cross-kind, and unsafe + values; tests never claim that a legacy team name is a production `TeamId`. +3. Revisions and cursors remain equality-only opaque values. They are not parsed, incremented, sorted, + or used as display/cache keys. +4. `QueryContext` contains only validated actor/session, deployment/boot, request, authorized scope, + deadline/cancellation values. It contains no cookies, headers, IPs, Electron events, paths, or + global auth lookup. +5. `SafeAppError` uses only the frozen application codes and bounded safe fields. Raw messages, + stacks, host paths, secrets, provider output, command bodies, and transport status cannot escape. +6. Same-version response parsing may ignore additive unknown fields after known-field validation; + input objects reject unknown fields, and malformed, missing, future, or incompatible versions fail + with the stable schema-version diagnostic. +7. Every exported primitive has a demonstrated P1.S1 use; no empty ceremonial layer or sixth + primitive family is added. +8. No route/catalog, capability, conformance harness, ratchet, feature slice, transport adapter, + production registration, filesystem adapter, migration, renderer change, or dependency change is + created. + +## Required checks + +Run from the bound `workspaceRoot` and record command, exit code, and relevant tool version in the +handoff: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts +pnpm lint:fast:files -- src/shared/contracts/hosted/app-error.ts src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts src/shared/contracts/hosted/query-context.ts src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-1/contracts/query-context.test.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts +pnpm typecheck +pnpm exec prettier --check .codex-handoff/phase-01-p1-1a.json src/shared/contracts/hosted/app-error.ts src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts src/shared/contracts/hosted/query-context.ts src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/app-error.test.ts test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-1/contracts/query-context.test.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts +git diff --check +git status --short +``` + +Also compare the changed-path set to the twelve exact writable paths, confirm `package.json`, +`pnpm-lock.yaml`, configs, accepted S0 evidence, and every non-owned path are unchanged, and scan every +changed/untracked file for secrets, auth/provider payloads, private/home paths, raw command/runtime +bodies, and real-project names. Confirm no new import reaches a forbidden layer or filesystem/process +API. A zero-match text scan without untracked-file coverage is insufficient. + +## Stop conditions + +Stop with a named blocker on stale base/revision, a phase start that does not contain this transition, +changed S0 evidence, path overlap, unclassified baseline failure, source/packet contradiction, new +dependency or config need, unsafe evidence, secret/private-path finding, production transport +exposure, filesystem/path-taking work, identity invention, or any attempt to start `P1.S2`. + +## Handoff + +Write `.codex-handoff/phase-01-p1-1a.json` using the runtime `PACKET_STANDARD` result schema. +Include the exact base/start SHA, packet revision, evidence IDs and proof levels, changed paths, +commands and exit codes, deliberate negative result, estimate actual, unverified production claims, +blockers, and the smallest next controller action. Return only `verified`, `characterized`, `blocked`, +or `failed`; never claim Phase 1 or production hosted behavior complete. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-s1-schema-version-remediation.md b/docs/hosted-web-phases/phase-01/lanes/p1-s1-schema-version-remediation.md new file mode 100644 index 0000000000..9994c9e244 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-s1-schema-version-remediation.md @@ -0,0 +1,199 @@ +# P1.S1 schema-version remediation lane + +## Authority and provenance + +- Project: `agent-teams-hosted-web-refactor` +- Phase/lane: `phase-01` / `p1-s1-schema-version-remediation` +- Evidence owner: `P1.1A-schema-version-remediation` +- Packet revision: `phase-01-s1-schema-version-remediation-r1` +- Controller: `docs/hosted-web-phases/phase-01/controller-packet.md` +- Remediation base: integrated P1.S1 commit `da9625e78c0c96699162793a7ebba0657140d937` +- Result states: `verified | characterized | blocked | failed` +- Capacity: exactly one future serial producer; no retry, refill, parallel duplicate, or successor + provisioning + +This packet records the authoritative operator-provided disposition of the independent integration +review, quoted verbatim: + +> "Independent integration review formally REJECTED P1.S1 commit da9625e78 only for incomplete +> P1.NEG.SCHEMA_VERSION." + +The review applies to the integrated commit above and only to `P1.NEG.SCHEMA_VERSION`. All other +useful integrated P1.S1 kernel work is preserved. This packet explicitly supersedes +`phase-01-s1-foundations-r1` as worker-start authority; it does not supersede, rewrite, or erase the +integrated commit, its handoff, accepted P1.S0 evidence, or any evidence-catalog row. The independent +review finding supplied by the operator is the provenance for this bounded replacement authority. + +## Mission + +Remediate only revision/schema-version parsing and the focused tests and fixtures needed to close the +quoted `P1.NEG.SCHEMA_VERSION` finding. The implementation must use the stable diagnostic +`phase1-schema-version-invalid-or-unsupported` for every schema-version or input-object rejection +described below. + +The future worker must preserve the rest of `da9625e78` byte-for-byte. Passing this lane returns a +candidate for independent review and controller integration. It does not authorize `P1.S2`, product +transport work, another remediation attempt, or a router advance. + +## Exact worker-start identity + +The hosting subscription runtime may admit work only when one `worker-start-v1` contract binds all of +the following values together: + +```text +projectId: agent-teams-hosted-web-refactor +controllerJobId: phase-01-p1-s1-schema-version-remediation-controller-r1 +phaseId: phase-01 +laneId: p1-s1-schema-version-remediation +packetRevision: phase-01-s1-schema-version-remediation-r1 +parentPlanCommit: 3bc0dfa7c00261785c0c752270cb302a9294e751 +baseSha: da9625e78c0c96699162793a7ebba0657140d937 +controllerPacket: docs/hosted-web-phases/phase-01/controller-packet.md +lanePacket: docs/hosted-web-phases/phase-01/lanes/p1-s1-schema-version-remediation.md +handoffPath: .codex-handoff/phase-01-p1-s1-schema-version-remediation.json +``` + +`planBundleCommit` and `phaseStartSha` must both resolve to the exact integrated docs-only router +packet commit selected by the controller. That commit must descend directly or transitively from +`baseSha`, contain this packet revision, and leave every non-router path identical to `baseSha`. +`sourceWorktree` must be a new isolated worktree created from that `phaseStartSha`. No worker may be +started from `da9625e78` before this packet is integrated. + +Every cross-product with the superseded foundations packet, a different lane, a different base, a +second job, or any `P1.S2` packet fails closed with `packet_conflict` or `packet_stale`. + +## Exact required reads + +The runtime contract must list every path below exactly. Directory roots, globs, recursive reads, and +implicit sibling authority are invalid. + +### Mandatory baseline, in reading order + +- `AGENTS.md` +- `docs/hosted-web-phases/START_HERE.md` +- `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +- `docs/hosted-web-phases/README.md` +- `docs/hosted-web-phases/EXECUTION_INDEX.json` +- `docs/hosted-web-phases/phase-01/controller-packet.md` +- `docs/hosted-web-phases/phase-01/lanes/p1-s1-schema-version-remediation.md` + +### Mandatory implementation inputs + +- `CLAUDE.md` +- `AGENT_CRITICAL_GUARDRAILS.md` +- `docs/hosted-web-phases/PACKET_STANDARD.md` +- `src/shared/contracts/hosted/revision.ts` +- `src/shared/contracts/hosted/index.ts` +- `test/architecture/hosted-web/phase-1/contracts/revision.test.ts` +- `test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json` +- `test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json` +- `.codex-handoff/phase-01-p1-1a.json` +- `package.json` +- `tsconfig.json` +- `vitest.config.ts` + +There are no mandatory scripts and no authority to read or change preserved Phase 0 or accepted P1.S0 +evidence. + +## Exact writable paths + +- `.codex-handoff/phase-01-p1-s1-schema-version-remediation.json` +- `src/shared/contracts/hosted/revision.ts` +- `test/architecture/hosted-web/phase-1/contracts/revision.test.ts` +- `test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json` +- `test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json` + +Everything else is read-only, including `src/shared/contracts/hosted/index.ts`, every other product or +test file, configuration, package and lock files, accepted P1.S0 evidence, the integrated P1.S1 +handoff, research, documentation, and orchestration. A need to change another path is a stop condition, +not permission to widen the lane. + +## Definition of Ready + +- [ ] The router packet commit is integrated after `da9625e78`, and the runtime binds that exact commit + as both `planBundleCommit` and `phaseStartSha`. +- [ ] The five owned implementation/test/fixture paths match `da9625e78` before work starts, and all + useful non-owned P1.S1 kernel work remains present and unchanged. +- [ ] The accepted P1.S0 evidence paths and historical P1.S0 `phaseStartSha` remain unchanged. +- [ ] The runtime contract contains the exact identity, reads, writable paths, checks, and handoff path + from this packet. +- [ ] No producer is running or has already consumed this packet revision; active `P1.S2` producer + count is zero. +- [ ] The controller records the quoted independent-review finding and confirms that no product + remediation occurred before packet integration. + +Failure of any Ready item stops admission. It does not authorize repair, launch, or retry from this +docs-only packet producer. + +## Acceptance + +1. Malformed, missing, non-object, future, and incompatible schema-version inputs fail with exactly + `phase1-schema-version-invalid-or-unsupported`. +2. A same-version response object is accepted only after every declared known field has been + validated. The returned value is a fresh known-field projection, so every additive response field + is discarded rather than retained, spread, or returned by reference. +3. A response with a valid version but an invalid or missing known field fails before additive fields + can be ignored, using the same schema-version diagnostic. +4. An input object is accepted only when its declared known fields validate and it contains no + disallowed unknown own fields. Any disallowed unknown input-object field fails with the same + schema-version diagnostic. +5. Focused fixtures and tests demonstrate additive response-field discard, known-field-first response + validation, disallowed input-field rejection, and the existing malformed/missing/future/ + incompatible version matrix. A test that merely asserts the source object still contains an + additive field is insufficient. +6. Revision/cursor equality-only behavior, all other exported kernel behavior, and every non-owned + byte from `da9625e78` remain unchanged. No dependency, public barrel, feature DTO, generic schema + framework, transport contract, route, capability, or production registration is added. +7. The handoff identifies `P1.1A.VERSION.REMEDIATION` and `P1.NEG.SCHEMA_VERSION`, records the exact + diagnostic and negative results, and makes no Phase 1, P1.S2, or production-hosted completion claim. + +## Required checks + +Run from the bound `sourceWorktree` and record the exact commands, exit codes, and relevant tool +versions in the handoff: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/revision.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts +pnpm lint:fast:files -- src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts +pnpm typecheck +pnpm exec prettier --check .codex-handoff/phase-01-p1-s1-schema-version-remediation.json src/shared/contracts/hosted/revision.ts test/architecture/hosted-web/phase-1/contracts/revision.test.ts test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json +git diff --check +git status --short +``` + +Also compare the changed-path set with the five exact writable implementation/test/fixture paths plus +the handoff path; prove every non-owned path matches `phaseStartSha`; prove `phaseStartSha` differs +from `da9625e78` only on the bounded router/controller/lane documentation paths; and scan every changed +or untracked file for secrets, credentials, auth/provider payloads, private/home paths, raw command or +runtime bodies, and real-project names. A zero-match tracked-only scan is insufficient. An inherited +check failure may be characterized only with an unchanged baseline fingerprint and no owned-path +diagnostic. + +## Stop conditions + +Stop and return the `PACKET_STANDARD.md` blocker record on a stale base, packet, plan bundle, or phase +start; a missing or altered review quote; pre-integration product remediation; concurrent or prior use +of this one-shot packet revision; path overlap; any changed accepted S0 evidence or non-owned P1.S1 +byte; a need for another source, test, fixture, config, dependency, docs, research, or orchestration +path; failure to produce the exact diagnostic; inability to validate known response fields before +discarding additive fields; inability to reject disallowed input fields; an owned-path baseline +failure; secret/private-path evidence; or any attempt to start `P1.S2`. + +A blocked, failed, or rejected result ends this node. Do not retry, refill, salvage into a broader +worktree, or launch a replacement. The controller must issue a separately reviewed packet revision +before any further producer can exist. + +## Handoff + +Write `.codex-handoff/phase-01-p1-s1-schema-version-remediation.json` with the schema from +`PACKET_STANDARD.md`. Include the immutable `baseSha`, exact `phaseStartSha`, plan-bundle commit, +packet revision, independent-review quote and provenance, explicit superseded packet revision, result +commit when available, both evidence IDs and proof levels, every changed path, exact commands and exit +codes, tool versions, the complete negative-result matrix, unverified claims, blockers, and the patch +manifest (`git diff --binary --full-index ` SHA-256 plus changed-path SHA-256 values). + +The smallest safe successor-controller objective is: independently review the remediation candidate +against the quoted finding; if and only if accepted, integrate it on top of the router packet commit, +record the exact integration commit and reciprocal provenance, and keep `P1.S2` blocked. Any `P1.S2` +authorization requires a separate later router packet and is not a successor action of this node. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-s2-conformance.md b/docs/hosted-web-phases/phase-01/lanes/p1-s2-conformance.md new file mode 100644 index 0000000000..b8a763931f --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-s2-conformance.md @@ -0,0 +1,183 @@ +# P1.S2 conformance lane + +## Authority and provenance + +- Phase/node: `phase-01` / `P1.1C` +- Packet revision: `phase-01-s2-conformance-r1` +- Base SHA: `041b5c7c2d3225b7dc2eca9e9b7b71aa33217060` +- Depends on: independently accepted and integrated `P1.1A` +- Evidence owner: `P1.1C` +- Evidence IDs: `P1.1C.CONFORMANCE`, `P1.1C.RATCHETS` +- Handoff: `.codex-handoff/phase-01-p1-1c.json` +- Result states: `verified | characterized | blocked | failed` + +This is one of exactly two P1.S2 producer packets. It becomes executable only after the router commit +containing it is integrated and the successor controller reports `live=true`. It neither launches a +worker nor authorizes P1.1B or P1.S3+ work. + +## Mission + +Implement only the frozen semantic-harness scaffold, synthetic in-memory team-lifecycle fixture +corpus, and ADR-19/20 dependency/parity/renderer ratchets. Prove the P1.1C-owned negative controls +without creating P1.1D feature code, test transport adapters, production registration, a global API, +or filesystem behavior. + +## Exact mandatory reads + +Read in this order; no directory, glob, or implicit sibling read is authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. `docs/hosted-web-phases/phase-01/lanes/p1-s2-conformance.md` +8. `CLAUDE.md` +9. `AGENT_CRITICAL_GUARDRAILS.md` +10. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +11. `docs/hosted-web-phases/PACKET_STANDARD.md` +12. `docs/hosted-web-phases/phase-01/execution-dag.md` +13. `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +14. `docs/hosted-web-phases/phase-01/conformance-and-tests.md` +15. `docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json` +16. `docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json` +17. `.codex-handoff/phase-01-p1-s1-schema-version-remediation.json` +18. `src/shared/contracts/hosted/app-error.ts` +19. `src/shared/contracts/hosted/identifiers.ts` +20. `src/shared/contracts/hosted/index.ts` +21. `src/shared/contracts/hosted/query-context.ts` +22. `src/shared/contracts/hosted/revision.ts` +23. `src/main/http/index.ts` +24. `src/main/http/teams.ts` +25. `src/main/ipc/teams.ts` +26. `src/main/services/infrastructure/HttpServer.ts` +27. `src/main/standalone.ts` +28. `src/preload/constants/ipcChannels.ts` +29. `src/preload/index.ts` +30. `src/renderer/api/index.ts` +31. `package.json` +32. `tsconfig.json` +33. `vitest.config.ts` + +The files in items 23–30 are inspection-only frozen production boundaries. There are no pre-existing +mandatory P1.1C scripts or fixtures; this lane creates only the exact owned paths below. + +## Exact writable paths + +- `.codex-handoff/phase-01-p1-1c.json` +- `scripts/hosted-web/phase-1/check-feature-dependencies.ts` +- `scripts/hosted-web/phase-1/check-parity-references.ts` +- `scripts/hosted-web/phase-1/check-renderer-boundaries.ts` +- `test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts` +- `test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts` +- `test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts` +- `test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts` +- `test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts` +- `test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts` +- `test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts` +- `test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts` +- `test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts` +- `test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts` +- `test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts` +- `test/architecture/hosted-web/phase-1/parity/parity-references.test.ts` +- `test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json` +- `test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json` + +Everything else is read-only, including P1.1B paths, P1.1D feature/tests/adapters, existing P1.1A +contracts/tests/handoffs, package/lock/config files, docs, research, production registration, and the +future P1.R1 evidence path. A needed extra path is a stop condition. + +## Acceptance and negative controls + +1. The semantic harness and fixed corpus are deterministic, in-memory, path-free, transport-neutral + P1.1D inputs; they do not implement the future list use case or an IPC/HTTP-shaped adapter. +2. The three scanners are narrow test tooling, content-sensitive where required, and fail deliberate + mutations without becoming production manifests or generated-client inputs. +3. Owned controls fail with the frozen diagnostics: `P1.NEG.CORE_SIDE_EFFECT` / + `phase1-core-side-effect-forbidden`; `P1.NEG.HOSTED_ELECTRON_API` / + `phase1-hosted-electron-api-forbidden`; `P1.NEG.IMPORT_FORBIDDEN` / + `phase1-core-import-forbidden`; `P1.NEG.LEGACY_GOD_DTO` / + `phase1-legacy-god-dto-forbidden`; `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1` / + `phase1-filesystem-adapter-forbidden`; `P1.NEG.PARITY_DRIFT` / + `phase1-parity-reference-drift`; `P1.NEG.PATH_SECRET_LEAK` / + `phase1-path-secret-leak`; `P1.NEG.PRODUCTION_ADAPTER_MOUNT` / + `phase1-test-adapter-production-import`; and `P1.NEG.RATCHET_REGRESSION` / + `phase1-ratchet-regression`. +4. Every negative has its accepted-manifest positive neighbor where that neighbor is P1.1C-owned. The + P1.1D-owned positive neighbors for `LEGACY_GOD_DTO` and `NO_FILESYSTEM_ADAPTER_PHASE1` remain + absent and explicitly `unverified`; this lane must not create or edit them. +5. Existing debt may be pinned or quarantined but not declared fixed. Counts may not increase; a rename + cannot evade a content-based scan; no new exception, dependency, config change, or shared ratchet + file is allowed. +6. Fixtures contain only synthetic IDs, fixed clocks, deterministic outcomes, and fake principals. No + host/project/runtime path, credential, auth/provider payload, command body, network, process, + watcher, repair, cleanup, mutable cache, or real project is used. +7. Both evidence IDs reach the strongest truthful proof level. P1.1D semantics, production isolation + beyond the owned ratchet, P1.R1 acceptance, and complete Phase 1 remain unverified. + +## Required checks + +Run and record exact commands, exit codes, and tool versions: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance test/architecture/hosted-web/phase-1/dependencies test/architecture/hosted-web/phase-1/parity test/architecture/hosted-web/phase-1/renderer-boundaries +pnpm lint:fast:files -- scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts +pnpm typecheck +pnpm exec prettier --check .codex-handoff/phase-01-p1-1c.json scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json +git diff --check +git status --short +``` + +The accepted manifest freezes these negative-control commands; run each applicable command and record +the exact diagnostic: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts +``` + +The manifest also freezes the cross-owner command +`pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/features/team-lifecycle/contracts/listTeamLifecycleSummaries.test.ts` +for `P1.NEG.LEGACY_GOD_DTO`. Its P1.1D-owned second path does not exist in P1.S2. Do not create it or +claim the combined result; record the combined check and P1.1D positive-neighbor proof as `unverified` +while the focused P1.1C dependency test proves only this lane's frozen fixture/scanner half. + +Prove that the changed/untracked set is exactly the 28 writable paths, +every non-owned path matches `phaseStartSha`, and the integrated router start differs from the +canonical base only on its eight contract-owned docs paths. Scan all changed and untracked files, +including JSON and the handoff, for secrets, credentials, auth/provider payloads, private, home, or +real-project paths, raw command/runtime bodies, and binary files; review every match. + +## Stop conditions + +Stop and return the standard blocker record on a stale base, router, packet, phase start, or controller +with `live!=true`; pre-integration work; duplicate/prior packet use; path overlap; changed predecessor +or accepted evidence; a need for any extra path or dependency; a missing mutation/positive neighbor +within P1.1C ownership or exact diagnostic; an attempt to create a P1.1D-owned neighbor; production +import/mount exposure; filesystem/path-taking/runtime work; non-deterministic or secret/private-path +evidence; unclassified owned-path failure; or any attempt to start P1.S3+. Do not retry, refill, repair +the sibling, or widen scope. + +## Handoff + +Write `.codex-handoff/phase-01-p1-1c.json` using `PACKET_STANDARD.md`. Include base, exact integrated +router `phaseStartSha`/plan bundle, packet revision, both evidence IDs and proof levels, exact changed +paths, all commands/exit codes/tool versions, the complete owned negative-result matrix and stable +diagnostics, fixture hashes, explicitly unverified P1.1D neighbor/semantic claims, blockers, and the +binary patch SHA-256 plus per-file SHA-256 values. + +The only safe next action is to wait for the independent P1.1B handoff and a separately authorized +P1.R1 router decision. Do not request or start P1.R1, P1.1D, integration, or production work. diff --git a/docs/hosted-web-phases/phase-01/lanes/p1-s2-routes.md b/docs/hosted-web-phases/phase-01/lanes/p1-s2-routes.md new file mode 100644 index 0000000000..3f770f6256 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/p1-s2-routes.md @@ -0,0 +1,132 @@ +# P1.S2 routes lane + +## Authority and provenance + +- Phase/node: `phase-01` / `P1.1B` +- Packet revision: `phase-01-s2-routes-r1` +- Base SHA: `041b5c7c2d3225b7dc2eca9e9b7b71aa33217060` +- Depends on: independently accepted and integrated `P1.1A` +- Evidence owner: `P1.1B` +- Evidence IDs: `P1.1B.ROUTES`, `P1.1B.CAPABILITIES` +- Handoff: `.codex-handoff/phase-01-p1-1b.json` +- Result states: `verified | characterized | blocked | failed` + +This is one of exactly two P1.S2 producer packets. It becomes executable only after the router commit +containing it is integrated and the successor controller reports `live=true`. It neither launches a +worker nor authorizes P1.1C or P1.S3+ work. + +## Mission + +Implement only the frozen RouteCatalog collection/assertions and separate capability cross-reference. +Route and method/path IDs, required handler/schema/policy/client/test references, and capability/action +IDs must be unique and complete. Catalog assertions operate on immutable test descriptors; production +catalogs reject `testOnly` routes and production support remains absent. Do not create a runtime route +framework, dispatcher, generated client, feature DTO, or production registration. + +## Exact mandatory reads + +Read in this order; no directory, glob, or implicit sibling read is authorized: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/controller-packet.md` +7. `docs/hosted-web-phases/phase-01/lanes/p1-s2-routes.md` +8. `CLAUDE.md` +9. `AGENT_CRITICAL_GUARDRAILS.md` +10. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +11. `docs/hosted-web-phases/PACKET_STANDARD.md` +12. `docs/hosted-web-phases/phase-01/execution-dag.md` +13. `docs/hosted-web-phases/phase-01/architecture-and-contracts.md` +14. `docs/hosted-web-phases/phase-01/conformance-and-tests.md` +15. `docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json` +16. `.codex-handoff/phase-01-p1-s1-schema-version-remediation.json` +17. `src/shared/contracts/hosted/app-error.ts` +18. `src/shared/contracts/hosted/identifiers.ts` +19. `src/shared/contracts/hosted/index.ts` +20. `src/shared/contracts/hosted/query-context.ts` +21. `src/shared/contracts/hosted/revision.ts` +22. `package.json` +23. `tsconfig.json` +24. `vitest.config.ts` + +There are no pre-existing mandatory scripts or fixtures. The lane creates only its owned scripts/tests +listed below and may not recursively read preserved research. + +## Exact writable paths + +- `.codex-handoff/phase-01-p1-1b.json` +- `src/main/composition/hosted/routing/RouteCatalog.ts` +- `src/main/composition/hosted/routing/index.ts` +- `src/main/composition/hosted/routing/route-types.ts` +- `test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts` +- `test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts` +- `test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts` +- `test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts` +- `test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts` + +Everything else is read-only, including P1.1C paths, existing P1.1A contracts/tests/handoffs, +package/lock/config files, docs, research, production IPC/HTTP/preload/renderer registration, legacy +APIs, and the future P1.R1 evidence path. A needed extra path is a stop condition. + +## Acceptance and negative controls + +1. Route IDs and method/path pairs are unique; every descriptor requires stable owner, trust kind, + auth-policy, readiness, schema, handler, client, and semantic-test references. +2. RouteCatalog is a frozen assertion collection over immutable descriptors, not a mutable cache, + runtime manifest, dispatcher, router framework, or source of feature business rules. +3. Capability/action IDs remain separate and feature-owned. Fixture route presence never implies + production support or dynamic resource allowance. +4. `P1.NEG.ROUTE_DRIFT` deliberately duplicates a route/method/path and removes a required reference; + the focused test fails it with exactly `phase1-route-catalog-drift` while the adjacent valid + descriptors pass. +5. `P1.NEG.CAPABILITY_MOUNT` sets production support or mounts a `testOnly` route; the focused test + fails it with exactly `phase1-test-capability-production-mount` while the adjacent capability + descriptors pass. +6. No production route, channel, client, handler, policy, schema, feature contract, or registration is + created or modified. No path, secret, auth/provider payload, or real-project data enters fixtures or + handoff evidence. +7. Both evidence IDs reach `target_verified`, or the handoff records the narrower truthful proof level + and leaves the unmet claim unverified. + +## Required checks + +Run and record exact commands, exit codes, and tool versions: + +```bash +pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts +pnpm exec vitest run test/architecture/hosted-web/phase-1/routes +pnpm lint:fast:files -- src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts +pnpm typecheck +pnpm exec prettier --check .codex-handoff/phase-01-p1-1b.json src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts +git diff --check +git status --short +``` + +Also prove that the changed/untracked set is exactly the nine writable paths, every non-owned path +matches `phaseStartSha`, and the integrated router start differs from the canonical base only on its +eight contract-owned docs paths. Scan all changed and untracked files, including the handoff, for +credentials, secrets, auth/provider payloads, private, home, or real-project paths, raw command/runtime +bodies, and binary files; record reviewed matches, not merely a tracked-only zero-match search. + +## Stop conditions + +Stop and return the standard blocker record on a stale base, router, packet, phase start, or controller +with `live!=true`; pre-integration work; duplicate/prior packet use; path overlap; changed predecessor +or accepted evidence; a need for any extra path or dependency; inability to keep route and capability +sources separate; a missing deliberate negative or exact diagnostic; mutable/runtime catalog behavior; +production exposure; filesystem/path-taking work; secret/private-path evidence; unclassified owned-path +failure; or any attempt to start P1.S3+. Do not retry, refill, repair the sibling, or widen scope. + +## Handoff + +Write `.codex-handoff/phase-01-p1-1b.json` using `PACKET_STANDARD.md`. Include base, exact integrated +router `phaseStartSha`/plan bundle, packet revision, both evidence IDs and proof levels, exact changed +paths, all commands/exit codes/tool versions, deliberate negative results and diagnostics, unverified +claims, blockers, and the binary patch SHA-256 plus per-file SHA-256 values. + +The only safe next action is to wait for the independent P1.1C handoff and a separately authorized +P1.R1 router decision. Do not request or start P1.R1, P1.1D, integration, or production work. diff --git a/docs/hosted-web-phases/phase-01/lanes/pr252-base-conflict-resolution.md b/docs/hosted-web-phases/phase-01/lanes/pr252-base-conflict-resolution.md new file mode 100644 index 0000000000..b892169e83 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/lanes/pr252-base-conflict-resolution.md @@ -0,0 +1,140 @@ +# PR #252 live-head conflict-resolution lane + +## Authority + +- Phase/node: `phase-02` / `PR252.SYNC.PRODUCER` +- Lane: `pr252-latest-base-conflict-resolution` +- Revision: `pr252-live-head-sync-router-v2` +- Repository/PR: `777genius/agent-teams-ai#252` +- Product capacity: one attempt and one producer +- Mechanical evaluator: controller directly; no mechanical reviewer +- Semantic reviewer: one fresh independent combined reviewer +- Terminal state: `HOLD` + +This lane contains no durable PR head, base, or conflict-path pin. Before worker start, the broker +atomically records: + +```text +attempt.canonicalHeadSha +attempt.resolvedBaseSha +attempt.materializationSourceSha = attempt.canonicalHeadSha +attempt.orderedParentShas = [attempt.canonicalHeadSha, attempt.resolvedBaseSha] +attempt.expectedOldHeadSha = attempt.canonicalHeadSha +attempt.conflictPaths +attempt.focusedTestCommands +``` + +The broker resolves the live head and resolves the live base exactly once. Both are immutable for the +attempt. A partial, ambiguous, non-commit, duplicate-capacity, or mismatched binding starts no worker. + +## Mandatory reads + +Read accepted router bytes and attempt inputs in this order: + +1. `AGENTS.md` +2. `docs/hosted-web-phases/START_HERE.md` +3. `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` +4. `docs/hosted-web-phases/README.md` +5. `docs/hosted-web-phases/EXECUTION_INDEX.json` +6. `docs/hosted-web-phases/phase-01/README.md` +7. `docs/hosted-web-phases/phase-01/controller-packet.md` +8. `docs/hosted-web-phases/phase-01/execution-dag.md` +9. this lane packet +10. `CLAUDE.md` +11. `AGENT_CRITICAL_GUARDRAILS.md` +12. `docs/FEATURE_ARCHITECTURE_STANDARD.md` +13. `docs/hosted-web-phases/PACKET_STANDARD.md` +14. `docs/hosted-web-phases/ORCHESTRATION_GUARDS.md` +15. the immutable attempt contract +16. every path in `attempt.conflictPaths` and command in `attempt.focusedTestCommands` + +Do not inspect unrelated projects, workers, repositories, evidence, provider state, team state, or +user/private directories. + +## Exact producer scope + +`attempt.conflictPaths` is the complete writable set derived from the ordered mechanical merge. The +producer may resolve those paths and nothing else. It must not add, remove, rename, move, reformat, +or compile-repair another path; mutate a mechanically merged non-conflict byte; import an old patch +or prior attempt tree; change either bound SHA; or widen scope. + +If preservation needs a non-conflict edit, report a blocker and end `HOLD`. + +## Both-parent contract + +For every conflict, preserve the relevant behavior from: + +1. `attempt.canonicalHeadSha`; and +2. `attempt.resolvedBaseSha`. + +Resolve overlap deliberately at the smallest conflict site. Whole-side selection, behavior deletion, +weakened validation, hidden fallback, skipped test, compatibility shim, unrelated cleanup, or a test +expectation changed to conceal a regression fails the lane. + +When requirements are incompatible inside exact scope, record the conflict; do not guess. + +## Focused tests and mechanical gates + +Run every exact `attempt.focusedTestCommands` entry. The frozen set covers each conflict and the +relevant behavior from both parents. A command that cannot run is a failed gate, not permission to +replace it or install dependencies. + +Also run and record: + +1. attempt schema, exact commit-object, source, ordered-parent, and expected-old-head validation; +2. fresh live-head/base comparison without rebinding; +3. exact conflict-only diff and non-conflict byte-equality proof; +4. zero unmerged index entries and unresolved conflict markers; +5. `pnpm typecheck`; +6. `pnpm lint:fast:files -- ` when non-empty; +7. `pnpm exec prettier --check `; +8. `git diff --check`; +9. binary, symlink, NUL, secret, credential, auth/provider payload, private/real-project path, and + destructive-behavior classification; and +10. proof that no install/update, real-project, team launch/provisioning, product terminal/smoke, + provider/auth, raw lifecycle, other-repository, broad-docs, or Fast activity occurred. + +Any mismatch or unclassified result fails the attempt. + +## Self-review and result + +Reread the complete resolved diff and review attempt binding, both-parent behavior preservation, +test adequacy, integration coherence, architecture, security, exact scope, command results, and +remaining risk. + +Return one immutable runtime-owned result bound to the attempt and resolved tree SHA, with exact +commands/exits, self-review, P0/P1/P2 findings, blockers, and `terminalState: HOLD`. Write no +repository handoff artifact; do not stage, commit, launch review, integrate, or authorize a successor. + +## Controller, review, and promotion + +After producer `HOLD`, the controller compares the live head/base, freshly materializes the exact +producer tree, and directly reruns all mechanical gates. There is no separate mechanical reviewer. + +After another equality check, exactly one fresh independent combined +integration/architecture/security semantic reviewer examines both parents, every resolution, the +complete tree, tests, architecture, security, producer self-review, and controller evidence. The +reviewer cannot edit or repair. Only exact `ACCEPT` with P0/P1/P2 `0/0/0` permits promotion. + +Immediately before promotion, the broker proves the live head equals `attempt.canonicalHeadSha` and +the live base equals `attempt.resolvedBaseSha`, then creates: + +```text +parents[0] = attempt.canonicalHeadSha +parents[1] = attempt.resolvedBaseSha +tree = attempt.acceptedReviewedTreeSha +``` + +It pushes with `attempt.canonicalHeadSha` as expected old head and proves the remote and GitHub head +equal the merge commit, the GitHub base still equals `attempt.resolvedBaseSha`, and mergeability is +resolved and non-conflicting for that exact pair. + +## Drift and HOLD + +Later head or base drift invalidates only the bound attempt and every attempt result. After terminal +state and clear capacity, the controller may admit a fresh atomic attempt using this same revision. +It never retargets a running attempt or reuses a worker. + +Stop on any router, repository, PR, attempt, head, base, commit-object, source, parent-order, conflict, +scope, byte, command, test, review, tree, push, remote, or GitHub-proof mismatch. End `HOLD`; launch no +successor. diff --git a/docs/hosted-web-phases/phase-01/operations-and-risk.md b/docs/hosted-web-phases/phase-01/operations-and-risk.md new file mode 100644 index 0000000000..0c74c0dcf3 --- /dev/null +++ b/docs/hosted-web-phases/phase-01/operations-and-risk.md @@ -0,0 +1,87 @@ +# Proposed migration, operations, and risk controls + +Status: blocked proposal. All IDs, thresholds, paths, and owners are proposed until serial bootstrap. + +## Migration and ratchet sequence + +1. Characterize and hash the legacy list seams; do not change them. +2. Add the isolated kernel and team-lifecycle contracts. +3. Prove the application query against an in-memory test reader. +4. Add IPC-shaped and HTTP-shaped adapters only under the isolated conformance test tree; add no + production channel, route, preload/global facet, renderer client, or registration. +5. Run shadow semantic comparisons in fixtures; no dual writes exist because the slice is read-only. +6. Add ratchets that forbid new legacy/global dependencies while counting untouched debt. +7. Freeze evidence. Phase 2 may add real output adapters and decide renderer/route cutover only after + stable identity and auth/readiness prerequisites exist. + +No old authority is removed in Phase 1. The conformance seam has no production reachability, so +rollback is removal of the new unregistered feature contracts, fixture descriptors, and test harness +while retaining rejected evidence. There are no new production IPC calls to disable. + +## Rollback triggers and procedure + +Trigger rollback on legacy desktop regression, accidental production IPC/HTTP adapter exposure, auth +admission bypass, semantic mismatch, raw error/path leak, unstable cursor/revision behavior, +dependency cycle, performance budget breach, or ratchet false-negative. + +The integration owner removes only the unregistered specimen and verifies that production IPC, preload, +HTTP, renderer, and legacy behavior remain byte/graph-equivalent to the frozen baseline, reruns +architecture scans, and records the rejected evidence IDs. +No state rollback, data migration, file repair, or dual-write reconciliation is needed. Never weaken a +negative gate to make rollback green. Phase 2 cannot consume rejected contracts until a new reviewed +revision supersedes them. + +## Observability + +The proposed slice emits structured, redacted diagnostics at adapters/composition only: + +- route/channel/action ID, request ID, outcome code, duration bucket, item count, page-present flag, + and diagnostic ID; +- no actor/session/team identifier, cursor value, display name, host path, cookie/token, provider + output, stack, or request body; +- metrics for request count, outcome count, cancellation, invalid cursor, use-case duration, adapter + duration, response bytes, and denied-before-use-case count; +- one startup assertion summary for catalog counts and test-only route production absence. + +Logs are not conformance evidence by themselves. Tests spy on calls and normalized outcomes. Proposed +cardinality and payload limits are frozen in serial bootstrap; unknown codes are rejected rather than +becoming labels. + +## Risk register + +| Proposed risk | Likelihood/impact | Detection | Mitigation / owner | +| ------------------------------------------------- | ----------------- | ------------------------------------------------- | ----------------------------------------------- | +| Phase 0 assumption changes contract shape | High/high | prerequisite and stale-packet checks | keep blocked; `P1.S0` re-derives proposal | +| Tiny kernel grows into god API | Medium/high | export census and unused-abstraction review | usage-required rule; `R2` | +| RouteCatalog becomes framework/runtime manifest | Medium/high | import/shape negatives | metadata/assertions only; `R1` | +| Capability and route state merge | Medium/high | source-separation and cross-reference tests | feature-owned separate descriptors; `1B` | +| List slice invents identity | High/high | fixture/DTO review and production-adapter absence | synthetic test IDs only; Phase 2 owns identity | +| Test IPC/HTTP adapter leaks into production | Low/critical | production import-graph/mount negative | test-only composition; `1C`/`P1.I` | +| IPC-shaped and HTTP-shaped mappings differ subtly | Medium/high | shared vectors plus use-case spy | semantic harness; `1C`/`R2` | +| Safe errors leak legacy paths/secrets | Medium/critical | canary vectors and changed-file scan | allowlisted envelope/redaction; `R2` | +| Cursor scope/revision replay is ambiguous | Medium/high | foreign/expired/snapshot-change vectors | opaque bound cursors; `1A`/`1D` | +| ADR-19 scanner misses an omission | Medium/high | omitted-row/signature/reference negatives | omission-sensitive fixtures; `R1` | +| ADR-20 scan has evasion or false positives | Medium/high | renamed/aliased/dynamic access fixtures | import graph plus AST/source census; `R1` | +| Shared-file merge corrupts ownership | Medium/high | path manifest and status checks | one integration writer; controller | +| New dependency destabilizes build/cache | Low/medium | lockfile/status check | no dependency changes; packet revision required | +| Performance gate is noisy or gamed | Medium/medium | fixed fixtures, separated overhead, repeated run | host-qualified baseline; `P1.I` | +| Existing failures are mislabeled green | Medium/high | fingerprint comparison | preserve exact inherited ledger; controller | +| Filesystem work enters deferred Phase 1 scope | Low/critical | port/signature/import no-filesystem negative | stop Phase 1; reopen `P1-GAP-009`; `R1` | + +No open critical risk may be accepted at Phase 1 freeze. High risks require a named owner, evidence, +and either mitigation or explicit phase-blocking decision. + +## Integration evidence and handoff + +The freeze must publish a decision register, evidence index with proof levels/hashes, unique estimate +reconciliation, inherited-failure comparison, performance report, dependency/lockfile assertion, +scope/diff/secret/path report, reciprocal reviews, rollback result, and the smallest Phase 2 input. +Evidence says `source_observed`, `fixture_characterized`, or `target_verified` precisely; it does not +call an isolated Fastify injection a production hosted verification. + +## Phase 1 completion boundary + +Phase 1 is done only when the [controller Definition of Done](./controller-packet.md#definition-of-done) +is proved at the integrated SHA. It does not mean hosted team reads, stable identities, browser auth, +production routing, renderer migration, or lifecycle mutation are complete. Those claims remain +explicitly unverified and feed later phases. diff --git a/docs/hosted-web-phases/phase-01/packet-inputs.md b/docs/hosted-web-phases/phase-01/packet-inputs.md new file mode 100644 index 0000000000..1bce66d09e --- /dev/null +++ b/docs/hosted-web-phases/phase-01/packet-inputs.md @@ -0,0 +1,133 @@ +# Phase 1 inputs and prerequisite gates + +Status: blocked proposal. All IDs and paths below are proposed until serial bootstrap. + +## Provenance + +| Fact | Current value | Consequence | +| ----------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| Exact planning base | `3bc0dfa7c00261785c0c752270cb302a9294e751` | Exact base for this reconciled bundle; not a Phase 1 start SHA. | +| Prior approved-plan predecessor | `42ec333848e29e97c41699b9fed73ed199740e3f` | Source of the independently approved plan content; superseded as the planning base. | +| Phase 0 start | `a32f509e6d9bd31ba2135940e336729bf90c3d93` | Provenance only; not completion evidence. | +| Earlier integrated Phase 0 evidence predecessor | `c958c872fa22edf9b2d6a0741d7781b00957903c` | Remains the evidence role recorded by the current freeze candidate. | +| Phase 0 freeze integration commit | Missing | Phase 1 remains blocked. | +| Candidate evidence-index SHA-256 | `d5c8725dfb22f7e0228e0dd51f53d978d117ed7253fdb279c8ddba7000ff8758` | Candidate only; must be replaced by the integrated digest. | +| Target-image decision | `P0.D.TARGET_IMAGE`: accepted narrowing | Closes the Phase 0 target-image gate; does not admit an image. | +| Phase 1 plan-bundle commit | Missing | Controller records it only after fresh review and integration. | +| Phase 1 start SHA | Missing | Created only after an authorized serial bootstrap. | +| Implementation authorization | Not granted | This task authorizes planning only. | + +`3bc0dfa7…` is one direct descendant of `42ec33384…`. It adds the accepted target-image narrowing but +does not rewrite the distinct Phase 0 evidence roles inside the freeze candidate. + +## Plan-review provenance + +- The remediation patch with SHA-256 + `cb38128a28e4f1edfacf5242579d421c16c295a638f98ec71cdb0a5aee42e830` received independent + `APPROVE` for its exact blocked planning scope. Its architecture, DAG, transport boundaries, + conformance design, gap dispositions, and operational controls are preserved here. +- The subsequent adoption patch with SHA-256 + `82e07f79d9f53d1ec9ba9253a380f24a786d547387857a5df4229f09b2a16295` was rejected only because + it carried the stale exact-image prerequisite onto `3bc0dfa7…`. It is superseded by this + reconciliation and is not integration authority. +- Because this bundle changes the approved prerequisite semantics, `P1.GATE.PLAN_REVIEW` remains + pending fresh exact-base review. Prior approval is evidence for preserved content, not approval of + this new diff. + +## Supported inputs + +- Parent-plan Phase 1 tasks 1–12, exit gate, ADR-15, ADR-19, ADR-20, and the feature architecture + standard define the intended result. +- The current W1 parity ledger classifies `P0.W1.API.TeamsAPI.list` as a decomposed + `team-lifecycle` read with proposed action `team.lifecycle.list`. +- Current source confirms four inconsistent legacy seams: `TeamsAPI.list`, `team:list`, + `GET /api/teams`, and a browser implementation that warns and returns `[]`. +- Phase 0 W1/W2, W3/W5, and W4/W6 current dispositions may inform Phase 1 without upgrading + characterization to target verification. +- `docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json` records + `P0.D.TARGET_IMAGE` as accepted, `phase0Gate=closed_by_accepted_narrowing`, and + `exactImageEarliestOwner=phase-5`. Its Phase 5 admission remains fail closed with all 51 + canonical-source gaps and nine terminal-sensitive surfaces preserved. +- The freeze candidate's older statement that final target-image proof is a current Phase 0 blocker + is narrowly superseded by that accepted decision. Its pending integration/digest, estimate, final + gate, decision-register, bootstrap, and authorization blockers are not superseded. +- Historical R12, RW35, R46, hold-all-adoption, and failed-freeze conclusions remain superseded as + current authority. Their bytes remain historical evidence. + +## Blocking prerequisite gates + +Every row must have controller-owned evidence and an explicit pass before implementation packet +materialization. + +| Proposed gate | State | Required proof | +| -------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `P1.GATE.BASE` | Pass for planning only | Exact planning base is `3bc0dfa7…`; no Phase 1 implementation worktree exists. | +| `P1.GATE.P0_FREEZE` | Blocked | Independent review and integration of a Phase 0 freeze candidate reconciled with `P0.D.TARGET_IMAGE`. | +| `P1.GATE.P0_DIGEST` | Blocked | Integrated evidence-index digest and byte provenance recorded. | +| `P1.GATE.TARGET_IMAGE` | Pass by narrowing | Base records the Phase 0 gate closed by accepted narrowing. Exact image/profile, target-executed provider canaries, full inventory, terminal-negative admission, and standalone production composition remain fail-closed Phase 5 obligations. | +| `P1.GATE.P0_FINAL` | Blocked | Final Phase 0 broad gate, including comparison with the inherited seven-diagnostic typecheck set; failures are classified, not normalized into passes. | +| `P1.GATE.ESTIMATE` | Blocked | Unique-bucket estimate reconciliation with duplicates removed and variance resolved. | +| `P1.GATE.DECISIONS` | Blocked | The complete Phase 0 decision register is frozen with the accepted target-image narrowing and every other outcome recorded as accepted, narrowed, reopened, or blocked; no producer recommendation is silently frozen. | +| `P1.GATE.PLAN_REVIEW` | Pending | This reconciled bundle receives fresh architecture, security, test, and scope review against its exact commit. | +| `P1.GATE.SERIAL_BOOTSTRAP` | Blocked | Proposed IDs, paths, fixtures, commands, shared writers, and review pairs are checked against integrated source and frozen in one bootstrap commit. | +| `P1.GATE.AUTHORIZATION` | Blocked | Fresh explicit user/controller authorization for Phase 1 implementation. | + +The passed target-image row is not an image-admission claim and cannot be used to mount a route, +advertise a capability, enable mutation, or compose a standalone production server. Those actions +remain behind the fail-closed Phase 5 gate. + +## Definition of Ready + +- [ ] Every blocking prerequisite above passes at its required proof level. +- [ ] The router marks Phase 1 ready and Phase 0 frozen; no two phases are active. +- [ ] The reviewed plan bundle commit, parent-plan commit, predecessor commit, evidence digest, and + inherited-failure ledger are recorded without conflating provenance roles. +- [ ] Serial bootstrap resolves every proposed identifier and confirms every proposed path exists or + is legal to create on the integrated predecessor. +- [ ] The first slice remains read-only; both IPC-shaped and HTTP-shaped adapters are test-only and + production-unreachable, so no identity or auth shortcut is required. +- [ ] Exact non-overlapping writer sets, shared-writer owner, reciprocal reviews, estimates, and checks + are frozen. +- [ ] No dependency or lockfile change is required; otherwise the controller issues a separately + reviewed packet revision. +- [ ] Baseline tests and architecture scans run from the eventual `phaseStartSha`; inherited failures + have owners and fingerprints. +- [ ] Explicit implementation authorization is attached. + +Failure of any item keeps producer target at zero. Planning completeness is not implementation +readiness. + +## Gap-audit disposition register + +Every audited gap remains carried by ID into serial bootstrap. “Proposal-resolved” means this bundle +chooses a coherent design; it is not target verification or permission to implement. “Acceptance +gate” means the choice must become an executable positive/negative fixture at `P1.S0`. A deferred row +names its later owner and a Phase 1 gate that prevents the deferred concern from entering this phase. + +| Audit ID | Proposal disposition | Required carry-forward evidence | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `P1-GAP-001` | Proposal-resolved: Phase 1 owns a non-advertised conformance specimen; Phase 2 owns canonical identity, real readers, and production registration. Both IPC and HTTP adapters are test-only. | `P1.NEG.PRODUCTION_ADAPTER_MOUNT` proves neither adapter is production-importable or mountable. | +| `P1-GAP-002` | Proposal-resolved: the allowlisted `TeamLifecycleSummary` projection is not a legacy aggregate and contains no raw name/path identity. | `P1.NEG.LEGACY_GOD_DTO` plus schema fixtures. | +| `P1-GAP-003` | Proposal-resolved and acceptance-gated by the complete outcome applicability/oracle table in [conformance-and-tests.md](./conformance-and-tests.md). | `P1.NEG.SEMANTIC_OUTCOME` and normalized-vector manifest, including all audited states. | +| `P1-GAP-004` | Proposal-resolved: no Phase 1 production route or IPC channel exists; test admission binders are conformance inputs, not product auth. | Production mount/import failure and absent-capability assertions. | +| `P1-GAP-005` | Acceptance-gated: the finite proposed kernel grammar must be frozen without widening during `P1.S0`. | Round-trip and malformed/version/ID/revision/cursor fixtures. | +| `P1-GAP-006` | Proposal-resolved: pairwise writer sets and the sole integration writer are listed in [execution-dag.md](./execution-dag.md). | Exact no-glob path manifest and overlap check at `P1.S0`. | +| `P1-GAP-007` | Acceptance-gated: every named negative in [conformance-and-tests.md](./conformance-and-tests.md) requires an adjacent positive, mutation, stable diagnostic, command, and owner. | Frozen fixture manifest; category-only evidence is rejected. | +| `P1-GAP-008` | Acceptance-gated: path/symbol baselines are monotonic and exception rows have owner, reason, introduced revision, removal phase, and expiry test. | Rollback keeps all legacy production behavior unchanged and both specimen adapters unreachable. | +| `P1-GAP-009` | Explicitly deferred to Phase 2's first filesystem-backed adapter: `P1.NEG.TEST_ROOT_ESCAPE` will require fresh marked temporary project/runtime roots, pre-access rejection of unmarked/ambient/home/symlink-escaped roots, and marker-checked narrow cleanup. | Phase 1 must instead pass `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1`, rejecting any path-taking, filesystem-backed, ambient-root, watcher, repair, or cleanup dependency in the specimen, reader, adapters, fixtures, or commands. | +| `P1-GAP-010` | Acceptance-gated: deterministic latency, cancellation, payload/count, invocation, cache/revision, fallback/source, and redacted telemetry budgets are mandatory. | Performance and determinism vectors at fixed 1/50/200-item scales. | + +No disposition may be dropped from the bootstrap decision record. Any proposal-resolved row that +cannot be frozen reopens as a blocker; deferral of `P1-GAP-009` is valid only while the strict Phase 1 +no-filesystem-adapter gate passes. + +## R1 remediation register + +These are planning corrections pending independent re-review, not closed implementation evidence. + +| Finding | Correction in this bundle | Re-review proof | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `P1-R1-001` | Both IPC-shaped and HTTP-shaped adapters, identities, identifiers, and composition are isolated under the test tree; Phase 1 adds no IPC/preload/renderer/HTTP registration. | Deliberate production import/mount failures for both adapters and an unchanged production graph. | +| `P1-R1-002` | Diagram, lane dependencies, capacity, review order, and adoption order all use exactly `1B + 1C -> R1 -> 1D -> R2 -> I`. | Structural DAG comparison across the plan bundle. | +| `P1-R1-003` | `P1.NEG.TEST_ROOT_ESCAPE` is explicitly deferred to Phase 2's first filesystem adapter with marked-root controls; Phase 1 has a strict no-path/no-filesystem-adapter gate. | Positive in-memory fixture plus a path-taking adapter that fails before execution. | +| `P1-R1-004` | Every audited state is classified applicable or inapplicable, with a normalized outcome, warnings/partial policy, ordering, revisions, adapter metadata, negative vector, and later owner. | Complete semantic vector manifest and R2 comparison across all three test paths. | diff --git a/docs/hosted-web-phases/phase-02/README.md b/docs/hosted-web-phases/phase-02/README.md new file mode 100644 index 0000000000..bd2c6e05dd --- /dev/null +++ b/docs/hosted-web-phases/phase-02/README.md @@ -0,0 +1,52 @@ +# Phase 2: identity substrate and read-only team lifecycle + +- Status: `candidate / product blocked` +- Packet revision: `phase-02-jit-router-r1` +- Router base and accepted Phase 1 integration: `d5afa87e79b1f2badd69e65262e5699c0fb61de7` +- Terminal state: `HOLD` + +Scope note: this candidate predates the accepted +[Core v1 scope lock](../../hosted-web-core-v1-scope-lock.md). Its existing code and evidence remain +preserved, but the packet is not authority for automatic startup adoption, full hosted parity, or +broad mutation recovery. Any future activation must first revise the packet against that scope lock. + +This minimal JIT packet turns the accepted Phase 1 exit into one bounded Phase 2 execution wave. It +does not implement product code and is not launch authority until independently reviewed and +broker-integrated. + +## Outcome + +Establish the identity foundation first, then preserve five independently testable, non-overlapping +product slices: workspace identity, team identity, workspace binding, roster identity and the legacy +read adapter. “Legacy adoption” in the historical lane name means transport-neutral read +compatibility; it does not authorize automatic identity publication or startup repair. A later serial +integration node alone owns shared exports, composition and legacy boundary wiring. + +## Packet map + +- [controller-packet.md](controller-packet.md): authority, readiness, capacity, review and integration + policy. +- [execution-dag.md](execution-dag.md): the only legal node ordering. +- [P2.F0 identity foundation](lanes/p2-identity-foundation.md): short serial foundation. +- [P2.A workspace identity](lanes/p2-a-workspace-identity.md). +- [P2.B team identity](lanes/p2-b-team-identity.md). +- [P2.C workspace binding](lanes/p2-c-workspace-binding.md). +- [P2.D roster identity](lanes/p2-d-roster-identity.md). +- [P2.E legacy adoption](lanes/p2-e-legacy-adoption.md). + +## Hard boundaries + +- `P2.A` through `P2.E` start only after accepted foundation integration and may then run in one + exactly-five-lane product epoch. +- Parallel lanes share no writable path and own no barrel, index or composition file. +- Product core follows Clean Architecture, DDD and SOLID. Transport and filesystem details depend on + application ports; core never depends on Electron, HTTP, IPC, Fastify, Node filesystem or main + services. +- The canonical team-lifecycle API facet is transport-neutral and uses canonical IDs. Legacy + team-name DTOs and Electron/HTTP concerns remain outer compatibility adapters. +- Hosted mutation, launch, provider/process work, terminal behavior and production registration are + outside this wave. +- Runtime services provide only execution, materialization, admission, evidence and integration + primitives. This controller packet owns orchestration decisions. + +Every handoff is strict `HOLD`, including successful producer, review and integration handoffs. diff --git a/docs/hosted-web-phases/phase-02/controller-packet.md b/docs/hosted-web-phases/phase-02/controller-packet.md new file mode 100644 index 0000000000..8b209940d1 --- /dev/null +++ b/docs/hosted-web-phases/phase-02/controller-packet.md @@ -0,0 +1,196 @@ +# Phase 2 controller packet: identity and read truth + +## Status and authority + +- Status: `candidate-awaiting-independent-root-review`; product state `blocked`. +- Packet revision: `phase-02-jit-router-r1`. +- Router base SHA: `d5afa87e79b1f2badd69e65262e5699c0fb61de7`. +- Accepted predecessor: `P1.F`, disposition `ACCEPT`, findings P0/P1/P2 `0/0/0`. +- Predecessor integration commit: `d5afa87e79b1f2badd69e65262e5699c0fb61de7`. +- Router review and integration: `unverified`. +- Terminal state: `HOLD`. + +The only current next action is independent root review followed, on acceptance, by broker integration +of the exact 12 router paths. No product admission occurs before the integrated router bytes become +canonical authority. + +## Outcome and non-goals + +The wave first replaces synthetic-only identity assumptions with the small identity foundation. After +that foundation is independently accepted and integrated, exactly five disjoint product lanes produce +the workspace-identity, team-identity, workspace-binding, roster-identity and legacy-adoption slices. +The wave is read-only from hosted transport and does not claim the full Phase 2 exit. + +Non-goals are hosted mutation, authentication, public transport exposure, process/provider launch, +terminal or PTY behavior, broad TeamsAPI parity, unrelated cleanup, dependency upgrades and real +project verification. Draft create/delete remain later desktop/test work and are not smuggled into +this read wave. + +## Definition of Ready + +All conditions are conjunctive: + +1. this router's exact 12 paths have an independent root `ACCEPT` with no unresolved P0/P1 finding; +2. the broker has integrated and activated those exact reviewed bytes; +3. current authority equals the activated router authority and the worktree is clean; +4. the assigned packet revision and exact writable paths match + [EXECUTION_INDEX.json](../EXECUTION_INDEX.json); +5. the predecessor `P1.F` handoff and review remain accepted and unchanged; and +6. the controller admits only the next legal DAG node. + +Until all six hold, product work is blocked. Conditional packet text is not admission. + +## Architecture contract + +Apply Clean Architecture, DDD and SOLID: + +- contracts contain browser-safe values and validation; +- domain owns identity and registration invariants; +- application use cases depend on narrow ports; +- filesystem, SQLite, legacy services, IPC, HTTP and Electron are adapters; +- composition alone selects adapters; and +- each source has one bounded reason to change and interfaces expose only what a use case consumes. + +The canonical Phase 2 team-lifecycle API facet is transport-neutral: no Electron callback/event, +Fastify request/reply, HTTP status, IPC channel, raw filesystem path or legacy team name crosses its +contract. Existing Electron-shaped `TeamsAPI` remains a compatibility boundary; adapters map it to +canonical IDs without importing it into core. Do not introduce an all-parity mega-interface. + +Runtime services own only execution, materialization, admission, evidence and integration primitives. +The controller documents own orchestration: DAG order, capacity, dependencies, reviewer roles, +replacement/retry decisions and successor authorization. Runtime observations never invent product +authority. + +## Capacity and review policy + +There are two product epochs: + +1. one short `P2.F0.IDENTITY` foundation slot; then +2. after accepted foundation integration, exactly five concurrent product slots `P2.A`-`P2.E`. + +A product slot requires declared product-source edits, focused tests and a handoff. Documentation, +research and evidence workers never count as product capacity and cannot satisfy a product node. +Architecture/security reviewers, integration actors and milestone reviewers are also non-product roles. +A replacement reuses the same lane ID and explicitly supersedes the failed attempt; it cannot create +an additional lane. + +Every producer performs its own scope, architecture, security and test self-review. There is no +separate per-lane code-review node. Separate reviewers are authorized only for combined +architecture/security gates, integration and milestone decisions. Reviewer independence and accepted +bytes must be recorded; all decisions end on `HOLD`. + +## Ownership and legal parallelism + +`P2.F0.IDENTITY` exclusively owns its five paths in the execution index, including the only parallel +wave predecessor allowed to edit the shared hosted-contract export. `P2.A`-`P2.E` own the exact, +pairwise-disjoint paths in their packets. They own no `index.ts`, barrel or composition file. + +All undeclared paths are read-only. Ownership overlap, a needed undeclared edit or a need for one +lane's unintegrated output is `packet_conflict`; stop and hand back `HOLD`. Do not coordinate an +informal shared edit. Shared exports, internal-storage registration, hosted composition, IPC/HTTP +wiring and renderer-client wiring are reserved to the later serial `P2.I.INTEGRATION` list in the +execution index. + +## DAG admission + +The legal sequence is: + +`router integration -> F0 -> R0 architecture/security -> IF foundation integration -> {A,B,C,D,E} +-> R1 architecture/security -> I integration -> F milestone`. + +No node may infer acceptance from a producer's `verified` result. `P2.A`-`P2.E` may consume only the +accepted integrated foundation, never a sibling worktree or handoff. The controller admits all five +only when the five exclusive slots and sufficient host budget are available; otherwise it admits a +subset without replacing the fixed DAG or counting support work as missing product capacity. + +## Review and integration node contracts + +The execution index is the exact ownership authority for these non-product nodes: + +- `P2.R0.ARCH_SECURITY` reads the F0 packet, handoff and complete changed paths; replays F0 checks and + reviews identity derivation, dependencies, scope and scans. It writes only + `docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md` and + `.codex-handoff/phase-02-p2-r0.json`. ACCEPT requires compatible opaque identity with complete + producer self-review and no unresolved P0/P1 finding. +- `P2.IF.INTEGRATION` reads accepted F0/R0 bytes and evidence, reconciles hashes, performs a clean + materialization/integration attempt and replays the F0 checks at integrated authority. Accepted F0 + and R0 paths must remain byte-identical; it additionally writes only + `docs/research/hosted-web/phase-2/foundation-integration.json` and + `.codex-handoff/phase-02-p2-if.json`. +- `P2.R1.ARCH_SECURITY` reads all five packets, handoffs and complete diffs plus foundation integration + evidence. It replays lane checks and proves pairwise ownership, sibling independence, safe-root + admission and transport neutrality. It writes only + `docs/research/hosted-web/phase-2/reviews/parallel-wave-architecture-security.md` and + `.codex-handoff/phase-02-p2-r1.json`. ACCEPT requires exactly five self-reviewed disjoint lanes and + no unresolved P0/P1 finding. +- `P2.I.INTEGRATION` reads only accepted A-E/R1 bytes and foundation evidence. It materializes accepted + bytes unchanged, owns the exact reserved shared paths in the index, runs the union of focused checks + plus IPC/test-HTTP conformance, and writes its exact handoff and integration report. It accepts only + a clean integration with hosted mutation still absent. +- `P2.F.MILESTONE` reads the complete integrated wave, P2.I evidence and P1.F predecessor evidence, + then freshly replays the integrated checks and reconciles hashes, scope, architecture, security and + deferred claims. It writes only `docs/research/hosted-web/phase-2/reviews/phase-2-jit-wave.md` and + `.codex-handoff/phase-02-p2-f.json`; it never edits product source or authorizes a successor. + +Each node writes a packet-standard handoff with exact reads, checks, acceptance result and +`terminalState: HOLD`. Rejection also remains `HOLD` and returns only the smallest controller action. + +## Required evidence and checks + +Every producer handoff contains: + +- authority/base and packet revision; +- exact declared and changed paths; +- focused positive and named negative test results; +- `pnpm lint:fast:files` for changed TypeScript; +- `pnpm typecheck` with inherited diagnostics explicitly classified; +- exact-path Prettier, `git diff --check`, scope diff and secret/private-path scan results; +- a complete diff self-review with Clean Architecture/DDD/SOLID and transport-boundary conclusions; +- evidence proof levels, unverified claims, findings and blockers; and +- the smallest next controller action plus `terminalState: HOLD`. + +Filesystem adapters use only fresh marker-owned temporary project/runtime roots. Admission rejects +unmarked, pre-existing, ambient, home, real-project and symlink-escaped roots before any access. +Cleanup is narrow and marker-checked. `P1.NEG.TEST_ROOT_ESCAPE` must be positively discharged by the +Phase 2 filesystem lanes, never waived because another lane happened to finish first. + +## Acceptance gates + +### Foundation gate + +The foundation is small, product-source real, compatible with Phase 1 and proves opaque cross-kind +identity validation. An architecture/security reviewer must accept it, then a distinct integration +actor must integrate and activate the accepted bytes before any parallel lane starts. + +### Parallel wave gate + +Each lane must satisfy its packet without touching a sibling or reserved integration path. The +combined architecture/security review rejects dependency inversion, unstable or name-derived IDs, +unadmitted roots, unsafe identity publication, raw path leakage, transport coupling, hidden hosted +mutation or unsupported parity claims. + +### Integration gate + +The serial integrator consumes only accepted lane handoffs, reconciles exact hashes, owns all shared +exports/composition and runs the union of focused tests plus IPC/HTTP semantic conformance. A clean +integration attempt, scope proof, negative controls, typecheck classification, fast lint, Prettier, +`git diff --check` and classified scans are required. Integration returns `HOLD`; it cannot launch a +milestone reviewer or a successor on its own. + +### Milestone gate + +A fresh milestone reviewer evaluates the integrated authority and records ACCEPT or REJECT without +editing product source. Phase completion, later Phase 2 work or public exposure remains unverified +until that evidence is separately integrated and activated. + +## Stop conditions and handoff + +Stop on stale authority, path overlap, missing predecessor, unclassified inherited failure, unavailable +safe test topology, a falsified architecture/security assumption, real-project pressure, secrets or +any need for product terminal/provider/process behavior. Do not commit, push, integrate, launch or +silently expand scope. + +All producer, review, integration and milestone handoffs are strict `HOLD`. A success handoff requests +only the next node named in the DAG; a blocker names the failed predicate and smallest controller +decision. No handoff claims its own independent acceptance, integration, push, remote equality or +successor launch. diff --git a/docs/hosted-web-phases/phase-02/execution-dag.md b/docs/hosted-web-phases/phase-02/execution-dag.md new file mode 100644 index 0000000000..b55db5a031 --- /dev/null +++ b/docs/hosted-web-phases/phase-02/execution-dag.md @@ -0,0 +1,83 @@ +# Phase 2 JIT execution DAG + +Status: `candidate`; product admission `blocked`; terminal state `HOLD`. + +## Graph + +```text +P2.ROUTER.INTEGRATED + | + v + P2.F0.IDENTITY one short product-source foundation + | + v + P2.R0.ARCH_SECURITY separate architecture/security review + | + v + P2.IF.INTEGRATION accepted foundation integration and activation + | + +----------+----------+----------+----------+ + | | | | | + v v v v v + P2.A P2.B P2.C P2.D P2.E + | | | | | + +----------+----------+----------+----------+ + | + v + P2.R1.ARCH_SECURITY + | + v + P2.I.INTEGRATION + | + v + P2.F.MILESTONE + | + HOLD +``` + +## Node registry + +| Node | Product slots | Dependency | Output | +| --------------------- | ------------: | ----------------------------- | ---------------------------------------- | +| `P2.F0.IDENTITY` | 1 | integrated router | identity foundation source/tests/handoff | +| `P2.R0.ARCH_SECURITY` | 0 | F0 producer self-review | foundation ACCEPT/REJECT review | +| `P2.IF.INTEGRATION` | 0 | accepted R0 | activated foundation authority | +| `P2.A` | 1 | accepted foundation authority | workspace identity/runtime context | +| `P2.B` | 1 | accepted foundation authority | team identity records/tombstones | +| `P2.C` | 1 | accepted foundation authority | workspace binding/admission | +| `P2.D` | 1 | accepted foundation authority | roster identity/directory safety | +| `P2.E` | 1 | accepted foundation authority | legacy adoption/read facet | +| `P2.R1.ARCH_SECURITY` | 0 | A-E producer self-reviews | combined architecture/security decision | +| `P2.I.INTEGRATION` | 0 | accepted R1 | serial exports/composition/conformance | +| `P2.F.MILESTONE` | 0 | accepted integration | fresh milestone decision | + +The parallel epoch contains exactly five product lanes, not five total workers. Documentation, +research, evidence, architecture/security review, integration and milestone roles have zero product +capacity. A support worker cannot replace a missing A-E producer. A replacement reuses the same lane +ID and supersedes its prior attempt. + +## Dependency and ownership law + +The router integration predicate is external to this candidate: an independent root review must first +accept the exact 12 paths and the broker must integrate and activate them. Until then F0 is blocked. +F0 is the only product node before the fan-out. A-E are blocked until the accepted F0 bytes are +integrated and active. + +A-E may run concurrently because their writable path arrays in +[EXECUTION_INDEX.json](../EXECUTION_INDEX.json) are pairwise disjoint. They may read, but never import +from or copy, a sibling's unintegrated output. None owns a barrel, `index.ts` or composition file. +Shared exports and wiring are reserved to serial F0 or P2.I. A needed overlap is a `packet_conflict` +and strict `HOLD`, not permission to coordinate an edit. + +## Review and integration law + +Each producer self-reviews its complete diff and evidence. Do not create reciprocal or separate lane +code reviewers. Separate reviewers are limited to `R0`/`R1` architecture and security, `IF`/`I` +integration, and `F` milestone work. + +An architecture/security ACCEPT authorizes only the named integration node. Integration may use only +controller-declared integration primitives and exact reserved paths. It does not choose successors. +The controller may admit the next node only after accepted bytes are active at authority. + +Every node, including successful review and integration, returns `terminalState: HOLD`. No node +commits, pushes, launches successors or claims the next node ran. diff --git a/docs/hosted-web-phases/phase-02/lanes/p2-a-workspace-identity.md b/docs/hosted-web-phases/phase-02/lanes/p2-a-workspace-identity.md new file mode 100644 index 0000000000..f9c8bfcf26 --- /dev/null +++ b/docs/hosted-web-phases/phase-02/lanes/p2-a-workspace-identity.md @@ -0,0 +1,71 @@ +# P2.A: workspace identity + +- Packet revision: `phase-02-jit-router-r1`. +- Role: product lane A; one of exactly five parallel product slots. +- Depends on: accepted and activated `P2.IF.INTEGRATION` foundation authority. +- Evidence IDs: `P2.A.RUNTIME_CONTEXT`, `P2.A.GLOBAL_PATH_RATCHET`. +- Result states: `verified | blocked | failed`; terminal state always `HOLD`. + +## Mission + +Define an immutable, value-only `RuntimeInstanceContext` and its domain validation so later composition +can inject deployment/boot/root references instead of adding mutable path globals. This lane defines +the boundary; it does not wire application composition or read the filesystem. + +## Required reads + +After the common mandatory order, read completely and in order: + +1. master plan `Phase 2: identity substrate and externally read-only team lifecycle`, task 1; +2. accepted foundation `src/shared/contracts/hosted/identifiers.ts`; +3. `src/main/utils/pathDecoder.ts`; +4. `src/main/services/infrastructure/ConfigManager.ts`; +5. `src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts`; and +6. `test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts`. + +## Exact writable paths + +1. `src/features/runtime-instance-context/contracts/runtime-instance-context.ts` +2. `src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts` +3. `test/features/runtime-instance-context/RuntimeInstanceContext.test.ts` +4. `test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts` +5. `.codex-handoff/phase-02-p2-a.json` + +Every barrel, `index.ts`, composition file, legacy path utility and sibling-lane path is read-only. + +## Acceptance + +- Context creation validates canonical deployment/boot identity and opaque root references, returns a + deeply immutable value and rejects missing, cross-kind, mutable or unknown input. +- Contracts are value-only and browser-safe; the domain has no Node, Electron, transport, process, + provider, filesystem or mutable-global dependency. +- The context exposes no operation authorization, process launch or orchestration policy. It is input + to later ports, not a service locator. +- An architecture negative test rejects core imports of main/path/filesystem modules and mutable + exported state. +- No existing global is migrated or newly read by product composition in this lane; actual composition + adoption remains unverified for serial integration. +- The producer self-reviews the whole diff for dependency direction, immutability, interface + segregation, exact scope and no sibling dependency. + +## Focused checks + +```text +pnpm exec vitest run test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts +pnpm lint:fast:files -- src/features/runtime-instance-context/contracts/runtime-instance-context.ts src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts +pnpm typecheck +pnpm exec prettier --check src/features/runtime-instance-context/contracts/runtime-instance-context.ts src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts test/features/runtime-instance-context/RuntimeInstanceContext.test.ts test/architecture/hosted-web/phase-2/runtime-instance-context-boundaries.test.ts .codex-handoff/phase-02-p2-a.json +git diff --check +``` + +Prove exact ownership and classified secret/private-path scans across all five writable paths. + +## Stop and handoff + +Stop on stale foundation authority, ownership overlap, a required barrel/composition edit, a need for +another lane's output, a mutable global or any launch/runtime side effect. Do not commit, push, +integrate or launch successors. + +The packet-standard handoff records exact evidence and the producer self-review. On success request +only combined `P2.R1.ARCH_SECURITY` after all five producers finish. Never claim sibling completion, +review or integration. End with `terminalState: HOLD`. diff --git a/docs/hosted-web-phases/phase-02/lanes/p2-b-team-identity.md b/docs/hosted-web-phases/phase-02/lanes/p2-b-team-identity.md new file mode 100644 index 0000000000..8219aa2b5f --- /dev/null +++ b/docs/hosted-web-phases/phase-02/lanes/p2-b-team-identity.md @@ -0,0 +1,78 @@ +# P2.B: team identity + +- Packet revision: `phase-02-jit-router-r1`. +- Role: product lane B; one of exactly five parallel product slots. +- Depends on: accepted and activated `P2.IF.INTEGRATION` foundation authority. +- Evidence IDs: `P2.B.IDENTITY_RECORDS`, `P2.B.IDENTITY_TOMBSTONES`, + `P2.B.ADOPTION_INTENTS`. +- Result states: `verified | blocked | failed`; terminal state always `HOLD`. + +## Mission + +Implement the isolated internal-storage contract, SQLite schema fragment and worker operations for +canonical team identity records, immutable legacy-key reservations/tombstones and prepared/committed +adoption intents. Registration into the shared worker protocol and composition is later serial +integration work. + +## Required reads + +After the common mandatory order, read completely and in order: + +1. master plan `Phase 2: identity substrate and externally read-only team lifecycle`, tasks 2 and 4; +2. accepted foundation `src/shared/contracts/hosted/identifiers.ts`; +3. `src/features/internal-storage/contracts/internalStorageContracts.ts`; +4. `src/features/internal-storage/main/infrastructure/worker/internalStorageSchema.ts`; +5. `src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts`; +6. `src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerProtocol.ts`; +7. `src/features/internal-storage/main/infrastructure/worker/InternalStorageWorkerCore.ts`; and +8. `test/features/internal-storage/InternalStorageWorkerCore.test.ts`. + +## Exact writable paths + +1. `src/features/internal-storage/contracts/teamIdentityStorageContracts.ts` +2. `src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts` +3. `src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts` +4. `test/features/internal-storage/TeamIdentityStorage.test.ts` +5. `.codex-handoff/phase-02-p2-b.json` + +Shared schema, migration, protocol, worker-core, composition and index files are read-only and reserved +to `P2.I.INTEGRATION`. + +## Acceptance + +- The contract represents canonical TeamId, legacy-key reservation, tombstone, directory fingerprint, + workspace binding generation and adoption states without exposing raw database rows to core. +- SQLite constraints make TeamId and active legacy-key ownership unique and prevent tombstoned key + reuse. Prepare/commit transitions are idempotent and distinguish retry, mismatch and tampering. +- Unknown schema/state, checksum disagreement, duplicate identity and illegal transition fail closed; + no name-based replacement or last-write-wins repair exists. +- There is no critical JSON fallback, filesystem identity file publication, workspace authorization, + transport or composition in this lane. +- Tests use an isolated test database under a fresh marker-owned temporary runtime root and perform + marker-checked cleanup. Unmarked, pre-existing, ambient or symlink-escaped roots are rejected before + access. +- Shared worker registration and durable restart proof remain unverified until integration imports + this fragment. The producer self-reviews exact scope, SQL constraints, transition invariants, + dependency direction and scan classifications. + +## Focused checks + +```text +pnpm exec vitest run test/features/internal-storage/TeamIdentityStorage.test.ts +pnpm lint:fast:files -- src/features/internal-storage/contracts/teamIdentityStorageContracts.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts test/features/internal-storage/TeamIdentityStorage.test.ts +pnpm typecheck +pnpm exec prettier --check src/features/internal-storage/contracts/teamIdentityStorageContracts.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts test/features/internal-storage/TeamIdentityStorage.test.ts .codex-handoff/phase-02-p2-b.json +git diff --check +``` + +Prove exact ownership plus classified secret/private-path scans across all five writable paths. + +## Stop and handoff + +Stop on stale foundation authority, shared-file pressure, a sibling dependency, unsafe root admission, +ambiguous recovery, destructive repair or a need for worker/composition registration. Do not commit, +push, integrate or launch successors. + +The packet-standard handoff records exact schema/transition tests and self-review. On success request +only combined `P2.R1.ARCH_SECURITY` after all five producers finish. End with +`terminalState: HOLD`. diff --git a/docs/hosted-web-phases/phase-02/lanes/p2-c-workspace-binding.md b/docs/hosted-web-phases/phase-02/lanes/p2-c-workspace-binding.md new file mode 100644 index 0000000000..4d9debaf61 --- /dev/null +++ b/docs/hosted-web-phases/phase-02/lanes/p2-c-workspace-binding.md @@ -0,0 +1,82 @@ +# P2.C: workspace binding + +- Packet revision: `phase-02-jit-router-r1`. +- Role: product lane C; one of exactly five parallel product slots. +- Depends on: accepted and activated `P2.IF.INTEGRATION` foundation authority. +- Evidence IDs: `P2.C.WORKSPACE_REGISTRY`, `P2.C.MOUNT_GENERATION`, + `P2.C.ROOT_ADMISSION`, `P1.NEG.TEST_ROOT_ESCAPE`. +- Result states: `verified | blocked | failed`; terminal state always `HOLD`. + +## Mission + +Create a bounded `workspace-registry` feature with registrationKey-stable opaque identity, +boot-scoped mount binding and operation-specific authorization. Its only infrastructure is a read-only +startup manifest adapter; it never becomes a global filesystem repository. + +## Required reads + +After the common mandatory order, read completely and in order: + +1. master plan `Phase 2: identity substrate and externally read-only team lifecycle`, task 2 and the + WorkspaceId/mount-generation exit gates; +2. master-plan `ADR-25`; +3. accepted foundation `src/shared/contracts/hosted/identifiers.ts`; +4. `src/features/recent-projects/contracts/dto.ts`; +5. `src/features/recent-projects/contracts/api.ts`; +6. `src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts`; +7. `src/main/utils/pathValidation.ts`; and +8. `test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts`. + +## Exact writable paths + +1. `src/features/workspace-registry/contracts/workspace-registration.ts` +2. `src/features/workspace-registry/core/domain/WorkspaceRegistration.ts` +3. `src/features/workspace-registry/core/application/AuthorizeWorkspaceOperation.ts` +4. `src/features/workspace-registry/main/infrastructure/ReadOnlyWorkspaceManifestAdapter.ts` +5. `test/features/workspace-registry/core/WorkspaceRegistration.test.ts` +6. `test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts` +7. `test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts` +8. `.codex-handoff/phase-02-p2-c.json` + +All indexes, barrels, composition, path utilities, shared contracts and sibling paths are read-only. + +## Acceptance + +- A stable registration key maps to one opaque WorkspaceId across restart; display/root changes cannot + silently replace identity. Duplicate, disabled, unknown-version or ambiguous registrations fail + closed. +- `WorkspaceMountBinding` is boot-scoped and mount generation advances on a new binding. Prior-boot or + stale-generation authorization inputs are rejected. +- Application authorization is operation-specific and returns server-only, non-serializable intent; + it does not expose a generic filesystem capability or raw host path to transport contracts. +- The manifest adapter reads only an injected, pre-admitted startup source and never writes, + auto-registers, scans ambient roots or performs composition. +- Tests use only newly created marker-owned temporary project/runtime roots. Root admission rejects + unmarked, pre-existing, ambient, home, real-project, parent/final-symlink and escaped roots before + any adapter access; cleanup is narrow and marker-checked. This supplies `P1.NEG.TEST_ROOT_ESCAPE`. +- Core imports no Node, main or transport module. Public barrels and production registration remain + unverified for serial integration. +- The producer self-reviews the complete diff for DDD invariants, authorization least privilege, + TOCTOU assumptions, dependency inversion, exact scope and scans. + +## Focused checks + +```text +pnpm exec vitest run test/features/workspace-registry/core/WorkspaceRegistration.test.ts test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts +pnpm lint:fast:files -- src/features/workspace-registry/contracts/workspace-registration.ts src/features/workspace-registry/core/domain/WorkspaceRegistration.ts src/features/workspace-registry/core/application/AuthorizeWorkspaceOperation.ts src/features/workspace-registry/main/infrastructure/ReadOnlyWorkspaceManifestAdapter.ts test/features/workspace-registry/core/WorkspaceRegistration.test.ts test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts +pnpm typecheck +pnpm exec prettier --check src/features/workspace-registry/contracts/workspace-registration.ts src/features/workspace-registry/core/domain/WorkspaceRegistration.ts src/features/workspace-registry/core/application/AuthorizeWorkspaceOperation.ts src/features/workspace-registry/main/infrastructure/ReadOnlyWorkspaceManifestAdapter.ts test/features/workspace-registry/core/WorkspaceRegistration.test.ts test/features/workspace-registry/core/AuthorizeWorkspaceOperation.test.ts test/features/workspace-registry/main/ReadOnlyWorkspaceManifestAdapter.test.ts .codex-handoff/phase-02-p2-c.json +git diff --check +``` + +Prove exact ownership and classified secret/private-path scans across all eight writable paths. + +## Stop and handoff + +Stop on stale foundation authority, an undeclared export/composition need, a sibling dependency, +unsupported filesystem semantics, unsafe roots, raw-path leakage or authorization broader than one +operation. Do not commit, push, integrate or launch successors. + +The packet-standard handoff records the root-admission negative matrix, exact evidence and producer +self-review. On success request only combined `P2.R1.ARCH_SECURITY` after all five producers finish. +End with `terminalState: HOLD`. diff --git a/docs/hosted-web-phases/phase-02/lanes/p2-d-roster-identity.md b/docs/hosted-web-phases/phase-02/lanes/p2-d-roster-identity.md new file mode 100644 index 0000000000..ea652cb6c4 --- /dev/null +++ b/docs/hosted-web-phases/phase-02/lanes/p2-d-roster-identity.md @@ -0,0 +1,88 @@ +# P2.D: roster identity + +- Packet revision: `phase-02-jit-router-r1`. +- Role: product lane D; one of exactly five parallel product slots. +- Depends on: accepted and activated `P2.IF.INTEGRATION` foundation authority. +- Evidence IDs: `P2.D.IDENTITY_FILE`, `P2.D.DIRECTORY_LIFECYCLE`, + `P2.D.BACKUP_COMPATIBILITY`, `P1.NEG.TEST_ROOT_ESCAPE`. +- Result states: `verified | blocked | failed`; terminal state always `HOLD`. + +## Mission + +Implement narrow ports and uncomposed infrastructure for a write-once `team.identity.json`, +identity-aware directory lifecycle and legacy backup compatibility. Prove the prepare/publish/commit +and cleanup invariants without wiring existing create/delete/backup call sites or consuming sibling +lane implementations. + +## Required reads + +After the common mandatory order, read completely and in order: + +1. master plan `Phase 2: identity substrate and externally read-only team lifecycle`, tasks 3 and 4 + and their identity publication/deletion/backup exit gates; +2. master-plan `ADR-6`, `ADR-25`, `ADR-28` and `ADR-29` identity-relevant rules; +3. accepted foundation `src/shared/contracts/hosted/identifiers.ts`; +4. `src/main/services/team/TeamBackupService.ts`; +5. `src/main/services/team/TeamConfigReader.ts`; +6. `src/main/services/team/TeamDataService.ts`; +7. `src/main/utils/atomicWrite.ts`; +8. `src/main/utils/pathValidation.ts`; and +9. `test/main/services/team/TeamBackupService.test.ts`. + +## Exact writable paths + +1. `src/features/team-lifecycle/core/application/ports/TeamIdentityPersistence.ts` +2. `src/features/team-lifecycle/main/infrastructure/TeamIdentityFileStore.ts` +3. `src/features/team-lifecycle/main/infrastructure/TeamDirectoryLifecycleAdapter.ts` +4. `src/features/team-lifecycle/main/infrastructure/TeamIdentityBackupCompatibility.ts` +5. `test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts` +6. `test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts` +7. `.codex-handoff/phase-02-p2-d.json` + +Existing team services, backup services, indexes, composition and sibling paths are read-only. Their +later wiring is serial integration work. + +## Acceptance + +- The application port describes prepare, publish evidence, commit, tombstone and mismatch outcomes + using canonical IDs and value-only records; it imports no infrastructure. +- `TeamIdentityFileStore` validates an admitted root, publishes exclusively and write-once, fsyncs the + file and parent as supported, and never rewrites an existing identity. File/intent/checksum mismatch, + missing-after-commit, corrupt/future identity and duplicate ID block read/write capability. +- The directory adapter preserves the anchor for committed draft/team failure, removes only + attempt-owned artifacts, durably tombstones before permanent removal, rejects same-key resurrection + and requires explicit delete semantics. Generic recursive root removal is not exposed. +- Backup compatibility explicitly includes canonical identity in async and shutdown/sync inventories, + keeps legacy identity evidence distinct and labels the result `legacy_unverified`; it does not claim + full recovery. +- All filesystem proofs use fresh marker-owned temporary project/runtime roots. Unmarked, + pre-existing, ambient, home, real-project and symlink-escaped roots fail before access; cleanup is + narrow and marker-checked. Architecture negatives reject bypass deletion. This supplies + `P1.NEG.TEST_ROOT_ESCAPE` independently of lane C. +- The adapter depends only on its own application port and the integrated identity kernel, not P2.B or + P2.C source. Actual storage/registry binding and legacy call-site routing remain unverified for + integration. +- The producer self-reviews crash boundaries, containment assumptions, identity immutability, + dependency direction, exact scope and scans. + +## Focused checks + +```text +pnpm exec vitest run test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts +pnpm lint:fast:files -- src/features/team-lifecycle/core/application/ports/TeamIdentityPersistence.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityFileStore.ts src/features/team-lifecycle/main/infrastructure/TeamDirectoryLifecycleAdapter.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityBackupCompatibility.ts test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts +pnpm typecheck +pnpm exec prettier --check src/features/team-lifecycle/core/application/ports/TeamIdentityPersistence.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityFileStore.ts src/features/team-lifecycle/main/infrastructure/TeamDirectoryLifecycleAdapter.ts src/features/team-lifecycle/main/infrastructure/TeamIdentityBackupCompatibility.ts test/features/team-lifecycle/main/TeamDirectoryIdentity.test.ts test/architecture/hosted-web/phase-2/team-directory-identity-boundaries.test.ts .codex-handoff/phase-02-p2-d.json +git diff --check +``` + +Prove exact ownership and classified secret/private-path scans across all seven writable paths. + +## Stop and handoff + +Stop on stale foundation authority, unsafe root admission, unsupported durability, undeclared legacy +edits, sibling coupling, automatic identity replacement, generic deletion or a claim of verified +backup recovery. Do not commit, push, integrate or launch successors. + +The packet-standard handoff records the crash/root negative matrix, exact evidence and producer +self-review. On success request only combined `P2.R1.ARCH_SECURITY` after all five producers finish. +End with `terminalState: HOLD`. diff --git a/docs/hosted-web-phases/phase-02/lanes/p2-e-legacy-adoption.md b/docs/hosted-web-phases/phase-02/lanes/p2-e-legacy-adoption.md new file mode 100644 index 0000000000..ac020453f8 --- /dev/null +++ b/docs/hosted-web-phases/phase-02/lanes/p2-e-legacy-adoption.md @@ -0,0 +1,91 @@ +# P2.E: legacy adoption + +- Packet revision: `phase-02-jit-router-r1`. +- Role: product lane E; one of exactly five parallel product slots. +- Depends on: accepted and activated `P2.IF.INTEGRATION` foundation authority. +- Evidence IDs: `P2.E.READ_USE_CASES`, `P2.E.TRANSPORT_NEUTRAL_API`, + `P2.E.LEGACY_READ_ADAPTER`. +- Result states: `verified | blocked | failed`; terminal state always `HOLD`. + +## Mission + +Extend the Phase 1 read slice with canonical snapshot/runtime/alive queries and one transport-neutral +team-lifecycle API facet. Implement a narrow legacy read source and input adapter without editing IPC, +HTTP, renderer clients, shared barrels or composition; serial integration later binds both transports +to the same use cases. + +## Required reads + +After the common mandatory order, read completely and in order: + +1. master plan `Detailed TeamsAPI parity inventory`, lifecycle-read rows only; +2. master plan `Phase 2: identity substrate and externally read-only team lifecycle`, first use cases + and tasks 5-10; +3. accepted foundation `src/shared/contracts/hosted/identifiers.ts`; +4. `src/features/team-lifecycle/contracts/team-lifecycle-read.ts`; +5. `src/features/team-lifecycle/core/application/ListTeamLifecycle.ts`; +6. `src/shared/types/api.ts`, `TeamsAPI` only; +7. `src/main/ipc/teams.ts`, list/detail/runtime/alive handlers only; +8. `src/main/http/teams.ts`, list/detail/runtime/alive routes only; +9. `src/renderer/api/httpClient.ts`, team read methods only; +10. `src/main/services/team/TeamDataService.ts`, list/detail/runtime read entrypoints only; and +11. `test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts`. + +## Exact writable paths + +1. `src/features/team-lifecycle/contracts/team-lifecycle-read.ts` +2. `src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts` +3. `src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts` +4. `src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts` +5. `src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts` +6. `src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts` +7. `src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts` +8. `test/features/team-lifecycle/TeamLifecycleReadApi.test.ts` +9. `test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts` +10. `.codex-handoff/phase-02-p2-e.json` + +All barrels, indexes, composition, existing IPC/HTTP handlers, renderer clients, shared mega-API types +and sibling paths are read-only. + +## Acceptance + +- Requests and responses use canonical TeamId/WorkspaceId, bounded value-only DTOs, explicit schema + behavior, safe errors, revision/cursor semantics and deterministic ordering. Canonical browser DTOs + contain no raw team name or project path. +- List, lifecycle snapshot, runtime projection and alive projection use cases depend on narrow read + ports. Core imports no main, Electron, Fastify, IPC, HTTP, React, filesystem, process or provider + module. +- `team-lifecycle-read-api.ts` is a transport-neutral facet: no Electron event/callback, channel, + Request/Reply, status code, header, URL or serialization framework. It is not an all-parity TeamsAPI. +- The input adapter validates then calls the same use cases for any transport. Legacy team-name and + current-service mapping stay inside the output compatibility adapter and do not escape canonical + results. +- Read semantics preserve Phase 1 outcomes for current, draft, provisioning, corrupt, partial, stale, + unavailable and unexpected fixtures. No route-level config existence check, cache invalidation, + runtime overlay policy or mutation is introduced. +- Actual IPC and test-HTTP wiring, public exports and cross-transport conformance remain unverified and + reserved to `P2.I.INTEGRATION`. Hosted mutation capabilities remain absent. +- The producer self-reviews the complete diff for semantic parity, dependency direction, API + segregation, ID/path privacy, exact scope and scans. + +## Focused checks + +```text +pnpm exec vitest run test/features/team-lifecycle/core/ListTeamLifecycle.test.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts +pnpm lint:fast:files -- src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts +pnpm typecheck +pnpm exec prettier --check src/features/team-lifecycle/contracts/team-lifecycle-read.ts src/features/team-lifecycle/contracts/team-lifecycle-read-api.ts src/features/team-lifecycle/core/application/GetTeamLifecycleSnapshot.ts src/features/team-lifecycle/core/application/GetRuntimeStateProjection.ts src/features/team-lifecycle/core/application/ListAliveTeamProjections.ts src/features/team-lifecycle/main/infrastructure/LegacyTeamLifecycleReadSource.ts src/features/team-lifecycle/main/adapters/input/TeamLifecycleReadApiAdapter.ts test/features/team-lifecycle/TeamLifecycleReadApi.test.ts test/architecture/hosted-web/phase-2/team-lifecycle-read-api-boundaries.test.ts .codex-handoff/phase-02-p2-e.json +git diff --check +``` + +Prove exact ownership and classified secret/private-path scans across all ten writable paths. + +## Stop and handoff + +Stop on stale foundation authority, an index/composition requirement, sibling coupling, raw path/name +leakage, transport types in core, mutation capability, semantics drift or pressure to edit legacy +handlers. Do not commit, push, integrate or launch successors. + +The packet-standard handoff records semantic fixtures, boundary negatives, exact evidence and producer +self-review. On success request only combined `P2.R1.ARCH_SECURITY` after all five producers finish. +End with `terminalState: HOLD`. diff --git a/docs/hosted-web-phases/phase-02/lanes/p2-identity-foundation.md b/docs/hosted-web-phases/phase-02/lanes/p2-identity-foundation.md new file mode 100644 index 0000000000..af50698fff --- /dev/null +++ b/docs/hosted-web-phases/phase-02/lanes/p2-identity-foundation.md @@ -0,0 +1,77 @@ +# P2.F0: identity foundation + +- Packet revision: `phase-02-jit-router-r1`. +- Role: short serial product producer; one product slot. +- Depends on: independently accepted and broker-integrated Phase 2 router authority. +- Evidence IDs: `P2.F0.CANONICAL_IDENTITY`, `P2.F0.IDENTITY_COMPATIBILITY`. +- Result states: `verified | blocked | failed`; terminal state always `HOLD`. + +## Mission + +Make the existing hosted identifier kernel production-capable for opaque canonical `TeamId` and +`WorkspaceId` values without deriving either identity from a display name, legacy directory key or +path. Preserve Phase 1 compatibility and keep the change small enough for a single architecture and +security review before serial foundation integration. + +## Required reads + +After the common mandatory order in [START_HERE.md](../../START_HERE.md), read completely and in order: + +1. master plan `Phase 2: identity substrate and externally read-only team lifecycle`, task 2 and its + identity exit gates in `docs/hosted-web-e2e-completion-plan.md`; +2. `src/shared/contracts/hosted/identifiers.ts`; +3. `src/shared/contracts/hosted/index.ts`; +4. `src/features/team-lifecycle/contracts/team-lifecycle-read.ts`; +5. `test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts`; and +6. `test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts`. + +## Exact writable paths + +1. `src/shared/contracts/hosted/identifiers.ts` +2. `src/shared/contracts/hosted/index.ts` +3. `test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts` +4. `test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts` +5. `.codex-handoff/phase-02-p2-f0.json` + +All other paths are read-only. This serial node may update the shared hosted-contract barrel only for +its identity exports; parallel lanes may not. No production persistence, filesystem or composition is +owned here. + +## Acceptance + +- Canonical TeamId and WorkspaceId types and parsers are opaque, kind-separated and bounded. +- Valid IDs survive parse/serialize/reparse byte-for-byte; invalid, cross-kind, whitespace-bearing, + name-like and path-like values fail closed. +- No API derives canonical identity from `teamName`, display name, legacy key, project path or root. +- The Phase 1 synthetic parser remains only as an explicit compatibility surface if required to keep + frozen Phase 1 callers green; new Phase 2 code consumes the canonical parsers. +- The shared kernel contains values and validation only: no filesystem, clock, random generator, + repository, transport, Electron or main-process dependency. +- Existing Phase 1 contract tests remain green. Restart/rename stability remains unverified until the + storage and registry lanes prove persistence. +- The producer rereads and self-reviews the complete diff for Clean Architecture, DDD, SOLID, safe + errors and exact scope. A separate per-lane reviewer is not requested. + +## Focused checks + +```text +pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts +pnpm lint:fast:files -- src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts +pnpm typecheck +pnpm exec prettier --check src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts .codex-handoff/phase-02-p2-f0.json +git diff --check +``` + +Also prove the exact ownership diff and run classified secret and private-path scans across the five +writable paths. + +## Stop and handoff + +Stop on stale router authority, an undeclared path, a need for persistence/composition, compatibility +breakage, identity derivation from mutable legacy data, or any unsafe test root. Do not commit, push, +integrate or launch successors. + +Write the packet-standard handoff with exact base/revision, changed paths, commands/exit codes, +evidence, proof levels, self-review, findings, blockers and unverified claims. On success the only +requested action is `P2.R0.ARCH_SECURITY`; do not claim its acceptance. End with +`terminalState: HOLD`. diff --git a/docs/hosted-web-phases/phase-03/README.md b/docs/hosted-web-phases/phase-03/README.md new file mode 100644 index 0000000000..60d5e77920 --- /dev/null +++ b/docs/hosted-web-phases/phase-03/README.md @@ -0,0 +1,37 @@ +# Phase 03: approval actual-owner closure + +- Status: `active / product binding only` +- Packet revision: `phase-03-actual-owner-closure-r2` +- Current node: `P3.A.PRODUCT_BINDING` +- Terminal state: `HOLD` + +## Outcome + +Close the remaining Hosted Core v1 approval actual-owner boundary with one thin vertical slice. The +product consumes only launcher-signed v4 per-team route authority, the orchestrator starts its actual +control owner before active admission, and one sandbox-only no-fake E2E proves the complete approval +decision path. Production eligibility stays false until all three exact revisions pass together. + +## Current scope + +Only [P3.A product binding](lanes/p3-a-product-binding.md) is launchable. The independent r1 +adjudication proved a concrete missing seam: product has no authenticated activation-v1 producer +between exact owner readiness and final ready. The r2 lane may implement only that vertical slice +inside its declared paths. Product remains the logical signer; orchestrator owns persistence because +the production run-directory mount is read-only to product. + +The orchestrator PR #44 review, cross-repository E2E, and coordinated activation are successor nodes. +Their implementation packets are intentionally not materialized before their predecessors are +accepted. + +## Non-goals + +- no new general lifecycle coordinator or runtime platform; +- no legacy v2/v3 approval route activation; +- no broad Hosted parity, terminal, review, attachments, member recovery, or destructive recovery; +- no OpenCode product customization beyond the bounded atomic approval compatibility patch; +- no real project, real provider agent, shared user state, or production rollout; and +- no production gate or artifact eligibility change in this packet. + +See [controller-packet.md](controller-packet.md) for acceptance and +[execution-dag.md](execution-dag.md) for the only legal ordering. diff --git a/docs/hosted-web-phases/phase-03/controller-packet.md b/docs/hosted-web-phases/phase-03/controller-packet.md new file mode 100644 index 0000000000..6cd2a1b1a5 --- /dev/null +++ b/docs/hosted-web-phases/phase-03/controller-packet.md @@ -0,0 +1,97 @@ +# Phase 03 controller packet: actual-owner closure + +## Status and authority + +- Status: `active-product-binding-closure`. +- Packet revision: `phase-03-actual-owner-closure-r2`. +- Product authority: exact `phaseStartSha` injected by `ProjectScopedControl`. +- Product PR: `777genius/agent-teams-ai#252`. +- Orchestrator PR: `777genius/agent_teams_orchestrator#44`. +- OpenCode inputs: `777genius/opencode-anomaly#1` and `#2`. +- Terminal state: `HOLD` for every node. + +The controller may admit only `P3.A.PRODUCT_BINDING` now. The worker cannot authorize its reviewer, +orchestrator integration, E2E, or activation. + +## Required outcome + +Provide the smallest product-side seam needed for a real actual-owner approval flow. Existing facts +must be reused: + +- signed-v4 admission already binds deployment, boot, workspace mount, owner generation/session, + socket identity, exact artifact, wire capability, per-team routes, approval generation and snapshot + digest; +- active signed-v4 routes are already the only routes mounted by production composition; +- v2/v3 and empty route catalogs remain fail-closed; and +- the scalar compatibility lifecycle coordinator is not signed-v4 authority. + +The completed r1 audit established that product code is missing: signed-v4 consumption does not +produce an authenticated activation after `owner_ready`. The r2 producer implements that one seam +without inventing another lifecycle authority. Product serializes and authenticates the canonical +activation-v1 envelope over lifecycle-control IPC; orchestrator owns 0600 persistence, file and +directory fsync, readback, atomic delegate activation and authenticated final `ready`. + +## Definition of Ready + +All conditions are conjunctive: + +1. the worktree is clean at the exact `phaseStartSha`; +2. the packet revision, phase, lane, ownership, docs and checks equal the immutable worker-launch + contract; +3. no other writer owns a declared path; +4. PR #252 exact-head core checks are green or any unrelated failure is classified; +5. product and OpenCode production eligibility remain false; and +6. no runtime, launch, terminal, browser, provider, auth, or real-project action is requested. + +## Ownership + +The exact writable paths are only those listed for `activeLane.ownedPaths` in +[EXECUTION_INDEX.json](../EXECUTION_INDEX.json). All other paths are read-only. A need to edit shared +feature authority, route registration outside the listed composition seam, artifact locks, +dependencies, CI, or another repository is `packet_conflict` and returns `HOLD`. + +## Acceptance + +The producer must prove: + +1. only a current launcher-signed v4 active route can create a product approval runtime authority; +2. route authority is partitioned by immutable `teamId` with no current-team or owner-writable + fallback; +3. missing, malformed, stale, replaced, revoked, wrong-socket, wrong-artifact, wrong-wire, or legacy + authority stays unmounted/absent; +4. owner loss and shutdown establish confirmed absence before the protected effect; +5. a product lifecycle seam, if added, consumes an evidence-bearing single-use owner lease and cannot + manufacture signed admission; and +6. no gate changes from false and no E2E claim is made. + +Focused tests, typecheck, changed-file lint, exact-path Prettier, `git diff --check`, ownership proof, +and a classified secret/private-path scan are required. The producer self-reviews the complete diff +for authority duplication, TOCTOU, fail-open fallback, lifecycle races, scope and test gaps. + +## Successor policy + +After producer `HOLD`, one fresh independent reviewer reruns the declared checks and reviews the exact +tree. ACCEPT requires P0/P1/P2 `0/0/0`. The controller then integrates only accepted bytes. + +The orchestrator node separately reviews the two-phase startup invariant: + +1. actual owner/runtime starts with approval delivery fail-closed; +2. exact owner readiness is published; +3. product validates the binding and publishes signed active admission; and +4. orchestrator atomically activates the approval delegate only for that exact admission. + +Only after both repositories are accepted at exact commits may the controller materialize the E2E +packet. That packet uses one fresh marker-owned sandbox and exact pinned OpenCode artifact. It proves +the full approval path and the negative/recovery matrix without automatic retries after ambiguous +settlement. + +Production activation is a final coordinated product/orchestrator/manifest change. Any head, artifact, +manifest, socket, authority, sandbox, or evidence mismatch returns `HOLD`. + +## OpenCode convergence guard + +The OpenCode downstream is not a separate product line. It is a temporary 17-file atomic-approval +patch queue over an exact upstream release plus reproducible artifact evidence. Upstream releases are +tracked daily; every stable/security update triggers a port-and-verification lane. The patch is +removed when upstream supplies equivalent atomic conditional reply or a safe plugin boundary. See +[the downstream policy](../../hosted-opencode-downstream-policy.md). diff --git a/docs/hosted-web-phases/phase-03/execution-dag.md b/docs/hosted-web-phases/phase-03/execution-dag.md new file mode 100644 index 0000000000..7ad4b60552 --- /dev/null +++ b/docs/hosted-web-phases/phase-03/execution-dag.md @@ -0,0 +1,36 @@ +# Phase 03 execution DAG + +Status: `active at P3.A only`; terminal state: `HOLD`. + +```text +PR252.CORE_HEAD.GREEN + | + v +P3.A.PRODUCT_BINDING implement the adjudicated activation-v1 product seam + | + v +P3.RA.PRODUCT_REVIEW fresh exact-tree architecture/security review + | + v +P3.B.ORCHESTRATOR_INTEGRATION + | review/integrate two-phase actual-owner startup in PR #44 + v +P3.C.NO_FAKE_E2E fresh sandbox, exact product/orchestrator/OpenCode revisions + | + v +P3.F.COORDINATED_ACTIVATION + product + orchestrator + manifest gates change together + | + HOLD +``` + +Only the first node is currently materialized as a lane packet. Each successor requires accepted +predecessor evidence and a new controller admission. No node launches its successor. + +Parallel support is allowed only for read-only auditing of preserved WIP and CI evidence. It cannot +write a P3 path, count as product completion, or supply release evidence. Cross-repository work uses +isolated worktrees and explicit ownership. + +The E2E node must never use an existing user project. It creates and marks its own sandbox, proves +canonical containment before every effect, performs narrow marker-checked cleanup, and reports any +residual instead of broad process/path cleanup. diff --git a/docs/hosted-web-phases/phase-03/lanes/p3-a-product-binding.md b/docs/hosted-web-phases/phase-03/lanes/p3-a-product-binding.md new file mode 100644 index 0000000000..4aeb3c285c --- /dev/null +++ b/docs/hosted-web-phases/phase-03/lanes/p3-a-product-binding.md @@ -0,0 +1,90 @@ +# P3.A: product actual-owner binding closure + +- Packet revision: `phase-03-actual-owner-closure-r2`. +- Role: one bounded product producer. +- Depends on: exact green PR #252 phase head. +- Result: `verified | blocked | failed`; terminal state always `HOLD`. +- The r1 zero-code finding is superseded by independent cross-repository adjudication. + +## Mission + +Implement the demonstrated missing product activation-v1 seam needed by the orchestrator's +two-phase actual-owner startup. Product validates authenticated `owner_ready`, serializes and signs +the canonical envelope, sends it over lifecycle-control IPC, and mounts routes only after exact +authenticated `ready`. Do not create a parallel lifecycle authority. + +## Required reads + +After the common order in [START_HERE.md](../../START_HERE.md), read completely: + +1. approval actual-owner and activation sections in + `docs/hosted-web-core-v1-scope-lock.md`; +2. `docs/hosted-opencode-downstream-policy.md`; +3. `src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts`; +4. `src/main/composition/hosted/createHostedApprovalProductionComposition.ts`; +5. every file in the exact writable set; +6. focused tests named below; and +7. the current orchestrator PR #44 actual-owner handoff supplied by the controller. + +## Exact writable paths + +1. `src/main/standalone.ts` +2. `src/main/composition/hosted/hostedLifecycleOrchestratorReadiness.ts` +3. `src/main/composition/hosted/createHostedApprovalProductionComposition.ts` +4. `src/main/services/team/provisioning/HostedApprovalRuntimeAdmissionPublisher.ts` +5. `src/main/services/team/provisioning/HostedApprovalRuntimeLifecycleCoordinator.ts` +6. `src/main/services/team/provisioning/HostedApprovalRuntimeProductionComposition.ts` +7. `src/main/services/team/provisioning/HostedApprovalRuntimeActivationEnvelope.ts` (new) +8. `src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeAdmissionPublisher.test.ts` +9. `src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeLifecycleCoordinator.test.ts` +10. `test/features/team-approvals/hosted/HostedApprovalRuntimeActivation.test.ts` (new) +11. `test/main/composition/hosted/hostedApprovalProductionActivation.test.ts` (new) +12. `test/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.test.ts` +13. `test/architecture/hosted-web/approval-production-unmounted.test.ts` +14. `docs/hosted-approval-activation-v1-golden.json` (new shared fixture) + +All other paths are read-only. + +## Decision order + +1. Trace signed-v4 owner admission from validated bootstrap input to per-team approval authority. +2. Trace owner loss, replacement, restart, shutdown and confirmed-absence behavior. +3. Compare the product contract to the orchestrator two-phase handoff. +4. Reuse the established missing invariant: no authenticated activation-v1 producer exists. +5. Implement the smallest change inside exact ownership and add focused regression tests. + +## Acceptance and negative controls + +- Active approval routes originate only from exact launcher-signed v4 admission. +- Provisioning, restart-required, v2/v3, empty, malformed and stale inputs remain unmounted. +- The scalar lifecycle coordinator is neither trusted admission nor a route catalog producer. +- Evidence-bearing owner leases are single-use and lifecycle-exact; missing or mismatched evidence + confirms absence. +- Owner loss/shutdown cannot race a later transition back into active state. +- No current-team, workspace file, ambient process, mutable tag, raw socket, or unsigned fallback is + added. +- Canonical proof-last JSON and HMAC use production activation-v1 domains and bind team, workspace, + boot/restore, owner/socket, generation, artifact, capability and signed-v4 manifest identities. +- The shared HMAC is documented as cross-process integrity, not exclusive product authorship. +- No production gate, artifact lock, route catalog, dependency or other repository is modified. + +## Focused checks + +```text +pnpm exec vitest run test/features/team-approvals/hosted/HostedApprovalRuntimeActivation.test.ts test/main/composition/hosted/hostedApprovalProductionActivation.test.ts test/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.test.ts src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeAdmissionPublisher.test.ts src/main/services/team/provisioning/__tests__/HostedApprovalRuntimeLifecycleCoordinator.test.ts test/architecture/hosted-web/approval-production-unmounted.test.ts +pnpm typecheck +pnpm lint:fast:files -- +pnpm exec prettier --check +git diff --check +``` + +Run exact ownership and classified secret/private-path scans. + +## Stop and handoff + +Stop on stale phase authority, path overlap, need for an undeclared path, unclear orchestrator +contract, missing exact artifact evidence, real-project pressure, or a request to enable production. +Do not commit, push, integrate, launch, or activate from the worker. + +The handoff reports the traced authority chain, changed-path decision, exact checks, +negative controls, complete self-review, blockers, and the smallest reviewer action. End `HOLD`. diff --git a/docs/research/hosted-web/README.md b/docs/research/hosted-web/README.md new file mode 100644 index 0000000000..2f48548fdd --- /dev/null +++ b/docs/research/hosted-web/README.md @@ -0,0 +1,25 @@ +# Hosted-web research evidence + +This tree contains current and archived agent research. Preserve every existing artifact: do not +delete, move, rename, truncate, or overwrite files to make a newer conclusion look authoritative. + +Start at `docs/hosted-web-phases/START_HERE.md`. Authority is explicit in the frozen evidence records +rather than inferred from directory names: + +- `canonical` is reviewed current authority; +- `raw` is immutable observed input; +- `generated` is a reproducible derivative; +- `historical` is retained context with no current authority; +- `rejected` is an explicitly declined candidate; and +- `superseded` is retained former authority with a named replacement. + +Definitions, review dispositions, supersession, and retention are normative in +`docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md`. Existing artifacts, including recorded historical +commands, are frozen evidence and are not executable hosted-worker instructions. + +The accepted product scope and preservation decisions live in +`docs/hosted-web-core-v1-scope-lock.md`. Historical “required parity” labels and implementation +estimates in this tree do not override that scope lock. + +New evidence must use a new exact path and stable evidence ID. Corrections link backward and forward +through the evidence record; they never erase the bytes or review history they replace. diff --git a/docs/research/hosted-web/phase-0/audits/completion/requirement-matrix.json b/docs/research/hosted-web/phase-0/audits/completion/requirement-matrix.json new file mode 100644 index 0000000000..0e6a22ebb9 --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/completion/requirement-matrix.json @@ -0,0 +1,1028 @@ +{ + "schemaVersion": 2, + "auditId": "P0.AUDIT.COMPLETION.V3", + "packetRevision": "phase-00-r2", + "auditedAt": "2026-07-11T19:28:45Z", + "rootSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "integrationEvidenceSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "overallClassification": "contradicted", + "overallDecision": "remediate", + "classificationDefinitions": { + "satisfied": "Current authoritative evidence proves the complete requirement at the required proof level.", + "contradicted": "Current evidence or a required gate directly disproves the requirement.", + "weak": "Useful evidence exists, but it is indirect, incomplete, circular, stale, or below the required topology/proof level.", + "missing": "No current authoritative evidence proves the requirement." + }, + "decisionDefinitions": { + "adopt": "The bounded evidence or conclusion is suitable for adoption at its stated proof level.", + "reject": "Do not adopt the current artifact or claim.", + "remediate": "Correct the artifact/gate and repeat the narrow check or review.", + "further-proof": "Keep the claim open until the named external, target-topology, review, or controller proof exists." + }, + "sourceRefs": { + "PACKET": "/var/data/agent-teams-hosted-web-refactor/control/plans/hosted-web-phase-0-execution-packet.full.md", + "LANES": "/var/data/agent-teams-hosted-web-refactor/control/plans/hosted-web-phases/phase-00/lanes", + "INTEGRATION": "git:0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "REGISTRY": "/var/data/agent-teams-hosted-web-refactor/control/consumed-output-ledger-v2/items", + "W1R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w1-v1", + "W2R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w2-v1", + "W3R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w3-v1", + "W4R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w4-v1", + "W5R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w5-v1", + "W6R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w6-v1", + "R12R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-review-w1-w2-v3/.codex-handoff/phase-00-remediation-review-w1-w2-v3.json", + "R35R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-review-w3-w5-v2/.codex-handoff/phase-00-review-w3-w5.json", + "R46R": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-review-w4-w6-v2/.codex-handoff/phase-00-remediation-review-w4-w6.json" + }, + "registrySnapshot": { + "originalProducers": "W1-W6 are consumed as rejected", + "originalReviews": "W1/W2, W3/W5 and W4/W6 review records are consumed as integrated and reject their producer packages", + "remediationProducers": "W3 has a completed latest-result; W1/W2/W4/W5/W6 remain unconsumed with stale running progress records at the audit snapshot", + "remediationReviews": "v1 review attempts were stopped as unreadable-scope/ineffective and W1/W2 v2 was never started. Scope-corrected reviews completed: W1/W2 v3 remediates/rejects both lanes; W3/W5 v2 adopts all W3 and remediates W5; W4/W6 v2 remediates both and rejects paired adoption.", + "integrationState": "clean at 0e8431b1935c71a2e77bea1384b134ee25c8aa12; contains baseline, original rejecting reviews, audits and a blocked Phase 1 draft, but no W1-W6 producer evidence" + }, + "artifactRows": [ + { + "id": "P0.W1.API_PARITY_LEDGER", + "classification": "weak", + "decision": "remediate", + "finding": "109 pinned members (86/20/3) reproduce, but the corrected reciprocal review approves only the selection invariant file and holds the ledger inside a formatting-red, lane-rejected bundle." + }, + { + "id": "P0.W1.RENDERER_ACTIONS", + "classification": "contradicted", + "decision": "remediate", + "finding": "The remediation claims every visible team control but scans only four reviewed composition files; an independent narrow census found interaction handlers in 127 non-test team/provider files, so the completeness boundary is not exhaustive." + }, + { + "id": "P0.W1.LEGACY_BYPASSES", + "classification": "weak", + "decision": "remediate", + "finding": "The compact summary plus deterministic external 610-row artifact/hash reproduces, but the corrected review holds the file inside the formatting-red rejected W1 bundle." + }, + { + "id": "P0.W1.SELECTION_INVARIANTS", + "classification": "satisfied", + "decision": "adopt", + "finding": "Selection, thin/full snapshots, tombstones, pagination and event/poll races remain explicit and were accepted by the original review." + }, + { + "id": "P0.W1.SCANNER", + "classification": "weak", + "decision": "remediate", + "finding": "Seven focused tests pass, but full semantic completeness is bounded to the manually selected four-file control set; direct regeneration could not run read-only because the scanner always rewrites evidence." + }, + { + "id": "P0.W1.ESTIMATE", + "classification": "weak", + "decision": "further-proof", + "finding": "Arithmetic and canonical bucket IDs are corrected, but two bucket changes exceed 20 percent and require controller scope review." + }, + + { + "id": "P0.W2.EXECUTION_TOPOLOGY", + "classification": "satisfied", + "decision": "adopt", + "finding": "Four provider identities and two backend families remain distinct with explicit compatibility statements." + }, + { + "id": "P0.W2.RUNTIME_INGRESS_INVENTORY", + "classification": "satisfied", + "decision": "adopt", + "finding": "All five current operations carry required direction, caller, authority, idempotency, body-ID, persisted-evidence, route and disposition fields; negative field-family checks pass." + }, + { + "id": "P0.W2.ENVIRONMENT_PROVENANCE", + "classification": "contradicted", + "decision": "remediate", + "finding": "The corrected reviewer removal probe proves eight explicit classified keys can be deleted without a completeness diagnostic." + }, + { + "id": "P0.W2.CREDENTIAL_EXPOSURE_MATRIX", + "classification": "satisfied", + "decision": "adopt", + "finding": "Artifact-specific nested schema and ownership negative checks pass without secret values." + }, + { + "id": "P0.W2.RUNTIME_SCANNER", + "classification": "contradicted", + "decision": "remediate", + "finding": "Its current rules and 8/8 tests pass, but eight removable environment keys and three gap_only provider cases make the completeness claim false; the scanner also fails formatting." + }, + { + "id": "P0.W2.ESTIMATE", + "classification": "satisfied", + "decision": "adopt", + "finding": "The corrected review explicitly approves the canonical EST-LIFECYCLE-RUNTIME input and schema; controller-wide total reconciliation remains a separate 0D gate." + }, + + { + "id": "P0.W3.STATE_FAMILY_CATALOG", + "classification": "satisfied", + "decision": "adopt", + "finding": "Seventeen families retain authority, writer, schema/version, atomicity, corruption and backup-role fields." + }, + { + "id": "P0.W3.WRITER_COORDINATION", + "classification": "satisfied", + "decision": "adopt", + "finding": "Twelve operations use honest fail-closed writer classes and the external-writer negative control is retained." + }, + { + "id": "P0.W3.SCHEMA_UNKNOWN_FIELDS", + "classification": "satisfied", + "decision": "adopt", + "finding": "Unknown-field behavior is catalogued and schema validation passes." + }, + { + "id": "P0.W3.BACKUP_BEHAVIOR", + "classification": "satisfied", + "decision": "adopt", + "finding": "The remediation constructs production TeamBackupService and passes seven tests covering twelve fault cases TB-01 through TB-12." + }, + { + "id": "P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", + "classification": "satisfied", + "decision": "adopt", + "finding": "WAL-active backup, independent 2,000-row reopen/integrity and fail-closed injected BUSY/corruption/deadline evidence remain valid on the current Node ABI." + }, + { + "id": "P0.W3.ESTIMATE", + "classification": "satisfied", + "decision": "adopt", + "finding": "The corrected review adopts all W3 files, including the explicit non-additive EST-RECOVERY-STATE overlap rule; controller-wide aggregation remains a separate 0D gate." + }, + + { + "id": "P0.W4.TARGET_HOST_ENVELOPE", + "classification": "weak", + "decision": "further-proof", + "finding": "The target is declared, but observed probes ran as root on Ubuntu/ext4, not the final non-root Debian-slim/init/seccomp/shared-volume image." + }, + { + "id": "P0.W4.INSTANCE_LEASE_SPIKE", + "classification": "weak", + "decision": "further-proof", + "finding": "Current-host mutual exclusion, pause/kill, close-order and identity-replacement cases pass; the mandatory two final-image containers plus manual contender did not run." + }, + { + "id": "P0.W4.WORKSPACE_GUARD_SPIKE", + "classification": "weak", + "decision": "remediate", + "finding": "Current-host adversarial races and high-FD closure are useful, but the focused native-probes test is red because its cleanup contract was not updated, and final-image behavior is absent." + }, + { + "id": "P0.W4.PROCESS_ANCHOR_SPIKE", + "classification": "contradicted", + "decision": "reject", + "finding": "The corrected review rejects R46-03: numeric process-group reuse remains a target and deterministic PID reuse is not forced; final init/container replacement is absent and the owned suite is red." + }, + { + "id": "P0.W4.NATIVE_ARTIFACT_PROPOSAL", + "classification": "satisfied", + "decision": "adopt", + "finding": "Three versioned protocol manifests, hashes, schemas and a W4/W6 contract now exist and validate at interface-ready/target-unverified proof level." + }, + { + "id": "P0.W4.ESTIMATE", + "classification": "weak", + "decision": "further-proof", + "finding": "Useful component ranges exist, but no standard remediation handoff, split approval or controller canonical-bucket reconciliation exists." + }, + + { + "id": "P0.W5.EVENT_CURSOR_INVENTORY", + "classification": "satisfied", + "decision": "adopt", + "finding": "Current cursor/event limitations and the accepted lower-C0/same-transaction handoff contract are explicit." + }, + { + "id": "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER", + "classification": "satisfied", + "decision": "adopt", + "finding": "The focused suite passes and exercises 288 durable crash/restart schedules across every required pause plus both lost-event negative controls." + }, + { + "id": "P0.W5.COMMAND_CATALOG", + "classification": "weak", + "decision": "remediate", + "finding": "Fifty-four source-observed mutation rows map to 51 command kinds, but the completeness census is manually enumerated and the gate proves listed rows exist rather than independently detecting every omitted mutating source method." + }, + { + "id": "P0.W5.EFFECT_RECOVERY_MATRIX", + "classification": "contradicted", + "decision": "remediate", + "finding": "The corrected review finds the model labels a crash and calls recovery inside the same closure, then continues the pre-crash path; it does not prove a fresh-run recovery boundary." + }, + { + "id": "P0.W5.FINGERPRINT_GOLDENS", + "classification": "satisfied", + "decision": "adopt", + "finding": "Schema/fingerprint/key versions, omitted/default equivalence, retained comparison, changed-intent conflict and no-body/no-key persistence are asserted; 7/7 focused tests pass." + }, + { + "id": "P0.W5.ESTIMATE", + "classification": "satisfied", + "decision": "adopt", + "finding": "The corrected review explicitly approves the estimate input with its non-additive W3 overlap; controller-wide aggregation remains a separate 0D gate." + }, + + { + "id": "P0.W6.AUTH_TRANSITIONS", + "classification": "weak", + "decision": "remediate", + "finding": "Restart revocation is repaired and 25/25 tests pass, but the corrected review finds W6 drain/reset fields incompatible with the W4 producer protocol and holds paired adoption." + }, + { + "id": "P0.W6.PROXY_ORIGIN_THREAT_MATRIX", + "classification": "weak", + "decision": "further-proof", + "finding": "Ordering and rejection fixtures pass, but no admitted HTTPS edge/private-listener/trusted-CIDR target topology ran." + }, + { + "id": "P0.W6.COOKIE_VERSION_EVIDENCE", + "classification": "missing", + "decision": "further-proof", + "finding": "The supplied/current package manifest confirms @fastify/cookie is absent; authoritative current Fastify-5 compatibility and a pinned version remain unproved." + }, + { + "id": "P0.W6.ARTIFACT_INVENTORY", + "classification": "contradicted", + "decision": "reject", + "finding": "The emitted current standalone artifact still lacks the storage worker, uses broad empty/native stubs and emits terminal service markers. The new proposed manifest is contract-only with null hashes and emitted=false." + }, + { + "id": "P0.W6.ABI_STUB_REPORT", + "classification": "weak", + "decision": "further-proof", + "finding": "Owned Node ABI 137 SQLite reopen and Electron ABI 143 lookup facts reproduce, but no final-image worker/native/helper load exists." + }, + { + "id": "P0.W6.TERMINAL_ABSENCE_REPORT", + "classification": "contradicted", + "decision": "reject", + "finding": "The current artifact includes copied terminal dependencies and a PtyTerminalService marker; only a synthetic clean negative fixture passes and no final v1 image exists." + }, + { + "id": "P0.W6.ESTIMATE", + "classification": "weak", + "decision": "further-proof", + "finding": "EST-HOSTED-OPS is canonical, but auth/proxy/final-image assumptions remain unverified and artifact absence is contradicted." + } + ], + "requirementRows": [ + { + "id": "P0.REQ.DOR.001", + "area": "ready", + "classification": "missing", + "decision": "further-proof", + "requirement": "Record explicit user authorization to begin implementation.", + "finding": "Execution occurred, but no audited 0A/registry record contains the explicit authorization evidence." + }, + { + "id": "P0.REQ.DOR.002", + "area": "ready", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Resolve the canonical remote to 777genius/agent-teams-ai.", + "finding": "base.json records the exact canonical URL and repository." + }, + { + "id": "P0.REQ.DOR.003", + "area": "ready", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Use broker tools and a project-scoped controller manifest.", + "finding": "Controller and registry records identify agent-teams-hosted-web-refactor-controller-v2 and project-scoped control." + }, + { + "id": "P0.REQ.DOR.004", + "area": "ready", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Use isolated project, integration and worker roots rather than the dirty dev checkout.", + "finding": "All audited jobs use /var/data project worktrees and the integration worktree." + }, + { + "id": "P0.REQ.DOR.005", + "area": "ready", + "classification": "weak", + "decision": "further-proof", + "requirement": "Record sufficient host capacity for the admitted worker pool.", + "finding": "Workers were admitted, but the evidence bundle does not record the accepted load threshold/measurement required by the packet." + }, + { + "id": "P0.REQ.DOR.006", + "area": "ready", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Use only new sandbox/test state and no real provider/project.", + "finding": "All producer/review scope audits and this audit found fixture/temp evidence only; no provider launch was claimed." + }, + { + "id": "P0.REQ.DOR.007", + "area": "ready", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Keep secrets in approved runtime mechanisms and out of evidence.", + "finding": "Producer/reviewer bounded scans report no secret values, auth payloads or real user paths." + }, + { + "id": "P0.REQ.DOR.008", + "area": "ready", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Enforce broker-only orchestration without raw tmux/registry/full-access fallback.", + "finding": "Project-control registry and isolated subscription jobs are recorded; no unmanaged worker fallback was found." + }, + + { + "id": "P0.REQ.0A.001", + "area": "0A", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Pin canonical remote, base SHA, toolchain and lockfile identity.", + "finding": "base.json records all required identities and hashes." + }, + { + "id": "P0.REQ.0A.002", + "area": "0A", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Adopt the reviewed plan bundle and establish phaseStartSha ancestry.", + "finding": "Git proves cbe501ad is an ancestor of phaseStart a32f509e and the plan/base evidence sequence is recorded." + }, + { + "id": "P0.REQ.0A.003", + "area": "0A", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Materialize frozen dependencies without lockfile change.", + "finding": "The frozen/offline command, dependency fingerprint and unchanged lockfile are recorded." + }, + { + "id": "P0.REQ.0A.004", + "area": "0A", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Capture typecheck:workspace, lint:fast, test:workspace:ci, standalone:build and check:ci independently with exit, duration and tail.", + "finding": "lint:fast and standalone:build were not run and nested type/test stages lack independent duration/tail evidence." + }, + { + "id": "P0.REQ.0A.005", + "area": "0A", + "classification": "weak", + "decision": "remediate", + "requirement": "Classify inherited failures and rerun after prerequisite fixes before dependent admission.", + "finding": "Two base-owned fixes were adopted before phaseStart and no unknown remained, but the required post-fix baseline rerun is missing." + }, + { + "id": "P0.REQ.0A.006", + "area": "0A", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Start W1-W6 from exact phaseStartSha and packet revision.", + "finding": "All original handoffs record a32f509e and phase-00-r2." + }, + { + "id": "P0.REQ.0A.007", + "area": "0A", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Maintain lane-ledger jobs, attempts, worktrees, progress and handoff hashes.", + "finding": "The integrated ledger still says six unstarted/null slots despite original, remediation and review jobs in registry." + }, + { + "id": "P0.REQ.0A.008", + "area": "0A", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Admit no lane depending on unknown baseline failure.", + "finding": "The ledger has no unknown or base_blocker entry." + }, + { + "id": "P0.REQ.0A.009", + "area": "0A", + "classification": "weak", + "decision": "further-proof", + "requirement": "Verify frozen install and unchanged lockfile independently in all six lane worktrees.", + "finding": "Dependency preflights and shared node_modules exist, but the integrated evidence lacks six immutable per-lane install results." + }, + { + "id": "P0.REQ.0A.010", + "area": "0A", + "classification": "weak", + "decision": "remediate", + "requirement": "Render each prompt with in-worktree packet and local handoff path and reject stale/conflicting scope.", + "finding": "Original producers used the correct packets, but remediation W4 has no handoff and multiple review attempts failed because broker read scope was wrong." + }, + + { + "id": "P0.REQ.SCOPE.001", + "area": "scope", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Keep producer/review writes inside owned paths.", + "finding": "All remediation additions remain in the six owned evidence/script/test surfaces or lane-local handoff; no production source mutation was found." + }, + { + "id": "P0.REQ.SCOPE.002", + "area": "scope", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Do not implement Phase 1, enable hosted mutation, use real projects/providers, or implement terminal.", + "finding": "No product source or terminal implementation changed in the producer/remediation worktrees." + }, + + { + "id": "P0.REQ.W1.001", + "area": "W1", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Deliver all six W1 evidence IDs and a standard handoff.", + "finding": "All files exist, but the completed corrected review rejects lane adoption, finds 11 formatting-red files and records no producer latest result." + }, + { + "id": "P0.REQ.W1.002", + "area": "W1", + "classification": "weak", + "decision": "remediate", + "requirement": "Give all 86/20/3 pinned API members exactly one disposition and owner.", + "finding": "The count and uniqueness reproduce, but the completed corrected review approves only the invariant file and holds the ledger inside the rejected formatting-red lane." + }, + { + "id": "P0.REQ.W1.003", + "area": "W1", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Map every visible team control to one semantic action or deliberate absence.", + "finding": "Only four manually selected composition files are scanned while 127 non-test team/provider files contain interaction handlers." + }, + { + "id": "P0.REQ.W1.004", + "area": "W1", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Annotate dynamic dispatch and fail unannotated dispatch.", + "finding": "The focused negative/positive test passes." + }, + { + "id": "P0.REQ.W1.005", + "area": "W1", + "classification": "weak", + "decision": "remediate", + "requirement": "Inventory Electron/global-client/fabricated-success bypasses without oversized checked-in raw output.", + "finding": "The raw artifact/hash reproduce and size policy is fixed, but the completed review holds the file in the rejected formatting-red bundle." + }, + { + "id": "P0.REQ.W1.006", + "area": "W1", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Capture selection, snapshot, tombstone, pagination and event/poll invariants.", + "finding": "All invariant classes are explicit and historically accepted." + }, + { + "id": "P0.REQ.W1.007", + "area": "W1", + "classification": "weak", + "decision": "remediate", + "requirement": "Fail missing/duplicate signatures and semantic actions with line-shift-stable IDs.", + "finding": "The seven tests prove these behaviors only inside the non-exhaustive reviewed-file set." + }, + { + "id": "P0.REQ.W1.008", + "area": "W1", + "classification": "weak", + "decision": "further-proof", + "requirement": "Provide correct canonical estimate input inside adoption budget.", + "finding": "Arithmetic/size are corrected; greater-than-20-percent changes still require controller scope decision." + }, + + { + "id": "P0.REQ.W2.001", + "area": "W2", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Separate four providers from two backend families.", + "finding": "Corrected topology and 8/8 focused tests prove the distinction." + }, + { + "id": "P0.REQ.W2.002", + "area": "W2", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Map five runtime operations with every acceptance field.", + "finding": "Artifact-specific schema negatives delete each required field and fail." + }, + { + "id": "P0.REQ.W2.003", + "area": "W2", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Discover/classify every provider child environment key.", + "finding": "The completed corrected review proves eight explicit classified keys are removable without a completeness error." + }, + { + "id": "P0.REQ.W2.004", + "area": "W2", + "classification": "weak", + "decision": "further-proof", + "requirement": "Prove browser-session and runtime-ingress authority disjoint.", + "finding": "The proposed contract intersection is empty, but target routes, relay and credentials do not exist." + }, + { + "id": "P0.REQ.W2.005", + "area": "W2", + "classification": "satisfied", + "decision": "adopt", + "requirement": "State Anthropic/Codex/Gemini assumptions and OpenCode differences.", + "finding": "They are explicit and source-observed." + }, + { + "id": "P0.REQ.W2.006", + "area": "W2", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Provide complete positive/negative fake-runtime provider matrix.", + "finding": "The completed review rejects completeness because malformed capability, restart/adoption and secondary recovery have neither positive nor negative fixture." + }, + { + "id": "P0.REQ.W2.007", + "area": "W2", + "classification": "weak", + "decision": "remediate", + "requirement": "Use artifact-specific nested schemas and deliberate negatives.", + "finding": "Schemas cover listed nested fields, but the runtime scanner is false-green for eight environment keys and three required provider cases." + }, + { + "id": "P0.REQ.W2.008", + "area": "W2", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Contribute a reconcilable canonical estimate.", + "finding": "The completed corrected review explicitly approves the EST-LIFECYCLE-RUNTIME estimate and schema." + }, + + { + "id": "P0.REQ.W3.001", + "area": "W3", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Catalog every state family with authority/writers/schema/atomicity/corruption/backup role.", + "finding": "The accepted 17-family catalog validates." + }, + { + "id": "P0.REQ.W3.002", + "area": "W3", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Assign every required mutation an honest writer class.", + "finding": "Twelve operations remain classified and unresolved writers fail closed." + }, + { + "id": "P0.REQ.W3.003", + "area": "W3", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Disprove app-only locking against external writer.", + "finding": "The marker-owned external-process fixture deterministically loses the external update." + }, + { + "id": "P0.REQ.W3.004", + "area": "W3", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Fault-characterize production TeamBackupService behavior.", + "finding": "Seven tests pass across twelve required fault cases." + }, + { + "id": "P0.REQ.W3.005", + "area": "W3", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Prove supported WAL Online Backup and fail-closed faults.", + "finding": "Verifier and focused tests pass at current-host Node ABI proof level." + }, + { + "id": "P0.REQ.W3.006", + "area": "W3", + "classification": "missing", + "decision": "further-proof", + "requirement": "Prove backup in the final Node artifact and deployment recovery topology.", + "finding": "Final worker wire op, image ABI load and quiesced recovery topology are absent." + }, + { + "id": "P0.REQ.W3.007", + "area": "W3", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Provide controller-reconcilable estimate without W5 duplication.", + "finding": "The completed corrected review adopts the W3 estimate and its explicit replace/not-add overlap rule." + }, + + { + "id": "P0.REQ.W4.001", + "area": "W4", + "classification": "missing", + "decision": "further-proof", + "requirement": "Run probes in the declared final Linux/container/filesystem envelope.", + "finding": "Only the root Ubuntu/ext4 current host ran." + }, + { + "id": "P0.REQ.W4.002", + "area": "W4", + "classification": "missing", + "decision": "further-proof", + "requirement": "Prove one winner across two final-image containers and manual start.", + "finding": "No final image or shared-volume container topology exists." + }, + { + "id": "P0.REQ.W4.003", + "area": "W4", + "classification": "weak", + "decision": "further-proof", + "requirement": "Prove pause/kill/close-order/anchor-replacement cannot overlap.", + "finding": "Current-host cases and measured cleanup pass, but final topology is absent." + }, + { + "id": "P0.REQ.W4.004", + "area": "W4", + "classification": "weak", + "decision": "further-proof", + "requirement": "Prove descriptor-bound effects and zero outside effects for all races.", + "finding": "Current-host races pass with a raw-path negative control; final UID/seccomp/mount topology is absent." + }, + { + "id": "P0.REQ.W4.005", + "area": "W4", + "classification": "contradicted", + "decision": "reject", + "requirement": "Prove nonce-ready pidfd/subreaper process ownership and typed drain without numeric PID fallback.", + "finding": "The corrected review rejects numeric process-group reuse and finds deterministic PID reuse, final init behavior and a green owned suite absent." + }, + { + "id": "P0.REQ.W4.006", + "area": "W4", + "classification": "weak", + "decision": "remediate", + "requirement": "Prove no lease/control FD leaks to provider/Git/helpers.", + "finding": "High-FD current-host canaries pass, but real adapters/final image are absent and the focused suite is red." + }, + { + "id": "P0.REQ.W4.007", + "area": "W4", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Reconcile reproducible W4 artifacts with W6 final image inventory.", + "finding": "The corrected review finds /opt versus /app paths, missing W4-required manifest fields and incompatible drain generations across W4/W6." + }, + { + "id": "P0.REQ.W4.008", + "area": "W4", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Keep adoption below 1,500 lines or record approved split.", + "finding": "W4 remediation is 2,548 changed lines and still has no handoff or controller-approved split." + }, + + { + "id": "P0.REQ.W5.001", + "area": "W5", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Exercise durable crash/restart at every required snapshot/effect boundary.", + "finding": "Snapshot pauses are valid, but the completed review finds effect recovery stays in the same closure and continues the pre-crash path rather than reloading in a fresh runner." + }, + { + "id": "P0.REQ.W5.002", + "area": "W5", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Reproduce cursor-after-read and query-then-listen lost events.", + "finding": "Both prohibited algorithms lose the committed mutation." + }, + { + "id": "P0.REQ.W5.003", + "area": "W5", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Converge with duplicates but no gaps for SQLite/external projections.", + "finding": "All 288 accepted schedules converge; lower-C0 schedules exercise duplicates." + }, + { + "id": "P0.REQ.W5.004", + "area": "W5", + "classification": "weak", + "decision": "remediate", + "requirement": "Independently census every mutation and assign one intent/fingerprint/effect class.", + "finding": "Every listed row maps once, but the manual census is not omission-detecting against the full mutating interface source." + }, + { + "id": "P0.REQ.W5.005", + "area": "W5", + "classification": "weak", + "decision": "remediate", + "requirement": "Fail unproved/ambiguous external effects to operator_required.", + "finding": "The rows are semantically fail-closed, but the completed review holds the effect matrix because the recovery runner does not establish a real fresh-run boundary." + }, + { + "id": "P0.REQ.W5.006", + "area": "W5", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Cover schema/fingerprint/key versions, defaults and retained comparison.", + "finding": "All required golden assertions pass." + }, + { + "id": "P0.REQ.W5.007", + "area": "W5", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Store no sensitive command body and provide deduplicated estimate.", + "finding": "No body/key persists and the completed review approves the estimate with its explicit non-additive W3 overlap." + }, + + { + "id": "P0.REQ.W6.001", + "area": "W6", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Cover restart-safe pairing/device/session/revoke/reset/expiry/response-loss/two-tab/keyring schedules.", + "finding": "The repaired 25-test fixture covers these transitions and keeps revoked authority closed across restart." + }, + { + "id": "P0.REQ.W6.002", + "area": "W6", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Fence reset/challenge on generation-bound typed drain evidence.", + "finding": "W6 rejects stale/unclassified fixture evidence, but the completed corrected review finds its deployment/anchor/reset fields incompatible with W4 runtime-generation protocol fields." + }, + { + "id": "P0.REQ.W6.003", + "area": "W6", + "classification": "weak", + "decision": "further-proof", + "requirement": "Reject proxy/origin threats before cookie/body/idempotency work.", + "finding": "Fixture ordering passes; final HTTPS edge/CIDR/private-listener proof is missing." + }, + { + "id": "P0.REQ.W6.004", + "area": "W6", + "classification": "weak", + "decision": "further-proof", + "requirement": "Keep session authority server-side with no plaintext durable recovery credential.", + "finding": "The symbolic model does so; real SQLite/keyring crash durability is missing." + }, + { + "id": "P0.REQ.W6.005", + "area": "W6", + "classification": "missing", + "decision": "further-proof", + "requirement": "Prove the exact emitted target artifact, worker, W4 binaries, ABI split and stub risks.", + "finding": "The target manifest is contract-only; every target artifact row is unbuilt." + }, + { + "id": "P0.REQ.W6.006", + "area": "W6", + "classification": "contradicted", + "decision": "reject", + "requirement": "Prove terminal daemon/gateway/SDK/routes/migrations/artifacts absent from v1 image.", + "finding": "Current emitted output includes terminal dependencies/service marker and no clean final image exists." + }, + { + "id": "P0.REQ.W6.007", + "area": "W6", + "classification": "missing", + "decision": "further-proof", + "requirement": "Verify authoritative Fastify-5 cookie compatibility before adoption.", + "finding": "@fastify/cookie is absent from the supplied/current package manifest and official compatibility is not captured." + }, + { + "id": "P0.REQ.W6.008", + "area": "W6", + "classification": "weak", + "decision": "further-proof", + "requirement": "Provide supported EST-HOSTED-OPS input.", + "finding": "Canonical input exists, but its target assumptions remain open." + }, + + { + "id": "P0.REQ.REVIEW.001", + "area": "review", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Complete corrected reciprocal W1/W2 review and resolve findings before adoption.", + "finding": "The scope-corrected v3 review completed and rejects both lanes with six unresolved findings." + }, + { + "id": "P0.REQ.REVIEW.002", + "area": "review", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Complete corrected reciprocal W3/W5 review and resolve findings before adoption.", + "finding": "The v2 review adopts all W3 but remediates W5 with four unresolved findings." + }, + { + "id": "P0.REQ.REVIEW.003", + "area": "review", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Complete corrected reciprocal W4/W6 review and resolve findings before adoption.", + "finding": "The v2 review remediates both and rejects paired adoption because protocols/artifact contracts conflict and checks are red." + }, + { + "id": "P0.REQ.REVIEW.004", + "area": "review", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Use self-review diff checks that cover every untracked addition.", + "finding": "W1/W3 explicitly record no-index checks; W2/W5/W6 handoffs are stale original records and W4 has no handoff." + }, + + { + "id": "P0.REQ.0D.001", + "area": "0D", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Adopt or explicitly reject all six corrected lanes through controller decisions.", + "finding": "Registry rejects all originals and has no consumed remediation outputs or lane decisions." + }, + { + "id": "P0.REQ.0D.002", + "area": "0D", + "classification": "missing", + "decision": "further-proof", + "requirement": "Freeze all twelve named v1 decision areas with no TBD state.", + "finding": "The decision register still contains only 0A decisions." + }, + { + "id": "P0.REQ.0D.003", + "area": "0D", + "classification": "missing", + "decision": "further-proof", + "requirement": "Regenerate canonical unique-bucket estimate/salvage/parity/state/provider/artifact ledgers.", + "finding": "The integrated ledgers remain pre-inventory baselines and producer evidence is not adopted." + }, + { + "id": "P0.REQ.0D.004", + "area": "0D", + "classification": "missing", + "decision": "further-proof", + "requirement": "Run all final-shape native/container/auth/proxy/artifact proofs.", + "finding": "No admitted final image/topology exists." + }, + { + "id": "P0.REQ.0D.005", + "area": "0D", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Validate every JSON acceptance field and every scanner/spike with positive and deliberate negative fixtures.", + "finding": "W4 owned tests are red; W1 and W5 completeness inputs are manually bounded and omission detection is incomplete." + }, + { + "id": "P0.REQ.0D.006", + "area": "0D", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Give every state/writer/provider/action row one correct disposition and owner.", + "finding": "State/provider rows are improved, but W1 visible-action coverage is non-exhaustive and W5 mutation census completeness is weak." + }, + { + "id": "P0.REQ.0D.007", + "area": "0D", + "classification": "missing", + "decision": "further-proof", + "requirement": "Run targeted combined checks and required broad gates after adoption.", + "finding": "No adoption exists; this task intentionally did not run broad/final gates." + }, + { + "id": "P0.REQ.0D.008", + "area": "0D", + "classification": "missing", + "decision": "further-proof", + "requirement": "Produce controller Phase 0 completion report with residual risks.", + "finding": "No completion report exists and current evidence rejects completion." + }, + { + "id": "P0.REQ.0D.009", + "area": "0D", + "classification": "contradicted", + "decision": "reject", + "requirement": "Generate Phase 1 JIT packet only from frozen decisions/current integration SHA.", + "finding": "A blocked Phase 1 controller draft was integrated at 0e8431b before Phase 0 freeze; it must not be treated as the required JIT packet." + }, + { + "id": "P0.REQ.0D.010", + "area": "0D", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Do not prestart Phase 1 workers.", + "finding": "No Phase 1 worker job or implementation was found." + }, + { + "id": "P0.REQ.0D.011", + "area": "0D", + "classification": "satisfied", + "decision": "adopt", + "requirement": "Keep the integration worktree clean after the current commit sequence.", + "finding": "git status is clean at 0e8431b." + }, + { + "id": "P0.REQ.0D.012", + "area": "0D", + "classification": "contradicted", + "decision": "remediate", + "requirement": "Reference every parent-plan Phase 0 exit gate from current evidence.", + "finding": "The integrated requirement matrix predates all remediation work and does not reference current producer state; several final-shape gates have no evidence." + } + ], + "narrowChecks": [ + { + "id": "CHK-W1-TEST", + "result": "pass", + "detail": "Vitest 1 file, 7 tests; temp cache config used to avoid writing the read-only producer worktree." + }, + { + "id": "CHK-W1-COVERAGE-CENSUS", + "result": "contradiction", + "detail": "127 non-test team/provider files contain common interaction handlers; W1 renderer action evidence scans four files." + }, + { + "id": "CHK-W1-RAW-HASH", + "result": "pass", + "detail": "External raw bypass artifact exists and SHA-256 matches the handoff." + }, + { + "id": "CHK-W2-SCANNER", + "result": "false_green", + "detail": "Current rules pass for 4 providers, 2 backends, 5 operations and 13 rows, but reviewer probes remove eight classified keys without error and find three rows with no proof pair." + }, + { "id": "CHK-W2-TEST", "result": "pass", "detail": "Vitest 1 file, 8 tests." }, + { + "id": "CHK-W3-VERIFY", + "result": "pass", + "detail": "6 evidence files, 17 state families, 12 operations." + }, + { + "id": "CHK-W3-TEST", + "result": "pass", + "detail": "State-writer test 1/1 and TeamBackupService fault tests 7/7." + }, + { + "id": "CHK-W4-PROBES", + "result": "pass_current_host_only", + "detail": "Current-host probes report zero outside effects/leaks/residuals and typed process outcomes; finalShapeContainer=false." + }, + { "id": "CHK-W4-SCAN", "result": "pass", "detail": "Host-primitives evidence scan passed." }, + { + "id": "CHK-W4-TEST", + "result": "fail", + "detail": "2/3 tests pass; native-probes test expects an obsolete exact cleanup object and rejects the added measured cleanup fields." + }, + { "id": "CHK-W5-GENERATE", "result": "pass", "detail": "9 evidence files verified." }, + { + "id": "CHK-W5-TEST", + "result": "false_green_for_effect_recovery", + "detail": "Node suite passes 7/7, but corrected review proves effect recovery remains in the same closure and the census cannot detect an omitted source row." + }, + { + "id": "CHK-W6-VERIFY", + "result": "false_green_contract", + "detail": "Schema, ownership, current ABI smoke and freshness pass, but the corrected review finds W4/W6 artifact and drain contracts incompatible." + }, + { "id": "CHK-W6-TEST", "result": "pass_fixture", "detail": "Vitest 1 file, 25 tests." }, + { + "id": "CHK-W6-LINT", + "result": "fail", + "detail": "Focused corrected review reports simple-import-sort/imports at auth-artifacts-spike.test.ts:1." + }, + { + "id": "CHK-REVIEW-W1-W2", + "result": "remediate_both", + "detail": "Completed v3 review approves 9 exact files and records 6 unresolved findings." + }, + { + "id": "CHK-REVIEW-W3-W5", + "result": "adopt_w3_remediate_w5", + "detail": "Completed v2 review adopts all 16 W3 files, approves 4 W5 files and records 4 W5 findings." + }, + { + "id": "CHK-REVIEW-W4-W6", + "result": "remediate_both_reject_pair", + "detail": "Completed v2 review approves 7 exact files; W4 test and W6 lint are red and cross-lane contracts conflict." + }, + { + "id": "CHK-INTEGRATION-CLEAN", + "result": "pass", + "detail": "Integration worktree status is clean." + }, + { + "id": "CHK-REGISTRY", + "result": "contradiction", + "detail": "Original producers are rejected; remediation producers/reviews lack consumed completed decisions." + } + ] +} diff --git a/docs/research/hosted-web/phase-0/audits/cross-lane-v2/README.md b/docs/research/hosted-web/phase-0/audits/cross-lane-v2/README.md new file mode 100644 index 0000000000..af9f40bf9e --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/cross-lane-v2/README.md @@ -0,0 +1,65 @@ +# Phase 0 cross-lane remediation audit v2 + +Disposition: **W3 bundle pair-accepted; hold every other lane bundle; no integration performed**. + +The original W1-W6 producers are complete, but all three original reciprocal reviews rejected both +lanes. The remediation worktrees preserve useful progress, yet only W3 has a terminal producer result +and complete remediation handoff. W1, W2, W4, W5 and W6 retain dirty output under stale `running` +registry records with no result. Their files were inspected directly; none was discarded or changed. + +The machine-readable authority for file-level approval, conflicts, risk and ordering is +[`approved-files-conflict-risk-manifest.json`](./approved-files-conflict-risk-manifest.json). + +## Outcome + +- No file bypasses the controller lifecycle. Thirty-six exact files are evidence-approved: the complete + 16-file W3 bundle is pair-accepted and policy-eligible after ledger reconciliation; 20 files are + selective evidence from lanes whose bundles remain rejected. +- W3 is the only remediation whose evidence package is internally complete. The corrected W3/W5 review + accepts all 16 W3 files together; its estimate remains an input requiring controller deduplication. +- The pair-approved original W1 selection/reconciliation record must be retained. W1 remediation + replaced it with a shorter, weaker summary; the corrected W1/W2 review still rejects both bundles. +- Eight W2 files are selectively approved. Environment omission detection, three provider proofs and + the stale handoff keep W2 rejected. +- Four W5 evidence files are selectively approved. Fresh effect recovery, independent census coverage, + ownership/direction, handoff and whitespace findings keep W5 rejected. +- Six W4 and one W6 files are selectively approved. The corrected W4/W6 review rejects both bundles. + +## Corrected pair-review dispositions + +- W1/W2 v3: remediate both; nine selective file approvals. This audit selects the stronger original W1 + invariant hash rather than the shortened remediation version at the same path. +- W3/W5 v2: adopt all 16 W3 files; remediate W5; four selective W5 evidence approvals. +- W4/W6 v2: remediate both; seven selective file approvals. + +## Acceptance-critical conflicts + +1. W4 emits `protocolVersion/runtimeGeneration/residualCount`; W6 requires a different invented drain + record containing purpose, reset/deployment/anchor generations, classification and residuals. Reset + fencing is not end-to-end until one shared DTO is emitted and consumed. +2. W4 specifies native binaries under `/opt/agent-teams/bin/*`; W6 specifies `/app/bin/*`. W6 also omits + W4-required protocol/build/compiler/ABI/UID/GID/mode metadata while reporting the contract valid. +3. W1 and W2 assign launch/stop/delete to `team.lifecycle`; W5 assigns launch/stop/cancel to + `team-runtime-control` and preserves legacy `runtime.permission_answer` direction. +4. W1's remediation scans 106 sites in four files. The same scanner finds 649 additional interaction + sites in 131 other team-component files, so the exhaustive visible-control claim is unproved. +5. Estimate files share a basename but not a unit or additive contract. W2/W4 and W3/W5 ranges overlap + and must be replaced/deduplicated by the controller. +6. W2's environment check passes its own test but misses eight one-key omissions; three required + provider cases remain explicit gaps rather than completed proof. +7. W5 effect recovery invokes `recover()` inside the crashed closure and resumes the old control path; + it does not reload durable state into a fresh recovery machine. + +## Narrow verification + +- W2 scanner: pass. +- W3 production `TeamBackupService` fault suite, evidence verifier and architecture test: pass. +- W4 marker-owned current-host probe: pass as characterization; final topology is false and PID reuse + was not deterministically forced. Corrected review also found the stale W4 Vitest expectation fails. +- W5 evidence freshness check and seven focused tests: pass at their scope; corrected review found two + no-index whitespace failures and the fresh-recovery gap. +- W6 revocation restart model and local verifier: pass; independent W4/W6 contract comparison rejects + compatibility, and corrected-review targeted lint fails import ordering. + +No broad/final gate, integration, push, Phase 1 work, terminal implementation or real-project/provider +test was performed. diff --git a/docs/research/hosted-web/phase-0/audits/cross-lane-v2/approved-files-conflict-risk-manifest.json b/docs/research/hosted-web/phase-0/audits/cross-lane-v2/approved-files-conflict-risk-manifest.json new file mode 100644 index 0000000000..39939ccaf5 --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/cross-lane-v2/approved-files-conflict-risk-manifest.json @@ -0,0 +1,857 @@ +{ + "schemaVersion": 1, + "auditId": "P0.AUDIT.CROSS_LANE.V2", + "generatedAt": "2026-07-11T19:33:43Z", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "remediationBaseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "auditHeadSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "overallDisposition": "w3_bundle_pair_accepted_hold_all_other_bundles_no_integration_performed", + "summary": { + "originalProducerJobs": 6, + "originalPairReviews": 3, + "originalPairsAccepted": 0, + "remediationProducerJobs": 6, + "remediationProducerResultsPresent": 1, + "remediationProducerResultsMissing": 5, + "immediatelyAdoptableFiles": 0, + "pairAcceptedBundleFiles": 16, + "selectiveEvidenceApprovedFiles": 20, + "evidenceApprovedButHeldFiles": 36, + "exactCrossLaneRelativePathCollisions": 0, + "openCriticalConflicts": 3, + "openHighConflicts": 8 + }, + "registrySnapshot": { + "registryRoot": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2", + "priorAuditInputs": [ + { + "path": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-audit-cross-lane-v1/docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json", + "sha256": "d6a727dcc93a9f1df64304a8f136b53bd8f061d99a525a4f52d9dd49d7cbda7b" + }, + { + "path": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-audit-requirements-v1/docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json", + "sha256": "406545d7881ee9f09df1068187d1b9e613e0e359f862fbc90a2edffa982424fe" + } + ], + "originals": [ + { + "lane": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w1-v1", + "result": "done", + "reviewDisposition": "reject" + }, + { + "lane": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w2-v1", + "result": "done", + "reviewDisposition": "reject" + }, + { + "lane": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w3-v1", + "result": "done", + "reviewDisposition": "reject" + }, + { + "lane": "w4", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w4-v1", + "result": "done", + "reviewDisposition": "reject" + }, + { + "lane": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w5-v1", + "result": "done", + "reviewDisposition": "reject" + }, + { + "lane": "w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w6-v1", + "result": "done", + "reviewDisposition": "reject" + } + ], + "originalPairReviews": [ + { + "pair": "w1-w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w1-w2-v1", + "status": "completed_reject_both", + "reportSha256": "213836d2c6104318d3e59848a856dea14bfefc9d56840645a76cf821fc918e2b", + "handoffSha256": "c12836733a03159b434223b6ffb63d2a561a3ff946dd3704fde6f5d7f88ef48e" + }, + { + "pair": "w3-w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1", + "status": "completed_reject_both", + "reportSha256": "8140261ef1cf3a2a37fc25d75f639fa9e20607e7bb1c104fe5a39b1585e38a15", + "handoffSha256": "f8d9b40c83ce4d895110162807cd8abafaac25566ef4dd32db0ab5e8afbd8beb" + }, + { + "pair": "w4-w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v1", + "status": "completed_reject_both", + "reportSha256": "4a509551fdf2466f1be2f6cc9e9b8c9c1058fb3c5c695f8706f78af42e47e5bf", + "handoffSha256": "7bf1e3e6f1c02ff352909cead19dd0a344f4277d3bbd7ac4e897dfee2a45a175" + } + ], + "remediations": [ + { + "lane": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v1", + "workspaceHead": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "dirtyFileCount": 13, + "result": "missing_stale_running_record", + "handoff": "present_but_base_and_completion_provenance_incomplete" + }, + { + "lane": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w2-v1", + "workspaceHead": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "dirtyFileCount": 18, + "result": "missing_stale_running_record", + "handoff": "stale_original_handoff" + }, + { + "lane": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w3-v1", + "workspaceHead": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "dirtyFileCount": 16, + "result": "done", + "handoff": "complete" + }, + { + "lane": "w4", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-v1", + "workspaceHead": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "dirtyFileCount": 21, + "result": "missing_stale_running_record", + "handoff": "missing" + }, + { + "lane": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w5-v1", + "workspaceHead": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "dirtyFileCount": 17, + "result": "missing_stale_running_record", + "handoff": "stale_and_omits_new_files_and_checks" + }, + { + "lane": "w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w6-v1", + "workspaceHead": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "dirtyFileCount": 11, + "result": "missing_stale_running_record", + "handoff": "stale_and_contradicts_finding_resolution" + } + ], + "remediationPairReviews": [ + { + "pair": "w1-w2", + "authoritativeAttempt": "agent-teams-hosted-web-refactor-phase-00-remediation-review-w1-w2-v3", + "statusAtSnapshot": "completed_remediate_both", + "approvedFileCount": 9, + "reportPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-review-w1-w2-v3/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2-remediation-v3.md", + "reportSha256": "b3d38bdc576474024a8aa96281723362465f939d8af70361bfcccdbfe35db77f", + "handoffSha256": "c17d5a45675ef2dc7664b309b58eaea7a279708591a7b2fc7c3f7e2a2bac13c8", + "earlierAttempts": ["v1_stopped", "v2_scope_invalid"] + }, + { + "pair": "w3-w5", + "authoritativeAttempt": "agent-teams-hosted-web-refactor-phase-00-remediation-review-w3-w5-v2", + "statusAtSnapshot": "completed_adopt_w3_remediate_w5", + "approvedFileCount": 20, + "reportPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-review-w3-w5-v2/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md", + "reportSha256": "688ef4be913b9659fa471a4938be13117694e18e7beb3113a4fbdf52842f8744", + "handoffSha256": "686a98e80d502e22e3feb63dd0e380fd7e343ae85d00dc0a70c1cd538e46b9fc", + "earlierAttempts": ["v1_stopped"] + }, + { + "pair": "w4-w6", + "authoritativeAttempt": "agent-teams-hosted-web-refactor-phase-00-remediation-review-w4-w6-v2", + "statusAtSnapshot": "completed_remediate_both", + "approvedFileCount": 7, + "reportPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-review-w4-w6-v2/docs/research/hosted-web/phase-0/remediation-review-w4-w6.md", + "reportSha256": "410a889faaea782b4a7c1ab3793ba24b58f6b7a18a57d13bb050598d2ab0ce37", + "handoffSha256": "be2041c273a17ea5673fcae0443eb9b29f4566d216b7edce76609affdf1d7cc7", + "earlierAttempts": ["v1_stopped"] + } + ] + }, + "filenameOverlap": { + "exactRelativePathCollisions": [], + "sameLaneOriginalRemediationPathOverlap": [ + { + "lane": "w1", + "overlappingPathCount": 13, + "resolution": "Use remediation versions only after correction except selection-reconciliation-invariants.md, where the original approved hash is stronger and selected." + }, + { + "lane": "w2", + "overlappingPathCount": 13, + "resolution": "Use only the eight selectively approved remediation files; do not combine old and new schema families." + }, + { + "lane": "w3", + "overlappingPathCount": 15, + "resolution": "Use the complete 16-file remediation bundle; it supersedes the original W3 set." + }, + { + "lane": "w4", + "overlappingPathCount": 14, + "resolution": "Use only the six selectively approved exact hashes; reject the current process-anchor and proposed cross-lane contracts." + }, + { + "lane": "w5", + "overlappingPathCount": 15, + "resolution": "Use only four selectively approved evidence records; event cursor and estimate retain their original semantics." + }, + { + "lane": "w6", + "overlappingPathCount": 9, + "resolution": "Use only observed-artifact-scan.json; all mixed auth/artifact files remain coupled to rejected contracts." + } + ], + "intentionalBasenameOverlap": [ + { + "basename": "estimate-input.json", + "lanes": ["w1", "w2", "w3", "w4", "w5", "w6"], + "semanticRisk": "The files use incompatible top-level and sub-bucket conventions; filename separation does not make their arithmetic additive." + }, + { + "basename": "evidence.schema.json", + "lanes": ["w3", "w5", "w6"], + "semanticRisk": "Lane-local schemas are safe by path but are not a shared envelope and cannot validate another lane's contract." + }, + { + "basename": "verify-evidence.mjs", + "lanes": ["w3", "w6"], + "semanticRisk": "Both verifiers pass their own artifacts while missing cross-lane incompatibilities." + }, + { + "basename": "README.md", + "lanes": ["w1", "w2", "w5"], + "semanticRisk": "Documentation-only basename overlap; full paths are disjoint." + } + ] + }, + "approvedFiles": [ + { + "order": 1, + "producer": "original-w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "sha256": "b9f9a3b24720fbac112a1b97e40725ecf484e18c15f1d7b6596d2446b4f4eefe", + "approval": "review_approved_original_only", + "adoptionStatus": "selective_original_evidence_approved_lane_rejected", + "note": "The W1 remediation rewrites this approved 137-line source-grounded record into a weaker 28-line summary; retain the original file." + }, + { + "order": 2, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sha256": "ed15cd0745492a78457183b27d0a974a3fbe7db34fde956768a44c7b9a217095", + "approval": "evidence_approved", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 3, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json", + "sha256": "2d9b4318118ffe190e0054969980be04383cf6e242f3f7407eced14851e4f8ac", + "approval": "evidence_approved", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 4, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "sha256": "11f6041495f67b34c2ae73d5c1318a44227966889e5c345deefc44096f445c28", + "approval": "evidence_approved_as_current_truth", + "adoptionStatus": "selective_evidence_approved_cross_lane_direction_authority" + }, + { + "order": 5, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json", + "sha256": "0f82d7d7de307133bacb9b93bbe1b9140a5012870cf035dc6c1ecfe6ddddbb9b", + "approval": "evidence_approved", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 6, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "sha256": "a9cff35be387062de2ce908a98aa0613cf460bd43cb5e1b3eeeb7d1c704f0623", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 7, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "sha256": "e115aa10ab1ad3842e8b44db03a07541cae9b6abdbeb1207ba3f01bd6d5ff7ae", + "approval": "estimate_input_approved", + "adoptionStatus": "pair_accepted_controller_dedup_required" + }, + { + "order": 8, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/evidence.schema.json", + "sha256": "d1b372576eb54025c9f9d244b87fadb2732b576e00adc9fa942daa6632113cbf", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 9, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/index.md", + "sha256": "3928efdf49599c058dcbccfd43df30a95e964809278e66d12bb9e093e5dad744", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 10, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "sha256": "aec02d87f8657ec91d51f252d0dcf652dc04cae2ae047cebef6e3455820906e3", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 11, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "sha256": "7b9e2a03da2f972239b36b248319d36d420917596f03d20467dbabc8dc0ee4f5", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 12, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md", + "sha256": "f3230f8b3dea6d0cad98d3e3a8c23c951570691a57376ad868752eda42fbdcf4", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 13, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "sha256": "f987dfb942445d3ac9ebfeb1a4f931291b7b834af45e5979d45518d59975e90a", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 14, + "producer": "remediation-w3", + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "sha256": "7c7c572e068b04c9b84886d2a7c47aa3e04ba714311b2f8c98b0fe066c7d52ef", + "approval": "evidence_approved_as_cross_lane_authority", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 15, + "producer": "remediation-w3", + "path": "scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs", + "sha256": "1436313385a3dde2ee5ac81dd40bc835047d3fbcc62cb5bffdac9f917e6559d2", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 16, + "producer": "remediation-w3", + "path": "scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs", + "sha256": "e1ca50f273a449c0c36dd143eb1117efea7f19c8df5daefb16d0da3553fe4054", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 17, + "producer": "remediation-w3", + "path": "scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs", + "sha256": "ed2887a0320f4f53f1870e0a25473e1d414ead8be7db24c53299f73e42d940d9", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 18, + "producer": "remediation-w3", + "path": "test/architecture/hosted-web/phase-0/state-writers/fixtures/invalid-state-family-catalog.json", + "sha256": "b905da5326aed7d2d964ab24c3df4a86e06bf5be1a43662acdb3fab2aa75638a", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 19, + "producer": "remediation-w3", + "path": "test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs", + "sha256": "f9b85a5a400852220f0c3cb14d995c2276f4d67b24355d09320c9f94403a655b", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 20, + "producer": "remediation-w3", + "path": "test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs", + "sha256": "9859e33b15a277edc5999693445064f9769b664efadd0b390b16682139dece73", + "approval": "evidence_approved", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 21, + "producer": "original_w5_byte_identical_in_remediation", + "path": "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "sha256": "2e296692f0415c3b82f3ce15cf1683bbd0aee846d2b571a5dff31bff406ddc15", + "approval": "review_approved_retained", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 22, + "producer": "remediation-w3", + "path": ".codex-handoff/phase-00-w3.json", + "sha256": "02108287b2f1c1a25ce1cd80ffe5b7808cc5afc0f51f6aec15c46c8bd7b28816", + "approval": "pair_approved_bundle_metadata", + "adoptionStatus": "pair_accepted_controller_policy_eligible" + }, + { + "order": 23, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "sha256": "408a7beb2297c30c74743e7fab8b6c63585c4ee574f20fff86fcc85ca7b994c9", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 24, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json", + "sha256": "dda38204181f01af5b58ef90d174db011a1da915f1707fa7be97b02cceebec7e", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 25, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "sha256": "b5a69d43bc7aaf7ab52309500b36b97d2b66c987601c6e61674fe48926085328", + "approval": "selective_pair_approval_as_input", + "adoptionStatus": "selective_evidence_approved_controller_scope_review_required" + }, + { + "order": 26, + "producer": "remediation-w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json", + "sha256": "43f41073a18c7ebd948f9283c1412fbf54ef7c6c0a7d738fdb66b87fd4fd2a16", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 27, + "producer": "remediation-w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json", + "sha256": "f19fcbefb7fe673156f07e3bf5c680b0d10bc4664466bd529b9c65e3ec7639f1", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 28, + "producer": "remediation-w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json", + "sha256": "515677b1d652c35238523b7648869dc75d54a17a90993ba9783056cd30a0be9c", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 29, + "producer": "remediation-w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "sha256": "03f41142c1845f913bd0dfbd59499cfb6390454c36f4fec596432e7faf714341", + "approval": "selective_pair_approval_as_input", + "adoptionStatus": "selective_evidence_approved_controller_dedup_required" + }, + { + "order": 30, + "producer": "remediation-w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md", + "sha256": "b269fa2b9a9126c90542ff6f2100043a1ae76e0eb566ef3c35171884368eb5cb", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 31, + "producer": "remediation-w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md", + "sha256": "14a1e8fea81cf6f4ac92cdd01c0af2654a7aaddacdbda4fecd069bf18a2f2200", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 32, + "producer": "remediation-w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "sha256": "f6182c02566f51837ab5a9027f181aa90ce5ac0cc61ad559a3e96f70e4c092a0", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 33, + "producer": "remediation-w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md", + "sha256": "646732d59d364b68bbde75edbd5e699c79f876a7b4fca928b634006e62224c82", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 34, + "producer": "remediation-w4", + "path": "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "sha256": "d9cc83ae82e3a1c11e654db39ecf6ddceea78f23f455db707cb5876652118c57", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 35, + "producer": "remediation-w4", + "path": "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "sha256": "66f7c58d1188fd8d4be09bdd890458002dbbe40bf94b153155f1f9a54366ab43", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + }, + { + "order": 36, + "producer": "remediation-w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "sha256": "cd8059153cd5ccd195deeab5ce9663d95a50543bf4fad9b6f1af6c3b1cf26fc0", + "approval": "selective_pair_approval", + "adoptionStatus": "selective_evidence_approved_lane_rejected" + } + ], + "laneDispositions": [ + { + "lane": "w1", + "disposition": "reject_remediation_as_a_set", + "approvedExceptions": [ + "Use the original selection-reconciliation-invariants.md, not the remediation rewrite." + ], + "reasons": [ + "The semantic catalog scans only four composition files and 106 sites while the same scanner finds 649 interaction sites in 131 other src/renderer/components/team files.", + "The claimed exhaustive visible-control inventory is therefore unproved.", + "The remediation handoff does not accurately identify the actual remediation base and no registry result exists." + ] + }, + { + "lane": "w2", + "disposition": "partial_evidence_approval_hold_lane", + "reasons": [ + "The scanner now passes nested schemas, 15 environment groups, 13 provider cases and EST-LIFECYCLE-RUNTIME arithmetic.", + "The corrected pair review found eight classified environment keys removable without a completeness diagnostic and three provider cases with missing positive/negative proof.", + "The handoff is the stale original artifact, omits current schemas, and reports obsolete estimate IDs and test counts.", + "No completed producer result exists; the corrected pair disposition is remediate." + ] + }, + { + "lane": "w3", + "disposition": "pair_accepted_all_16_controller_policy_eligible", + "reasons": [ + "The only completed remediation result is present and its base/provenance are accurate.", + "Seven production TeamBackupService fault tests, the evidence verifier, and the state-writers architecture test passed in this audit.", + "The corrected W3/W5 pair review accepts all 16 W3 files; its estimate remains a non-additive controller input to EST-RECOVERY-STATE." + ] + }, + { + "lane": "w4", + "disposition": "reject_incomplete_remediation", + "reasons": [ + "The current-host probe now verifies cleanup before emission, high-FD closure and group-only signaling, but no handoff or result exists.", + "The process anchor can issue a second kill(-pgid) after the owned group empties; the rapid PID/PGID schedule does not deterministically force reuse and the document falsely claims pidfd signaling.", + "The unchanged native-probes.test.ts fails against the new cleanup record and none of the seven new protocol/schema files is covered by the scanner.", + "The unchanged W4 estimate still uses noncanonical host.* buckets.", + "The new W4/W6 native artifact contract conflicts with W6 paths and required metadata." + ] + }, + { + "lane": "w5", + "disposition": "reject_remediation_as_a_set", + "reasons": [ + "The new schedulers execute explicit restart transitions, all 103 effect rows fail closed, and focused checks pass.", + "The effect scheduler calls recover() inside the crashed closure and then resumes the pre-crash control path instead of loading a fresh recovery machine.", + "The mutation census still assigns team.launch, team.stop and cancel provisioning to team-runtime-control, contradicting W1 and W2 canonical team.lifecycle ownership.", + "runtime.permission_answer remains a required hosted mutation even though W2 requires runtime.permission-request ingress plus an operator-owned outbound decision.", + "The handoff is stale, omits mutation-census artifacts, reports obsolete schedule counts, and two files fail no-index whitespace checking." + ] + }, + { + "lane": "w6", + "disposition": "reject_remediation_as_a_set", + "reasons": [ + "Restart after logout, forget-device and replay-family revocation now remains closed in the executable model.", + "W6's invented typed-drain record cannot be emitted by W4's proposed process-anchor protocol.", + "W6 uses /app/bin while W4 specifies /opt/agent-teams/bin and W6 omits W4-required protocol/build/compiler/UID/GID/mode fields.", + "The W6 verifier incorrectly reports the shared artifact contract valid because it does not load or compare W4's contract.", + "The stale handoff still records only vacuous git diff --check and omits the new files; targeted fast lint also fails import ordering in the modified test." + ] + } + ], + "conflicts": [ + { + "order": 1, + "id": "P0.V2.CONFLICT.W4_W6.DRAIN_SCHEMA", + "severity": "critical", + "owners": ["w4", "w6"], + "evidence": "W4 drained response has protocolVersion/runtimeGeneration/residualCount; W6 requires kind/purpose/resetGeneration/deploymentGeneration/processAnchorGeneration/classificationId/residuals.", + "resolution": "Define one shared generation-bound drain DTO. W4 must emit it and W6 must consume exactly it; neither lane-local schema is authoritative alone." + }, + { + "order": 2, + "id": "P0.V2.CONFLICT.W4_W6.ARTIFACT_CONTRACT", + "severity": "critical", + "owners": ["w4", "w6"], + "evidence": "W4 final paths are /opt/agent-teams/bin/* and require protocol/build/compiler/ABI/UID/GID/mode facts; W6 uses /app/bin/* and validates none of those fields.", + "resolution": "Choose one path layout and make the W6 manifest import and validate W4's exact contract hashes and required metadata." + }, + { + "order": 3, + "id": "P0.V2.CONFLICT.W1_W2_W5.ACTION_OWNERSHIP", + "severity": "critical", + "owners": ["w1", "w2", "w5"], + "evidence": "W1/W2 use team.lifecycle for launch/stop/delete; W5 labels launch/stop/cancel as team-runtime-control and carries legacy runtime.permission_answer direction.", + "resolution": "Use W2 ingress direction and the canonical ownership matrix, then regenerate W1 and W5 from one shared action/command ownership source." + }, + { + "order": 4, + "id": "P0.V2.CONFLICT.W1.EXHAUSTIVENESS", + "severity": "high", + "owners": ["w1"], + "evidence": "106 scanned sites in four files versus 649 additional team-component sites found by the same scan function.", + "resolution": "Enumerate all mounted hosted-team surfaces or provide a reviewed exclusion manifest that proves every excluded control is absent before mount." + }, + { + "order": 5, + "id": "P0.V2.CONFLICT.ESTIMATE_CONVENTIONS", + "severity": "high", + "owners": ["w1", "w2", "w3", "w4", "w5", "w6"], + "evidence": "W2 uses EST-LIFECYCLE-RUNTIME, W4 still uses host.* buckets, W3 uses EST-W3-* sub-buckets, W5 uses EST-RECOVERY-STATE, and arithmetic units differ between net and gross changed lines.", + "resolution": "Controller defines one unit and replaces, rather than adds, overlapping W2/W4 and W3/W5 ranges." + }, + { + "order": 6, + "id": "P0.V2.CONFLICT.STALE_HANDOFFS", + "severity": "high", + "owners": ["w1", "w2", "w4", "w5", "w6"], + "evidence": "Five remediation jobs have no latest result; W2/W5/W6 handoffs are original artifacts, W4 has none, and W1 does not identify the actual remediation base/result state.", + "resolution": "Resume in place and produce owner-authored terminal handoffs/results without discarding dirty outputs." + }, + { + "order": 7, + "id": "P0.V2.CONFLICT.W1.APPROVED_FILE_REGRESSION", + "severity": "high", + "owners": ["w1"], + "evidence": "The remediation replaces the pair-approved 137-line invariant record with a 28-line summary that drops detailed current-source observations and fail-closed gates.", + "resolution": "Restore the original approved file byte-for-byte unless a reviewer explicitly accepts a superseding stronger record." + }, + { + "order": 8, + "id": "P0.V2.CONFLICT.W5.CENSUS_INDEPENDENCE", + "severity": "high", + "owners": ["w5"], + "evidence": "The manual mutation census and command catalog live in the same generator and source verification checks only substring presence, not exhaustive mutation extraction.", + "resolution": "Use an independent API/runtime-control extractor or a separately reviewed exhaustive manifest with missing/extra negative fixtures." + }, + { + "order": 9, + "id": "P0.V2.CONFLICT.W4.PID_REUSE_PROOF", + "severity": "high", + "owners": ["w4"], + "evidence": "The record calls the reuse schedule present while pidReuseDeterministicallyForced is false.", + "resolution": "Keep current-host result characterized and do not claim closure until a deterministic reuse/fail-closed schedule or stronger kernel identity argument is reviewed." + }, + { + "order": 10, + "id": "P0.V2.CONFLICT.W2.ENVIRONMENT_COMPLETENESS", + "severity": "high", + "owners": ["w2"], + "evidence": "The corrected pair reviewer removed classified keys one at a time and found eight omissions that the source-discovery check did not diagnose.", + "resolution": "Make every explicit non-wildcard classified key removal-sensitive and add all-key omission fixtures before approving environment provenance or its scanner." + }, + { + "order": 11, + "id": "P0.V2.CONFLICT.W5.FRESH_RECOVERY_MACHINE", + "severity": "high", + "owners": ["w5"], + "evidence": "Effect recovery snapshots state, calls recover() in the same closure, then continues the pre-crash path; it does not serialize/reload into a fresh runner.", + "resolution": "Terminate each crashed attempt, reload only durable state in a new machine, and assert exact effect, compensation, commit and publication counts at every boundary." + } + ], + "risks": [ + { + "order": 1, + "id": "P0.V2.RISK.NO_FULL_PAIR_ACCEPTANCE", + "severity": "critical", + "status": "open", + "requiredAction": "W3's exact 16-file bundle is accepted; all other lane bundles remain rejected. Selective evidence requires controller policy, and every corrected lane must be re-reviewed before bundle adoption." + }, + { + "order": 2, + "id": "P0.V2.RISK.PRODUCER_RECOVERY", + "severity": "critical", + "status": "open", + "requiredAction": "Resume W1/W2/W4/W5/W6 in their preserved dirty worktrees; do not restart or overwrite them." + }, + { + "order": 3, + "id": "P0.V2.RISK.CROSS_LANE_VERIFIER_BLINDNESS", + "severity": "high", + "status": "open", + "requiredAction": "Add narrow cross-lane checks for action ownership, drain DTO equality, artifact path/metadata equality and estimate bucket/unit equality." + }, + { + "order": 4, + "id": "P0.V2.RISK.TARGET_TOPOLOGY", + "severity": "critical", + "status": "open", + "requiredAction": "Keep every final-image/HTTPS/non-root/init/seccomp/two-container claim unverified; those checks are later controller work and were not run here." + }, + { + "order": 5, + "id": "P0.V2.RISK.ESTIMATE_FREEZE", + "severity": "high", + "status": "open", + "requiredAction": "Do not freeze 28k-45k or any replacement range until canonical units and overlaps are reconciled." + }, + { + "order": 6, + "id": "P0.V2.RISK.TERMINAL_BOUNDARY", + "severity": "high", + "status": "open", + "requiredAction": "Retain the W6 current-artifact negative findings; do not implement a terminal or call the current standalone artifact terminal-free." + }, + { + "order": 7, + "id": "P0.V2.RISK.EVIDENCE_PROVENANCE", + "severity": "high", + "status": "open", + "requiredAction": "Require actual remediation base SHA, full changed-file list, no-index diff check and exact narrow-command results in every resumed handoff." + }, + { + "order": 8, + "id": "P0.V2.RISK.NARROW_CHECK_FAILURES", + "severity": "high", + "status": "open", + "requiredAction": "Correct W1/W2 formatting, W4 native-probes test coverage, W5 two whitespace defects, and W6 import-order lint; passing lane-local generators do not override these failures." + } + ], + "safeOrdering": [ + { + "order": 1, + "owner": "controller", + "action": "Reconcile the controller lane ledger to original/remediation registry state and immutable producer hashes without adopting files.", + "status": "required_before_any_adoption" + }, + { + "order": 2, + "owner": "integration_controller", + "action": "Offer the exact 16-file W3 remediation bundle to the normal policy lifecycle; preserve its target-topology limitations and treat its estimate as an input, not a frozen range.", + "status": "pair_accepted_not_integrated_by_this_audit" + }, + { + "order": 3, + "owner": "w1_w2_w5", + "action": "Resolve the shared action ownership and runtime permission direction first; restore the original W1 invariant record and make W1 coverage exhaustive.", + "status": "blocked" + }, + { + "order": 4, + "owner": "w4_w6", + "action": "Define one shared typed-drain DTO and one native artifact/path/metadata contract, then update both lane verifiers.", + "status": "blocked" + }, + { + "order": 5, + "owner": "w2_w4_controller", + "action": "Normalize EST-LIFECYCLE-RUNTIME ownership and units; W2 provider/runtime contribution and W4 host primitives must replace overlapping ranges, not add them.", + "status": "blocked" + }, + { + "order": 6, + "owner": "w3_w5_controller", + "action": "Use the accepted W3 writer coordination as authority, correct W5 ownership/census/fresh-recovery execution, and deduplicate both under EST-RECOVERY-STATE.", + "status": "blocked" + }, + { + "order": 7, + "owner": "pair_reviewers", + "action": "After in-place producer completion, repeat W1/W2, W3/W5 for corrected W5, and W4/W6 reviews against exact hashes.", + "status": "current_reviews_complete_corrections_pending" + }, + { + "order": 8, + "owner": "integration_controller", + "action": "After each corrected review, adopt exact approved hashes in dependency order W1/W2, accepted W3 before W5, and W4 before W6, with controller estimate/decision records last.", + "status": "not_authorized_in_this_audit" + } + ], + "checks": [ + { + "scope": "w1", + "command": "read-only import of scanControls across src/renderer/components/team", + "result": "649 interaction sites in 131 files outside the three W1 team composition files; W1 claim not exhaustive" + }, + { + "scope": "w2", + "command": "node --experimental-strip-types scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "exitCode": 0, + "result": "4 providers, 2 backend families, 5 operations, 13 provider cases" + }, + { + "scope": "w3", + "command": "node --import tsx team-backup-service-faults.test.mjs; node verify-evidence.mjs; node --test state-writers.test.mjs", + "exitCode": 0, + "result": "7+1 tests passed and 6 evidence files verified" + }, + { + "scope": "w4", + "command": "python3 scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "exitCode": 0, + "result": "current-host characterized; cleanup verified; finalShapeContainer=false; pidReuseDeterministicallyForced=false" + }, + { + "scope": "w5", + "command": "node generate-evidence.mjs --check; node recovery-events.test.mjs", + "exitCode": 0, + "result": "9 evidence files verified; 7 tests passed" + }, + { + "scope": "w6", + "command": "read-only auth restart reproducer plus node verify-evidence.mjs", + "exitCode": 0, + "result": "revoked states stayed closed and local verifier passed, but independent W4/W6 contract comparison failed" + }, + { + "scope": "corrected_pair_review_w1_w2_v3", + "command": "reviewer narrow scanners/tests/lint/format/omission probes", + "exitCode": 1, + "result": "both lanes remediate; W1 omitted child controls and formatting failed; W2 missed eight classified-key omissions, three provider proofs, handoff freshness and formatting" + }, + { + "scope": "corrected_pair_review_w3_w5_v2", + "command": "reviewer narrow fault/scheduler/lint/no-index checks", + "exitCode": 1, + "result": "all 16 W3 files accepted; W5 fresh effect recovery/census/handoff remain open and two no-index whitespace checks fail" + }, + { + "scope": "corrected_pair_review_w4_w6_v2", + "command": "reviewer native runner, focused Vitest/lint and contract comparison", + "exitCode": 1, + "result": "both lanes remediate; W4 one focused test fails and PGID risk remains; W6 lint fails and contract verifier is false-green" + } + ], + "scopeAttestation": { + "producerWorktreesModifiedByAudit": false, + "reviewWorktreesModifiedByAudit": false, + "integrationPerformed": false, + "pushPerformed": false, + "broadOrFinalGateRun": false, + "phase1Implemented": false, + "terminalImplemented": false + } +} diff --git a/docs/research/hosted-web/phase-0/audits/cross-lane/README.md b/docs/research/hosted-web/phase-0/audits/cross-lane/README.md new file mode 100644 index 0000000000..9e2af0b379 --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/cross-lane/README.md @@ -0,0 +1,68 @@ +# Phase 0 cross-lane contract and integration-prep audit + +## Disposition + +Hold all producer adoption. The registry shows all six producer jobs and all three reciprocal review +jobs as done, but every reciprocal pair rejected both producer outputs pending correction. No producer +pair is ready for integration and no file is immediately adoptable. + +The machine-readable authority for this audit is `ordered-manifest.json`. It records 24 reciprocal +review findings: 3 critical, 14 high, 5 medium and 2 low. It also records nine individual evidence +files that reviewers approved or accepted as useful. Those files remain held because their producer +pairs are rejected and the packet requires correction/review reconciliation before adoption. + +## Review-approved file order + +| Order | Lane | Evidence | File | Current adoption state | +| ----: | ---- | ---------------------------------- | -------------------------------------------------------- | ---------------------------- | +| 1 | W1 | `P0.W1.SELECTION_INVARIANTS` | `parity-renderer/selection-reconciliation-invariants.md` | Held; W1/W2 pair rejected | +| 2 | W2 | `P0.W2.EXECUTION_TOPOLOGY` | `provider-runtime/execution-topology.json` | Held; W1/W2 pair rejected | +| 3 | W3 | `P0.W3.STATE_FAMILY_CATALOG` | `state-writers/state-family-catalog.json` | Held; W3/W5 pair rejected | +| 4 | W3 | `P0.W3.WRITER_COORDINATION` | `state-writers/writer-coordination.json` | Held; W3/W5 pair rejected | +| 5 | W3 | `P0.W3.SCHEMA_UNKNOWN_FIELDS` | `state-writers/schema-unknown-fields.json` | Held; W3/W5 pair rejected | +| 6 | W3 | `P0.W3.SQLITE_ONLINE_BACKUP_SPIKE` | `state-writers/sqlite-online-backup-results.json` | Held; W3/W5 pair rejected | +| 7 | W3 | `P0.W3.ESTIMATE` | `state-writers/estimate-input.json` | Held for W3/W5 deduplication | +| 8 | W5 | `P0.W5.EVENT_CURSOR_INVENTORY` | `recovery-events/event-cursor-inventory.json` | Held; W3/W5 pair rejected | +| 9 | W5 | `P0.W5.ESTIMATE` | `recovery-events/estimate-input.json` | Held for W3/W5 deduplication | + +W4/W6 received no file-level approval. Review approval here means only that the named evidence is +useful at its declared proof level; it is not authorization to cherry-pick a file out of a rejected +pair. Exact source hashes are in the manifest. + +## Integration blockers + +1. W6 restart restores mutation admission after logout/revocation (`R46-01`). +2. W5's crash schedules and retry classes contradict W3 writer truth (`RW35-002`, `RW35-003`). +3. W1 manufactures source-line-dependent JSX actions and conflicts with W2/canonical ownership + (`R12-W1-001`, `R12-W1-002`, `R12-X-001`). +4. W4 process signaling/cleanup and W6 reset fencing are unsafe (`R46-02` through `R46-04`). +5. W4/W6 have no shared final artifact/topology contract (`R46-06`, `R46-07`). +6. W2 environment completeness, schemas and provider matrix are insufficient (`R12-W2-001`, + `R12-W2-002`, `R12-W2-004`). +7. W3 legacy backup behavior and W5 fingerprints/mutation census are incomplete (`RW35-001`, + `RW35-004`, `RW35-005`). +8. The estimate cannot be frozen: W1 arithmetic is wrong, W2 bucket IDs are non-canonical, W3/W5 + overlap, and W2/W4 overlap is unresolved. +9. W1's 48,661-line output violates evidence/adoption budgets without compact/hash treatment or an + explicit split approval. + +## Controller-state drift + +The integration worktree remains at `a32f509e6d9bd31ba2135940e336729bf90c3d93`, but its checked-in +`base.json` and `lane-ledger.json` still contain `phaseStartSha: null`; the lane ledger still reports +the bootstrap epoch, six unstarted slots, null job/worktree IDs and null handoff hashes. The project +registry, by contrast, shows all six producers and all three review jobs done. The integration owner +must reconcile those controller-owned records from registry evidence before opening an adoption +attempt. This audit did not modify integration or any other worktree. + +## Ordered next actions + +1. Integration owner reconciles the base/lane ledger with the registry and immutable handoff hashes. +2. W1/W2 owners correct all R12 findings, compact W1 evidence and repeat reciprocal review. +3. W3/W5 owners correct all RW35 findings, bind recovery to writer coordination, deduplicate the + recovery estimate and repeat reciprocal review. +4. W4/W6 owners correct all R46 findings, agree one artifact/topology manifest and repeat review. +5. Only then open the policy integration attempt and adopt the final reviewed files in packet order, + splitting adoptions above 1,500 lines. +6. Target-host probes, decision/estimate freeze and broad Phase 0 gates remain later controller-owned + work. This audit ran none of them and performed no Phase 1 work. diff --git a/docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json b/docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json new file mode 100644 index 0000000000..7a2cc8fbeb --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/cross-lane/ordered-manifest.json @@ -0,0 +1,385 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "auditId": "P0.AUDIT.CROSS_LANE", + "packetRevision": "phase-00-r2", + "generatedAt": "2026-07-11T18:20:45Z", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "canonicalBaseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "planBundleCommit": "f1ad7a8cba2f26abf5f42ddd206937c24d143f77", + "overallDisposition": "hold_all_adoption_pending_owner_correction_and_repeat_review", + "summary": { + "producerJobsDone": 6, + "reciprocalReviewJobsDone": 3, + "producerPairsApproved": 0, + "reviewApprovedFiles": 9, + "immediatelyAdoptableFiles": 0, + "findings": { + "critical": 3, + "high": 14, + "medium": 5, + "low": 2, + "total": 24 + } + }, + "registrySnapshot": { + "registryRoot": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2", + "producers": [ + { + "laneId": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w1-v1", + "registryStatus": "done", + "producerStatus": "characterized", + "handoffSha256": "d000ec9d1aae5995f66279ca70fb3d0da80eafcba808b153f3398d1d4c86c110" + }, + { + "laneId": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w2-v1", + "registryStatus": "done", + "producerStatus": "characterized", + "handoffSha256": "df2b4edcccad1ffac46091ceca124710cfcf68058eafddadbeaf141808b02b50" + }, + { + "laneId": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w3-v1", + "registryStatus": "done", + "producerStatus": "characterized", + "handoffSha256": "a0ceff5b4c384e551abe336060922cb278d7a4768344a89663094ddde44308fc" + }, + { + "laneId": "w4", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w4-v1", + "registryStatus": "done", + "producerStatus": "characterized", + "handoffSha256": "4fd9c0bce7717f1947fff64a9cee6edcd8fc1321b0504365d3341bb0786d3e1d" + }, + { + "laneId": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w5-v1", + "registryStatus": "done", + "producerStatus": "characterized", + "handoffSha256": "9f87bbc855161c368da5b4fef16a3d2002f6cdc69acce996555df49b801038e5" + }, + { + "laneId": "w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w6-v1", + "registryStatus": "done", + "producerStatus": "characterized", + "handoffSha256": "64a053f03111dcd79a7ca05aaff773cbbc6990ec458cd676a21a228233f59d2f" + } + ], + "reviews": [ + { + "pair": "w1-w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w1-w2-v1", + "registryStatus": "done", + "reviewStatus": "failed", + "producerDispositions": ["w1:reject", "w2:reject"], + "handoffSha256": "c12836733a03159b434223b6ffb63d2a561a3ff946dd3704fde6f5d7f88ef48e", + "reportSha256": "213836d2c6104318d3e59848a856dea14bfefc9d56840645a76cf821fc918e2b" + }, + { + "pair": "w3-w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1", + "registryStatus": "done", + "reviewStatus": "rejected", + "producerDispositions": ["w3:reject", "w5:reject"], + "handoffSha256": "f8d9b40c83ce4d895110162807cd8abafaac25566ef4dd32db0ab5e8afbd8beb", + "reportSha256": "8140261ef1cf3a2a37fc25d75f639fa9e20607e7bb1c104fe5a39b1585e38a15" + }, + { + "pair": "w4-w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v1", + "registryStatus": "done", + "reviewStatus": "rejected_pending_correction", + "producerDispositions": ["w4:reject", "w6:reject"], + "handoffSha256": "7bf1e3e6f1c02ff352909cead19dd0a344f4277d3bbd7ac4e897dfee2a45a175", + "reportSha256": "4a509551fdf2466f1be2f6cc9e9b8c9c1058fb3c5c695f8706f78af42e47e5bf" + } + ] + }, + "approvedFiles": [ + { + "order": 1, + "laneId": "w1", + "evidenceId": "P0.W1.SELECTION_INVARIANTS", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "sha256": "b9f9a3b24720fbac112a1b97e40725ecf484e18c15f1d7b6596d2446b4f4eefe", + "reviewApproval": "approved", + "adoptionStatus": "held_pair_rejected" + }, + { + "order": 2, + "laneId": "w2", + "evidenceId": "P0.W2.EXECUTION_TOPOLOGY", + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sha256": "b9b4824158f00ee43bcc6c0104dfb6d344b03929eb84dde06907c06d6df50955", + "reviewApproval": "approved", + "adoptionStatus": "held_pair_rejected" + }, + { + "order": 3, + "laneId": "w3", + "evidenceId": "P0.W3.STATE_FAMILY_CATALOG", + "path": "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "sha256": "c36fa922a39d91cb125edb99df27721350f00b2d4ff382fc4f9c209b6b419566", + "reviewApproval": "accepted_as_useful", + "adoptionStatus": "held_pair_rejected" + }, + { + "order": 4, + "laneId": "w3", + "evidenceId": "P0.W3.WRITER_COORDINATION", + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "sha256": "756626d98ff3d52abdc911f8ab74b4ba6daceaa146a853fdbf04990227159bcb", + "reviewApproval": "accepted_as_useful", + "adoptionStatus": "held_pair_rejected" + }, + { + "order": 5, + "laneId": "w3", + "evidenceId": "P0.W3.SCHEMA_UNKNOWN_FIELDS", + "path": "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "sha256": "09f0ed9d08776592a6ed8db2e558d34fde6db161d7bd213714a578db216964f3", + "reviewApproval": "accepted_as_useful", + "adoptionStatus": "held_pair_rejected" + }, + { + "order": 6, + "laneId": "w3", + "evidenceId": "P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "sha256": "b4c43b3e6494bea0d8ff3a42e188c40b184c5c6d84deceff00561011b5d47315", + "reviewApproval": "accepted_as_useful", + "adoptionStatus": "held_pair_rejected" + }, + { + "order": 7, + "laneId": "w3", + "evidenceId": "P0.W3.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "sha256": "6ee6b1f69d075e8e39f4f8dd468269cebac368495a530e8e8f1193ed38b54531", + "reviewApproval": "accepted_as_useful", + "adoptionStatus": "held_for_w3_w5_deduplication" + }, + { + "order": 8, + "laneId": "w5", + "evidenceId": "P0.W5.EVENT_CURSOR_INVENTORY", + "path": "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "sha256": "2e296692f0415c3b82f3ce15cf1683bbd0aee846d2b571a5dff31bff406ddc15", + "reviewApproval": "accepted_as_useful", + "adoptionStatus": "held_pair_rejected" + }, + { + "order": 9, + "laneId": "w5", + "evidenceId": "P0.W5.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "sha256": "03f41142c1845f913bd0dfbd59499cfb6390454c36f4fec596432e7faf714341", + "reviewApproval": "accepted_as_useful", + "adoptionStatus": "held_for_w3_w5_deduplication" + } + ], + "conflicts": [ + { + "order": 1, + "severity": "critical", + "findingIds": ["R46-01"], + "owners": ["w6"], + "summary": "Restart restores mutation admission after logout, device revocation, or family replay revocation.", + "integrationEffect": "Reject W6 auth evidence and all remote-mutation prerequisites." + }, + { + "order": 2, + "severity": "critical", + "findingIds": ["RW35-002", "RW35-003"], + "owners": ["w3", "w5"], + "summary": "W5 counts labels as crash boundaries and assigns automatic retry classes that contradict W3's uncoordinated external-writer evidence.", + "integrationEffect": "Reject W5 scheduler, command catalog, effect matrix, and fingerprint package." + }, + { + "order": 3, + "severity": "high", + "findingIds": ["R12-W1-001", "R12-W1-002", "R12-X-001"], + "owners": ["w1", "w2"], + "summary": "W1 manufactures line-dependent JSX actions and conflicts with W2/canonical ownership and trust direction.", + "integrationEffect": "Reject W1 parity/action/scanner artifacts until a semantic action catalog is reviewed." + }, + { + "order": 4, + "severity": "high", + "findingIds": ["R46-02", "R46-03", "R46-04"], + "owners": ["w4", "w6"], + "summary": "Auth reset is not fenced by typed drain truth; W4 uses reusable numeric PIDs and emits hard-coded cleanup success.", + "integrationEffect": "Reject W4 process/cleanup claims and W6 reset schedules." + }, + { + "order": 5, + "severity": "high", + "findingIds": ["R46-06", "R46-07"], + "owners": ["w4", "w6"], + "summary": "The artifact scanner omits all three W4 native artifacts and final topology; ABI/native smoke facts are not reproducible from owned code.", + "integrationEffect": "Reject paired host/artifact adoption until one concrete final-image manifest exists." + }, + { + "order": 6, + "severity": "high", + "findingIds": ["R12-W2-001", "R12-W2-002", "R12-W2-004"], + "owners": ["w2"], + "summary": "W2 does not discover omitted environment keys, nested schemas are shallow, and the hosted fake-runtime matrix is incomplete.", + "integrationEffect": "Reject W2 ingress/environment/credential scanner package." + }, + { + "order": 7, + "severity": "high", + "findingIds": ["RW35-001", "RW35-004", "RW35-005"], + "owners": ["w3", "w5"], + "summary": "Legacy backup behavior lacks the required fault fixture, fingerprint vectors are incomplete, and mutation coverage is circular.", + "integrationEffect": "Reject W3 backup behavior and the remaining W5 recovery evidence." + }, + { + "order": 8, + "severity": "high", + "findingIds": ["R12-W1-003", "R12-W2-003"], + "owners": ["w1", "w2", "w3", "w4", "w5", "w6"], + "summary": "Estimate arithmetic and bucket identities cannot be reconciled; W3/W5 overlap and W2/W4 overlap are not frozen.", + "integrationEffect": "The 28k-45k v1 estimate is not yet evidence-backed." + }, + { + "order": 9, + "severity": "high", + "findingIds": ["R12-W1-004"], + "owners": ["w1"], + "summary": "W1 emits 48,661 lines, including 46,262 raw ledger lines, without the required compact/hash or adoption exception.", + "integrationEffect": "Do not adopt the raw W1 ledger files." + }, + { + "order": 10, + "severity": "medium", + "findingIds": ["R12-W2-005", "RW35-006", "R46-05", "R46-08"], + "owners": ["w2", "w3", "w4", "w5", "w6"], + "summary": "Producer diff checks skipped untracked files, and W4's close-all proof stops at descriptor 1023.", + "integrationEffect": "Repeat real staged/no-index checks and add a high-FD canary before adoption." + } + ], + "risks": [ + { + "order": 1, + "riskId": "P0.RISK.LEDGER_DRIFT", + "severity": "high", + "status": "open", + "evidence": "The integration lane-ledger still says bootstrap/unstarted with null phaseStartSha, job IDs, and handoff hashes while the registry shows all six producers and all three reviews done.", + "requiredAction": "Integration owner must reconcile the controller-owned base/lane ledger from registry evidence before opening an adoption attempt." + }, + { + "order": 2, + "riskId": "P0.RISK.NO_PAIR_APPROVAL", + "severity": "critical", + "status": "open", + "evidence": "All three reciprocal reviews reject both producers in their pair.", + "requiredAction": "Return findings to owners and repeat focused reciprocal reviews; do not integrate isolated approved files yet." + }, + { + "order": 3, + "riskId": "P0.RISK.TARGET_TOPOLOGY", + "severity": "critical", + "status": "open", + "evidence": "W4 and W6 remain characterized; no final non-root/init/seccomp/image/HTTPS topology exists for target verification.", + "requiredAction": "After corrections and manifest agreement, run the required marker-owned probes in the admitted final-shape topology." + }, + { + "order": 4, + "riskId": "P0.RISK.ESTIMATE_FREEZE", + "severity": "high", + "status": "open", + "evidence": "W1 reports false <=20% variance, W2 uses non-canonical buckets, W3/W5 overlap, and W4 ranges are not reconciled with W2 or the canonical ledger.", + "requiredAction": "Regenerate canonical unique-bucket ranges after corrections and perform the packet-required scope review." + }, + { + "order": 5, + "riskId": "P0.RISK.TERMINAL_BOUNDARY", + "severity": "high", + "status": "open", + "evidence": "The current standalone artifact emits terminal service/gateway/SDK surfaces and cannot be designated the terminal-free hosted v1 artifact.", + "requiredAction": "Adopt an allowlisted artifact manifest and terminal-negative image gate; do not implement a hosted terminal." + }, + { + "order": 6, + "riskId": "P0.RISK.WRITER_AUTHORITY", + "severity": "high", + "status": "open", + "evidence": "W3 records unresolved provider/task/inbox/member/transcript writers and quiescent-only active writes.", + "requiredAction": "Keep affected mutations unavailable or operator-required until W2/provider evidence proves coordination." + }, + { + "order": 7, + "riskId": "P0.RISK.PROVIDER_CONFORMANCE", + "severity": "high", + "status": "open", + "evidence": "W2 provider compatibility and environment exposure are source-observed only; the explicit key census and hosted provider matrix are incomplete.", + "requiredAction": "Correct the scanner/matrix and retain target/live-sandbox proof obligations." + }, + { + "order": 8, + "riskId": "P0.RISK.EVIDENCE_BUDGET", + "severity": "high", + "status": "open", + "evidence": "W1 exceeds both lane and single-adoption budgets by orders of magnitude.", + "requiredAction": "Keep compact indexes and hashes in Git or obtain an explicit controller-approved split." + }, + { + "order": 9, + "riskId": "P0.RISK.SELF_REVIEW_PROVENANCE", + "severity": "low", + "status": "contained_not_closed", + "evidence": "Plain producer git diff --check commands skipped untracked additions; reviewers independently found no whitespace defects.", + "requiredAction": "Use staged or per-file no-index checks in every correction handoff." + } + ], + "integrationOrder": [ + { + "order": 1, + "owner": "integration_controller", + "action": "Reconcile base.json and lane-ledger.json with phaseStartSha, job/worktree IDs, review states, and handoff hashes from the registry.", + "status": "required_before_adoption" + }, + { + "order": 2, + "owner": "w1-w2", + "action": "Correct R12 findings, compact W1 evidence, align canonical actions/direction/buckets, and repeat reciprocal review.", + "status": "pending" + }, + { + "order": 3, + "owner": "w3-w5", + "action": "Correct RW35 findings, bind recovery classes to writer truth, deduplicate EST-RECOVERY-STATE, and repeat reciprocal review.", + "status": "pending" + }, + { + "order": 4, + "owner": "w4-w6", + "action": "Correct R46 findings, agree one artifact/topology contract, and repeat reciprocal review.", + "status": "pending" + }, + { + "order": 5, + "owner": "integration_controller", + "action": "Open a policy integration attempt and adopt only the final reviewed file set in packet order, splitting any adoption above 1,500 lines.", + "status": "blocked_by_steps_1_to_4" + }, + { + "order": 6, + "owner": "integration_controller", + "action": "Run targeted combined checks, admitted target-topology probes, estimate/decision freeze, then the separately authorized broad gate.", + "status": "not_authorized_in_this_audit" + } + ], + "scopeAttestation": { + "modifiedOtherWorktrees": false, + "modifiedIntegration": false, + "broadOrFinalGatesRun": false, + "phase1Implemented": false, + "hostedTerminalImplemented": false + } +} diff --git a/docs/research/hosted-web/phase-0/audits/oom-adoption-v2/README.md b/docs/research/hosted-web/phase-0/audits/oom-adoption-v2/README.md new file mode 100644 index 0000000000..7257c1b235 --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/oom-adoption-v2/README.md @@ -0,0 +1,218 @@ +# Phase 0 OOM preservation and adoption audit v2 + +## Decision + +Preserve every producer. Adopt only the completed W3 remediation through the controller policy +lifecycle. Reject all six original producers as already recorded in the consumed-output ledger. Continue +the OOM-stopped W1, W2, W4, W5 and W6 remediations from their exact dirty trees; none is a completed job +and none is directly adoptable. + +The machine-readable authority for exact classifications, worktree digests, preservation file lists, +approved W3 hashes, overlaps and lifecycle order is `candidate-manifest.json`. + +| Candidate | Registry/worktree truth | Classification | Integration-approved files | +| -------------- | ---------------------------------------------- | -------------- | -------------------------: | +| Original W1 | done, dirty output, consumed ledger rejected | reject | 0 | +| Original W2 | done, dirty output, consumed ledger rejected | reject | 0 | +| Original W3 | done, dirty output, consumed ledger rejected | reject | 0 | +| Original W4 | done, dirty output, consumed ledger rejected | reject | 0 | +| Original W5 | done, dirty output, consumed ledger rejected | reject | 0 | +| Original W6 | done, dirty output, consumed ledger rejected | reject | 0 | +| Remediation W1 | OOM-stopped, 13 dirty files, no result | remediate | 0 | +| Remediation W2 | OOM-stopped, 18 dirty files, no result | remediate | 0 | +| Remediation W3 | strict result `done`, 16 dirty files | adopt | 16 | +| Remediation W4 | OOM-stopped, 21 dirty files, no handoff/result | remediate | 0 | +| Remediation W5 | OOM-stopped, 17 dirty files, no result | remediate | 0 | +| Remediation W6 | OOM-stopped, 11 dirty files, no result | remediate | 0 | + +`adopt` means eligible for a new controller-owned lifecycle attempt, not integrated by this audit. The +audit did not open an integration attempt, edit a producer, commit, push, run a broad/final gate, start +Phase 1, or implement terminal behavior. + +## OOM and preservation truth + +The controller signal at `2026-07-11T19:00:21.921Z` states that global OOM with no swap killed W1, W2, +W4, W5, W6 and the W3 fastgate, requires `preservePatch=true`, and forbids treating dirty worktrees as +completed. Registry/job evidence agrees: + +- W1, W2, W4, W5 and W6 retain stale `running` progress records and have no latest-result record. +- W3 alone has `latest-result.status=done`, one completed attempt and the same 16 paths found directly in + its worktree. +- The five stopped jobs have useful output, so rejecting or recreating their worktrees would discard + recoverable work. Their exact path sets and aggregate tree digests are frozen in the manifest. +- The old review/audit manifests had ineffective read scope (only their own workspace plus registry). + Corrected v2/v3 manifests read the project worktrees root while retaining isolated write roots. At + audit completion, all three corrected pair-review worktrees contained dirty, nonterminal drafts. This + audit inspected those drafts directly, reproduced their important checks, and incorporated their + findings, but did not edit or present them as terminal registry results. They classify W1/W2 and W4/W6 + as remediate, W3 as adopt, and W5 as remediate. + +The original outputs remain preserved at their consumed-ledger backup paths. Nine original evidence +files were historically accepted as useful, but the pair-level reviews and consumed ledger rejected all +six original jobs. One current contradiction must stay explicit: cross-lane-v2 prefers the original +137-line W1 selection-invariant hash, while the corrected pair/completion reviews approve its 28-line +remediation replacement. The manifest records both hashes; neither is integration-approved until the +controller resolves that exact-file conflict. + +## Candidate findings + +### W1 + +The remediation replaces line-dependent JSX manufacture with 30 reviewed semantic actions, 95 direct +API bindings and three deliberate-absence classes. `team.lifecycle.stop` is owned by team lifecycle; +provider credential/auth actions are owned by runtime-provider-management. The evidence is compact: 13 +files and 591 physical lines. The 610-row raw bypass projection is reproducible from the generator and is +bound by SHA-256 `2ea065639675df126935790d86578cfdae9cf9feb60371826b03f1f877c5f497`. + +Seven focused tests pass. A marker-owned `/tmp` regeneration reproduced the API ledger, action inventory +and estimate byte-for-byte and reproduced the raw hash; only the raw artifact's declared external path +changed with the temporary root. The passing gate is incomplete: it hard-codes four composition files +and omits reachable child controls such as the five interaction sites in `TeamListFilterPopover`. The +synthetic missing/duplicate fixture cannot detect that omission, and 11 W1 files fail the repository +format check. The job also has no terminal result and its handoff records the phase start as `baseSha` +instead of the actual remediation base. Pair/completion review approves the remediation selection +document, but cross-lane-v2 rejects it as a weaker rewrite and prefers the original hash. Continue the +lane, restore or strengthen the invariant record, close the child-control closure, format it, regenerate +the handoff and repeat review. + +### W2 + +The remediation scanner now discovers environment tokens from 17 bounded source surfaces, compares 90 +classified keys, enforces artifact-specific nested schemas, covers 13 provider rows, separates browser +and runtime authority, and maps its range to canonical `EST-LIFECYCLE-RUNTIME`. The scanner and eight +focused tests pass, but the stronger omission probe fails: eight explicit classified keys can be removed +without a completeness diagnostic. Three required provider rows (`malformed_capability_response`, +`restart_adoption`, and `opencode_secondary_lane_recovery`) still have neither positive nor negative +proof, so the matrix is an honest gap ledger rather than a complete fixture matrix. + +The handoff is stale: it omits five new schema files, still lists the removed generic schema, identifies +the phase start rather than remediation base, and has no terminal result. Eight topology/ingress/exposure +and estimate files are useful-only approved at exact hashes in the manifest. Preserve all 18 files, fix +the environment and provider proof gaps, finish the handoff/result and repeat W1/W2 review. + +### W3 + +Approve all 16 files at the hashes in the manifest. The completed remediation retains the previously +useful state/writer/SQLite evidence and adds a marker-owned suite that constructs production +`TeamBackupService`. Seven tests cover twelve readiness, enumeration, identity, copy, pruning, +publication, registry and restore cases while honestly retaining `legacy_unverified` for the service. +The W3 verifier and architecture test pass; the existing WAL Online Backup proof remains scoped to the +current host and no final-image/production-worker claim is made. + +W3 is 2,454 lines, so the lifecycle must use the three recorded sub-1,500-line splits in one attempt. +W3's 4.5k-7.25k estimate overlaps W5; adopt it as an input, never add it to W5's 4.5k-7.5k range. + +### W4 + +The preserved W4 work is meaningful. Its current-host probe now reports verified cleanup before +emission, 146 tracked identities, 106 tracked groups, zero residuals, high-FD closure, process-group +signaling, typed `drained`/`unclassified_residual` outcomes and no unrelated signal. The direct probe +rerun passed, while correctly reporting `finalShapeContainer=false` and that PID reuse was not +deterministically forced. The full owned test pair is not green: one test retains the old two-field +cleanup equality and fails against the new measured record. + +It is not adoptable: the anchor replaces raw descendant PID signals with `kill(-owned_pgid, ...)`, but an +escaped residual can leave that numeric process group empty before the second KILL and allow PGID reuse +to target an unrelated group. The markdown claim of pidfd signaling is therefore false. No handoff or +result exists; final image/two-container evidence remains absent; its `host.*` estimate rows are not +mapped to W2's canonical bucket; and the W4/W6 native contract conflicts. Six lease/guard/envelope files +are useful-only approved at exact hashes in the manifest. Preserve all 21 files and continue the lane. + +### W5 + +The dirty remediation improves snapshot schedules, fingerprint version/default vectors and writer +truth. All 103 effects now keep automatic recovery closed where W3 cannot prove coordination. The +generator check and seven focused tests pass. Four records are useful-only evidence at their declared +proof levels: event cursor inventory, snapshot scheduler, fingerprint goldens and estimate. Their exact +hashes are in the manifest; they are not integration approval for the lane. + +Five blockers remain: + +1. Effect recovery snapshots state, records crash/restart labels, calls recovery in the same closure and + resumes the pre-crash runner. It does not instantiate a fresh durable recovery machine. +2. The mutation census is separate from command descriptors but defined in the same generator; deleting + or forgetting a census row removes both the artifact and the obligation. No independent source + extraction or omitted-row fixture proves completeness. +3. The handoff omits both mutation-census files, reports the wrong base and has no strict result. +4. No-index checks report extra blank lines in `.codex-handoff/phase-00-w5.json` and + `fixtures/invalid-command-catalog.json`. +5. The census assigns launch/stop/cancel to `team-runtime-control` and keeps + `runtime.permission_answer` as a hosted mutation, contradicting W1/W2 canonical ownership and + permission direction. + +Preserve all 17 files, remediate from that state, then repeat W3/W5 review. + +### W6 + +The stopped remediation materially fixes the original restart/auth model: mutation admission requires +an active unrevoked session, restart schedules keep logout/family revocation closed, and reset requires +typed generation-bound drain evidence. The verifier and 25 focused tests pass. It also adds ABI probes, +a proposed nine-row artifact manifest and a negative terminal gate. Those greens overstate the pair: +the verifier returns success for the contradictory/incomplete W4 contract, and targeted fast lint fails +the W6 test import order. + +It remains nonterminal and internally honest that the current standalone artifact is not the hosted v1 +artifact. The handoff omits the finding-resolution and proposed-manifest files and reports the wrong +base; the finding-resolution record falsely marks the drain/artifact-contract findings resolved. Only +the reproduced current-artifact scan is useful-only approved. Preserve all 11 files, reconcile the pair +and repeat review. + +## Cross-lane compatibility + +There is no dirty-path overlap among the six remediation worktrees. Semantic overlap is the real gate: + +- W1/W2: the original stop/provider ownership contradiction is closed in content. Both stopped outputs + still need correct handoffs, strict results and focused review. +- W2/W6: browser-session authority and machine runtime ingress are disjoint in the dirty evidence, but + nonterminal handoffs cannot freeze the decision. +- W3/W5: writer/recovery semantics agree. W5 defaults unproved task/inbox/provider effects to + `operator_required`; the estimate ranges overlap and must be deduplicated. Command ownership does not + yet agree: W5 labels launch/stop/cancel as `team-runtime-control` and retains + `runtime.permission_answer`, contradicting W1/W2 `team.lifecycle` ownership and request-ingress plus + operator-outbound permission direction. +- W2/W4: W2 declares one `EST-LIFECYCLE-RUNTIME` replacement range and excludes W4 primitives, while W4 + still uses unmapped `host.*` records. Do not add the ranges. +- W4/W6: W4 requires binaries at `/opt/agent-teams/bin`; W6 declares `/app/bin`. W6 also omits W4's + required protocol/build/builder/compiler/UID/GID/mode fields. W6's adapter-level + `process_drain_outcome_v1` is not schema/hash-mapped to W4's raw process-anchor response. These are + adoption blockers, not naming polish. + +## Safe ordered lifecycle + +1. Before any reconcile/restart/cleanup, capture immutable patch, status, path list and tree digest for + every remediation worktree. Do not recreate producer worktrees. +2. Record that W3's estimate is a non-additive input to `EST-RECOVERY-STATE` and does not freeze or add to + W5's range. Then open one controller policy integration attempt for exact W3 digest + `0f0aeca56e4c2b9363ae557b72dcab2d27c794e7f2dc99306a9ceb3704ac5ce2`. Adopt its scripts/tests, + evidence and handoff as the three manifest splits; verify every hash and rerun only W3 checks. +3. Continue W1 and W2 from preserved state, regenerate accurate handoffs/results, repeat W1/W2 review, + then adopt W1 before W2 only if the review accepts both. +4. Continue W5 against adopted W3 truth. Add a fresh durable recovery runner, independent mutation + census and omission controls; fix provenance/whitespace; repeat W3/W5 review before W5 adoption. +5. Reconcile W4/W6 paths, manifest fields, typed-drain schema/hash mapping and W2/W4 estimate ownership. + Complete both jobs, repeat paired review, and adopt W4 before W6 only if accepted. +6. Reconcile controller lane ledger, decision register and unique-bucket estimate only after accepted + lane adoptions. Final-image/HTTPS/container probes and any broad/final repository gate remain later, + separately authorized work. + +## Narrow verification + +| Check | Result | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| All Phase 0 worktree HEAD/status surfaces | inspected; producers preserved; this audit edited none | +| Registry manifests, job progress/results, consumed ledger and OOM signal | inspected | +| W1 read-only Vitest | 7/7 pass; omitted-child coverage probe fails | +| W1 temporary deterministic regeneration | API/action/estimate equal; raw hash equal | +| W2 scanner | passes current rules: 4 providers, 2 backend families, 5 operations, 13 rows | +| W2 read-only Vitest | 8/8 pass; exhaustive key-removal probe fails for 8 keys | +| W3 production-service fault suite | 7/7 pass, 12 cases | +| W3 verifier and architecture test | pass | +| W4 current-host native probes | direct runner passes; owned Vitest has 1 stale-cleanup failure; final-shape target false | +| W5 generator and node tests | pass; 7/7, with substantive gaps above | +| W5 no-index whitespace diagnostics | fail in exactly two named files | +| W6 verifier | exits 0 with a false-green pair contract; Node ABI 137, Electron ABI 143 | +| W6 read-only Vitest / targeted lint | 25/25 tests pass; lint fails import ordering | +| Broad/final gate, integration, Phase 1, terminal implementation | not performed | + +Passing focused checks establish only the behavior they exercise. They do not convert a stopped job into +a terminal result or erase the cross-lane contradictions above. diff --git a/docs/research/hosted-web/phase-0/audits/oom-adoption-v2/candidate-manifest.json b/docs/research/hosted-web/phase-0/audits/oom-adoption-v2/candidate-manifest.json new file mode 100644 index 0000000000..6d05029d99 --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/oom-adoption-v2/candidate-manifest.json @@ -0,0 +1,604 @@ +{ + "schemaVersion": 1, + "auditId": "P0.AUDIT.OOM_ADOPTION.V2", + "packetRevision": "phase-00-r2", + "recordedAt": "2026-07-11T19:28:00Z", + "canonicalBaseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "remediationBaseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "overallDisposition": "one_remediation_adoptable_five_require_continuation_all_originals_rejected", + "classificationDefinitions": { + "adopt": "Approved for a controller-owned policy lifecycle attempt from the exact hashed worktree state; this audit does not integrate it.", + "reject": "Do not adopt this candidate; preserve its existing backup only as historical evidence.", + "remediate": "Preserve the exact dirty tree and continue from it; no file is approved for integration unless separately listed as useful-only evidence.", + "insufficient": "No output or proof strong enough to decide the producer candidate." + }, + "registryEvidence": { + "originalProducerJobs": "six latest-result records are done; the consumed-output ledger rejects W1-W6", + "remediationProducerJobs": { + "w1": "stale running progress; no latest result; OOM guidance identifies the process as killed", + "w2": "stale running progress; no latest result; OOM guidance identifies the process as killed", + "w3": "latest result done; one completed attempt; 16 changed files", + "w4": "stale running progress; no latest result; OOM guidance identifies the process as killed", + "w5": "stale running progress; no latest result; OOM guidance identifies the process as killed", + "w6": "stale running progress; no latest result; OOM guidance identifies the process as killed" + }, + "oomAuthority": "/var/data/agent-teams-hosted-web-refactor/state/controller-v2/worker-control-inbox/b6622130e795581056ce2bb065de6fac0256f7d8483d3bba0fe949efaa9af59d/signals.jsonl#signal-c0a68d49-2af2-4277-ba64-b72825ccad3a", + "reviewScopeAuthority": "corrected v2/v3 manifests include the project worktrees root as read-only and only their own worktree as writable; earlier review/audit manifests did not" + }, + "originalCandidates": [ + { + "laneId": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w1-v1", + "classification": "reject", + "treeDigest": "f1443e0d487402181d090151cd8e38f93e54cf44e0402deb1764059607ec328d", + "fileCount": 23, + "lineCount": 97452, + "consumedLedgerStatus": "rejected", + "integrationApprovedFiles": [] + }, + { + "laneId": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w2-v1", + "classification": "reject", + "treeDigest": "e28a338fa858787a9949f7241d96e25eb60d61f98ceabeab05968f6925842021", + "fileCount": 16, + "lineCount": 1936, + "consumedLedgerStatus": "rejected", + "integrationApprovedFiles": [] + }, + { + "laneId": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w3-v1", + "classification": "reject", + "treeDigest": "55a69e3bef6c355655d1c676616b2cb67421dc490b1df1980da65001089c2ed5", + "fileCount": 17, + "lineCount": 2356, + "consumedLedgerStatus": "rejected", + "integrationApprovedFiles": [] + }, + { + "laneId": "w4", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w4-v1", + "classification": "reject", + "treeDigest": "92a1028ad6b57ebd9192127637862a600a0e2126a8ff99a3320a628716011f59", + "fileCount": 17, + "lineCount": 3926, + "consumedLedgerStatus": "rejected", + "integrationApprovedFiles": [] + }, + { + "laneId": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w5-v1", + "classification": "reject", + "treeDigest": "6b38786244acf0463f929477e7e1d466ba7ba03a3800d6bbb2453d3233b2e2cf", + "fileCount": 17, + "lineCount": 2626, + "consumedLedgerStatus": "rejected", + "integrationApprovedFiles": [] + }, + { + "laneId": "w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w6-v1", + "classification": "reject", + "treeDigest": "552266839833b234b767285b392d21cef9142b963f091c0aa65e60c8fa87dc52", + "fileCount": 11, + "lineCount": 2920, + "consumedLedgerStatus": "rejected", + "integrationApprovedFiles": [] + } + ], + "historicalUsefulOnlyFiles": [ + { + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "sha256": "b9f9a3b24720fbac112a1b97e40725ecf484e18c15f1d7b6596d2446b4f4eefe", + "currentDisposition": "cross_lane_review_prefers_original_but_pair_and_completion_reviews_prefer_remediation_controller_must_resolve" + }, + { + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sha256": "b9b4824158f00ee43bcc6c0104dfb6d344b03929eb84dde06907c06d6df50955", + "currentDisposition": "superseded_by_remediation" + }, + { + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "sha256": "c36fa922a39d91cb125edb99df27721350f00b2d4ff382fc4f9c209b6b419566", + "currentDisposition": "superseded_by_adoptable_remediation" + }, + { + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "sha256": "756626d98ff3d52abdc911f8ab74b4ba6daceaa146a853fdbf04990227159bcb", + "currentDisposition": "superseded_by_adoptable_remediation" + }, + { + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "sha256": "09f0ed9d08776592a6ed8db2e558d34fde6db161d7bd213714a578db216964f3", + "currentDisposition": "superseded_by_adoptable_remediation" + }, + { + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "sha256": "b4c43b3e6494bea0d8ff3a42e188c40b184c5c6d84deceff00561011b5d47315", + "currentDisposition": "superseded_by_adoptable_remediation" + }, + { + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "sha256": "6ee6b1f69d075e8e39f4f8dd468269cebac368495a530e8e8f1193ed38b54531", + "currentDisposition": "superseded_by_adoptable_remediation" + }, + { + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "sha256": "2e296692f0415c3b82f3ce15cf1683bbd0aee846d2b571a5dff31bff406ddc15", + "currentDisposition": "same_hash_in_remediation_useful_only" + }, + { + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "sha256": "03f41142c1845f913bd0dfbd59499cfb6390454c36f4fec596432e7faf714341", + "currentDisposition": "same_hash_in_remediation_useful_only" + } + ], + "remediationCandidates": [ + { + "laneId": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v1", + "worktree": "phase-00-remediation-w1-v1", + "classification": "remediate", + "treeDigest": "e48be0aa2d5751ddafc50f546ec038ebe2137be17064c84589a34f7036d29498", + "fileCount": 13, + "lineCount": 591, + "integrationApprovedFiles": [], + "competingUsefulOnlyFiles": [ + { + "producer": "original-w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "sha256": "b9f9a3b24720fbac112a1b97e40725ecf484e18c15f1d7b6596d2446b4f4eefe", + "reviewPosition": "cross-lane-v2 prefers the detailed 137-line original" + }, + { + "producer": "remediation-w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "sha256": "6b7db032970d19eff99a8518a006a36f31ce74780003a5a80245565ea3411b2a", + "reviewPosition": "pair/completion review approves the 28-line remediation" + } + ], + "reason": "The compact evidence and seven focused tests are useful, but the four-file action catalog omits reachable child controls, 11 W1 files need formatting, the OOM-stopped job has no strict result, and its handoff reports phaseStartSha as baseSha instead of the actual remediation base.", + "preserveFiles": [ + ".codex-handoff/phase-00-w1.json", + "docs/research/hosted-web/phase-0/parity-renderer/README.md", + "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json", + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/schemas/api-parity-ledger.schema.json", + "docs/research/hosted-web/phase-0/parity-renderer/schemas/estimate-input.schema.json", + "docs/research/hosted-web/phase-0/parity-renderer/schemas/legacy-bypass-inventory.schema.json", + "docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-action-inventory.schema.json", + "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts" + ] + }, + { + "laneId": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w2-v1", + "worktree": "phase-00-remediation-w2-v1", + "classification": "remediate", + "treeDigest": "697ac0097c0e78317b0a6fec68b4ae4c633d03b8ce3b936479554e30d0437795", + "fileCount": 18, + "lineCount": 1814, + "integrationApprovedFiles": [], + "usefulOnlyFiles": [ + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sha256": "ed15cd0745492a78457183b27d0a974a3fbe7db34fde956768a44c7b9a217095" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json", + "sha256": "2d9b4318118ffe190e0054969980be04383cf6e242f3f7407eced14851e4f8ac" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "sha256": "11f6041495f67b34c2ae73d5c1318a44227966889e5c345deefc44096f445c28" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json", + "sha256": "0f82d7d7de307133bacb9b93bbe1b9140a5012870cf035dc6c1ecfe6ddddbb9b" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "sha256": "408a7beb2297c30c74743e7fab8b6c63585c4ee574f20fff86fcc85ca7b994c9" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json", + "sha256": "dda38204181f01af5b58ef90d174db011a1da915f1707fa7be97b02cceebec7e" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "sha256": "b5a69d43bc7aaf7ab52309500b36b97d2b66c987601c6e61674fe48926085328" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json", + "sha256": "43f41073a18c7ebd948f9283c1412fbf54ef7c6c0a7d738fdb66b87fd4fd2a16" + } + ], + "reason": "The artifact-specific schemas, canonical estimate and eight focused tests are useful, but eight explicit environment keys are not removal-sensitive, three required provider cases have no positive or negative proof, no strict result exists, and the handoff omits five new schemas while listing the removed generic schema.", + "preserveFiles": [ + ".codex-handoff/phase-00-w2.json", + "docs/research/hosted-web/phase-0/provider-runtime/README.md", + "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json", + "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json", + "docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json", + "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/surfaces-negative.json", + "test/architecture/hosted-web/phase-0/provider-runtime/fixtures/surfaces-positive.json", + "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts" + ] + }, + { + "laneId": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w3-v1", + "worktree": "phase-00-remediation-w3-v1", + "classification": "adopt", + "treeDigest": "0f0aeca56e4c2b9363ae557b72dcab2d27c794e7f2dc99306a9ceb3704ac5ce2", + "fileCount": 16, + "lineCount": 2454, + "reason": "The job has a strict done result, the production TeamBackupService fault suite closes RW35-001, no-index provenance closes RW35-006, targeted checks pass, and W5 now follows W3's fail-closed writer truth.", + "integrationApprovedFiles": [ + { + "path": ".codex-handoff/phase-00-w3.json", + "sha256": "02108287b2f1c1a25ce1cd80ffe5b7808cc5afc0f51f6aec15c46c8bd7b28816", + "lines": 314 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "sha256": "a9cff35be387062de2ce908a98aa0613cf460bd43cb5e1b3eeeb7d1c704f0623", + "lines": 133 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "sha256": "e115aa10ab1ad3842e8b44db03a07541cae9b6abdbeb1207ba3f01bd6d5ff7ae", + "lines": 88 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/evidence.schema.json", + "sha256": "d1b372576eb54025c9f9d244b87fadb2732b576e00adc9fa942daa6632113cbf", + "lines": 15 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/index.md", + "sha256": "3928efdf49599c058dcbccfd43df30a95e964809278e66d12bb9e093e5dad744", + "lines": 68 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "sha256": "aec02d87f8657ec91d51f252d0dcf652dc04cae2ae047cebef6e3455820906e3", + "lines": 105 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "sha256": "7b9e2a03da2f972239b36b248319d36d420917596f03d20467dbabc8dc0ee4f5", + "lines": 45 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md", + "sha256": "f3230f8b3dea6d0cad98d3e3a8c23c951570691a57376ad868752eda42fbdcf4", + "lines": 49 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "sha256": "f987dfb942445d3ac9ebfeb1a4f931291b7b834af45e5979d45518d59975e90a", + "lines": 397 + }, + { + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "sha256": "7c7c572e068b04c9b84886d2a7c47aa3e04ba714311b2f8c98b0fe066c7d52ef", + "lines": 189 + }, + { + "path": "scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs", + "sha256": "1436313385a3dde2ee5ac81dd40bc835047d3fbcc62cb5bffdac9f917e6559d2", + "lines": 76 + }, + { + "path": "scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs", + "sha256": "e1ca50f273a449c0c36dd143eb1117efea7f19c8df5daefb16d0da3553fe4054", + "lines": 172 + }, + { + "path": "scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs", + "sha256": "ed2887a0320f4f53f1870e0a25473e1d414ead8be7db24c53299f73e42d940d9", + "lines": 257 + }, + { + "path": "test/architecture/hosted-web/phase-0/state-writers/fixtures/invalid-state-family-catalog.json", + "sha256": "b905da5326aed7d2d964ab24c3df4a86e06bf5be1a43662acdb3fab2aa75638a", + "lines": 16 + }, + { + "path": "test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs", + "sha256": "f9b85a5a400852220f0c3cb14d995c2276f4d67b24355d09320c9f94403a655b", + "lines": 147 + }, + { + "path": "test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs", + "sha256": "9859e33b15a277edc5999693445064f9769b664efadd0b390b16682139dece73", + "lines": 383 + } + ] + }, + { + "laneId": "w4", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-v1", + "worktree": "phase-00-remediation-w4-v1", + "classification": "remediate", + "treeDigest": "a82271568e79de8e053c7333f2d4b8017a9ab6eb4b4e2b408af1f4026f356fe6", + "fileCount": 21, + "lineCount": 2548, + "integrationApprovedFiles": [], + "usefulOnlyFiles": [ + { + "path": "docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md", + "sha256": "b269fa2b9a9126c90542ff6f2100043a1ae76e0eb566ef3c35171884368eb5cb" + }, + { + "path": "docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md", + "sha256": "14a1e8fea81cf6f4ac92cdd01c0af2654a7aaddacdbda4fecd069bf18a2f2200" + }, + { + "path": "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "sha256": "f6182c02566f51837ab5a9027f181aa90ce5ac0cc61ad559a3e96f70e4c092a0" + }, + { + "path": "docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md", + "sha256": "646732d59d364b68bbde75edbd5e699c79f876a7b4fca928b634006e62224c82" + }, + { + "path": "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "sha256": "d9cc83ae82e3a1c11e654db39ecf6ddceea78f23f455db707cb5876652118c57" + }, + { + "path": "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "sha256": "66f7c58d1188fd8d4be09bdd890458002dbbe40bf94b153155f1f9a54366ab43" + } + ], + "reason": "Cleanup and high-FD fixes are useful, but process-anchor still signals a reusable numeric PGID after the group can become empty, one owned Vitest file fails against the new cleanup shape, the OOM-stopped candidate has no handoff/result, final topology is unverified, its estimate is not mapped to W2's canonical bucket, and its native target paths contradict W6.", + "preserveFiles": [ + "docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json", + "docs/research/hosted-web/phase-0/host-primitives/estimate-input.json", + "docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json", + "docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json", + "docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.schema.json", + "docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md", + "docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json", + "docs/research/hosted-web/phase-0/host-primitives/probe-results.schema.json", + "docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md", + "docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json", + "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c", + "scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs", + "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "test/architecture/hosted-web/phase-0/host-primitives/native-probes.test.ts" + ] + }, + { + "laneId": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w5-v1", + "worktree": "phase-00-remediation-w5-v1", + "classification": "remediate", + "treeDigest": "67a8f2f3302433d06413f91caf1be24e0bd28197c0e103abc80e21f91daad459", + "fileCount": 17, + "lineCount": 2041, + "integrationApprovedFiles": [], + "usefulOnlyFiles": [ + { + "path": "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "sha256": "2e296692f0415c3b82f3ce15cf1683bbd0aee846d2b571a5dff31bff406ddc15" + }, + { + "path": "docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json", + "sha256": "f19fcbefb7fe673156f07e3bf5c680b0d10bc4664466bd529b9c65e3ec7639f1" + }, + { + "path": "docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json", + "sha256": "515677b1d652c35238523b7648869dc75d54a17a90993ba9783056cd30a0be9c" + }, + { + "path": "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "sha256": "03f41142c1845f913bd0dfbd59499cfb6390454c36f4fec596432e7faf714341" + } + ], + "reason": "Writer recovery is now fail closed and snapshot/fingerprint evidence improved, but the effect crash runner recovers in the same closure, the census is generator-local rather than independently complete, the stale handoff omits two files, no strict result exists, and two files fail no-index whitespace checks.", + "preserveFiles": [ + ".codex-handoff/phase-00-w5.json", + "docs/research/hosted-web/phase-0/recovery-events/README.md", + "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json", + "docs/research/hosted-web/phase-0/recovery-events/index.json", + "docs/research/hosted-web/phase-0/recovery-events/index.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json", + "docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json", + "scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs", + "scripts/hosted-web/phase-0/recovery-events/model.mjs", + "test/architecture/hosted-web/phase-0/recovery-events/fixtures/invalid-command-catalog.json", + "test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs" + ] + }, + { + "laneId": "w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w6-v1", + "worktree": "phase-00-remediation-w6-v1", + "classification": "remediate", + "treeDigest": "00c385f81894e7fc2b424de55f06432f95de7f6dafdb46fe15015fc15a20cdbd", + "fileCount": 11, + "lineCount": 2301, + "integrationApprovedFiles": [], + "usefulOnlyFiles": [ + { + "path": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "sha256": "cd8059153cd5ccd195deeab5ce9663d95a50543bf4fad9b6f1af6c3b1cf26fc0" + } + ], + "reason": "Auth restart and ABI probes materially improve the lane, but the verifier is false-green for the conflicting artifact contract, targeted lint fails import ordering, the stopped job has no result, the stale handoff omits two files, the current artifact is still negative, and the W4/W6 target paths and manifest fields disagree.", + "preserveFiles": [ + ".codex-handoff/phase-00-w6.json", + "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json", + "docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json", + "docs/research/hosted-web/phase-0/auth-artifacts/report.md", + "scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs", + "scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs", + "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts" + ] + } + ], + "crossLaneFindings": [ + { + "id": "OOM-X-001", + "severity": "high", + "lanes": ["w4", "w6"], + "finding": "W4 requires native artifacts under /opt/agent-teams/bin while W6 declares /app/bin; W6 rows also omit W4-required protocol/build/builder/compiler/UID/GID/mode fields.", + "disposition": "must_reconcile_before_review" + }, + { + "id": "OOM-X-002", + "severity": "high", + "lanes": ["w4", "w6"], + "finding": "W6's process_drain_outcome_v1 adapter is not mapped by schema or hash to W4's raw protocolVersion/spawnNonce/runtimeGeneration/residualCount response.", + "disposition": "must_reconcile_before_review" + }, + { + "id": "OOM-X-003", + "severity": "high", + "lanes": ["w2", "w4"], + "finding": "W2 maps both contributions to canonical EST-LIFECYCLE-RUNTIME and excludes W4 primitives, while W4 still exposes unmapped host.* buckets; the controller must replace, not add, and W4 must declare the canonical mapping.", + "disposition": "estimate_freeze_blocked" + }, + { + "id": "OOM-X-004", + "severity": "medium", + "lanes": ["w3", "w5"], + "finding": "Writer/recovery semantics now agree, but both estimates are overlapping decompositions of EST-RECOVERY-STATE and must never be summed.", + "disposition": "compatible_with_controller_deduplication" + }, + { + "id": "OOM-X-005", + "severity": "medium", + "lanes": ["w1", "w2"], + "finding": "The prior stop/provider ownership contradiction is corrected: W1 owns team.lifecycle.stop in team-lifecycle and provider credentials in runtime-provider-management; W2 keeps machine ingress separate.", + "disposition": "semantic_conflict_closed_but_nonterminal_outputs_still_held" + }, + { + "id": "OOM-X-006", + "severity": "medium", + "lanes": ["w2", "w6"], + "finding": "The browser-session versus machine-ingress split is compatible in the dirty outputs, but neither nonterminal handoff can freeze it.", + "disposition": "compatible_pending_completed_handoffs_and_review" + }, + { + "id": "OOM-X-007", + "severity": "critical", + "lanes": ["w1", "w2", "w5"], + "finding": "W1/W2 place launch, stop and delete under team.lifecycle and replace runtime.permission_answer with request ingress plus operator-owned outbound decision; W5 still assigns launch/stop/cancel to team-runtime-control and retains runtime.permission_answer as a hosted mutation.", + "disposition": "w5_command_ownership_must_be_regenerated_from_shared_authority" + }, + { + "id": "OOM-X-008", + "severity": "high", + "lanes": ["w1"], + "finding": "The corrected pair/completion reviews approve the 28-line remediation selection-invariant record, while cross-lane-v2 rejects it as a regression and approves the original 137-line hash. No W1 file is unambiguously approved across audits.", + "disposition": "controller_must_choose_exact_hash_before_selective_adoption" + } + ], + "fileOverlap": { + "remediationDirtyPathIntersections": [], + "result": "all six remediation candidates remain within exclusive lane-owned paths" + }, + "adoptionSplits": [ + { + "order": 1, + "laneId": "w3", + "name": "scripts_and_tests", + "lineCount": 1051, + "paths": [ + "scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs", + "scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs", + "scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs", + "test/architecture/hosted-web/phase-0/state-writers/fixtures/invalid-state-family-catalog.json", + "test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs", + "test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs" + ] + }, + { + "order": 2, + "laneId": "w3", + "name": "evidence", + "lineCount": 1089, + "paths": [ + "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "docs/research/hosted-web/phase-0/state-writers/evidence.schema.json", + "docs/research/hosted-web/phase-0/state-writers/index.md", + "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md", + "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json" + ] + }, + { + "order": 3, + "laneId": "w3", + "name": "handoff", + "lineCount": 314, + "paths": [".codex-handoff/phase-00-w3.json"] + } + ], + "safeLifecycle": [ + "Capture immutable patch/status/hash backups for every remediation tree before any reconcile, restart, or cleanup; never recreate the producer worktrees.", + "Record W3 as a non-additive EST-RECOVERY-STATE input, then open one controller-owned policy integration attempt for the exact W3 tree digest and adopt its three sub-1500-line splits in order; verify all per-file hashes and run only the W3 targeted checks.", + "Continue W1 and W2 from their preserved trees, regenerate accurate remediation-base handoffs and strict results, repeat focused W1/W2 review, then adopt in W1-before-W2 order if accepted.", + "Continue W5 from its preserved tree after W3 truth is available; use a fresh durable recovery runner, an independently extracted mutation census, regenerate launch/stop/cancel and permission ownership from W1/W2 authority, fix whitespace and regenerate the handoff/result; repeat W3/W5 review before any W5 adoption.", + "Reconcile W4/W6 artifact paths, fields, typed-drain mapping and W2/W4 estimate ownership; finish both handoffs/results, repeat paired review, and adopt W4 before W6 only if accepted.", + "Only after accepted lane adoption may the controller reconcile lane ledger, decision register and unique-bucket estimate; target-image/HTTPS/container probes and any broad/final gate remain separately authorized later work.", + "Do not start Phase 1 and do not implement hosted terminal behavior from this audit." + ], + "scopeAttestation": { + "modifiedProducerWorktrees": false, + "modifiedIntegration": false, + "integratedOutput": false, + "pushed": false, + "broadOrFinalGateRun": false, + "phase1Implemented": false, + "terminalImplemented": false + } +} diff --git a/docs/research/hosted-web/phase-0/audits/requirements/README.md b/docs/research/hosted-web/phase-0/audits/requirements/README.md new file mode 100644 index 0000000000..f58b652d11 --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/requirements/README.md @@ -0,0 +1,146 @@ +# Phase 0 requirement-to-evidence acceptance audit + +- Audit ID: `P0.AUDIT.REQUIREMENTS.V1` +- Packet: `phase-00-r2` +- Phase start: `a32f509e6d9bd31ba2135940e336729bf90c3d93` +- Registry snapshot: `2026-07-11T18:21:15Z` +- Disposition: **reject pending correction and reciprocal re-review** + +## Outcome + +Phase 0 is not acceptable for adoption or freeze. The project registry proves that all six producer +jobs and all three reciprocal review jobs completed, but every producer lane was rejected by its +assigned reciprocal review. There are zero approved lanes. No correction attempt or focused re-review +is present. + +The requirement matrix contains 70 checks: + +| Status | Count | Meaning in this audit | +| ----------- | ----: | ----------------------------------------------------------------------- | +| `satisfied` | 17 | Current authoritative evidence proves the bounded requirement. | +| `partial` | 14 | Useful evidence exists but is weaker or narrower than required. | +| `failed` | 28 | Evidence contradicts the requirement or reciprocal review rejects it. | +| `missing` | 5 | The required proof, usually final-topology proof, does not exist. | +| `pending` | 6 | A 0D/freeze deliverable cannot proceed while lane evidence is rejected. | + +The machine-readable authority is +[`requirement-matrix.json`](./requirement-matrix.json). The registry/worktree mapping, completion +states and SHA-256 evidence snapshot are in [`registry-snapshot.json`](./registry-snapshot.json). +Matrix references resolve as follows: `Wn_HANDOFF` and `Wn:` use the matching +`producerJobs[laneId=wN]` workspace/handoff; `R12`, `R35` and `R46` use the matching `reviewJobs` +record and hashed report; the remaining uppercase names use the `sources` array. + +## Acceptance-critical findings + +### W1 / W2 + +The W1/W2 review rejects both lanes. + +- W1's 86/20/3 AST count and selection/reconciliation invariants are useful, but the action inventory + is not a semantic action ledger. It manufactures one action per JSX site, includes non-actions such + as `stopPropagation`, assigns ownership from heuristics and hashes source line/location into + supposedly stable IDs (`R12-W1-001`, `R12-W1-002`, `R12-X-001`). +- W1's estimate reports incorrect net comparisons and suppresses a greater-than-20-percent variance. + Its 48,661-line output also violates the evidence/adoption budget without an approved split or + external raw-artifact treatment (`R12-W1-003`, `R12-W1-004`). +- W2 correctly separates four provider identities from two backend families, but environment + completeness is not scanned from source and known explicit keys are absent (`R12-W2-001`). +- W2's generic schema does not require the ingress authority/idempotency/body/evidence fields that the + packet makes acceptance-critical. Its negative fixture tests only route removal/duplication + (`R12-W2-002`). The fake-provider matrix is incomplete and estimate IDs do not map to the canonical + `EST-LIFECYCLE-RUNTIME` bucket (`R12-W2-003`, `R12-W2-004`). + +### W3 / W5 + +The W3/W5 review rejects both lanes. + +- W3's state-family catalog, writer matrix, external-writer negative control and WAL Online Backup + spike are useful at their stated proof levels. `TeamBackupService`, however, was never exercised by + the required fault-injected fixture; the current behavior record is source deduction only + (`RW35-001`). +- W5 counts commit, compensation and event-publication trace labels as if they were real crash/restart + transitions (`RW35-002`). Its claimed convergence and no-duplicate-effect result therefore does not + cover the required boundaries. +- W5 marks task/inbox effects idempotent despite W3 proving active writer coordination and durable + lookup unproven (`RW35-003`). Its fingerprint vectors omit fingerprint-version and default/omission + cases (`RW35-004`), and its mutation census is circular (`RW35-005`). + +### W4 / W6 + +The W4/W6 review rejects both lanes and paired integration. + +- No required W4 proof ran in the admitted final Debian-slim, non-root, init/seccomp/volume topology. + Two final-image containers plus the manual contender were not exercised. +- The process anchor signals descendants through reusable numeric PIDs, contrary to ADR-31 + (`R46-03`). Cleanup success/zero residuals are hard-coded before ignored cleanup (`R46-04`), and FD + closure stops at descriptor 1023 (`R46-05`). +- W6's auth model restores mutation admission after logout, forget-device and replay-family + revocation on restart (`R46-01`). Reset trusts a caller-provided count rather than a generation-bound + W4 `drained` result (`R46-02`). +- The current standalone artifact omits the internal-storage worker, retains catch-all native/Electron + stubs and terminal service/package surfaces, and has no manifest rows for the three W4 native + artifacts (`R46-06`). Recorded ABI/native smoke facts have no owned reproducible command or assertion + (`R46-07`). + +## Cross-cutting gaps + +The bootstrap evidence is not literally closed: `lint:fast` and `standalone:build` were not captured, +the nested typecheck/test stages lack independent duration/tail records, and there is no post-fix broad +rerun before lane admission. The checked-in lane ledger also remains at six `unstarted` slots with null +job IDs and phase start even though the registry now records nine completed jobs. + +The estimate cannot be frozen. The shared ledger is still the pre-inventory 28k-45k baseline; W1 has +incorrect arithmetic, W2 uses noncanonical bucket IDs, W3/W5 overlap is not reconciled, and W4's native +sub-buckets have no controller mapping. There is no evidence-backed answer yet to whether 28k-45k +remains credible. + +All final-topology claims remain open: instance lease, workspace guard, process ownership, HTTPS +proxy/auth schedules, Node/Electron/final-image native loading, W4 native packaging and terminal-free +artifact composition. These are mandatory proof gaps, not later release niceties. + +The decision register contains only 0A outcomes. It does not record the reciprocal rejections, +contested findings, reopened ADRs or any of the required freeze decisions. Combined targeted checks, +the 0D broad gate, completion report and Phase 1 JIT packet are therefore not ready to run or create. + +## Evidence that can be retained + +The rejection does not make every artifact useless. The following are bounded, reviewed inputs for a +correction attempt: + +- the W1 86/20/3 AST census, dynamic-dispatch negative control and selection/reconciliation invariants; +- the W2 four-provider/two-backend topology and five current runtime-ingress observations, after + schema and completeness repair; +- the W3 17-family catalog, 12-operation writer matrix, external-writer negative control and current- + host WAL Online Backup feasibility result; +- W5's two deliberate lost-event negative controls and current-surface inventory, not its exhaustive + or retry-safety claims; +- W4's current-host negative controls as characterization only, after fixing process targeting, + cleanup and high-FD closure; +- W6's current artifact defect inventory and proxy ordering model, not its restart-safe auth or final + artifact claims. + +## Required correction order + +1. Return R12 findings to W1/W2. Replace heuristic action generation with a reviewed semantic catalog, + make IDs source-location independent, discover environment inputs from source, enforce artifact- + specific schemas, correct estimate inputs and compact/externally hash oversized raw evidence. +2. Return RW35 findings to W3/W5. Add the marker-owned `TeamBackupService` fault suite; model every + scheduler boundary as a real durable crash/restart transition; default unproved writer effects to + `operator_required`; use an independent mutation census and complete retained-version goldens. +3. Return R46 findings to W4/W6. Fix revoked-session restart and typed-drain reset fencing; remove + numeric-PID descendant signaling; measure cleanup; close the complete FD range; add the three W4 + artifacts and reproducible ABI probes to the hosted manifest/scanner. +4. Repeat all three reciprocal reviews. A lane remains rejected until its owner-generated evidence and + focused re-review close or explicitly contest every finding. +5. Reconcile the canonical unique estimate buckets, record every decision/rejection/reopened ADR in the + controller register, then run the exact admitted final image/edge topology proofs. +6. Only after accepted adoption may the controller run combined/final gates, freeze Phase 0 and + generate the Phase 1 JIT packet. + +## Audit boundaries + +This audit was read-only outside its owned directory and local handoff. It did not modify producer, +review or integration worktrees; it did not run broad/final gates, integrate output, contact a real +provider/project, or implement Phase 1. Reviewers' targeted command results are treated as evidence only +at the scope they exercised; passing tests do not override the reciprocal findings that those tests are +shallow, circular or missing required schedules. diff --git a/docs/research/hosted-web/phase-0/audits/requirements/registry-snapshot.json b/docs/research/hosted-web/phase-0/audits/requirements/registry-snapshot.json new file mode 100644 index 0000000000..5941307aff --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/requirements/registry-snapshot.json @@ -0,0 +1,200 @@ +{ + "schemaVersion": 1, + "auditId": "P0.AUDIT.REQUIREMENTS.V1", + "observedAt": "2026-07-11T18:21:15Z", + "projectRegistryRoot": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "packetRevision": "phase-00-r2", + "sources": [ + { + "id": "PACKET", + "path": "docs/hosted-web-phase-0-execution-packet.md", + "sha256": "71c53d24f3211613dcfba11493bf18a2b6d49ae31e86186fc94c259d21f17fa8" + }, + { + "id": "PARENT_PLAN", + "path": "docs/hosted-web-e2e-completion-plan.md", + "sha256": "4901a37dc4da373efab939b43a406739ed02e1b4e250a7a2e5754ad659fa4080" + }, + { + "id": "BASE", + "path": "docs/research/hosted-web/phase-0/base.json", + "sha256": "1c8e69a8588084825cfd5b181ac8f13c8c02021b75dc23c4557cf7b083c42104" + }, + { + "id": "BASELINE", + "path": "docs/research/hosted-web/phase-0/baseline.md", + "sha256": "7dc7b6b691ae5394ca6dbff759ae155bc421027bca98b567552b4d085fc3481b" + }, + { + "id": "LANE_LEDGER", + "path": "docs/research/hosted-web/phase-0/lane-ledger.json", + "sha256": "133910e497f35d4e8e27080e3304af08991283c737f1c9e856852f00852bc418" + }, + { + "id": "ESTIMATE_LEDGER", + "path": "docs/research/hosted-web/phase-0/estimate-ledger.md", + "sha256": "1b0f45bfdeb17e2ddc6058482c9b190f16b09501561c361370fac5bfad783ab0" + }, + { + "id": "DECISION_REGISTER", + "path": "docs/research/hosted-web/phase-0/decision-register.md", + "sha256": "b26e363fc9c591b4122143eb8777242782b52ad3b443407b09cdf7d5dec0ee81" + } + ], + "producerJobs": [ + { + "laneId": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w1-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-w1-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w1-parity-renderer-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 1, + "producerDisposition": "characterized", + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w1-parity-renderer-v1/.codex-handoff/phase-00-w1.json", + "handoffSha256": "d000ec9d1aae5995f66279ca70fb3d0da80eafcba808b153f3398d1d4c86c110", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-w1-v1/agent-teams-hosted-web-refactor-phase-00-w1-v1.latest-result.json", + "latestResultSha256": "35bb9c270cc84c0ad429039c5e1c2b60cfbdcd7fb81ab83ec71339a91973db10" + }, + { + "laneId": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w2-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-w2-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w2-provider-runtime-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 2, + "producerDisposition": "characterized", + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w2-provider-runtime-v1/.codex-handoff/phase-00-w2.json", + "handoffSha256": "df2b4edcccad1ffac46091ceca124710cfcf68058eafddadbeaf141808b02b50", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-w2-v1/agent-teams-hosted-web-refactor-phase-00-w2-v1.latest-result.json", + "latestResultSha256": "893c1b3ff460367af84fbd45aa5efec0916a2530578ffad6b13c79433fed7701" + }, + { + "laneId": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w3-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-w3-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w3-state-writers-backup-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 3, + "producerDisposition": "characterized", + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w3-state-writers-backup-v1/.codex-handoff/phase-00-w3.json", + "handoffSha256": "a0ceff5b4c384e551abe336060922cb278d7a4768344a89663094ddde44308fc", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-w3-v1/agent-teams-hosted-web-refactor-phase-00-w3-v1.latest-result.json", + "latestResultSha256": "a07db789611d8d126dbf2ea5a83d1ee57b7a962e099272f8a03f7dded0a71979" + }, + { + "laneId": "w4", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w4-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-w4-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w4-lease-guard-process-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 1, + "producerDisposition": "characterized", + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w4-lease-guard-process-v1/.codex-handoff/phase-00-w4.json", + "handoffSha256": "4fd9c0bce7717f1947fff64a9cee6edcd8fc1321b0504365d3341bb0786d3e1d", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-w4-v1/agent-teams-hosted-web-refactor-phase-00-w4-v1.latest-result.json", + "latestResultSha256": "709ae6bcf548aae73b7df33ef754bdbb3a10c71b72ca5f7775e5a6aa6abf8c4e" + }, + { + "laneId": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w5-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-w5-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w5-events-commands-recovery-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 2, + "producerDisposition": "characterized", + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w5-events-commands-recovery-v1/.codex-handoff/phase-00-w5.json", + "handoffSha256": "9f87bbc855161c368da5b4fef16a3d2002f6cdc69acce996555df49b801038e5", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-w5-v1/agent-teams-hosted-web-refactor-phase-00-w5-v1.latest-result.json", + "latestResultSha256": "894573155a28a3aea9d9d2978565c243fa71e1327978eadb86e146da833b38be" + }, + { + "laneId": "w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w6-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-w6-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w6-auth-proxy-artifacts-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 1, + "producerDisposition": "characterized", + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w6-auth-proxy-artifacts-v1/.codex-handoff/phase-00-w6.json", + "handoffSha256": "64a053f03111dcd79a7ca05aaff773cbbc6990ec458cd676a21a228233f59d2f", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-w6-v1/agent-teams-hosted-web-refactor-phase-00-w6-v1.latest-result.json", + "latestResultSha256": "80f3835108abb9078b53a0da190389c51dbb8c5c98546ddd68a146d54320ebac" + } + ], + "reviewJobs": [ + { + "reviewId": "P0.R12.W1_W2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w1-w2-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-review-w1-w2-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w1-w2-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 3, + "reviewDisposition": "reject", + "producerDispositions": { + "w1": "reject", + "w2": "reject" + }, + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w1-w2-v1/.codex-handoff/phase-00-review-w1-w2.json", + "handoffSha256": "c12836733a03159b434223b6ffb63d2a561a3ff946dd3704fde6f5d7f88ef48e", + "reportPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w1-w2-v1/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md", + "reportSha256": "213836d2c6104318d3e59848a856dea14bfefc9d56840645a76cf821fc918e2b", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/controlled-agent/home/.cache/subscription-runtime/agent-teams-hosted-web-refactor-phase-00-review-w1-w2-v1/agent-teams-hosted-web-refactor-phase-00-review-w1-w2-v1.latest-result.json", + "latestResultSha256": "4a3ba6471486c4d8890ab4ff2bc5e2fbd6a5233ec8ba65691c344311c2804fa0" + }, + { + "reviewId": "P0.R35.W3_W5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w3-w5-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 1, + "reviewDisposition": "reject", + "producerDispositions": { + "w3": "reject", + "w5": "reject" + }, + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w3-w5-v1/.codex-handoff/phase-00-review-w3-w5.json", + "handoffSha256": "f8d9b40c83ce4d895110162807cd8abafaac25566ef4dd32db0ab5e8afbd8beb", + "reportPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w3-w5-v1/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md", + "reportSha256": "8140261ef1cf3a2a37fc25d75f639fa9e20607e7bb1c104fe5a39b1585e38a15", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/controlled-agent/home/.cache/subscription-runtime/agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1/agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1.latest-result.json", + "latestResultSha256": "5390b196d2fcd1e387169b55a73c006a1e68fcc72e4d0d3eca957072d6eeff67" + }, + { + "reviewId": "P0.R46.W4_W6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v1", + "registryPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v1/job.json", + "workspacePath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w4-w6-v1", + "status": "completed", + "resultStatus": "completed", + "attemptCount": 5, + "reviewDisposition": "reject", + "producerDispositions": { + "w4": "reject", + "w6": "reject" + }, + "handoffPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w4-w6-v1/.codex-handoff/phase-00-review-w4-w6.json", + "handoffSha256": "7bf1e3e6f1c02ff352909cead19dd0a344f4277d3bbd7ac4e897dfee2a45a175", + "reportPath": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w4-w6-v1/docs/research/hosted-web/phase-0/reciprocal-review-w4-w6.md", + "reportSha256": "4a509551fdf2466f1be2f6cc9e9b8c9c1058fb3c5c695f8706f78af42e47e5bf", + "latestResultPath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/controlled-agent/home/.cache/subscription-runtime/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v1/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v1.latest-result.json", + "latestResultSha256": "c1bd54a477689d257f44f18fa52142cc893eb2dcc2083a722f61fd241909d14d" + } + ], + "registryConclusion": { + "completedProducerJobs": 6, + "completedReviewJobs": 3, + "approvedProducerLanes": 0, + "rejectedProducerLanes": 6, + "pendingCorrectionAndRereview": 6 + } +} diff --git a/docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json b/docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json new file mode 100644 index 0000000000..566000e2cf --- /dev/null +++ b/docs/research/hosted-web/phase-0/audits/requirements/requirement-matrix.json @@ -0,0 +1,662 @@ +{ + "schemaVersion": 1, + "auditId": "P0.AUDIT.REQUIREMENTS.V1", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "disposition": "reject_pending_correction", + "statusDefinitions": { + "satisfied": "Authoritative current evidence proves the requirement at its required proof level.", + "partial": "Useful evidence exists, but it does not prove the full requirement or required topology.", + "failed": "Current evidence contradicts the requirement or a completed reciprocal review rejected it.", + "missing": "No authoritative evidence was found.", + "pending": "The requirement belongs to 0D/final freeze and cannot yet run because prerequisite evidence is rejected." + }, + "requirements": [ + { + "id": "P0.REQ.0A.001", + "area": "0A", + "requirement": "Pin the canonical remote and exact base SHA with toolchain and lockfile identity.", + "status": "satisfied", + "evidenceStrength": "authoritative", + "evidenceRefs": ["BASE", "PACKET:0A.1"], + "finding": "base.json records the canonical URL, base cbe501ad0f1fa0e51a038e832ad35fce4120321b, observed time, Node/pnpm/OS/kernel/architecture and lockfile hash." + }, + { + "id": "P0.REQ.0A.002", + "area": "0A", + "requirement": "Adopt the reviewed plan bundle and establish a phaseStartSha descending from base and plan evidence.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["BASE", "REGISTRY_SNAPSHOT", "W1_HANDOFF", "W6_HANDOFF"], + "finding": "The checked-in record uses the documented self-SHA exception, while every registered lane and handoff uses a32f509e6d9bd31ba2135940e336729bf90c3d93 and the Git ancestry contains the adopted plan and 0A evidence." + }, + { + "id": "P0.REQ.0A.003", + "area": "0A", + "requirement": "Materialize frozen dependencies without changing the lockfile.", + "status": "satisfied", + "evidenceStrength": "authoritative", + "evidenceRefs": ["BASE", "BASELINE"], + "finding": "The offline frozen install command, dependency fingerprint and unchanged lockfile are recorded." + }, + { + "id": "P0.REQ.0A.004", + "area": "0A", + "requirement": "Capture each of typecheck:workspace, lint:fast, test:workspace:ci, standalone:build and check:ci with exit code, duration and final tail.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["BASELINE:Packet command coverage"], + "finding": "lint:fast and standalone:build were not run; nested typecheck/test lacked independent duration/tail records." + }, + { + "id": "P0.REQ.0A.005", + "area": "0A", + "requirement": "Classify every inherited failure and rerun after adopting any base-owned prerequisite fix before dependent admission.", + "status": "partial", + "evidenceStrength": "authoritative", + "evidenceRefs": ["BASELINE", "BASE", "REGISTRY_SNAPSHOT"], + "finding": "Two base-owned records/five lint errors have owners and no unknowns, but no post-fix broad rerun is recorded before W1-W6 admission." + }, + { + "id": "P0.REQ.0A.006", + "area": "0A", + "requirement": "Start all six lanes from the exact phaseStartSha with packet revision phase-00-r2.", + "status": "satisfied", + "evidenceStrength": "authoritative", + "evidenceRefs": [ + "REGISTRY_SNAPSHOT", + "W1_HANDOFF", + "W2_HANDOFF", + "W3_HANDOFF", + "W4_HANDOFF", + "W5_HANDOFF", + "W6_HANDOFF" + ], + "finding": "All producer registry jobs name the same phase start and all handoffs report it." + }, + { + "id": "P0.REQ.0A.007", + "area": "0A", + "requirement": "Maintain lane-ledger state, attempt, job/worktree identity, progress and handoff hashes through evidence and review epochs.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["LANE_LEDGER", "REGISTRY_SNAPSHOT"], + "finding": "The checked-in lane ledger still reports all lanes unstarted with null jobs/phaseStartSha despite nine completed registry jobs." + }, + { + "id": "P0.REQ.0A.008", + "area": "0A", + "requirement": "Admit no lane that depends on an unknown baseline failure.", + "status": "satisfied", + "evidenceStrength": "authoritative", + "evidenceRefs": ["BASELINE"], + "finding": "The inherited-failure ledger contains zero unknown or base_blocker records." + }, + { + "id": "P0.REQ.SCOPE.001", + "area": "scope", + "requirement": "Keep producer and review writes within exclusive owned paths.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["R12", "R35", "R46"], + "finding": "All three reviews independently found lane-owned paths only; this audit also observed only untracked owned additions." + }, + { + "id": "P0.REQ.SCOPE.002", + "area": "scope", + "requirement": "Do not implement Phase 1, enable hosted mutation, use a real project/provider, or add hosted terminal behavior.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["R12", "R35", "R46", "W6_HANDOFF"], + "finding": "Review scope and secret/path scans found no Phase 1/product mutation, real-project/provider use or terminal implementation change." + }, + + { + "id": "P0.REQ.W1.001", + "area": "W1", + "requirement": "Deliver all six W1 evidence IDs and a standard handoff.", + "status": "partial", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W1_HANDOFF", "R12"], + "finding": "All files exist, but reciprocal review rejected five evidence groups and approved only the selection invariants." + }, + { + "id": "P0.REQ.W1.002", + "area": "W1", + "requirement": "Extract exactly one disposition for every pinned TeamsAPI, ReviewAPI and CrossTeamAPI member and explain count differences.", + "status": "partial", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W1:P0.W1.API_PARITY_LEDGER", "R12:R12-W1-001", "R12:R12-X-001"], + "finding": "The 86/20/3 AST count is reproducible, but heuristic ownership/action/phase assignments are wrong, including TeamsAPI.stop." + }, + { + "id": "P0.REQ.W1.003", + "area": "W1", + "requirement": "Map every visible team control to exactly one semantic action or deliberate absence before mount.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W1-001", "R12:R12-W1-002"], + "finding": "The generator maps JSX sites, including event containment and widget subparts, rather than stable semantic actions; line-based IDs are unstable." + }, + { + "id": "P0.REQ.W1.004", + "area": "W1", + "requirement": "Annotate dynamic dispatch and fail a deliberate unannotated fixture.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W1:P0.W1.SCANNER", "R12:verification record"], + "finding": "The scanner and six-test suite pass the annotated/unannotated dynamic dispatch control, although this does not repair semantic action coverage." + }, + { + "id": "P0.REQ.W1.005", + "area": "W1", + "requirement": "Inventory direct Electron, global mega-client and fabricated-success browser paths.", + "status": "partial", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W1:P0.W1.LEGACY_BYPASSES", "R12"], + "finding": "The source observations are useful, but the 7,095-line raw projection violates evidence-size policy and was rejected for adoption." + }, + { + "id": "P0.REQ.W1.006", + "area": "W1", + "requirement": "Capture selection, thin/full snapshot, tombstone, pagination and event/poll race invariants.", + "status": "satisfied", + "evidenceStrength": "authoritative", + "evidenceRefs": ["W1:P0.W1.SELECTION_INVARIANTS", "R12:artifact dispositions"], + "finding": "Reciprocal review explicitly approved the invariant document and its labeled proof gaps." + }, + { + "id": "P0.REQ.W1.007", + "area": "W1", + "requirement": "Make missing/duplicate signature and semantic action fixtures fail the gate.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W1-001", "R12:R12-W1-002"], + "finding": "Current fixtures prove one structural row per AST site, not semantic missing/duplicate actions or line-shift-stable IDs." + }, + { + "id": "P0.REQ.W1.008", + "area": "W1", + "requirement": "Provide a correct canonical estimate input within evidence/adoption budget.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W1-003", "R12:R12-W1-004"], + "finding": "Net ranges are arithmetically misreported and W1 emits 48,661 lines without an approved split or external raw-artifact treatment." + }, + + { + "id": "P0.REQ.W2.001", + "area": "W2", + "requirement": "Separate four provider identities from two execution backend families.", + "status": "satisfied", + "evidenceStrength": "authoritative", + "evidenceRefs": ["W2:P0.W2.EXECUTION_TOPOLOGY", "R12:artifact dispositions"], + "finding": "Reciprocal review approved the topology evidence at source_observed proof level." + }, + { + "id": "P0.REQ.W2.002", + "area": "W2", + "requirement": "Map bootstrap, delivery, task, heartbeat and permission operations by direction, caller, authority, idempotency, body IDs, evidence and current route.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W2:P0.W2.RUNTIME_INGRESS_INVENTORY", "R12:R12-W2-002"], + "finding": "Five useful rows exist, but nested acceptance fields are not schema-enforced and can be deleted without failing the gate." + }, + { + "id": "P0.REQ.W2.003", + "area": "W2", + "requirement": "Discover and classify every provider child environment key by provenance and required/optional/forbidden policy.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W2-001"], + "finding": "The scanner only checks prelisted keys; at least ten explicit source inputs are omitted and an omitted key still passes." + }, + { + "id": "P0.REQ.W2.004", + "area": "W2", + "requirement": "Prove browser-session and runtime-ingress authority are disjoint in the proposed mapping.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W2:P0.W2.RUNTIME_INGRESS_INVENTORY", "W2_HANDOFF"], + "finding": "The proposed ADR-14 mapping is explicit, but no /api/hosted/v1, /api/runtime/v1, relay or lane credential exists; current callbacks remain mixed under /api/teams." + }, + { + "id": "P0.REQ.W2.005", + "area": "W2", + "requirement": "Make Anthropic/Codex/Gemini assumptions and OpenCode differences explicit.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W2:P0.W2.EXECUTION_TOPOLOGY", "W2_HANDOFF"], + "finding": "Provider assumptions and OpenCode-specific processes, journals and routes are explicit and labeled source-observed." + }, + { + "id": "P0.REQ.W2.006", + "area": "W2", + "requirement": "Provide a complete positive/negative fake-runtime provider matrix.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W2-004"], + "finding": "Unsupported backend and malformed capability response cases are absent; future hosted cases are collapsed into one gap row." + }, + { + "id": "P0.REQ.W2.007", + "area": "W2", + "requirement": "Use artifact-specific schemas and deliberate negative fixtures for every W2 evidence family.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W2-002"], + "finding": "The generic schema requires little beyond envelope IDs, and the only negative fixture removes/duplicates a route." + }, + { + "id": "P0.REQ.W2.008", + "area": "W2", + "requirement": "Contribute a reconcilable estimate to the canonical unique bucket model.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W2-003", "ESTIMATE_LEDGER"], + "finding": "W2 invents three top-level IDs instead of mapping low/high ranges and W4 overlap into EST-LIFECYCLE-RUNTIME." + }, + + { + "id": "P0.REQ.W3.001", + "area": "W3", + "requirement": "Catalog every state family with authority, writers, schema/version, atomicity, corruption policy and backup role.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W3:P0.W3.STATE_FAMILY_CATALOG", "R35:artifact dispositions"], + "finding": "The 17-family catalog was accepted as useful at source_observed proof level." + }, + { + "id": "P0.REQ.W3.002", + "area": "W3", + "requirement": "Assign each required mutation an honest writer coordination class.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W3:P0.W3.WRITER_COORDINATION", "R35:cross-lane reconciliation"], + "finding": "Twelve operations are classified; unresolved external writes fail closed as quiescent-only/unavailable and control W5 assumptions." + }, + { + "id": "P0.REQ.W3.003", + "area": "W3", + "requirement": "Prove an app-only lock does not coordinate an external writer.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W3:P0.W3.WRITER_COORDINATION", "R35"], + "finding": "The marker-owned child-process fixture deterministically loses the external update." + }, + { + "id": "P0.REQ.W3.004", + "area": "W3", + "requirement": "Fault-characterize TeamBackupService async/sync enumeration, readiness, error swallowing, prune, identity and restore behavior.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R35:RW35-001"], + "finding": "The behavior file records source deductions only; production TeamBackupService is never constructed or fault-injected." + }, + { + "id": "P0.REQ.W3.005", + "area": "W3", + "requirement": "Run supported SQLite Online Backup under WAL, reopen independently and fail closed on BUSY/corruption without raw copy.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W3:P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", "R35:artifact dispositions"], + "finding": "The fixture proves live WAL, 2,000-row independent reopen/integrity and injected BUSY/corruption/deadline failure with no DB/WAL/SHM fallback." + }, + { + "id": "P0.REQ.W3.006", + "area": "W3", + "requirement": "Prove the SQLite backup operation in the final Node-ABI artifact and deployment recovery topology.", + "status": "missing", + "evidenceStrength": "missing", + "evidenceRefs": ["W3_HANDOFF", "W6_HANDOFF"], + "finding": "Only current-host Node ABI 137 is exercised; the production async worker wire op, final image load and deployment quiescence are unverified." + }, + { + "id": "P0.REQ.W3.007", + "area": "W3", + "requirement": "Provide a controller-reconcilable W3 estimate without double-counting W5.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W3:P0.W3.ESTIMATE", "R35:cross-lane reconciliation"], + "finding": "The 4.5k-7.25k decomposition is useful, but R35 says it overlaps W5 and cannot be added until controller reconciliation." + }, + + { + "id": "P0.REQ.W4.001", + "area": "W4", + "requirement": "Declare the supported final Linux/container/filesystem envelope and run probes in it.", + "status": "missing", + "evidenceStrength": "missing", + "evidenceRefs": ["W4:P0.W4.TARGET_HOST_ENVELOPE", "R46"], + "finding": "The envelope is documented, but the observed root Ubuntu host is not the final Debian-slim non-root/init/seccomp/volume topology and Docker access was denied." + }, + { + "id": "P0.REQ.W4.002", + "area": "W4", + "requirement": "Prove only one of two final-image containers/manual starts reaches Node/effect code on one supported volume.", + "status": "missing", + "evidenceStrength": "missing", + "evidenceRefs": ["W4_HANDOFF", "R46"], + "finding": "Only current-host contenders were exercised; the mandatory two-container/manual-contender proof did not run." + }, + { + "id": "P0.REQ.W4.003", + "area": "W4", + "requirement": "Prove paused/killed owners, duplicate close ordering and anchor replacement cannot overlap.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W4:P0.W4.INSTANCE_LEASE_SPIKE", "R46:R46-04"], + "finding": "Current-host cases pass, but cleanup/zero-residual evidence is hard-coded before ignored cleanup and no final topology ran." + }, + { + "id": "P0.REQ.W4.004", + "area": "W4", + "requirement": "Prove descriptor-bound workspace effects and zero outside-marker effects for symlink, rename, bind-mount and stale-generation races.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W4:P0.W4.WORKSPACE_GUARD_SPIKE", "R46:R46-04", "R46:R46-05"], + "finding": "Useful current-host races and raw Node/Git controls exist, but cleanup is overstated, descriptors above 1023 are not closed, and final seccomp/image proof is absent." + }, + { + "id": "P0.REQ.W4.005", + "area": "W4", + "requirement": "Prove nonce-ready pidfd/subreaper process ownership, double-fork reap, escalation, EOF and typed drained/unclassified outcomes without numeric-PID fallback.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R46:R46-03", "R46:R46-04"], + "finding": "Only the main child uses pidfd; descendants are later signaled through reusable numeric PIDs, and residual cleanup claims are not measured." + }, + { + "id": "P0.REQ.W4.006", + "area": "W4", + "requirement": "Prove lease/control descriptors do not leak into provider, Git or helper descendants.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R46:R46-05"], + "finding": "The fixture closes only file descriptors 3-1023, so a canary duplicated above 1023 can survive." + }, + { + "id": "P0.REQ.W4.007", + "area": "W4", + "requirement": "Define reproducible native artifacts/manifests and reconcile them with the hosted image inventory.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["W4:P0.W4.NATIVE_ARTIFACT_PROPOSAL", "R46:R46-06"], + "finding": "A proposal exists, but W6 has no manifest rows/probes for the three W4 binaries, protocol hashes, ordering, image placement or syscall readiness." + }, + { + "id": "P0.REQ.W4.008", + "area": "W4", + "requirement": "Keep a single adoption below 1,500 lines or record an approved split.", + "status": "partial", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W4_HANDOFF", "R46"], + "finding": "W4 correctly reports 1,916 lines and requests a three-part split, but no controller approval/adoption record exists." + }, + + { + "id": "P0.REQ.W5.001", + "area": "W5", + "requirement": "Pause before/after read, commit, cursor, serialization, listener, replay, compensation and event publication as real crash/resume transitions.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R35:RW35-002"], + "finding": "Commit, compensation and event-publication labels are appended to traces without real scheduled crashes or durable restart transitions." + }, + { + "id": "P0.REQ.W5.002", + "area": "W5", + "requirement": "Reproduce cursor-after-read and query-then-listen lost-event schedules.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W5:P0.W5.SNAPSHOT_HANDOFF_SCHEDULER", "R35:artifact dispositions"], + "finding": "Both deliberate negative algorithms lose the injected committed event." + }, + { + "id": "P0.REQ.W5.003", + "area": "W5", + "requirement": "Prove accepted SQLite and external-file projection designs converge with duplicates but no gaps across exhaustive schedules.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R35:RW35-002"], + "finding": "The claimed 20 schedules are not exhaustive over required commit boundaries, so convergence is not established." + }, + { + "id": "P0.REQ.W5.004", + "area": "W5", + "requirement": "Give every independently censused required mutation one versioned normalized intent/fingerprint and effect class.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R35:RW35-005"], + "finding": "The required mutation census is derived from the generated catalog, so deleting a descriptor also deletes the requirement and the gate remains green." + }, + { + "id": "P0.REQ.W5.005", + "area": "W5", + "requirement": "Default unproven/ambiguous external effects to operator_required and never automatic replay.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R35:RW35-003"], + "finding": "Several task/inbox effects are marked idempotent_by_operation_id despite W3 proving writer coordination and durable lookup unproven." + }, + { + "id": "P0.REQ.W5.006", + "area": "W5", + "requirement": "Cover schema, fingerprint and key version changes, omitted/default semantics and retained comparison in golden vectors.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R35:RW35-004"], + "finding": "Fingerprint-version and omitted-versus-materialized-default vectors are missing." + }, + { + "id": "P0.REQ.W5.007", + "area": "W5", + "requirement": "Avoid storing sensitive command bodies and provide a deduplicated estimate input.", + "status": "partial", + "evidenceStrength": "corroborated", + "evidenceRefs": ["W5_HANDOFF", "R35"], + "finding": "No sensitive body/secret issue was found, and overlap is acknowledged, but the rejected recovery evidence cannot support the range until corrected and reconciled with W3." + }, + + { + "id": "P0.REQ.W6.001", + "area": "W6", + "requirement": "Cover restart-safe pairing, device/session renewal/rotation/revoke/reset, expiry, response loss, two tabs, replay and keyring failure.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R46:R46-01"], + "finding": "Restart re-enables mutation after logout, forget-device and replay-family revocation." + }, + { + "id": "P0.REQ.W6.002", + "area": "W6", + "requirement": "Fence reset/challenge issuance on generation-bound typed process drain evidence.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R46:R46-02"], + "finding": "The model trusts a caller-provided runtimeCount=0 and cannot distinguish drained from unclassified/stale anchor state." + }, + { + "id": "P0.REQ.W6.003", + "area": "W6", + "requirement": "Reject direct HTTP, forwarded spoofing, wildcard CORS and sibling authority before cookie/body/idempotency work.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W6:P0.W6.PROXY_ORIGIN_THREAT_MATRIX", "W6_HANDOFF"], + "finding": "The fixture ordering is useful, but loopback/HTTPS edge execution was denied and no final trusted CIDR/private-listener topology exists." + }, + { + "id": "P0.REQ.W6.004", + "area": "W6", + "requirement": "Keep session authority server-side and avoid durable plaintext recovery credentials.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W6:P0.W6.AUTH_TRANSITIONS", "R46:R46-01", "R46:R46-02"], + "finding": "The model stores symbolic/server records only, but its admission and reset transitions are unsafe and real SQLite/keyring crash behavior is unverified." + }, + { + "id": "P0.REQ.W6.005", + "area": "W6", + "requirement": "Prove exact emitted hosted artifact, worker needs, W4 native artifacts, ABI split and missing/empty stub risks.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["W6:P0.W6.ARTIFACT_INVENTORY", "R46:R46-06", "R46:R46-07"], + "finding": "The current build omits internal-storage-worker, retains catch-all stubs and lacks W4 artifact rows; ABI smoke claims have no owned reproducible command/assertion." + }, + { + "id": "P0.REQ.W6.006", + "area": "W6", + "requirement": "Prove terminal daemon/gateway/SDK/routes/migrations/artifacts are absent from the v1 hosted image.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["W6:P0.W6.TERMINAL_ABSENCE_REPORT", "W6_HANDOFF"], + "finding": "Routes and terminal-named migrations are absent, but terminal packages are copied and PtyTerminalService is emitted; no final allowlisted image exists." + }, + { + "id": "P0.REQ.W6.007", + "area": "W6", + "requirement": "Verify current Fastify-5 cookie compatibility from authoritative package metadata before dependency adoption.", + "status": "missing", + "evidenceStrength": "missing", + "evidenceRefs": ["W6_HANDOFF"], + "finding": "@fastify/cookie is absent and the plan-time 11.0.2 reference was not officially rechecked." + }, + { + "id": "P0.REQ.W6.008", + "area": "W6", + "requirement": "Provide a canonical EST-HOSTED-OPS estimate supported by accepted evidence.", + "status": "partial", + "evidenceStrength": "weak", + "evidenceRefs": ["W6:P0.W6.ESTIMATE", "R46"], + "finding": "The bucket ID is canonical and overlaps are explicit, but critical auth/artifact assumptions are falsified or missing." + }, + + { + "id": "P0.REQ.REVIEW.001", + "area": "review", + "requirement": "Complete reciprocal W1/W2 review and resolve findings before adoption.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["REGISTRY_SNAPSHOT", "R12"], + "finding": "Review completed and rejected both W1 and W2 with ten actionable findings; no correction or re-review exists." + }, + { + "id": "P0.REQ.REVIEW.002", + "area": "review", + "requirement": "Complete reciprocal W3/W5 review and resolve findings before adoption.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["REGISTRY_SNAPSHOT", "R35"], + "finding": "Review completed and rejected both W3 and W5 with six findings; no correction or re-review exists." + }, + { + "id": "P0.REQ.REVIEW.003", + "area": "review", + "requirement": "Complete reciprocal W4/W6 review and resolve findings before adoption.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["REGISTRY_SNAPSHOT", "R46"], + "finding": "Review completed and rejected both W4 and W6 and paired integration with eight findings; no correction or re-review exists." + }, + { + "id": "P0.REQ.REVIEW.004", + "area": "review", + "requirement": "Use self-review diff checks that inspect all untracked producer additions.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W2-005", "R35:RW35-006", "R46:R46-08"], + "finding": "Plain git diff --check was vacuous for W2-W6 all-untracked additions; reviewers compensated with clean no-index checks. W1 alone recorded the extra no-index coverage." + }, + + { + "id": "P0.REQ.0D.001", + "area": "0D", + "requirement": "Adopt all six reviewed lanes or explicitly reject each through the controller decision register.", + "status": "pending", + "evidenceStrength": "missing", + "evidenceRefs": ["DECISION_REGISTER", "R12", "R35", "R46"], + "finding": "Reviews reject every lane, but the controller-owned decision register still contains only 0A decisions and no contested/rejection outcomes." + }, + { + "id": "P0.REQ.0D.002", + "area": "0D", + "requirement": "Freeze exact v1 scope, contract/facet conventions, identity authority, provider topology, state/writer classes, environment policy, host primitives, recovery, auth/proxy, artifacts, terminal absence and Phase 1 prerequisites.", + "status": "pending", + "evidenceStrength": "missing", + "evidenceRefs": ["DECISION_REGISTER"], + "finding": "The register explicitly says these lane-dependent outcomes remain unresolved; rejected evidence cannot freeze them." + }, + { + "id": "P0.REQ.0D.003", + "area": "0D", + "requirement": "Regenerate the estimate ledger from all accepted lane inputs using canonical unique buckets and the 28k-45k fail-closed range.", + "status": "pending", + "evidenceStrength": "missing", + "evidenceRefs": ["ESTIMATE_LEDGER", "R12:R12-W1-003", "R12:R12-W2-003", "R35"], + "finding": "The shared ledger is still the pre-inventory baseline; W1 arithmetic is wrong, W2 IDs are noncanonical and W3/W5 overlap is unreconciled. W4 also lacks a controller mapping." + }, + { + "id": "P0.REQ.0D.004", + "area": "0D", + "requirement": "Run every required final-shape native/container/auth/proxy/artifact proof.", + "status": "missing", + "evidenceStrength": "missing", + "evidenceRefs": ["W4_HANDOFF", "W6_HANDOFF", "R46"], + "finding": "No admitted final image, two-container lease proof, final seccomp/init/process probe, HTTPS edge schedule, final ABI load or terminal-negative image scan exists." + }, + { + "id": "P0.REQ.0D.005", + "area": "0D", + "requirement": "Validate every machine-readable evidence file against a schema that covers its acceptance fields and every scanner/spike with positive and deliberate negative fixtures.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W1-001", "R12:R12-W2-002", "R35:RW35-002", "R35:RW35-005", "R46"], + "finding": "Multiple gates are structural, shallow, circular or record labels without exercising the claimed boundary." + }, + { + "id": "P0.REQ.0D.006", + "area": "0D", + "requirement": "Ensure every state/writer/provider/action row has one correct disposition and owner.", + "status": "failed", + "evidenceStrength": "authoritative", + "evidenceRefs": ["R12:R12-W1-001", "R12:R12-X-001", "R12:R12-W2-001", "R35:RW35-003"], + "finding": "Action ownership is heuristic/wrong, environment rows are incomplete and recovery classes contradict writer authority." + }, + { + "id": "P0.REQ.0D.007", + "area": "0D", + "requirement": "Run the Phase 0 targeted combined checks and required broad repository gates after adoption.", + "status": "pending", + "evidenceStrength": "missing", + "evidenceRefs": ["PACKET:0D.3", "BASELINE"], + "finding": "No adoption exists and this audit was forbidden from running broad/final gates; the only broad evidence is the incomplete/red 0A baseline." + }, + { + "id": "P0.REQ.0D.008", + "area": "0D", + "requirement": "Produce a concise Phase 0 completion report with residual risks.", + "status": "pending", + "evidenceStrength": "missing", + "evidenceRefs": [], + "finding": "No controller completion report exists; this document is an acceptance audit and explicitly rejects completion." + }, + { + "id": "P0.REQ.0D.009", + "area": "0D", + "requirement": "Generate a Phase 1 JIT packet only from frozen decisions and the current integration SHA.", + "status": "pending", + "evidenceStrength": "missing", + "evidenceRefs": ["PACKET:Definition of Done"], + "finding": "No valid Phase 1 packet can be generated while all lane adoptions and freeze decisions are unresolved." + }, + { + "id": "P0.REQ.0D.010", + "area": "0D", + "requirement": "Do not prestart Phase 1 workers.", + "status": "satisfied", + "evidenceStrength": "corroborated", + "evidenceRefs": ["REGISTRY_SNAPSHOT", "R12", "R35", "R46"], + "finding": "The audited registry scope contains Phase 0 producers/reviewers only and all reviews report no Phase 1 implementation." + } + ] +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json b/docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json new file mode 100644 index 0000000000..1a8f30aa5b --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "recordType": "w6-estimate-input", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "bucketId": "EST-HOSTED-OPS", + "artifactEvolutionAssumption": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet.", + "packages": [ + "hosted-access", + "hosted composition/build", + "internal-storage worker packaging", + "Docker/edge fixtures" + ], + "lines": { + "production": { "low": 2200, "high": 3300 }, + "test": { "low": 1000, "high": 1600 }, + "deleted": { "low": 100, "high": 250 } + }, + "excludedGeneratedVendor": [ + "generated bundles and manifests", + "vendor terminal SDK archives", + "W4 host primitive implementation", + "W2 runtime ingress", + "controller-owned generic RouteCatalog work" + ], + "overlap": "Auth/proxy and build fixtures are counted once in W6; W2 owns runtime ingress and W4 owns lease/process probes.", + "confidence": "medium", + "assumptions": [ + "one TLS edge topology", + "one active device family", + "no OIDC/passkey in v1", + "no hosted terminal in v1", + "the exact canonical artifact remains rejected; artifact evolution is unproved and any resulting candidate requires a separately reviewed packet" + ], + "evidenceRefs": [ + "P0.W6.AUTH_TRANSITIONS", + "P0.W6.PROXY_ORIGIN_THREAT_MATRIX", + "P0.W6.ARTIFACT_INVENTORY", + "P0.W6.ABI_STUB_REPORT", + "P0.W6.TERMINAL_ABSENCE_REPORT" + ] +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/evidence.json b/docs/research/hosted-web/phase-0/auth-artifacts/evidence.json new file mode 100644 index 0000000000..821900e8e3 --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/evidence.json @@ -0,0 +1,388 @@ +{ + "schemaVersion": 1, + "recordType": "w6-evidence", + "phaseId": "phase-00", + "laneId": "w6", + "packetRevision": "phase-00-r3", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "artifactAuthority": { + "artifactEvolutionAssumption": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet.", + "proofLevels": { + "P0.W6.ARTIFACT_INVENTORY": "targeted_current_canonical_plus_input_patch_build_observed", + "P0.W6.TERMINAL_ABSENCE_REPORT": "targeted_current_canonical_plus_input_patch_build_observed" + } + }, + "evidence": [ + { + "id": "P0.W6.AUTH_TRANSITIONS", + "owner": "w6", + "proofLevel": "fixture_characterized", + "status": "characterized_contract_only_no_remote_auth_or_mutation_admission", + "requirementIds": ["P0.REQ.W6.001", "P0.REQ.W6.002", "P0.REQ.W6.004"], + "reviewFindingIds": ["R46-01", "R46-02", "P0.C1.DRAIN.001.W6.CONSUME"], + "assertions": [ + "AUTH.RESTART.REVOCATION", + "AUTH.DRAIN.TYPED", + "AUTH.DRAIN.STALE", + "AUTH.DRAIN.UNCLASSIFIED", + "AUTH.COOKIE.TRANSITIONS" + ], + "reproduction": [ + "pnpm exec vitest run test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts" + ], + "facts": { + "authority": "server-side keyed-hash rows only; cookies are opaque selectors", + "records": [ + "PairingChallenge", + "OperatorDeviceGrant", + "OperatorSession", + "AuthResetIntent" + ], + "schedules": [ + "pairing consumes the challenge and creates one durable device family plus a short session", + "ordinary restart reloads the device/session family and creates no new challenge", + "idle and absolute session expiry renew through the durable device and rotate forward", + "lost renewal response accepts one bounded predecessor and never restores an older generation", + "two-tab contention converges through the current shared cookie or bounded predecessor grace", + "predecessor replay outside grace revokes the device family and all sessions", + "logout revokes one server session; forget-device revokes the family; neither can regain mutation admission on restart", + "missing/corrupt keyring with auth rows closes readiness and cannot issue a challenge", + "initial pairing and host reset directly consume the controller-owned P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1 ready/drained objects with no W6-owned authority wrapper; purpose, resetGeneration, deploymentGeneration and processAnchorGeneration are equality-fenced, and envelope/path/hash drift, mismatches, residuals, malformed shapes or unclassified outcomes fail closed", + "while resetIntent exists, bootstrap, pair and renew reject with reset_in_progress and every other transition preserves mutationAdmission=false", + "reset revokes old authority before activating the replacement key and issuing a challenge" + ], + "cookieTransitions": "Pair sets both opaque cookies, renew rotates both, logout clears only __Host-ats and forget-device clears both with the exact Secure/HttpOnly/SameSite/Path/no-Domain contract.", + "secretHandling": "The executable model uses symbolic record references and emits no credential values." + }, + "uncertainty": [ + "Exact idle, absolute, rotation and predecessor-grace durations are not frozen.", + "SQLite/keyring fsync crash points and a real multi-tab HTTPS browser schedule remain target verification." + ] + }, + { + "id": "P0.W6.PROXY_ORIGIN_THREAT_MATRIX", + "owner": "w6", + "proofLevel": "fixture_characterized", + "status": "characterized_negative_contract_not_remote_proxy_readiness", + "requirementIds": ["P0.REQ.W6.003"], + "reviewFindingIds": [], + "assertions": ["PROXY.ORDERING.NEGATIVE", "PROXY.BROWSER.RUNTIME.DISJOINT"], + "reproduction": [ + "pnpm exec vitest run test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts" + ], + "facts": { + "authoritySource": "one normalized immutable HTTPS PUBLIC_ORIGIN plus exact trusted proxy peers/CIDRs", + "ordering": "transport -> authority -> Origin -> session/CSRF -> body -> idempotency", + "cases": [ + { + "case": "trusted edge, exact forwarded proto/host and Origin", + "result": "accept_to_auth", + "stage": "auth_next" + }, + { "case": "direct production HTTP", "result": "reject", "stage": "transport" }, + { + "case": "forwarding headers from an untrusted peer", + "result": "reject", + "stage": "transport" + }, + { + "case": "ambiguous comma-separated forwarding values", + "result": "reject", + "stage": "transport" + }, + { "case": "wildcard CORS", "result": "reject", "stage": "readiness" }, + { + "case": "dedicated hostname on a sibling port/authority", + "result": "reject", + "stage": "authority" + }, + { "case": "cross-origin browser request", "result": "reject", "stage": "origin" }, + { + "case": "missing Origin on browser auth/mutation", + "result": "reject", + "stage": "origin" + } + ], + "negativeInvariant": "Every rejected fixture reports cookieLookup=false, bodyParsed=false and idempotencyClaimed=false.", + "ownershipBoundary": "W6 owns browser PUBLIC_ORIGIN/cookie/CSRF policy; W2 owns machine runtime ingress. A runtime-surface request is rejected before browser cookie lookup." + }, + "uncertainty": [ + "Accepted production proxy CIDRs, edge product, private listener and direct-TLS-vs-edge termination are deployment inputs.", + "The sandbox denied TCP listen (EPERM), so no live edge/container request is relabeled target_verified." + ] + }, + { + "id": "P0.W6.COOKIE_VERSION_EVIDENCE", + "owner": "w6", + "proofLevel": "source_observed", + "status": "fail_closed_no_dependency_adoption", + "requirementIds": ["P0.REQ.W6.007"], + "reviewFindingIds": [], + "assertions": ["COOKIE.DEPENDENCY.ABSENT", "COOKIE.SHAPE.PRELOOKUP"], + "reproduction": ["node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs"], + "facts": { + "installed": [ + { + "package": "fastify", + "version": "5.10.0", + "source": "installed package metadata and pnpm lock" + }, + { + "package": "@fastify/cors", + "version": "11.2.0", + "source": "installed package metadata and pnpm lock" + } + ], + "candidate": { + "package": "@fastify/cookie", + "version": "11.0.2", + "source": "ADR-7 plan-time official package reference", + "installed": false, + "gate": "No package is approved or installed from this evidence. Network-restricted remediation could not query current npm metadata; dependency adoption remains mechanically blocked until an authorized run captures the official dist-tag and Fastify-5 compatibility, pins it exactly, and runs parser integration tests." + }, + "cookies": [ + { + "name": "__Secure-atd", + "purpose": "durable device selector", + "attributes": "Secure; HttpOnly; SameSite=Strict; no Domain; renew-route Path" + }, + { + "name": "__Host-ats", + "purpose": "short session selector", + "attributes": "Secure; HttpOnly; SameSite=Strict; Path=/; no Domain" + } + ], + "forbidden": [ + "plugin-signed stateless session authority", + "cookie identity/role/state", + "body/header/query credential alternatives" + ], + "prelookupFixtures": [ + "malformed parser result", + "duplicate authority cookie", + "oversized header" + ] + }, + "uncertainty": [ + "Current online package metadata remains unavailable because network access is restricted; this is recorded as a fail-closed dependency-adoption gate, not as a compatibility claim." + ] + }, + { + "id": "P0.W6.ARTIFACT_INVENTORY", + "owner": "w6", + "proofLevel": "targeted_current_canonical_plus_input_patch_build_observed", + "status": "current_standalone_rejected_for_hosted_v1", + "requirementIds": ["P0.REQ.W6.005"], + "reviewFindingIds": ["R46-06", "P0-FREEZE-W46-C1-001", "P0.C1.W6.001", "P0-FREEZE-W6-E1-001"], + "assertions": [ + "ARTIFACT.CONTRACT.W4_ROWS", + "ARTIFACT.CONTRACT.FAIL_CLOSED", + "ARTIFACT.CONTRACT.OMISSION_NEGATIVES" + ], + "reproduction": ["node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs"], + "facts": { + "buildCommand": "pnpm exec vite build --config docker/vite.standalone.config.ts --outDir --emptyOutDir", + "buildResult": "the targeted build from canonical source commit f09ad2af137c02bf4e660d5cce398a1acdbc73d3 plus exact input patch SHA-256 d336045cf53e814d067280b5a32c9f55f07c7e19685ab6e16d692703b92aa673 emitted 13 CJS files; the verifier rebuilds the materialized source and compares every relative path, byte count and SHA-256 hash to the committed canonical-plus-patch authority", + "topology": [ + { + "artifact": "standalone server", + "source": "src/main/standalone.ts", + "target": "dist-standalone/index.cjs", + "state": "emitted" + }, + { + "artifact": "renderer", + "source": "Electron renderer build", + "target": "out/renderer", + "state": "required by Docker; not rebuilt by the targeted server-only command" + }, + { + "artifact": "internal-storage worker", + "source": "internalStorageWorkerEntry.ts declared by docker/vite.standalone.config.ts", + "target": "dist-standalone/assets/internal-storage-worker.cjs", + "state": "emitted" + }, + { + "artifact": "production modules", + "source": "pnpm --prod tree", + "target": "image /app/node_modules", + "state": "copied wholesale, not manifest-pruned" + }, + { + "artifact": "controller helper", + "source": "agent-teams-controller directory", + "target": "image /app/agent-teams-controller", + "state": "copied without a W6-owned startup manifest/probe" + } + ], + "characterizationAuthority": { + "authorityPath": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "authorityRecordType": "w6-current-canonical-plus-input-patch-artifact-scan", + "authoritySha256": "c9e1ff1a38349c0fb0b07ff39f6702b265b47b4552356c700909de1db7d5af8f", + "disposition": "rejected_for_hosted_v1" + }, + "emittedManifest": "observed-artifact-scan.json is the sole exact canonical-plus-input-patch standalone characterization authority. historical-rejected-candidate-artifact-scan.json preserves the rejected integration candidate as historical provenance only; all current W6 records carry the current authority's checked semantic-hash projection.", + "artifactContract": "The Phase 0 controller owns one schema/path/hash source at docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json. W4 and W6 carry byte-equivalent read-only projections using artifactId, finalImagePath and binarySha256. Missing, extra, renamed, stale-path and stale-hash projections fail closed.", + "r3Narrowing": "The current standalone artifact is rejected for hosted v1. This record makes no remote-auth, remote-mutation or production-composition readiness claim." + }, + "uncertainty": [ + "The full standalone:build alias was intentionally not run because it also performs the broad Electron build.", + "No final hosted image/profile, pinned builder/compiler identity, final UID/GID/mode, binary hashes, executable-bit inventory, two-build comparison or startup/seccomp/load probe exists yet." + ] + }, + { + "id": "P0.W6.ABI_STUB_REPORT", + "owner": "w6", + "proofLevel": "fixture_characterized", + "status": "current_host_characterization_only_no_production_abi_claim", + "requirementIds": ["P0.REQ.W6.005"], + "reviewFindingIds": ["R46-07"], + "assertions": ["ABI.NODE.SQLITE.REOPEN", "ABI.ELECTRON.LOOKUP"], + "reproduction": ["node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs"], + "facts": { + "abis": [ + { + "runtime": "Node 24.16.0", + "moduleAbi": "137", + "napi": "10", + "source": "safe Linux process.versions probe" + }, + { + "runtime": "Electron 41.10.5", + "moduleAbi": "145", + "source": "locked node-abi 3.94.0 and 4.26.0 lookup" + } + ], + "nativePackages": [ + { + "package": "better-sqlite3", + "version": "12.11.1", + "use": "production internal-storage worker", + "nodeSmoke": "Owned probe writes, reads, closes and reopens SQLite 3.53.2 under Node ABI 137", + "risk": "the emitted standalone worker has no final-image native load proof; Electron and Node ABI resolution is not split" + }, + { + "package": "better-sqlite3-node", + "version": "12.10.1", + "use": "Node-ABI test alias only", + "nodeSmoke": "Owned probe writes, reads, closes and reopens SQLite 3.53.2 under Node ABI 137", + "risk": "not used by production worker" + } + ], + "stubs": [ + { + "kind": "native catch-all", + "behavior": "every .node import becomes export default {}", + "result": "forbidden for canonical hosted artifact" + }, + { + "kind": "Electron catch-all", + "behavior": "electron/electron-updater imports become broad fake exports", + "result": "fake safeStorage code observed in emitted standalone chunks" + }, + { + "kind": "optional internal storage", + "behavior": "missing worker silently selects JSON stores", + "result": "not valid when SQLite is required by hosted composition" + }, + { + "kind": "terminal-platform-node install-time stub", + "behavior": "client surface exists but native operations throw unavailable", + "result": "copied via the whole production dependency tree and forbidden from v1" + }, + { + "kind": "standalone updater/SSH service stubs", + "behavior": "no-op updater and explicitly unavailable SSH adapter", + "result": "must be omitted from hosted composition/capabilities rather than advertised as functional" + } + ] + }, + "uncertainty": [ + "No final-image native smoke load or Electron-runtime better-sqlite3 load was run." + ] + }, + { + "id": "P0.W6.TERMINAL_ABSENCE_REPORT", + "owner": "w6", + "proofLevel": "targeted_current_canonical_plus_input_patch_build_observed", + "status": "rule_only_current_standalone_rejected_absence_not_achieved", + "requirementIds": ["P0.REQ.W6.006"], + "reviewFindingIds": ["P0-FREEZE-W6-E1-001"], + "assertions": ["TERMINAL.CURRENT.REJECT", "TERMINAL.FINAL.ALL_SURFACES"], + "reproduction": ["node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs"], + "facts": { + "absenceGate": "The scanner rejects any terminal SDK dependency, unpruned production tree, terminal HTTP registration, terminal migration, terminal service marker or terminal-platform marker.", + "current": [ + { + "surface": "terminal daemon/runtime binary under resources/terminal-platform", + "absent": true + }, + { "surface": "HTTP terminal route", "absent": true }, + { "surface": "terminal migration in internal-storage migrations", "absent": true }, + { "surface": "terminal-platform marker in server CJS", "absent": true }, + { "surface": "PtyTerminalService marker in server CJS", "absent": false }, + { + "surface": "terminal SDK/gateway packages in production dependencies copied into image", + "absent": false + } + ], + "finalImageSurfaces": [ + "packages", + "files", + "routes", + "migrations", + "capabilities", + "processes", + "renderer chunks", + "ports", + "volumes" + ], + "verdict": "Current standalone output is rejected as the v1 hosted artifact. Terminal absence remains a rule; it is not claimed achieved." + }, + "uncertainty": [ + "No proposed final v1 image exists to scan target_verified; absence is a fail-closed build prerequisite." + ] + }, + { + "id": "P0.W6.ESTIMATE", + "owner": "w6", + "proofLevel": "source_observed", + "status": "estimate_input", + "requirementIds": ["P0.REQ.W6.008"], + "reviewFindingIds": ["P0-FREEZE-W6-E1-001"], + "assertions": ["ESTIMATE.CANONICAL.ID", "ESTIMATE.OVERLAP.OWNERS"], + "reproduction": ["node scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs"], + "facts": { + "bucketId": "EST-HOSTED-OPS", + "packages": [ + "hosted-access", + "hosted composition/build", + "internal-storage worker packaging", + "Docker/edge fixtures" + ], + "productionLines": { "low": 2200, "high": 3300 }, + "testLines": { "low": 1000, "high": 1600 }, + "deletedLines": { "low": 100, "high": 250 }, + "excluded": [ + "generated bundles", + "vendor terminal SDK", + "W4 host primitive implementation", + "generic RouteCatalog/controller work" + ], + "overlap": "Auth/proxy fixtures and build fixtures counted here once; provider/runtime ingress belongs to W2 and host lease/process probes to W4.", + "confidence": "medium", + "artifactEvolutionAssumption": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet.", + "assumptions": [ + "one edge topology", + "one active device family", + "no OIDC/passkey", + "no hosted terminal", + "the exact canonical artifact is rejected; artifact evolution is unproved and any resulting candidate requires a separately reviewed packet" + ] + }, + "uncertainty": [ + "Proxy product choice and final image/helper manifest can move this bucket; >20% variation requires controller scope review." + ] + } + ] +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json b/docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json new file mode 100644 index 0000000000..a9c7debe78 --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json @@ -0,0 +1,372 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://agent-teams-ai.invalid/schemas/phase-00-w6-evidence.json", + "oneOf": [ + { + "type": "object", + "required": [ + "schemaVersion", + "recordType", + "phaseId", + "laneId", + "packetRevision", + "phaseStartSha", + "artifactAuthority", + "evidence" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "recordType": { "const": "w6-evidence" }, + "phaseId": { "const": "phase-00" }, + "laneId": { "const": "w6" }, + "packetRevision": { "const": "phase-00-r3" }, + "phaseStartSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "artifactAuthority": { + "type": "object", + "required": ["artifactEvolutionAssumption", "proofLevels"], + "properties": { + "artifactEvolutionAssumption": { + "const": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet." + }, + "proofLevels": { + "type": "object", + "required": ["P0.W6.ARTIFACT_INVENTORY", "P0.W6.TERMINAL_ABSENCE_REPORT"], + "properties": { + "P0.W6.ARTIFACT_INVENTORY": { + "const": "targeted_current_canonical_plus_input_patch_build_observed" + }, + "P0.W6.TERMINAL_ABSENCE_REPORT": { + "const": "targeted_current_canonical_plus_input_patch_build_observed" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "evidence": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "items": { + "type": "object", + "required": [ + "id", + "owner", + "proofLevel", + "status", + "requirementIds", + "reviewFindingIds", + "assertions", + "reproduction", + "facts", + "uncertainty" + ], + "properties": { + "id": { "type": "string", "pattern": "^P0\\.W6\\." }, + "owner": { "const": "w6" }, + "proofLevel": { + "enum": [ + "source_observed", + "fixture_characterized", + "targeted_current_canonical_plus_input_patch_build_observed", + "unverified" + ] + }, + "status": { "type": "string", "minLength": 1 }, + "requirementIds": { "type": "array", "minItems": 1 }, + "reviewFindingIds": { "type": "array" }, + "assertions": { "type": "array", "minItems": 1 }, + "reproduction": { "type": "array", "minItems": 1 }, + "facts": { "type": "object" }, + "uncertainty": { "type": "array" } + }, + "additionalProperties": false + } + } + }, + "allOf": [ + { + "properties": { + "evidence": { + "contains": { + "type": "object", + "required": ["id", "proofLevel"], + "properties": { + "id": { "const": "P0.W6.ARTIFACT_INVENTORY" }, + "proofLevel": { + "const": "targeted_current_canonical_plus_input_patch_build_observed" + } + } + } + } + } + }, + { + "properties": { + "evidence": { + "contains": { + "type": "object", + "required": ["id", "proofLevel"], + "properties": { + "id": { "const": "P0.W6.TERMINAL_ABSENCE_REPORT" }, + "proofLevel": { + "const": "targeted_current_canonical_plus_input_patch_build_observed" + } + } + } + } + } + }, + { + "properties": { + "evidence": { + "contains": { + "type": "object", + "required": ["id", "facts"], + "properties": { + "id": { "const": "P0.W6.ESTIMATE" }, + "facts": { + "type": "object", + "required": ["artifactEvolutionAssumption"], + "properties": { + "artifactEvolutionAssumption": { + "const": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet." + } + } + } + } + } + } + } + } + ], + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "schemaVersion", + "recordType", + "phaseStartSha", + "canonicalSourceCommit", + "inputPatchSha256", + "proofLevel", + "characterizationScope", + "build", + "historicalProvenance", + "source", + "emitted", + "terminalAbsence" + ], + "properties": { + "schemaVersion": { "const": 3 }, + "recordType": { "const": "w6-current-canonical-plus-input-patch-artifact-scan" }, + "phaseStartSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "canonicalSourceCommit": { + "const": "f09ad2af137c02bf4e660d5cce398a1acdbc73d3" + }, + "inputPatchSha256": { + "const": "d336045cf53e814d067280b5a32c9f55f07c7e19685ab6e16d692703b92aa673" + }, + "proofLevel": { + "const": "targeted_current_canonical_plus_input_patch_build_observed" + }, + "characterizationScope": { + "const": "exact_current_canonical_plus_input_patch_targeted_standalone_build" + }, + "build": { + "type": "object", + "required": ["command", "config", "input", "output", "sourceMaps", "comparison"], + "properties": { + "command": { "type": "string" }, + "config": { "const": "docker/vite.standalone.config.ts" }, + "input": { "const": "src/main/standalone.ts" }, + "output": { "const": "ephemeral_target_directory" }, + "sourceMaps": { "const": true }, + "comparison": { "const": "exact_relative_path_byte_count_and_sha256" } + }, + "additionalProperties": false + }, + "historicalProvenance": { + "type": "object", + "required": ["authorityPath", "authorityRecordType", "relationship"], + "properties": { + "authorityPath": { + "const": "docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json" + }, + "authorityRecordType": { + "const": "w6-historical-rejected-candidate-artifact-scan" + }, + "relationship": { "const": "historical_only_not_current_artifact_authority" } + }, + "additionalProperties": false + }, + "source": { "type": "object" }, + "emitted": { "type": "object" }, + "terminalAbsence": { "type": "object" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "schemaVersion", + "recordType", + "phaseStartSha", + "proofLevel", + "characterizationScope", + "provenance", + "source", + "emitted", + "terminalAbsence" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "recordType": { "const": "w6-historical-rejected-candidate-artifact-scan" }, + "phaseStartSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "proofLevel": { "const": "historical_rejected_candidate_build_observed" }, + "characterizationScope": { + "const": "canonical_integration_candidate_rejected_gate" + }, + "provenance": { + "type": "object", + "required": [ + "integrationAttemptId", + "archivePath", + "producerTaskId", + "canonicalBaseSha", + "disposition" + ] + }, + "source": { "type": "object" }, + "emitted": { "type": "object" }, + "terminalAbsence": { "type": "object" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "schemaVersion", + "recordType", + "phaseStartSha", + "bucketId", + "artifactEvolutionAssumption", + "packages", + "lines", + "excludedGeneratedVendor", + "overlap", + "confidence", + "assumptions", + "evidenceRefs" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "recordType": { "const": "w6-estimate-input" }, + "phaseStartSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "bucketId": { "const": "EST-HOSTED-OPS" }, + "artifactEvolutionAssumption": { + "const": "The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet." + }, + "packages": { "type": "array" }, + "lines": { "type": "object" }, + "excludedGeneratedVendor": { "type": "array" }, + "overlap": { "type": "string" }, + "confidence": { "enum": ["low", "medium", "high"] }, + "assumptions": { "type": "array" }, + "evidenceRefs": { "type": "array" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "schemaVersion", + "recordType", + "phaseStartSha", + "canonicalBaseSha", + "inputPatchSha256", + "proofLevel", + "status", + "controllerContractPath", + "controllerContractSha256", + "artifacts", + "currentStandalone", + "capabilityClaims", + "rejectionReasons" + ], + "properties": { + "schemaVersion": { "const": 2 }, + "recordType": { "const": "w6-standalone-artifact-characterization" }, + "phaseStartSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "canonicalBaseSha": { "const": "f09ad2af137c02bf4e660d5cce398a1acdbc73d3" }, + "inputPatchSha256": { + "const": "d336045cf53e814d067280b5a32c9f55f07c7e19685ab6e16d692703b92aa673" + }, + "proofLevel": { + "const": "targeted_current_canonical_plus_input_patch_build_observed" + }, + "status": { "const": "rejected_for_hosted_v1" }, + "controllerContractPath": { "type": "string" }, + "controllerContractSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "artifacts": { "type": "array", "minItems": 3, "maxItems": 3 }, + "currentStandalone": { + "type": "object", + "required": ["authorityPath", "authorityRecordType", "authoritySha256", "disposition"], + "properties": { + "authorityPath": { + "const": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json" + }, + "authorityRecordType": { + "const": "w6-current-canonical-plus-input-patch-artifact-scan" + }, + "authoritySha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "disposition": { "const": "rejected_for_hosted_v1" } + }, + "additionalProperties": false + }, + "capabilityClaims": { + "type": "object", + "properties": { + "remoteAuthReady": { "const": false }, + "remoteMutationReady": { "const": false }, + "productionCompositionReady": { "const": false }, + "terminalAbsenceAchieved": { "const": false } + }, + "required": [ + "remoteAuthReady", + "remoteMutationReady", + "productionCompositionReady", + "terminalAbsenceAchieved" + ], + "additionalProperties": false + }, + "rejectionReasons": { "type": "array", "minItems": 1 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "schemaVersion", + "recordType", + "phaseStartSha", + "owner", + "reviewFindings", + "requirements", + "ownership" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "recordType": { "const": "w6-finding-resolution" }, + "phaseStartSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "owner": { "const": "w6" }, + "reviewFindings": { "type": "array" }, + "requirements": { "type": "array" }, + "ownership": { "type": "object" } + }, + "additionalProperties": false + } + ] +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json b/docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json new file mode 100644 index 0000000000..25313d125e --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json @@ -0,0 +1,141 @@ +{ + "schemaVersion": 1, + "recordType": "w6-finding-resolution", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "owner": "w6", + "reviewFindings": [ + { + "id": "R46-01", + "status": "resolved", + "assertions": ["AUTH.RESTART.REVOCATION", "AUTH.COOKIE.TRANSITIONS"], + "evidenceId": "P0.W6.AUTH_TRANSITIONS" + }, + { + "id": "R46-02", + "status": "resolved_exact_dto_contract_characterization_only", + "assertions": ["AUTH.DRAIN.TYPED", "AUTH.DRAIN.STALE", "AUTH.DRAIN.UNCLASSIFIED"], + "evidenceId": "P0.W6.AUTH_TRANSITIONS", + "dependencyOwner": "w4" + }, + { + "id": "R46-06", + "status": "resolved_single_controller_authority_current_standalone_rejected", + "assertions": [ + "ARTIFACT.CONTRACT.W4_ROWS", + "ARTIFACT.CONTRACT.FAIL_CLOSED", + "ARTIFACT.CONTRACT.OMISSION_NEGATIVES" + ], + "evidenceId": "P0.W6.ARTIFACT_INVENTORY", + "dependencyOwner": "w4" + }, + { + "id": "R46-07", + "status": "resolved", + "assertions": ["ABI.NODE.SQLITE.REOPEN", "ABI.ELECTRON.LOOKUP"], + "evidenceId": "P0.W6.ABI_STUB_REPORT" + }, + { + "id": "R46-08", + "status": "resolved", + "assertions": ["SELF_REVIEW.UNTRACKED.NO_INDEX"], + "evidenceId": "W6_HANDOFF" + }, + { + "id": "P0-FREEZE-W46-C1-001", + "status": "resolved_exact_current_canonical_plus_input_patch_authority_with_historical_split", + "assertions": [ + "ARTIFACT.CURRENT_COMMIT.AUTHORITY", + "ARTIFACT.HISTORICAL.PROVENANCE.SPLIT", + "ARTIFACT.TARGETED_BUILD.EXACT_COMPARE" + ], + "evidenceId": "P0.W6.ARTIFACT_INVENTORY" + }, + { + "id": "P0.C1.W6.001", + "status": "resolved_evolution_assumption_and_proof_levels_aligned", + "assertions": [ + "ARTIFACT.EVOLUTION.UNPROVED", + "ARTIFACT.PROOF_LEVEL.CURRENT_BUILD", + "TERMINAL.EXCLUSION.NOT_ACHIEVED" + ], + "evidenceId": "P0.W6.ARTIFACT_INVENTORY" + }, + { + "id": "P0.C1.DRAIN.001.W6.CONSUME", + "status": "resolved_direct_controller_envelope_consumption", + "assertions": [ + "AUTH.DRAIN.CONTROLLER_ENVELOPE", + "AUTH.DRAIN.SCHEMA_HASH_PINNED", + "AUTH.DRAIN.NO_W6_AUTHORITY_WRAPPER" + ], + "evidenceId": "P0.W6.AUTH_TRANSITIONS", + "dependencyOwner": "phase-00-controller" + }, + { + "id": "P0-FREEZE-W6-E1-001", + "status": "resolved_all_authority_projections_fail_closed", + "assertions": [ + "ARTIFACT.EVOLUTION.AUTHORITY", + "ARTIFACT.ESTIMATE.PROJECTION", + "ARTIFACT.INVENTORY.PROOF_LEVEL", + "TERMINAL.RULE.PROOF_LEVEL", + "ARTIFACT.PROJECTION.NEGATIVES" + ], + "evidenceId": "P0.W6.ARTIFACT_INVENTORY" + } + ], + "requirements": [ + { "id": "P0.REQ.W6.001", "status": "resolved", "evidenceId": "P0.W6.AUTH_TRANSITIONS" }, + { "id": "P0.REQ.W6.002", "status": "resolved", "evidenceId": "P0.W6.AUTH_TRANSITIONS" }, + { + "id": "P0.REQ.W6.003", + "status": "fixture_resolved_final_https_target_pending", + "evidenceId": "P0.W6.PROXY_ORIGIN_THREAT_MATRIX" + }, + { + "id": "P0.REQ.W6.004", + "status": "fixture_resolved_durable_storage_target_pending", + "evidenceId": "P0.W6.AUTH_TRANSITIONS" + }, + { + "id": "P0.REQ.W6.005", + "status": "controller_contract_projection_only_no_production_composition", + "evidenceId": "P0.W6.ARTIFACT_INVENTORY" + }, + { + "id": "P0.REQ.W6.006", + "status": "terminal_absence_rule_current_standalone_rejected_not_achieved", + "evidenceId": "P0.W6.TERMINAL_ABSENCE_REPORT" + }, + { + "id": "P0.REQ.W6.007", + "status": "fail_closed_no_dependency_adoption", + "evidenceId": "P0.W6.COOKIE_VERSION_EVIDENCE" + }, + { + "id": "P0.REQ.W6.008", + "status": "resolved_input_pending_controller_acceptance", + "evidenceId": "P0.W6.ESTIMATE" + } + ], + "ownership": { + "w6": [ + "auth transition contract characterization", + "PUBLIC_ORIGIN/proxy negative-policy characterization", + "exact current-canonical-plus-input-patch standalone artifact rejection", + "historical rejected-candidate provenance separation", + "terminal-absence rule evaluation" + ], + "w2": ["machine runtime ingress credentials and provider ingress"], + "w4": [ + "current-host native spike characterization", + "typed process-drain DTO definition", + "read-only controller artifact projection" + ], + "controller": [ + "single artifact schema/path/hash authority", + "estimate acceptance", + "repeat reciprocal review" + ] + } +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json b/docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json new file mode 100644 index 0000000000..3f07a4b1d2 --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json @@ -0,0 +1,105 @@ +{ + "schemaVersion": 1, + "recordType": "w6-historical-rejected-candidate-artifact-scan", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "proofLevel": "historical_rejected_candidate_build_observed", + "characterizationScope": "canonical_integration_candidate_rejected_gate", + "provenance": { + "integrationAttemptId": "a8405fd56102c02a0319e197c5b1b892d612616e39e5e871167cdb42798d5767", + "archivePath": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-controller-v2/project-integration/integration-attempts/a8405fd56102c02a0319e197c5b1b892d612616e39e5e871167cdb42798d5767/attempt.json", + "producerTaskId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v6", + "canonicalBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "disposition": "rejected_non_integrable_stale_characterization" + }, + "source": { + "standaloneInput": "src/main/standalone.ts", + "rendererOutput": "out/renderer", + "externalPackages": ["fastify", "@fastify/cors", "@fastify/static", "agent-teams-controller"], + "nativeCatchAllEmptyStub": true, + "broadElectronStub": true, + "standaloneServiceStubs": true, + "terminalNodeInstallStub": true, + "terminalRuntimeArtifactPresent": false, + "standaloneWorkerEntry": false, + "electronWorkerEntry": true, + "internalWorkerRuntimeFilename": "internal-storage-worker.cjs", + "defaultWildcardCors": true, + "directHttpPublished": true, + "productionNodeModulesCopiedWhole": true, + "terminalPackages": [ + "@terminal-platform/design-tokens", + "@terminal-platform/foundation", + "@terminal-platform/runtime-types", + "@terminal-platform/workspace-adapter-websocket", + "@terminal-platform/workspace-contracts", + "@terminal-platform/workspace-core", + "@terminal-platform/workspace-elements", + "@terminal-platform/workspace-gateway-node", + "@terminal-platform/workspace-react", + "terminal-platform-node" + ], + "cookiePlugin": null, + "versions": { + "fastify": "^5.8.5", + "fastifyCors": "^11.2.0", + "betterSqlite3": "^12.11.1", + "electron": "^40.10.0", + "node": ">=24.15.0 <25" + }, + "terminalHttpRegistration": false, + "terminalMigration": false + }, + "emitted": { + "observed": true, + "files": [ + { + "path": "dist-standalone/assets/HttpServer-CgGq0m_Y.cjs", + "bytes": 360000, + "sha256": "d5b478c03fe44a83008264927ab56395e7e533ef85b9a13ef63cd838786ab0b1" + }, + { + "path": "dist-standalone/assets/NotificationManager-B8_AtzLu.cjs", + "bytes": 270694, + "sha256": "88f99fc01f244cc2f8b06271c57918967142968140d9316985337011bed5105b" + }, + { + "path": "dist-standalone/assets/ProjectPathResolver-BI-xT66r.cjs", + "bytes": 246320, + "sha256": "23e747f08c84f1f98b0805371f26d5c64800addf5b6fdf0a48b686723d5a957a" + }, + { + "path": "dist-standalone/assets/ServiceContext--qE6X1hf.cjs", + "bytes": 332256, + "sha256": "1d7d7c485d872c43cfad44ec8906db84dd613fb7b85e9a51b5ca9881d59fcef0" + }, + { + "path": "dist-standalone/assets/index-Ds6Ihcda.cjs", + "bytes": 5171299, + "sha256": "749f6b1304e14a1cb5b39ffc6738cf8e525f5dd3a38777182ab3cecb03d7840d" + }, + { + "path": "dist-standalone/assets/tokenizer-BIjeYPC0.cjs", + "bytes": 9831, + "sha256": "a305b186e2d2a77295626771702f76505d93888b2dfc5c2523b2e9f2c92f709a" + }, + { + "path": "dist-standalone/index.cjs", + "bytes": 97537, + "sha256": "2b0e9fbfbb3cd7c63e4fa3d7041b6dae0c83e701604b804c41e45f51e1adb062" + } + ], + "internalStorageWorkerPresent": false, + "electronEmptyStubPresent": true, + "terminalServiceMarkerPresent": true, + "terminalPlatformMarkerPresent": false + }, + "terminalAbsence": { + "passes": false, + "violations": [ + "terminal_sdk_dependencies_in_production_manifest", + "terminal_node_install_stub", + "unpruned_production_node_modules", + "terminal_service_in_server_bundle" + ] + } +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json b/docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json new file mode 100644 index 0000000000..44735888d6 --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json @@ -0,0 +1,143 @@ +{ + "schemaVersion": 3, + "recordType": "w6-current-canonical-plus-input-patch-artifact-scan", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "canonicalSourceCommit": "f09ad2af137c02bf4e660d5cce398a1acdbc73d3", + "inputPatchSha256": "d336045cf53e814d067280b5a32c9f55f07c7e19685ab6e16d692703b92aa673", + "proofLevel": "targeted_current_canonical_plus_input_patch_build_observed", + "characterizationScope": "exact_current_canonical_plus_input_patch_targeted_standalone_build", + "build": { + "command": "pnpm exec vite build --config docker/vite.standalone.config.ts --outDir --emptyOutDir", + "config": "docker/vite.standalone.config.ts", + "input": "src/main/standalone.ts", + "output": "ephemeral_target_directory", + "sourceMaps": true, + "comparison": "exact_relative_path_byte_count_and_sha256" + }, + "historicalProvenance": { + "authorityPath": "docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json", + "authorityRecordType": "w6-historical-rejected-candidate-artifact-scan", + "relationship": "historical_only_not_current_artifact_authority" + }, + "source": { + "standaloneInput": "src/main/standalone.ts", + "rendererOutput": "out/renderer", + "externalPackages": ["fastify", "@fastify/cors", "@fastify/static", "agent-teams-controller"], + "nativeCatchAllEmptyStub": true, + "broadElectronStub": true, + "standaloneServiceStubs": true, + "terminalNodeInstallStub": true, + "terminalRuntimeArtifactPresent": false, + "standaloneWorkerEntry": true, + "electronWorkerEntry": true, + "internalWorkerRuntimeFilename": "internal-storage-worker.cjs", + "defaultWildcardCors": false, + "directHttpPublished": false, + "productionNodeModulesCopiedWhole": true, + "terminalPackages": [ + "@terminal-platform/design-tokens", + "@terminal-platform/foundation", + "@terminal-platform/runtime-types", + "@terminal-platform/workspace-adapter-websocket", + "@terminal-platform/workspace-contracts", + "@terminal-platform/workspace-core", + "@terminal-platform/workspace-elements", + "@terminal-platform/workspace-gateway-node", + "@terminal-platform/workspace-react", + "terminal-platform-node" + ], + "cookiePlugin": null, + "versions": { + "fastify": "^5.10.0", + "fastifyCors": "^11.2.0", + "betterSqlite3": "^12.11.1", + "electron": "^41.10.5", + "node": ">=24.15.0 <25" + }, + "terminalHttpRegistration": false, + "terminalMigration": false + }, + "emitted": { + "observed": true, + "files": [ + { + "path": "dist-standalone/assets/ConfigManager-CzaeAth9.cjs", + "bytes": 48055, + "sha256": "a02aaf15a961d61318bc026819f42c10cf88c14032e26f1404a7c8d7239adf3e" + }, + { + "path": "dist-standalone/assets/ErrorDetector-DyQUseZW.cjs", + "bytes": 28012, + "sha256": "81754efc8c1002097abf489180c84d7b95eb76cf34011ddd4807febba29eb005" + }, + { + "path": "dist-standalone/assets/HttpServer-X9FuMqVs.cjs", + "bytes": 6328, + "sha256": "e07f5a6054644ef71404e9a62dd5606eedbf298c67cd5a31404da7751ad4d64d" + }, + { + "path": "dist-standalone/assets/NotificationManager-C-gM6wZ0.cjs", + "bytes": 269969, + "sha256": "a5b622b5b82c0c449cc40cda8be9e350a095ce8a8e0a2a8f5ae5349fe45fee24" + }, + { + "path": "dist-standalone/assets/ProjectPathResolver-BB-TwF0_.cjs", + "bytes": 230494, + "sha256": "79c83cd5ed625428cde0ae9049091b57898a967ccf7048be197fa2c6c41b4bf0" + }, + { + "path": "dist-standalone/assets/ServiceContext-D-99-oDL.cjs", + "bytes": 291296, + "sha256": "084d764b9d6c4ca453b82825dce9185467f06b7b133b7b4d972368c9b6166d88" + }, + { + "path": "dist-standalone/assets/hostedTeamApprovalAuthorityStorage-CWiHm9jj.cjs", + "bytes": 204001, + "sha256": "f0b4b903aab05076da1d1d248a26a312ee5ac644080eeed5cdd64664398c1975" + }, + { + "path": "dist-standalone/assets/index-31aQWgQC.cjs", + "bytes": 451376, + "sha256": "ade7927d9c248ef02b767868317c11b6b45711b9f78a33cb174c1f3eba87b1d6" + }, + { + "path": "dist-standalone/assets/index-CrpEIxGs.cjs", + "bytes": 3374, + "sha256": "9479aed3686d106410303f53c54b4564aee7f888b4e8c1ac7816e483268ba5c9" + }, + { + "path": "dist-standalone/assets/index-kfSLO2Te.cjs", + "bytes": 6203560, + "sha256": "ba6c8dbbf2c962588920a7b0a41b795016e66d70441894abcb2fcc90ffeef6f5" + }, + { + "path": "dist-standalone/assets/internal-storage-worker.cjs", + "bytes": 724157, + "sha256": "bda388b3b64be3a413d4ac4f6c366a0c0edad3508743c15e67e2fe451f2db686" + }, + { + "path": "dist-standalone/assets/tokenizer-BIjeYPC0.cjs", + "bytes": 9831, + "sha256": "a305b186e2d2a77295626771702f76505d93888b2dfc5c2523b2e9f2c92f709a" + }, + { + "path": "dist-standalone/index.cjs", + "bytes": 1644395, + "sha256": "f571217bd651f8a3e4be6f0cf0ba3ca3c4e72ad4518f9065055fee288f1f86c0" + } + ], + "internalStorageWorkerPresent": true, + "electronEmptyStubPresent": true, + "terminalServiceMarkerPresent": true, + "terminalPlatformMarkerPresent": false + }, + "terminalAbsence": { + "passes": false, + "violations": [ + "terminal_sdk_dependencies_in_production_manifest", + "terminal_node_install_stub", + "unpruned_production_node_modules", + "terminal_service_in_server_bundle" + ] + } +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json b/docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json new file mode 100644 index 0000000000..f3c9cd1fe4 --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": 2, + "recordType": "w6-standalone-artifact-characterization", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "canonicalBaseSha": "f09ad2af137c02bf4e660d5cce398a1acdbc73d3", + "inputPatchSha256": "d336045cf53e814d067280b5a32c9f55f07c7e19685ab6e16d692703b92aa673", + "proofLevel": "targeted_current_canonical_plus_input_patch_build_observed", + "status": "rejected_for_hosted_v1", + "controllerContractPath": "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "controllerContractSha256": "4e8f2b43f47f94586692530cba2f5198f6de3740225b51dfba627f36fc82812f", + "artifacts": [ + { + "artifactId": "agent-teams-instance-lock", + "finalImagePath": "/app/bin/agent-teams-instance-lock", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json", + "protocolSha256": "ded8949371646d490ba5175cf2992cb308df6fdca44537d91d728345be3b139f", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "spikeSourceSha256": "d9cc83ae82e3a1c11e654db39ecf6ddceea78f23f455db707cb5876652118c57", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "pre_node_contract_only" + }, + { + "artifactId": "agent-teams-process-anchor", + "finalImagePath": "/app/bin/agent-teams-process-anchor", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "protocolSha256": "17e8b63b294a88959b7907cb931ea828a23b139abbbb3959b36ff77966cf41de", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c", + "spikeSourceSha256": "8fd3414ca172a2947eb2545457417f42b16b76612e91162f74a51e8728ebff20", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "after_lease_contract_only" + }, + { + "artifactId": "agent-teams-workspace-guard", + "finalImagePath": "/app/bin/agent-teams-workspace-guard", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json", + "protocolSha256": "d73abe4570fb87f42824c22e84a184fd617bd42384f097e4fae341c1d33b02c3", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "spikeSourceSha256": "66f7c58d1188fd8d4be09bdd890458002dbbe40bf94b153155f1f9a54366ab43", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "per_effect_contract_only" + } + ], + "currentStandalone": { + "authorityPath": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "authorityRecordType": "w6-current-canonical-plus-input-patch-artifact-scan", + "authoritySha256": "c9e1ff1a38349c0fb0b07ff39f6702b265b47b4552356c700909de1db7d5af8f", + "disposition": "rejected_for_hosted_v1" + }, + "capabilityClaims": { + "remoteAuthReady": false, + "remoteMutationReady": false, + "productionCompositionReady": false, + "terminalAbsenceAchieved": false + }, + "rejectionReasons": [ + "the current graph contains broad Electron and native empty stubs", + "the current image copies the production dependency tree wholesale", + "the current terminal-absence rule fails on terminal SDK and service surfaces", + "no admitted final image or production composition exists" + ] +} diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/report.md b/docs/research/hosted-web/phase-0/auth-artifacts/report.md new file mode 100644 index 0000000000..414fe896fa --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/report.md @@ -0,0 +1,108 @@ +# Phase 0 W6 auth and standalone-artifact characterization + +Current standalone materialization identity: canonical source +`f09ad2af137c02bf4e660d5cce398a1acdbc73d3` plus exact input patch SHA-256 +`d336045cf53e814d067280b5a32c9f55f07c7e19685ab6e16d692703b92aa673`. +Historical rejected-candidate provenance is retained separately and is not current artifact +authority. +Older `.codex-handoff` records retain their point-in-time `42ec3338…` fields as historical bytes; +the current verifier does not use those fields as standalone artifact authority. +Historical producer phase start: `a32f509e6d9bd31ba2135940e336729bf90c3d93`. +Packet narrowing: `phase-00-r3`. + +This lane is contract characterization only. It enables no authentication, CORS, remote mutation, +route, cookie, migration, production composition, terminal behavior, or hosted capability. + +## Reset and drain contract + +The executable model directly consumes controller envelope +`P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1`, pinned to +`docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json` and its +SHA-256. It accepts only the envelope's exact ready and drained objects and never adds a W6-owned +source or authority wrapper. The drained record binds `purpose`, `resetGeneration`, +`deploymentGeneration`, and `processAnchorGeneration`; mismatch in any field rejects. Protocol, +anchor identity, nonce, pidfd/process-group readiness, classification and empty residuals are also +fail-closed. + +While `resetIntent` exists, bootstrap, pair and renew reject with `reset_in_progress`. Restart and all +remaining transitions preserve `mutationAdmission=false` at every durable reset stage. These are +fixture-characterized invariants, not remote-auth or remote-mutation readiness. + +## One controller-owned artifact authority + +The controller-owned source is +`docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json`, with its adjacent +schema. W4 and W6 load that exact path and SHA-256 and expose equal read-only projections. The +cross-lane suite rejects a missing artifact, extra artifact, renamed field, stale path, and stale +protocol hash. Neither lane owns a competing path or hash table. + +## Standalone disposition and terminal rule + +`observed-artifact-scan.json` is the sole exact current-canonical-plus-input-patch standalone +characterization authority. Its 14 emitted CJS rows come from the configured targeted Vite build +of the materialized source identified above into an ephemeral directory. The repository verifier +rebuilds the already materialized source and compares the complete record, including every relative +path, byte count and SHA-256. It checks the recorded immutable identities but does not claim to +reconstruct the externally supplied input patch from repository bytes. It never accepts a mutable +ambient `dist-standalone` as evidence. + +`historical-rejected-candidate-artifact-scan.json` separately preserves the rows and provenance from +rejected integration attempt `a8405fd56102c02a0319e197c5b1b892d612616e39e5e871167cdb42798d5767`. +That record is historical contradiction evidence only. The manifest and current evidence project the +semantic hash of `observed-artifact-scan.json`; changing a current identity or emitted hash fails +closed. + +The characterized standalone artifact is rejected for hosted v1. The targeted build emits its +configured internal-storage worker at `dist-standalone/assets/internal-storage-worker.cjs`, but it +still includes broad Electron/native stubs, copies production dependencies wholesale, and contains +terminal SDK/service surfaces. `proposed-hosted-artifact-manifest.json` therefore records all hosted +readiness claims as false; it is a rejection record, not a production manifest. + +Terminal exclusion remains a v1 rule. The targeted current-canonical-plus-input-patch build +demonstrates that the materialized artifact violates the rule, so exclusion is not claimed achieved. +No final hosted image or production composition is proposed or admitted by this remediation. + +The estimate assumption is deliberately narrower than an artifact-admission claim: the existing +standalone source/build path may evolve in place, but evolution is unproved. The exact canonical +artifact above remains rejected, and any evolved candidate requires its own reviewed packet. + +## Other current-host characterization + +Artifact inventory and current terminal-rule evaluation are +`targeted_current_canonical_plus_input_patch_build_observed`. Proxy/origin, auth transitions and ABI +behavior remain fixture/current-host characterization at their declared levels. No live edge, +browser, keyring crash schedule, Electron native load, final-image load, or production deployment was +run. + +## Target-image gate: accepted Phase 0 capability narrowing + +Separately, the immutable source for the accepted historical target-image narrowing decision is commit +`42ec333848e29e97c41699b9fed73ed199740e3f`, tree +`4bc04a743c20ea48e06ada55c761d03881117cac`. That source is separate from the +repository commit that later adopts this evidence. Verification requires the adopting HEAD to be the +source or its descendant and reports that mutable evidence identity separately; it never rewrites the +source identity inside the decision. + +Decision `P0.D.TARGET_IMAGE` is `accepted` with outcome `capability_narrowed`. Phase 0 closes its +target-image gate by preserving the complete admission contract and all known gaps, not by claiming +that a final image already exists. Phase 5 owns the production composition, build graph, native +artifacts, hardened profile and in-image probes, so exact-image admission before Phase 5 would be +circular. No hosted route, mutation, provider runtime, credential canary, production composition or +terminal-negative image readiness is enabled by this decision. + +The deterministic canonical-source evaluation remains fail closed with exactly 51 obligations: 21 +composition, four image identity, three inventory, 12 runtime profile, two provider-runtime and nine +terminal-negative scan obligations. The current standalone candidate is not a substitute and retains +four observed terminal-absence violations. Terminal absence is still mandatory over capabilities, +files, migrations, packages, ports, processes, renderer chunks, routes and volumes. + +The Phase 5 gate must remain closed until one reviewed immutable target-image manifest/profile is +instantiated and supplies digest-bound complete inventory, native binary/builder/compiler and +ownership/mode provenance, init/lock/anchor-before-Node ordering, target-executed provider canaries +bound to the same digest, and negative terminal scans over every named surface. The same gate blocks +Phase 5 route admission and capability advertisement and Phase 6 non-loopback mutations. + +Live Docker state is intentionally absent from deterministic decision facts. Phase 0 verification +does not invoke or assume a Docker CLI, daemon or socket. Synthetic provider canaries check only +redaction and cross-provider isolation in the admission harness; they remain explicitly below +target-image provider execution. No real project or credential is used. diff --git a/docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json b/docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json new file mode 100644 index 0000000000..09fe48f9b9 --- /dev/null +++ b/docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json @@ -0,0 +1,251 @@ +{ + "schemaVersion": 2, + "recordType": "phase-0-target-image-capability-narrowing-decision", + "decision": { + "id": "P0.D.TARGET_IMAGE", + "state": "accepted", + "outcome": "capability_narrowed", + "phase0Gate": "closed_by_accepted_narrowing", + "exactImageEarliestOwner": "phase-5", + "rationale": "Phase 0 characterizes source and contracts but does not implement the production composition that Phase 5 must build; exact-image admission before Phase 5 would be circular.", + "phase0Capability": "Preserve the complete fail-closed admission contract and canonical-source gaps without claiming an image exists.", + "deferredCapability": "No hosted route, mutation, provider runtime, credential canary, production composition, or terminal-negative image readiness is admitted." + }, + "sourceIdentity": { + "canonicalCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "canonicalTree": "4bc04a743c20ea48e06ada55c761d03881117cac", + "verificationRelationship": "repository_head_is_source_or_descendant", + "evidenceIdentityPolicy": "The verifier reports repositoryHeadAtVerification separately; it never substitutes that mutable commit into this immutable source decision." + }, + "scope": { + "sandboxAndSyntheticFixturesOnly": true, + "realUserProjectsOpened": false, + "dockerSocketRequiredForPhase0Decision": false, + "liveContainerRuntimeObservationInDeterministicFacts": false, + "phase1AuthorizedOrImplemented": false + }, + "authorities": [ + { + "path": "docker/Dockerfile", + "sourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "sha256": "74d803a3898f8c274979af1183f9f62a5aa819331865811b15364e91f24f9eb7" + }, + { + "path": "docker/docker-compose.yml", + "sourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "sha256": "fde5e1358a15e15efe6ce2a05582ed4562fa5a17eeb44c55bd9fb2f88fc41c09" + }, + { + "path": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "sourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "sha256": "e93deb209a9c3b4f569f94d427fc45b6afd22c769ef4eea393ea7a80b5113bb8" + }, + { + "path": "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "sourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "sha256": "83fede2fb9ce5e663963af951f7bac0fb24ee636024ac767108a7045114a9eec" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "sourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "sha256": "5f039a47a65ced6556f5c096e4135d18aeea3377db1f21fb347025c1af5b36e1" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "sha256": "40650b7ec3549a30419932fa43330b1484468c393ddef0555438f9f308c97e3a" + }, + { + "path": "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "sourceCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "sha256": "4e8f2b43f47f94586692530cba2f5198f6de3740225b51dfba627f36fc82812f" + } + ], + "canonicalSourceFacts": { + "currentCandidate": { + "dockerfileBaseDigestPinned": false, + "finalImageDigestDeclared": false, + "nonRootUserDeclared": false, + "initEntrypointDeclared": false, + "controllerArtifactsCopied": false, + "terminalAbsence": { + "passes": false, + "violations": [ + "terminal_sdk_dependencies_in_production_manifest", + "terminal_node_install_stub", + "unpruned_production_node_modules", + "terminal_service_in_server_bundle" + ] + } + }, + "controllerArtifacts": [ + { + "artifactId": "agent-teams-instance-lock", + "finalImagePath": "/app/bin/agent-teams-instance-lock", + "binaryDigest": null, + "builderImageDigest": null, + "compilerIdentity": null, + "uid": null, + "gid": null, + "mode": null + }, + { + "artifactId": "agent-teams-process-anchor", + "finalImagePath": "/app/bin/agent-teams-process-anchor", + "binaryDigest": null, + "builderImageDigest": null, + "compilerIdentity": null, + "uid": null, + "gid": null, + "mode": null + }, + { + "artifactId": "agent-teams-workspace-guard", + "finalImagePath": "/app/bin/agent-teams-workspace-guard", + "binaryDigest": null, + "builderImageDigest": null, + "compilerIdentity": null, + "uid": null, + "gid": null, + "mode": null + } + ] + }, + "providerRuntimeCanaryFixture": { + "status": "passed_fixture_only", + "executionBoundary": "synthetic_environment_records_no_project_opened", + "rawCredentialValueRecorded": false, + "redactionToken": "[REDACTED]", + "records": [ + { + "provider": "anthropic", + "expectedKey": "ANTHROPIC_API_KEY", + "expectedCanaryPresent": true, + "crossProviderCanaryKeys": [], + "canaryRendering": { + "ANTHROPIC_API_KEY": "[REDACTED]" + }, + "rawCredentialValueRecorded": false, + "fixtureEvaluationPassed": true + }, + { + "provider": "codex", + "expectedKey": "OPENAI_API_KEY", + "expectedCanaryPresent": true, + "crossProviderCanaryKeys": [], + "canaryRendering": { + "OPENAI_API_KEY": "[REDACTED]" + }, + "rawCredentialValueRecorded": false, + "fixtureEvaluationPassed": true + }, + { + "provider": "gemini", + "expectedKey": "GEMINI_API_KEY", + "expectedCanaryPresent": true, + "crossProviderCanaryKeys": [], + "canaryRendering": { + "GEMINI_API_KEY": "[REDACTED]" + }, + "rawCredentialValueRecorded": false, + "fixtureEvaluationPassed": true + }, + { + "provider": "opencode", + "expectedKey": "OPENCODE_CONFIG_CONTENT", + "expectedCanaryPresent": true, + "crossProviderCanaryKeys": [], + "canaryRendering": { + "OPENCODE_CONFIG_CONTENT": "[REDACTED]" + }, + "rawCredentialValueRecorded": false, + "fixtureEvaluationPassed": true + } + ], + "limitation": "This fixture proves only deterministic admission-harness behavior; it is not target-image provider execution." + }, + "phase5AdmissionGate": { + "state": "fail_closed", + "admitted": false, + "admissionRequiredBefore": [ + "phase-5 route admission", + "phase-5 capability advertisement", + "phase-6 non-loopback mutation enablement" + ], + "terminalSensitiveSurfaces": [ + "capabilities", + "files", + "migrations", + "packages", + "ports", + "processes", + "rendererChunks", + "routes", + "volumes" + ], + "canonicalSourceGapCount": 51, + "canonicalSourceGaps": [ + "composition:agent-teams-instance-lock:binary_digest_missing", + "composition:agent-teams-instance-lock:builder_image_digest_missing", + "composition:agent-teams-instance-lock:compiler_identity_missing", + "composition:agent-teams-instance-lock:gid_missing", + "composition:agent-teams-instance-lock:mode_missing", + "composition:agent-teams-instance-lock:not_in_file_inventory", + "composition:agent-teams-instance-lock:uid_missing", + "composition:agent-teams-process-anchor:binary_digest_missing", + "composition:agent-teams-process-anchor:builder_image_digest_missing", + "composition:agent-teams-process-anchor:compiler_identity_missing", + "composition:agent-teams-process-anchor:gid_missing", + "composition:agent-teams-process-anchor:mode_missing", + "composition:agent-teams-process-anchor:not_in_file_inventory", + "composition:agent-teams-process-anchor:uid_missing", + "composition:agent-teams-workspace-guard:binary_digest_missing", + "composition:agent-teams-workspace-guard:builder_image_digest_missing", + "composition:agent-teams-workspace-guard:compiler_identity_missing", + "composition:agent-teams-workspace-guard:gid_missing", + "composition:agent-teams-workspace-guard:mode_missing", + "composition:agent-teams-workspace-guard:not_in_file_inventory", + "composition:agent-teams-workspace-guard:uid_missing", + "image:immutable_config_digest_missing", + "image:immutable_digest_missing", + "image:immutable_manifest_digest_missing", + "image:pinned_base_image_missing", + "inventory:completeness_unproved", + "inventory:image_digest_binding_mismatch", + "inventory:scanner_digest_missing", + "profile:capability_drop_all_unproved", + "profile:debian_slim_unproved", + "profile:launcher_before_node_unproved", + "profile:linux_x64_unproved", + "profile:minimal_init_unproved", + "profile:no_new_privileges_unproved", + "profile:node_24_unproved", + "profile:non_root_gid_missing", + "profile:non_root_uid_missing", + "profile:read_only_root_unproved", + "profile:seccomp_digest_missing", + "profile:startup_order_missing", + "provider_runtime:canary_record_invalid", + "provider_runtime:target_image_canaries_unproved", + "terminal_negative:unscanned_surface:capability", + "terminal_negative:unscanned_surface:file", + "terminal_negative:unscanned_surface:migration", + "terminal_negative:unscanned_surface:package", + "terminal_negative:unscanned_surface:port", + "terminal_negative:unscanned_surface:process", + "terminal_negative:unscanned_surface:renderer_chunk", + "terminal_negative:unscanned_surface:route", + "terminal_negative:unscanned_surface:volume" + ], + "terminalNegative": false, + "requiredEvidence": "One reviewed immutable target-image manifest/profile and an instantiated digest with complete digest-bound inventory, native provenance/ownership/modes, startup-order proof, target-executed provider canaries, and terminal-negative scans over every named surface." + }, + "claims": { + "exactImageInstantiated": false, + "exactHostedCompositionProved": false, + "providerRuntimeTargetProved": false, + "credentialCanariesTargetProved": false, + "terminalNegativeAdmission": false, + "phase1AuthorizedOrImplemented": false + } +} diff --git a/docs/research/hosted-web/phase-0/base.json b/docs/research/hosted-web/phase-0/base.json new file mode 100644 index 0000000000..8f4a4f11c1 --- /dev/null +++ b/docs/research/hosted-web/phase-0/base.json @@ -0,0 +1,171 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "scope": "0A-baseline-evidence-only", + "packetRevision": "phase-00-r2", + "capturedAt": "2026-07-11T17:30:54Z", + "baseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "sourcePlanCommit": "16c156db8a85e75a6b679f6919e1013af74fb112", + "planBundleCommit": "f1ad7a8cba2f26abf5f42ddd206937c24d143f77", + "currentIntegrationSha": "c1b8e3fe69e1c05ad94ec0c0301def25c8a464b5", + "phaseStartSha": null, + "phaseStartResolution": { + "status": "pending_external_integration_record", + "reason": "The evidence commit cannot embed its own Git SHA without changing the commit and invalidating that SHA.", + "requiredAction": "Adopt exactly the seven Phase 0A evidence files, record the resulting commit SHA in the controller integration-attempt record, and inject it unchanged into every lane job and prompt.", + "laneRule": "The externally recorded evidence commit is the lane phaseStartSha; currentIntegrationSha is not a substitute.", + "externalRecordOwner": "agent-teams-hosted-web-refactor-controller-v2" + }, + "repository": { + "canonical": "777genius/agent-teams-ai", + "remoteUrl": "https://github.com/777genius/agent-teams-ai.git", + "baseBranch": "refactor/team-provisioning-round2-reapply", + "implementationBranch": "refactor/hosted-web-feature-boundaries" + }, + "lineage": { + "baseObservedAt": "2026-07-11T13:58:01Z", + "baseObservation": "Host prepared-state record plus canonical remote-tracking ref; the evidence worker did not perform another network fetch.", + "ancestryChecks": { + "baseIsAncestorOfCurrentIntegration": true, + "adoptedPlanIsAncestorOfCurrentIntegration": true, + "sourcePlanIsAncestorOfCurrentIntegration": false + } + }, + "planBundle": { + "preparedTreeDigestSha256": "cbeae6e4dce01f4e7f3f4d28ac476b18b7f838c8d993a21788dccb43a7510c68", + "files": [ + { + "path": "docs/hosted-web-e2e-completion-plan.md", + "sha256": "4901a37dc4da373efab939b43a406739ed02e1b4e250a7a2e5754ad659fa4080" + }, + { + "path": "docs/hosted-web-phase-0-execution-packet.md", + "sha256": "71c53d24f3211613dcfba11493bf18a2b6d49ae31e86186fc94c259d21f17fa8" + }, + { + "path": "docs/hosted-web-phases/PACKET_STANDARD.md", + "sha256": "6e1539728840c1b311dd0575c514fc3c0f5284087467c73314c347901108f6d6" + }, + { + "path": "docs/hosted-web-phases/README.md", + "sha256": "b53ff97a9a5db3ccc6226793ccd48f72f06e2e1c1027db0ed80f0e725ddfd80b" + }, + { + "path": "docs/hosted-web-phases/_templates/phase-controller-packet.md", + "sha256": "ce79be9c8aa87aa7ccec8947fc1bec89bd7f30c3ef0cb6e8bf5874e2677e2f9d" + }, + { + "path": "docs/hosted-web-phases/_templates/worker-lane-packet.md", + "sha256": "022d6f1d83ffcf396295a6793f834628633a04fd6c6b93efe893515737fc0ba7" + }, + { + "path": "docs/hosted-web-phases/phase-00/lanes/w1-parity-renderer.md", + "sha256": "b4f926763478d263aeac5739b8da03916c8363d330585392ea2ac6cc6b9cfe56" + }, + { + "path": "docs/hosted-web-phases/phase-00/lanes/w2-provider-runtime.md", + "sha256": "fc72e78c4d361f93443677c03500bcfa6e6737256753853855830afddad867b9" + }, + { + "path": "docs/hosted-web-phases/phase-00/lanes/w3-state-writers-backup.md", + "sha256": "dac064c17427319ddf6c4930a84c6e2f9080a07fa90fc05582e313395fc0508f" + }, + { + "path": "docs/hosted-web-phases/phase-00/lanes/w4-lease-guard-process.md", + "sha256": "4173ade12b5c8baf9057ea5dddbc98dc60108101a8f915014b0896a708451b79" + }, + { + "path": "docs/hosted-web-phases/phase-00/lanes/w5-events-commands-recovery.md", + "sha256": "1bc4fad1f9596c6bd5f5b51dfc46d3f49b2defd23e2d452ae9f8aa5294b32912" + }, + { + "path": "docs/hosted-web-phases/phase-00/lanes/w6-auth-proxy-artifacts.md", + "sha256": "540c39eaa1466bbd6a47bce84c17a1db2bf4d60b93436026ab6ef8f0cf1e69c5" + } + ] + }, + "toolchain": { + "node": "24.16.0", + "pnpm": "10.33.4", + "packageManagerSpec": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800", + "os": "Ubuntu 24.04.4 LTS", + "kernel": "Linux 6.8.0-124-generic", + "architecture": "x86_64", + "packageJsonSha256": "636d20eedef9a5ff48edd2c39d434c3ecb029119e20ee3353cd325199dced1f5", + "lockfileSha256": "2132ffb086bc3c75b94f1ae3eabca9640c342b4e084742710e53f46fabf111b0" + }, + "dependencyMaterialization": { + "status": "ready", + "preflightFingerprint": "2c5340064d9a9856cbc8963519448f43db448d2f03141788bad88252b88a21f7", + "nodeModulesExists": true, + "requiredBinariesPresent": [ + "eslint", + "tsc", + "vitest" + ], + "cacheRoot": "/var/data/agent-dependency-cache/agent-teams-hosted-web-refactor-45ab8b8b5f8e", + "installCommand": "pnpm fetch --frozen-lockfile --store-dir /var/data/agent-dependency-cache/agent-teams-hosted-web-refactor-45ab8b8b5f8e/pnpm-store && pnpm install --offline --frozen-lockfile --store-dir /var/data/agent-dependency-cache/agent-teams-hosted-web-refactor-45ab8b8b5f8e/pnpm-store", + "lockfileChanged": false + }, + "controller": { + "projectId": "agent-teams-hosted-web-refactor", + "manifestId": "agent-teams-hosted-web-refactor-controller-v2", + "accessBoundary": "project_scoped_control", + "model": "gpt-5.6-sol", + "reasoningEffort": "high", + "serviceTier": "fast", + "runtimeRelease": "7c264e3eed97261f57711bc63eb0d4ad8cf8435c", + "capacityController": { + "desiredWorkers": 0, + "dryRun": true, + "maxStartsPerRun": 0 + }, + "baselineJob": { + "jobId": "agent-teams-hosted-web-refactor-phase-00-baseline-evidence-v2", + "accessBoundary": "isolated_workspace_write", + "prewarmOnStart": false + } + }, + "baselineEvidence": { + "ledger": "docs/research/hosted-web/phase-0/baseline.md", + "command": "pnpm check:ci", + "integrationSha": "c1b8e3fe69e1c05ad94ec0c0301def25c8a464b5", + "exitCode": 1, + "durationSeconds": 2211, + "completedAt": "2026-07-11T17:08:58Z", + "result": "failed_only_at_full_root_lint", + "failureCounts": { + "baseOwnedFixRecords": 2, + "lintErrors": 5, + "lintWarnings": 2942, + "unknownFailures": 0 + }, + "externalArtifacts": [ + { + "path": "/var/data/agent-teams-hosted-web-refactor/control/evidence/phase-00-check-ci.log", + "sha256": "78efb84ac4cc055f36fbe14a5ce481517b6193f5cea3b883e20401ec260cf0fa" + }, + { + "path": "/var/data/agent-teams-hosted-web-refactor/control/evidence/phase-00-check-ci.result", + "sha256": "e0663fb85588bba536c90f09908dea9ebb2c10cc44b973ae765cc8be639fc1ce" + }, + { + "path": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-baseline-evidence-v2/dependency-preflight.json", + "sha256": "7fa40575663888bcfd98a129f8e174a7d13ed3413ec9cc32e05f2b115db28208" + } + ] + }, + "orchestrationIncident": { + "incidentId": "P0A-PREWARM-RUNAWAY-01", + "jobId": "agent-teams-hosted-web-refactor-phase-00-baseline-evidence-v1", + "prewarmOnStart": true, + "attemptCount": 22, + "startedAt": "2026-07-11T17:18:29.835Z", + "failedAt": "2026-07-11T17:23:16.287Z", + "result": "subscription_worker_prewarm_failed", + "cause": "Worker pool failed to prewarm; codex_app_server_exited:1", + "repositoryFilesChanged": false, + "resolution": "Clean v2 baseline and subsequent clean v2 jobs use prewarmOnStart=false.", + "externalResultSha256": "584d42c4afc347115a1a0ad4d058bdbe0802ee22d56271a05197c16b8812bda8" + } +} diff --git a/docs/research/hosted-web/phase-0/baseline.md b/docs/research/hosted-web/phase-0/baseline.md new file mode 100644 index 0000000000..98136b5046 --- /dev/null +++ b/docs/research/hosted-web/phase-0/baseline.md @@ -0,0 +1,101 @@ +# Phase 0A baseline + +## Reproduction envelope + +- Repository: `https://github.com/777genius/agent-teams-ai.git` +- Pinned base: `cbe501ad0f1fa0e51a038e832ad35fce4120321b` +- Tested integration: `c1b8e3fe69e1c05ad94ec0c0301def25c8a464b5` +- Packet: `phase-00-r2` +- Node: `24.16.0` +- pnpm: `10.33.4` +- Host: Ubuntu 24.04.4 LTS, Linux `6.8.0-124-generic`, `x86_64` +- Lockfile SHA-256: `2132ffb086bc3c75b94f1ae3eabca9640c342b4e084742710e53f46fabf111b0` +- Dependency preflight: `ready`, fingerprint + `2c5340064d9a9856cbc8963519448f43db448d2f03141788bad88252b88a21f7` +- Dependency cache: `/var/data/agent-dependency-cache/agent-teams-hosted-web-refactor-45ab8b8b5f8e` + +The frozen/offline materialization command was: + +```text +pnpm fetch --frozen-lockfile --store-dir /var/data/agent-dependency-cache/agent-teams-hosted-web-refactor-45ab8b8b5f8e/pnpm-store && pnpm install --offline --frozen-lockfile --store-dir /var/data/agent-dependency-cache/agent-teams-hosted-web-refactor-45ab8b8b5f8e/pnpm-store +``` + +The preflight found `node_modules` plus `eslint`, `tsc`, and `vitest`, and the lockfile remained +unchanged. + +## Captured broad gate + +Command: + +```text +pnpm check:ci +``` + +Result: exit `1`, duration `2211s`, completed `2026-07-11T17:08:58Z`. + +The command expands to: + +```text +pnpm check:workspace:ci && pnpm lint && pnpm lint:mcp +``` + +`check:workspace:ci` completed successfully before full lint ran. Its stages were: + +| Stage | Result | Evidence | +| --- | --- | --- | +| `pnpm typecheck:workspace` | passed | Root TypeScript, MCP source and MCP test typechecks all advanced to the next stage. | +| `pnpm test:workspace:ci` | passed | Root: 1,105 files and 11,421 tests passed; 22 files and 49 tests skipped. Controller: 4 files/134 tests passed. MCP: 2 files/43 tests passed. | +| `pnpm build:workspace` | passed | App, controller and MCP builds completed; renderer build reported `built in 1m 10s`. | +| `pnpm --filter agent-teams-mcp test:e2e` | passed | 2 files/18 tests passed. | +| `pnpm lint` | failed | 2,947 findings: 5 errors and 2,942 warnings. | +| `pnpm lint:mcp` | not reached | Shell short-circuit after root lint failure. This is not an MCP E2E failure. | + +External evidence retained by the controller: + +- `/var/data/agent-teams-hosted-web-refactor/control/evidence/phase-00-check-ci.log`, SHA-256 + `78efb84ac4cc055f36fbe14a5ce481517b6193f5cea3b883e20401ec260cf0fa` +- `/var/data/agent-teams-hosted-web-refactor/control/evidence/phase-00-check-ci.result`, SHA-256 + `e0663fb85588bba536c90f09908dea9ebb2c10cc44b973ae765cc8be639fc1ce` + +## Packet command coverage + +The packet lists five baseline commands. This evidence does not pretend that nested or adjacent gates +are identical when they are not. + +| Packet command | Captured status | Duration/final-tail status | +| --- | --- | --- | +| `pnpm typecheck:workspace` | passed as the first nested `check:ci` stage | No independent duration or separate final-20 capture. | +| `pnpm lint:fast` | not separately run for this record | Not available; the user prohibited another broad/long run. | +| `pnpm test:workspace:ci` | passed as a nested `check:ci` stage | Counts are recorded above; no independent duration. | +| `pnpm standalone:build` | not run in the supplied baseline | `build:workspace` passed, but is not relabeled as this artifact-specific gate. | +| `pnpm check:ci` | failed only at full root lint | Exit `1`, `2211s`; controller log and result hashes are recorded above. | + +Consequently this is reproducible baseline evidence, not a claim that every 0A.4 command was separately +green. `lint:fast` and `standalone:build` remain explicit prerequisites for the controller before it +admits dependent lanes if it requires literal 0A.4 closure. + +## Inherited failure ledger + +Both failing source paths are byte-identical between the pinned base and the tested integration. Git +blame places the offending lines in commits already contained by the base. The five errors therefore +form two `base_owned_fix` records; none is `unknown` or caused by the plan/evidence bundle. + +| Failure ID | Command | Exit | Classification | First-known-bad evidence | Affected package/path | Owner | Isolation/fix and rerun evidence | +| --- | --- | ---: | --- | --- | --- | --- | --- | +| `P0A-LINT-001` | `pnpm lint` | 1 | `base_owned_fix` | `defd86eb43b479ab9e4a6c8d0fbd0328762f3922` owns lines 336-339 at the pinned base. | `src/main/services/team/contracts/__tests__/TeamProvisioningApis.test.ts:336` | Base prerequisite, job `agent-teams-hosted-web-refactor-phase-00-lint-contract-test-v2` | Four `@typescript-eslint/no-unnecessary-type-assertion` errors. Narrow reviewed output removes only the four assertions; type-aware ESLint passed with zero errors and targeted Vitest passed 13/13. Adoption and broad rerun remain controller-owned. | +| `P0A-LINT-002` | `pnpm lint` | 1 | `base_owned_fix` | `3a93ce54aab0f21db0e8c9bfbb45033def875afd` owns line 193 at the pinned base. | `src/main/services/team/provisioning/TeamProvisioningToolApprovalTimeouts.ts:193` | Base prerequisite, job `agent-teams-hosted-web-refactor-phase-00-lint-approval-timeout-v2` | One `sonarjs/no-try-promise` error. A narrow prerequisite must preserve timeout/rejection semantics, pass focused tests/type-aware lint, be adopted separately, and then rerun the broad gate. | + +The 2,942 warnings are diagnostic debt but did not determine the nonzero exit. No evidence here accepts +new warnings or authorizes broad formatting/fixing. + +## Classification conclusion + +- `base_blocker`: 0 +- `base_owned_fix`: 2 records / 5 lint errors +- `isolated_known_failure`: 0 +- `environment_failure`: 0 +- `unknown`: 0 + +The captured `check:ci` failure is solely the five inherited/base-owned lint errors. Workspace tests, +workspace builds and MCP E2E passed. No product code, package file, test, packet or Git state was +changed while producing this record. diff --git a/docs/research/hosted-web/phase-0/decision-register.md b/docs/research/hosted-web/phase-0/decision-register.md new file mode 100644 index 0000000000..dbbe6451bf --- /dev/null +++ b/docs/research/hosted-web/phase-0/decision-register.md @@ -0,0 +1,25 @@ +# Phase 0 decision register + +## Scope + +This register contains only decisions established or narrowed by Phase 0A. It is not the Phase 0 +freeze register. W1-W6 evidence and reciprocal reviews must add the remaining architecture outcomes +before 0D; no unevaluated question is presented here as accepted. + +| Decision ID | Question | Source evidence | Options | Outcome | Confidence | Affected capabilities / ADRs | Owner | State | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `P0.D.BASE_PIN` | What immutable repository base governs Phase 0? | Canonical remote ref, prepared-state record, Git ancestry | Moving branch; pinned SHA | Pin `cbe501ad0f1fa0e51a038e832ad35fce4120321b`; later remote movement requires explicit impact/rebase review. | high | All Phase 0 evidence | Integration controller | `accepted` | +| `P0.D.PLAN_ADOPTION` | Which plan commit is executable on the implementation ancestry? | Source `16c156db8`; reviewed integration attempt; adopted `f1ad7a8c`; identical hashes | Read out-of-tree source; adopt content-equivalent bundle | Treat `16c156db8a85e75a6b679f6919e1013af74fb112` as source provenance and `f1ad7a8cba2f26abf5f42ddd206937c24d143f77` as authoritative in-branch plan bundle. | high | Packet `phase-00-r2`; all ADRs | Integration controller | `accepted` | +| `P0.D.BASELINE_FAILURES` | Does the broad gate failure block evidence work or authorize product changes? | `baseline.md`; hashed `check:ci` log; base/current path identity | Block all work; ignore; isolate narrow base fixes | Classify two records/five errors as `base_owned_fix`; no `unknown` failure exists. Adopt fixes independently and rerun before dependent admission. | high | Phase 0 admission | Integration controller / named prerequisite jobs | `narrowed` | +| `P0.D.PHASE_START` | How is a self-referential evidence commit represented? | Git content-addressing; packet requirement that lanes start at the evidence commit | Embed current head; rewrite after commit; external resolution | Store `phaseStartSha: null` in the self-containing evidence, then make the controller's immutable integration-attempt commit record authoritative and inject that SHA into lane jobs/prompts. | high | All W1-W6 lane identity and deduplication | Integration controller | `accepted` | +| `P0.D.PREWARM` | May clean Phase 0 jobs use subscription prewarm after the v1 incident? | v1 job: 22 attempts and prewarm failure; v2 manifest | Retry prewarm; disable prewarm | Clean v2 jobs use `prewarmOnStart=false`; the incident is orchestration evidence, not a repository failure. | high | Controller/job launch policy | Broker-only controller | `narrowed` | +| `P0.D.CAPACITY` | May the generic capacity controller refill W1-W6 before a phase start exists? | Capacity config; lane ledger; packet 0A.5 | Target six now; remain dry-run/zero | Keep `desiredWorkers=0`, `dryRun=true`, and all six slots `unstarted` until the external `phaseStartSha` and exact lane requests exist. | high | Worker admission and deduplication | Broker-only controller | `accepted` | +| `P0.D.TERMINAL_V1` | Does 0A add or estimate hosted terminal work in v1? | Parent plan; packet non-goals; estimate ledger | Include; exclude | Hosted terminal remains absent from v1 and contributes zero v1 implementation/packaging lines; only later absence evidence is permitted. | high | ADR-10/35; v1 artifact/capabilities | Phase controller | `accepted` | + +## Decisions still requiring lane evidence + +The following freeze areas intentionally have no 0A outcome: exact parity/actions and contract facets; +identity authority; provider/runtime-ingress topology; state/external-writer classes; child environment +exposure; lease/guard/process feasibility; snapshot/event and command/effect recovery; auth/proxy +schedules; artifact/ABI/backup feasibility; and the evidence-backed final estimate. Their owners and +acceptance criteria remain in the six `phase-00-r2` lane packets. diff --git a/docs/research/hosted-web/phase-0/estimate-ledger.md b/docs/research/hosted-web/phase-0/estimate-ledger.md new file mode 100644 index 0000000000..8822327f59 --- /dev/null +++ b/docs/research/hosted-web/phase-0/estimate-ledger.md @@ -0,0 +1,35 @@ +# Phase 0 estimate ledger + +## Status + +This is the 0A accounting baseline, not the post-inventory Phase 0 estimate freeze. It preserves the +parent plan's non-duplicated v1 range and gives each line one unique bucket. W1-W6 must replace the +assumptions with evidence-backed `estimate-input.json` records; 0D then regenerates this ledger. + +Generated/vendor bundles, lockfile churn, mechanical formatting and post-v1 terminal work are +excluded. Production, focused tests, E2E, native guards and required docs/migrations are included. + +## Unique v1 buckets + +| Bucket ID | Packages / owned surface | Production/test/deleted net lines | Overlap rule | Confidence | Assumptions | Evidence refs | +| --- | --- | ---: | --- | --- | --- | --- | +| `EST-CONTRACTS` | Shared kernel; feature contracts; capability/route/architecture gates | 2.0k-3.0k | Count contract/schema/fixture edits once even when consumed by later phases. | high | ADR-19 scanner stays bounded and no mega-interface is introduced. | Parent plan estimate table; `P0.W1.ESTIMATE` pending | +| `EST-IDENTITY-WORKSPACE` | Team/member/workspace identity; workspace registry; ADR-28 guard | 3.5k-5.5k | Guard and identity fixtures belong here, not again in hosted composition. | medium | Legacy adoption remains compatible; final Linux probes do not force a new deployment model. | Parent plan; `P0.W4.ESTIMATE` pending | +| `EST-LIFECYCLE-RUNTIME` | Team lifecycle; runtime control; provider ingress; ADR-30/31 | 5.0k-8.0k | Provider fixtures and process ownership counted once across launch and security phases. | medium | Deterministic provisioning stays behind compatibility adapters. | Parent plan; `P0.W2.ESTIMATE` and `P0.W4.ESTIMATE` pending | +| `EST-RECOVERY-STATE` | Command/event recovery; external writers; SQLite coordination/backup | 4.5k-7.5k | Shared receipts, journals and state fixtures are not repeated per feature. | medium-low | Required provider JSON operations can be classified without a universal repository. | Parent plan; `P0.W3.ESTIMATE` and `P0.W5.ESTIMATE` pending | +| `EST-HOSTED-OPS` | Hosted composition; auth/proxy; build/package/runtime operations | 3.5k-5.5k | Build fixtures and auth topology counted once, not in E2E again. | medium | Existing standalone path can be evolved in place and required ABI artifacts are supportable. | Parent plan; `P0.W6.ESTIMATE` pending | +| `EST-RENDERER-LIFECYCLE` | Team console; transport reconciler; lifecycle-screen migration | 3.0k-5.0k | Renderer fixtures reused by parity closure stay in this bucket. | medium | Existing teamSlice/TeamDetail invariants can be preserved behind narrow facets. | Parent plan; `P0.W1.ESTIMATE` pending | +| `EST-REMAINING-PARITY` | Tasks, messaging, review, approvals, members and attachments | 4.0k-6.5k | A visible action belongs to one owning feature, never one row per old method and phase. | medium-low | W1 action inventory does not reveal a larger visible-screen dependency closure. | Parent plan; `P0.W1.ESTIMATE` pending | +| `EST-RELEASE-E2E` | Real-browser E2E; desktop regression; rollout docs/tooling | 2.5k-4.0k | Production-shape harnesses counted here only when not already native/feature fixtures. | medium | Most deterministic fixtures are reusable in production-shape tests. | Parent plan; all lane estimate inputs pending | +| **`EST-V1-TOTAL`** | **All non-terminal v1 scope** | **28k-45k** | **Net integrated diff; shared work counted once.** | **7/10** | **Lower bound retains strangler adapters; upper bound splits unsafe legacy authority.** | **Parent plan accepted range** | + +Arithmetic lower/upper sums are 28k/45k. The plan's 33.9k-57.6k phase-touch range is deliberately not +used because it repeats work across phases; the table is the unique-bucket net model. + +## Re-estimation triggers + +- Regenerate after W1 parity/action inventory and all lane estimate inputs are reviewed. +- Regenerate after Phase 7. +- A projected total outside 28k-45k or any unique bucket variance above 20% requires explicit + scope/design review before capacity expands. +- ADR-35 and every hosted-terminal implementation or packaging line contribute zero to v1. diff --git a/docs/research/hosted-web/phase-0/estimate-reconciliation/README.md b/docs/research/hosted-web/phase-0/estimate-reconciliation/README.md new file mode 100644 index 0000000000..c7960bb2cb --- /dev/null +++ b/docs/research/hosted-web/phase-0/estimate-reconciliation/README.md @@ -0,0 +1,116 @@ +# Phase 0 unique-bucket estimate reconciliation + +## Outcome + +Against target candidate 1587615c751c3cb12b5078ab4b7264b6e9fd42ad, the reconciled non-terminal v1 estimate is **38,300-62,100 gross integrated changed lines**. The estimate model remains anchored at source snapshot 42ec333848e29e97c41699b9fed73ed199740e3f, while the two auth-artifact inputs are reproduced from their target-candidate bytes. The parent unique-bucket baseline remains 28,000-45,000. The 38,300 low endpoint is inside that inclusive interval; only the 62,100 high endpoint is above it. The partially out-of-range interval, bucket variance, controller estimates, and unallocated migration split still require explicit scope/design review before capacity expands. + +The accounting unit is additions plus deletions in the final integrated diff, even though the parent plan calls it “net changed lines.” Generated/vendor bundles, lockfile churn, mechanical formatting, Phase 0 research bytes, and post-v1 hosted terminal scope are excluded. + +This directory is a current estimate candidate pending review, not canonical evidence. `docs/hosted-web-phases/START_HERE.md` remains the canonical hosted-web entrypoint, and `docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md` governs review, authority, retention, and supersession. The rejected `estimate-candidate-reconcile-h4` output remains rejected and non-authoritative; this remediation reuses its preserved four estimate files as input without reviving its authority. + +The source snapshot identifies where the estimate inputs are reproduced; the target candidate identifies the release-scope bytes being evaluated. Neither label is an evidence-authority claim. The target release scope is non-terminal v1. Counted lines are implementation additions, tests/evidence additions, deleted legacy lines, and explicitly unallocated mixed scope. Generated/vendor and other excluded lines contribute zero. Uncertainty remains visible in confidence, assumptions, controller-estimated allocations, and unallocated mixed scope. The hosted terminal slice remains a separately estimated post-v1 deferral and contributes zero to this v1 total. + +The machine-readable artifacts are: + +- estimate-ledger.json — source hashes, nine unique buckets, W3/W4/W5 allocation reconciliation, confidence transitions, totals, deferred scope, and review triggers; +- estimate-ledger.schema.json — strict Draft 2020-12 schema; +- verify-ledger.mjs — pinned source/target snapshot hashes, allocation ownership, overlap subtraction, W4 scope completeness, confidence, arithmetic, variance, and terminal-exclusion checks. + +## Reconciled totals + +| Accounting component | Low | High | +| ------------------------------ | ---------: | ---------: | +| Known implementation additions | 17,300 | 27,000 | +| Known tests/evidence additions | 12,550 | 20,250 | +| Deleted legacy lines | 3,300 | 6,750 | +| Unallocated mixed scope | 5,150 | 8,100 | +| **Reconciled non-terminal v1** | **38,300** | **62,100** | +| Parent unique-bucket baseline | 28,000 | 45,000 | +| Deferred hosted terminal T1 | 6,500 | 11,500 | + +Deferred hosted terminal contributes zero to v1. + +## W3/W5 recovery split + +Numeric endpoint containment is not used as an overlap rule. The original parent recovery/state baseline is split, without changing the 28,000-45,000 total baseline: + +| Bucket | Baseline | Reconciled | Numeric treatment | +| -------------------------- | ----------: | ----------: | -------------------------------------------------------------------------------- | +| EST-RECOVERY-STATE | 2,500-4,000 | 4,950-8,500 | All four W3 source sub-buckets, gross-normalized and summed | +| EST-COMMAND-EVENT-RECOVERY | 2,000-3,500 | 4,250-7,000 | W5 source envelope less the bounded shared storage/transaction-fixture allowance | + +W5’s source envelope is 4,700-8,000 gross lines. The ledger decomposes it into: + +- 4,250-7,000 unique command descriptors/fingerprints, effect recovery, event journal/SSE handoff, renderer reconciliation, and provider-effect adapters; +- 450-1,000 shared transaction/storage fixtures excluded from the W5 bucket because the W3 writer-coordination and SQLite-backup allocations already represent them. + +The verifier proves that unique plus excluded overlap reconstructs every W5 source component, that all four W3 source sub-buckets reconstruct the W3 bucket, and that a negative fixture with nested numeric ranges but disjoint allocation keys is rejected as semantic overlap. + +## W4 executable native estimate + +W4 r3 remains valid source evidence: it characterizes the host and admits zero executable lines. The reconciliation does not mutate or delete that evidence. It adds a controller estimate for every executable primitive that W2 explicitly excludes: + +| W4 allocation | Owner bucket | Gross range | +| ------------------------------------ | ---------------------- | --------------: | +| Workspace guard | EST-IDENTITY-WORKSPACE | 850-1,400 | +| Instance lease | EST-LIFECYCLE-RUNTIME | 950-1,650 | +| Process anchor | EST-LIFECYCLE-RUNTIME | 900-1,500 | +| Native-helper build and artifact ABI | EST-LIFECYCLE-RUNTIME | 1,150-2,000 | +| Final-image probes | EST-LIFECYCLE-RUNTIME | 650-1,100 | +| **Complete W4 executable scope** | two owner buckets | **4,500-7,650** | + +The workspace guard is allocated inside the existing identity/workspace parent envelope; it replaces part of that bucket’s unallocated range. The remaining four W4 allocations are added to W2’s 5,050-7,500 gross lifecycle/runtime contribution, producing 8,700-13,750. + +The rejected historical W4 r2 envelope remains source evidence and contributes zero. + +## Confidence transitions + +Every bucket stores baseline rating, reconciled rating, changed flag, source provenance, and transition reason. The verifier checks the baseline ratings against the parent ledger and proves each changed flag from the two ratings. + +Total confidence transitions from the parent **7/10** to reconciled **5/10**. The reduction reflects two controller-estimated inputs: the complete W4 executable allocation and the W3/W5 shared-fixture bound. The schema and verifier require both scores, scale, provenance, changed flag, and reason. + +## Bucket summary + +| Bucket | Baseline | Reconciled | Confidence | +| -------------------------- | ----------: | -----------: | ------------------- | +| EST-CONTRACTS | 2,000-3,000 | 2,000-3,000 | high → high | +| EST-IDENTITY-WORKSPACE | 3,500-5,500 | 3,500-5,500 | medium → medium-low | +| EST-LIFECYCLE-RUNTIME | 5,000-8,000 | 8,700-13,750 | medium → low | +| EST-RECOVERY-STATE | 2,500-4,000 | 4,950-8,500 | medium-low → low | +| EST-COMMAND-EVENT-RECOVERY | 2,000-3,500 | 4,250-7,000 | medium-low → low | +| EST-HOSTED-OPS | 3,500-5,500 | 3,300-5,150 | medium → medium-low | +| EST-RENDERER-LIFECYCLE | 3,000-5,000 | 3,900-6,400 | medium → medium-low | +| EST-REMAINING-PARITY | 4,000-6,500 | 5,200-8,800 | medium-low → low | +| EST-RELEASE-E2E | 2,500-4,000 | 2,500-4,000 | medium → low | + +Endpoint-relative variance is +36.7857% for 38,300 versus the 28,000 lower endpoint and +38% for 62,100 versus the 45,000 upper endpoint. Those paired variance calculations do not make 38,300 outside the full 28,000-45,000 interval. Lifecycle/runtime, both split W3/W5 buckets, renderer lifecycle, and remaining parity exceed the greater-than-20-percent bucket trigger. + +## Source integrity + +Every declared source carries an explicit provenance class. Twenty-one inputs are reproduced with `git show` from source snapshot 42ec333848e29e97c41699b9fed73ed199740e3f. The two auth-artifact inputs are reproduced from target snapshot 1587615c751c3cb12b5078ab4b7264b6e9fd42ad. The verifier requires the source snapshot to be an ancestor of the target snapshot, but intentionally does not assert `HEAD == asOfCommit`; this keeps verification valid when the reconciliation commit advances HEAD. + +| Source | Provenance snapshot | SHA-256 | +| ----------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------- | +| docs/hosted-web-e2e-completion-plan.md | source | 4901a37dc4da373efab939b43a406739ed02e1b4e250a7a2e5754ad659fa4080 | +| docs/research/hosted-web/phase-0/estimate-ledger.md | source | 1b0f45bfdeb17e2ddc6058482c9b190f16b09501561c361370fac5bfad783ab0 | +| docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json | source | d5c8725dfb22f7e0228e0dd51f53d978d117ed7253fdb279c8ddba7000ff8758 | +| docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json | source | af3fe8edc17ac5f3ca77533a949625b5b97c61c3ebab111726f72ebdc9adf669 | +| docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json | source | 6316c642472506f51638673aa3ede535dc358264c77b4cff43ca83364d879625 | +| docs/research/hosted-web/phase-0/state-writers/estimate-input.json | source | e115aa10ab1ad3842e8b44db03a07541cae9b6abdbeb1207ba3f01bd6d5ff7ae | +| docs/research/hosted-web/phase-0/host-primitives/estimate-input.json | source | a1b527c3f9ecd1863223eed43bc6e0a3ea720af9138114b9924d0e8acf062f7c | +| docs/research/hosted-web/phase-0/recovery-events/estimate-input.json | source | 03f41142c1845f913bd0dfbd59499cfb6390454c36f4fec596432e7faf714341 | +| docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json | source | 1309dd32d3ebf57447dc181b802fa5e625f1b7bb069fc040c08fef74513750b8 | + +The ledger also pins fourteen supporting evidence files. Of those, `auth-artifacts/evidence.json` is target-classified at `082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a`, and `auth-artifacts/proposed-hosted-artifact-manifest.json` is target-classified at `8903c40cf3761996f5fc732e4d54e0803e2ff6c2eed32f7cbff1befcd7f65f73`. Verification recomputes all twenty-three hashes from their declared Git snapshots. + +## Validation + +From repository root: + + node docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs + python3 -m json.tool docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json >/dev/null + python3 -m json.tool docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json >/dev/null + python3 -m jsonschema -i docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json + pnpm exec prettier --check "docs/research/hosted-web/phase-0/estimate-reconciliation/**/*.{json,md,mjs}" + +Owned-path scope, current-candidate diff, and validation results are recorded in the single handoff under .codex-handoff/. diff --git a/docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json b/docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json new file mode 100644 index 0000000000..b0ca7c2cbf --- /dev/null +++ b/docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json @@ -0,0 +1,1453 @@ +{ + "$schema": "./estimate-ledger.schema.json", + "schemaVersion": 1, + "ledgerId": "P0.ESTIMATE.UNIQUE_BUCKET_RECONCILIATION", + "asOfCommit": "42ec333848e29e97c41699b9fed73ed199740e3f", + "targetHeadCommit": "1587615c751c3cb12b5078ab4b7264b6e9fd42ad", + "provenance": { + "sourceSnapshot": { + "commitField": "asOfCommit", + "classification": "source_snapshot" + }, + "targetSnapshot": { + "commitField": "targetHeadCommit", + "classification": "target_candidate" + }, + "headPolicy": "pinned_snapshots_no_head_equality", + "ancestryPolicy": "source_snapshot_must_be_ancestor_of_target_candidate", + "authority": { + "artifactStatus": "current_candidate_not_canonical", + "reviewDisposition": "pending", + "canonicalEntrypoint": "docs/hosted-web-phases/START_HERE.md", + "evidenceLifecycle": "docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md", + "supersededRejectedArtifact": "estimate-candidate-reconcile-h4" + } + }, + "status": "current_candidate_requires_scope_design_review", + "unit": "gross_integrated_changed_lines", + "accountingPolicy": { + "definition": "Additions plus deletions in the final integrated non-terminal v1 diff. A line is counted once after integration, matching the parent plan's definition even though that plan calls the measure net changed lines.", + "endpointRule": "Normalize every lane endpoint as production additions plus test/evidence additions plus deleted legacy lines at the same low or high endpoint. Never subtract deletions.", + "deletionRule": "Deleted legacy lines are a positive changed-line component, not negative implementation. Migration and rollout are tracked as scope and are not inferred from deletion counts.", + "deduplicationRule": "Each semantic allocation has exactly one numeric disposition and one bucket owner. Source envelopes are decomposed into unique and explicitly excluded shared allocations; numeric endpoint containment is never evidence of semantic containment. Cross-feature release evidence includes only production-shape work not already owned by a feature bucket.", + "excluded": [ + "generated bundles and manifests", + "lockfile and vendor churn", + "mechanical formatting", + "Phase 0 research artifact lines", + "post-v1 hosted terminal implementation and packaging" + ] + }, + "sources": [ + { + "sourceId": "PARENT_PLAN", + "path": "docs/hosted-web-e2e-completion-plan.md", + "sha256": "4901a37dc4da373efab939b43a406739ed02e1b4e250a7a2e5754ad659fa4080", + "role": "parent_plan", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.BASELINE_ESTIMATE", + "path": "docs/research/hosted-web/phase-0/estimate-ledger.md", + "sha256": "1b0f45bfdeb17e2ddc6058482c9b190f16b09501561c361370fac5bfad783ab0", + "role": "baseline", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.FREEZE_EVIDENCE_INDEX", + "path": "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "sha256": "d5c8725dfb22f7e0228e0dd51f53d978d117ed7253fdb279c8ddba7000ff8758", + "role": "freeze_index", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W1.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json", + "sha256": "af3fe8edc17ac5f3ca77533a949625b5b97c61c3ebab111726f72ebdc9adf669", + "role": "lane_estimate", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W2.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "sha256": "6316c642472506f51638673aa3ede535dc358264c77b4cff43ca83364d879625", + "role": "lane_estimate", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W3.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "sha256": "e115aa10ab1ad3842e8b44db03a07541cae9b6abdbeb1207ba3f01bd6d5ff7ae", + "role": "lane_estimate", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W4.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/host-primitives/estimate-input.json", + "sha256": "a1b527c3f9ecd1863223eed43bc6e0a3ea720af9138114b9924d0e8acf062f7c", + "role": "lane_estimate", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W5.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "sha256": "03f41142c1845f913bd0dfbd59499cfb6390454c36f4fec596432e7faf714341", + "role": "lane_estimate", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W6.ESTIMATE", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json", + "sha256": "1309dd32d3ebf57447dc181b802fa5e625f1b7bb069fc040c08fef74513750b8", + "role": "lane_estimate", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W1.ACTION_INVENTORY", + "path": "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "sha256": "9ff8164fb83dd235b69aef81168d03ed5c6dd84f5ffd25fab25bacd594d175b4", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W1.API_PARITY", + "path": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "sha256": "a11f469d9c981c3e05be57fed7a6303772349a79297a0f1887db97d54e84c0a3", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W2.EXECUTION_TOPOLOGY", + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sha256": "40650b7ec3549a30419932fa43330b1484468c393ddef0555438f9f308c97e3a", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W2.RUNTIME_INGRESS", + "path": "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "sha256": "531ae8746d48e527c4e1a857e8b6077c5c9d58cf1ca0ca6523cd629a98c0e807", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W3.STATE_FAMILIES", + "path": "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "sha256": "f987dfb942445d3ac9ebfeb1a4f931291b7b834af45e5979d45518d59975e90a", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W3.WRITER_COORDINATION", + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "sha256": "7c7c572e068b04c9b84886d2a7c47aa3e04ba714311b2f8c98b0fe066c7d52ef", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W3.BACKUP_BEHAVIOR", + "path": "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "sha256": "a9cff35be387062de2ce908a98aa0613cf460bd43cb5e1b3eeeb7d1c704f0623", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W4.HOST_PROBES", + "path": "docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json", + "sha256": "4eca557b6f457e4da93fd5a7fcb1603d63d77f007a9f51101302b177e3a40378", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W4.NATIVE_ARTIFACT", + "path": "docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json", + "sha256": "c5db2f4de55d3a5010cab653d9fe430862722fbd2f815dbc32c1a0fe46d92afb", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W5.COMMAND_CATALOG", + "path": "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "sha256": "a40fd876f0a9ffc63e5932985c969ab867411716a48718586abf1552a35780ff", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W5.EFFECT_RECOVERY", + "path": "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "sha256": "c7166ca1ac43f72591fd1c56221dd3f2b6ffe8bebdbdca2b9fb89288874a79bc", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W5.EVENT_CURSORS", + "path": "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "sha256": "2e296692f0415c3b82f3ce15cf1683bbd0aee846d2b571a5dff31bff406ddc15", + "role": "supporting_evidence", + "provenanceClass": "source_snapshot" + }, + { + "sourceId": "P0.W6.AUTH_ARTIFACT_EVIDENCE", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "sha256": "082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a", + "role": "supporting_evidence", + "provenanceClass": "target_candidate" + }, + { + "sourceId": "P0.W6.ARTIFACT_MANIFEST", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json", + "sha256": "8903c40cf3761996f5fc732e4d54e0803e2ff6c2eed32f7cbff1befcd7f65f73", + "role": "supporting_evidence", + "provenanceClass": "target_candidate" + } + ], + "buckets": [ + { + "bucketId": "EST-CONTRACTS", + "title": "Shared contracts, feature skeletons, capability/route and architecture gates", + "confidence": { + "baseline": { + "rating": "high", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "high" + }, + "changed": false, + "reason": "W1 preserves the parent contract scope and supplies direct parity evidence without changing the range." + }, + "estimateStatus": "evidence_reconciled", + "allocationKeys": [ + "architecture-gates", + "capability-route-contracts", + "shared-feature-contracts" + ], + "baseline": { + "low": 2000, + "high": 3000 + }, + "components": { + "implementationAdditions": { + "low": 1200, + "high": 1800 + }, + "testsAndEvidenceAdditions": { + "low": 800, + "high": 1200 + }, + "deletedLegacyLines": { + "low": 0, + "high": 0 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "reconciled": { + "low": 2000, + "high": 3000 + }, + "scopePartition": { + "implementation": [ + "shared capability and action contracts", + "feature entrypoint skeletons" + ], + "testsAndEvidence": ["ADR-19 parity, signature and architecture gates"], + "migrationAndRollout": { + "items": [], + "numericStatus": "none_identified", + "explanation": "No distinct migration or rollout item is identified in the W1 contract estimate." + } + }, + "deduplication": { + "strategy": "single_source", + "includedSourceIds": ["P0.W1.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": [ + "contracts-consumed-by-later-features", + "parity-fixtures-reused-by-release-gates" + ], + "explanation": "Contracts and their focused gates are owned here once even when every later feature consumes them." + }, + "variance": { + "lowPercent": 0, + "highPercent": 0, + "overTwentyPercent": false + }, + "assumptions": ["No replacement mega-interface is introduced."], + "sourceIds": ["PARENT_PLAN", "P0.BASELINE_ESTIMATE", "P0.W1.ESTIMATE", "P0.W1.API_PARITY"] + }, + { + "bucketId": "EST-IDENTITY-WORKSPACE", + "title": "Team/member/workspace identity, workspace registry and ADR-28 guard", + "confidence": { + "baseline": { + "rating": "medium", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "medium-low" + }, + "changed": true, + "reason": "W4 executable workspace-guard scope is now quantified, but identity adoption, registry migration, and final integration remain parent-range allocations." + }, + "estimateStatus": "parent_provisional", + "allocationKeys": ["identity-adoption", "w4-workspace-guard", "workspace-registry"], + "baseline": { + "low": 3500, + "high": 5500 + }, + "components": { + "implementationAdditions": { + "low": 450, + "high": 700 + }, + "testsAndEvidenceAdditions": { + "low": 350, + "high": 600 + }, + "deletedLegacyLines": { + "low": 50, + "high": 100 + }, + "unallocatedMixedScope": { + "low": 2650, + "high": 4100 + } + }, + "reconciled": { + "low": 3500, + "high": 5500 + }, + "scopePartition": { + "implementation": ["identity adoption", "workspace registry", "native workspace guard"], + "testsAndEvidence": [ + "workspace-root containment, symlink, traversal, and race probes", + "identity and workspace registration integration evidence" + ], + "migrationAndRollout": { + "items": ["identity and workspace registration adoption"], + "numericStatus": "included_not_separately_quantified", + "explanation": "The W4 workspace guard is quantified as 850-1,400 gross lines inside the accepted identity/workspace envelope; the remaining 2,650-4,100 mixed range covers identity adoption, registry work, and migration that current evidence cannot split." + } + }, + "deduplication": { + "strategy": "controller_allocation", + "includedSourceIds": ["P0.BASELINE_ESTIMATE", "P0.W4.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": ["identity-guard-composition-fixtures", "workspace-guard-probes"], + "explanation": "The controller estimates the W4 workspace guard from the characterized executable scope and preserves it inside, rather than in addition to, the parent identity/workspace range. W4's rejected historical r2 envelope remains excluded." + }, + "variance": { + "lowPercent": 0, + "highPercent": 0, + "overTwentyPercent": false + }, + "assumptions": [ + "Legacy identity adoption remains compatible.", + "The workspace guard stays a small reusable native primitive and does not introduce a second workspace registry." + ], + "sourceIds": [ + "PARENT_PLAN", + "P0.BASELINE_ESTIMATE", + "P0.W4.ESTIMATE", + "P0.W4.HOST_PROBES", + "P0.W4.NATIVE_ARTIFACT" + ] + }, + { + "bucketId": "EST-LIFECYCLE-RUNTIME", + "title": "Lifecycle/runtime extraction, provider ingress and ADR-30/31 ownership", + "confidence": { + "baseline": { + "rating": "medium", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "low" + }, + "changed": true, + "reason": "W2 is source-estimated, while W4 lease, anchor, native build, and final-image probe allocations are new controller estimates over characterized but previously unestimated executable scope." + }, + "estimateStatus": "controller_reconciled", + "allocationKeys": [ + "environment-credential-policy", + "provider-runtime-ingress", + "team-lifecycle-runtime-control", + "w4-final-image-probes", + "w4-instance-lease", + "w4-native-helper-build", + "w4-process-anchor" + ], + "baseline": { + "low": 5000, + "high": 8000 + }, + "components": { + "implementationAdditions": { + "low": 4250, + "high": 6600 + }, + "testsAndEvidenceAdditions": { + "low": 4050, + "high": 6350 + }, + "deletedLegacyLines": { + "low": 400, + "high": 800 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "reconciled": { + "low": 8700, + "high": 13750 + }, + "scopePartition": { + "implementation": [ + "provider plan and capability composition", + "runtime ingress and lane relay", + "environment and credential policy", + "instance lease and process anchor primitives", + "production native-helper build and final-image composition" + ], + "testsAndEvidence": [ + "provider conformance fixtures", + "runtime ingress and credential negative controls", + "lease/anchor race and stale-owner probes", + "native artifact and final-image runtime probes" + ], + "migrationAndRollout": { + "items": ["strangler migration from legacy runtime authority"], + "numericStatus": "included_not_separately_quantified", + "explanation": "The W2 production range includes compatibility-adapter migration work. W4 executable scope is separately allocated, but neither lane isolates rollout work numerically." + } + }, + "deduplication": { + "strategy": "allocation_sum", + "includedSourceIds": ["P0.W2.ESTIMATE", "P0.W4.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": [ + "process-ownership-fixtures", + "provider-launch-fixtures", + "runtime-security-fixtures" + ], + "explanation": "W2 gross normalization contributes 5,050-7,500. Four disjoint W4 executable allocations contribute another 3,650-6,250; W2 explicitly excludes these primitives. W4's rejected historical r2 envelope contributes zero." + }, + "variance": { + "lowPercent": 74, + "highPercent": 71.875, + "overTwentyPercent": true + }, + "assumptions": [ + "Two backend families are preserved.", + "ADR-30 owns the controller relay.", + "The native helper remains narrowly limited to lease, process-anchor, workspace-guard, and probe duties." + ], + "sourceIds": [ + "PARENT_PLAN", + "P0.BASELINE_ESTIMATE", + "P0.W2.ESTIMATE", + "P0.W4.ESTIMATE", + "P0.W2.EXECUTION_TOPOLOGY", + "P0.W2.RUNTIME_INGRESS", + "P0.W4.HOST_PROBES", + "P0.W4.NATIVE_ARTIFACT" + ] + }, + { + "bucketId": "EST-RECOVERY-STATE", + "title": "State compatibility, external-writer coordination, SQLite backup and participants", + "confidence": { + "baseline": { + "rating": "medium-low", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "low" + }, + "changed": true, + "reason": "W3 supplies allocation-level ranges, but writer coordination and backup-participant scope retain low-confidence external-writer and cutover assumptions." + }, + "estimateStatus": "evidence_reconciled", + "allocationKeys": [ + "w3-backup-participants", + "w3-compat-catalog", + "w3-sqlite-backup", + "w3-writer-coordination" + ], + "baseline": { + "low": 2500, + "high": 4000 + }, + "components": { + "implementationAdditions": { + "low": 2450, + "high": 4000 + }, + "testsAndEvidenceAdditions": { + "low": 2050, + "high": 3250 + }, + "deletedLegacyLines": { + "low": 450, + "high": 1250 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "reconciled": { + "low": 4950, + "high": 8500 + }, + "scopePartition": { + "implementation": [ + "state compatibility catalog", + "external-writer coordination", + "SQLite online backup adapter", + "backup participants and restore verifier" + ], + "testsAndEvidence": [ + "unknown-schema and compatibility fixtures", + "writer-coordination schedules", + "SQLite online-backup and restore faults", + "backup participant cutover fixtures" + ], + "migrationAndRollout": { + "items": [ + "state compatibility migrations", + "backup participant cutover", + "legacy backup retirement" + ], + "numericStatus": "included_not_separately_quantified", + "explanation": "W3 includes compatibility migration, backup participant cutover, and legacy backup retirement inside its production and deletion ranges." + } + }, + "deduplication": { + "strategy": "allocation_sum", + "includedSourceIds": ["P0.W3.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": ["internal-storage-fixtures", "state-transaction-fixtures"], + "explanation": "All four W3 source sub-buckets are retained at their recorded gross endpoints. Command descriptors, effect recovery, journal/SSE handoff, and renderer reconciliation are absent and owned only by EST-COMMAND-EVENT-RECOVERY." + }, + "variance": { + "lowPercent": 98, + "highPercent": 112.5, + "overTwentyPercent": true + }, + "assumptions": [ + "Provider-owned JSON remains behind compatibility adapters.", + "Publication and durable BackupRun orchestration are owned by the W5 bucket and are not estimated here." + ], + "sourceIds": [ + "PARENT_PLAN", + "P0.BASELINE_ESTIMATE", + "P0.W3.ESTIMATE", + "P0.W3.STATE_FAMILIES", + "P0.W3.WRITER_COORDINATION", + "P0.W3.BACKUP_BEHAVIOR" + ] + }, + { + "bucketId": "EST-COMMAND-EVENT-RECOVERY", + "title": "Command descriptors, effect recovery, event journal/SSE handoff and renderer reconciliation", + "confidence": { + "baseline": { + "rating": "medium-low", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "low" + }, + "changed": true, + "reason": "W5 is source-estimated, but its shared transaction/storage fixture allowance is controller-bounded and subtracted because the producer did not publish component-level overlap ranges." + }, + "estimateStatus": "controller_reconciled", + "allocationKeys": ["w5-command-event-recovery"], + "baseline": { + "low": 2000, + "high": 3500 + }, + "components": { + "implementationAdditions": { + "low": 2450, + "high": 3900 + }, + "testsAndEvidenceAdditions": { + "low": 1600, + "high": 2650 + }, + "deletedLegacyLines": { + "low": 200, + "high": 450 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "reconciled": { + "low": 4250, + "high": 7000 + }, + "scopePartition": { + "implementation": [ + "command descriptors and HMAC fingerprints", + "command/effect registry and recovery classification", + "event journal and SSE snapshot/cursor handoff", + "renderer reconciliation", + "provider effect adapters" + ], + "testsAndEvidence": [ + "fingerprint golden vectors", + "crash-window and ambiguous-effect fixtures", + "snapshot/cursor schedule fixtures", + "renderer reconciliation fixtures" + ], + "migrationAndRollout": { + "items": ["journal and descriptor retention/key-version adoption"], + "numericStatus": "included_not_separately_quantified", + "explanation": "W5 includes descriptor and journal adoption in its production range and does not isolate migration lines." + } + }, + "deduplication": { + "strategy": "allocation_subtraction", + "includedSourceIds": ["P0.W5.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": ["internal-storage-fixtures", "state-transaction-fixtures"], + "explanation": "W5's 4,700-8,000 gross source envelope is decomposed into 4,250-7,000 unique command/event/recovery scope plus a 450-1,000 shared storage/transaction-fixture allowance represented by W3. Numeric containment is not used." + }, + "variance": { + "lowPercent": 112.5, + "highPercent": 100, + "overTwentyPercent": true + }, + "assumptions": [ + "One hosted journal writer and one internal SQLite substrate are accepted.", + "Current OpenCode delivery evidence is adapted rather than rewritten wholesale.", + "Terminal recovery remains excluded from v1." + ], + "sourceIds": [ + "PARENT_PLAN", + "P0.BASELINE_ESTIMATE", + "P0.W5.ESTIMATE", + "P0.W5.COMMAND_CATALOG", + "P0.W5.EFFECT_RECOVERY", + "P0.W5.EVENT_CURSORS" + ] + }, + { + "bucketId": "EST-HOSTED-OPS", + "title": "Hosted composition, auth/proxy, packaging and production operations", + "confidence": { + "baseline": { + "rating": "medium", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "medium-low" + }, + "changed": true, + "reason": "W6 provides a numeric range, while final standalone artifact and deployment-topology evidence remain unresolved." + }, + "estimateStatus": "evidence_reconciled", + "allocationKeys": [ + "docker-edge-fixtures", + "hosted-access-auth-proxy", + "hosted-composition-build", + "internal-storage-worker-packaging" + ], + "baseline": { + "low": 3500, + "high": 5500 + }, + "components": { + "implementationAdditions": { + "low": 2200, + "high": 3300 + }, + "testsAndEvidenceAdditions": { + "low": 1000, + "high": 1600 + }, + "deletedLegacyLines": { + "low": 100, + "high": 250 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "reconciled": { + "low": 3300, + "high": 5150 + }, + "scopePartition": { + "implementation": [ + "hosted access", + "composition and build", + "internal-storage worker packaging" + ], + "testsAndEvidence": ["auth/proxy topology fixtures", "Docker and artifact probes"], + "migrationAndRollout": { + "items": ["standalone artifact evolution"], + "numericStatus": "included_not_separately_quantified", + "explanation": "W6 includes artifact evolution within production lines and does not quantify a separate migration slice." + } + }, + "deduplication": { + "strategy": "single_source", + "includedSourceIds": ["P0.W6.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": ["auth-e2e-fixtures", "build-image-fixtures", "internal-storage-packaging"], + "explanation": "W6 owns auth/proxy/build fixtures once. W2 ingress, W4 native primitives and generic release E2E are excluded from this numeric contribution." + }, + "variance": { + "lowPercent": -5.7143, + "highPercent": -6.3636, + "overTwentyPercent": false + }, + "assumptions": [ + "The rejected standalone artifact proposal remains non-authoritative; any replacement requires a separately reviewed packet.", + "Hosted terminal is absent from v1 composition and packaging." + ], + "sourceIds": [ + "PARENT_PLAN", + "P0.BASELINE_ESTIMATE", + "P0.W6.ESTIMATE", + "P0.W6.AUTH_ARTIFACT_EVIDENCE", + "P0.W6.ARTIFACT_MANIFEST" + ] + }, + { + "bucketId": "EST-RENDERER-LIFECYCLE", + "title": "Renderer transport/reconciler and lifecycle-screen migration", + "confidence": { + "baseline": { + "rating": "medium", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "medium-low" + }, + "changed": true, + "reason": "W1 evidence raises both gross endpoints over 20 percent and retains hidden renderer state-machine uncertainty." + }, + "estimateStatus": "evidence_reconciled", + "allocationKeys": [ + "renderer-lifecycle-migration", + "team-console-composition", + "transport-reconciler" + ], + "baseline": { + "low": 3000, + "high": 5000 + }, + "components": { + "implementationAdditions": { + "low": 1800, + "high": 2800 + }, + "testsAndEvidenceAdditions": { + "low": 1200, + "high": 2000 + }, + "deletedLegacyLines": { + "low": 900, + "high": 1600 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "reconciled": { + "low": 3900, + "high": 6400 + }, + "scopePartition": { + "implementation": ["team console", "transport reconciler", "lifecycle-screen composition"], + "testsAndEvidence": ["selection and reconciliation invariant fixtures"], + "migrationAndRollout": { + "items": ["teamSlice and TeamDetail lifecycle-screen migration"], + "numericStatus": "included_not_separately_quantified", + "explanation": "W1 combines the renderer migration with production additions and reports deleted legacy lines separately." + } + }, + "deduplication": { + "strategy": "single_source", + "includedSourceIds": ["P0.W1.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": [ + "renderer-fixtures-reused-by-parity", + "renderer-fixtures-reused-by-release-e2e" + ], + "explanation": "Lifecycle renderer work is owned here once. W1's 2,100-3,200 net range subtracts deletions and therefore does not use the parent metric; normalized gross is 3,900-6,400." + }, + "variance": { + "lowPercent": 30, + "highPercent": 28, + "overTwentyPercent": true + }, + "assumptions": [ + "Desktop-only controls are absent before hosted mount.", + "No task/message/review/provider action is duplicated from its owning bucket." + ], + "sourceIds": [ + "PARENT_PLAN", + "P0.BASELINE_ESTIMATE", + "P0.W1.ESTIMATE", + "P0.W1.ACTION_INVENTORY" + ] + }, + { + "bucketId": "EST-REMAINING-PARITY", + "title": "Tasks, messages, review, approvals, members and attachments parity", + "confidence": { + "baseline": { + "rating": "medium-low", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "low" + }, + "changed": true, + "reason": "W1 evidence raises both gross endpoints over 20 percent and visible-screen dependency closure remains uncertain." + }, + "estimateStatus": "evidence_reconciled", + "allocationKeys": [ + "agent-attachments", + "member-management", + "team-approvals", + "team-messaging", + "team-review", + "team-task-board" + ], + "baseline": { + "low": 4000, + "high": 6500 + }, + "components": { + "implementationAdditions": { + "low": 2500, + "high": 3900 + }, + "testsAndEvidenceAdditions": { + "low": 1500, + "high": 2600 + }, + "deletedLegacyLines": { + "low": 1200, + "high": 2300 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "reconciled": { + "low": 5200, + "high": 8800 + }, + "scopePartition": { + "implementation": [ + "task board", + "messaging", + "review", + "approvals", + "members", + "attachments" + ], + "testsAndEvidence": ["semantic action and visible-screen parity fixtures"], + "migrationAndRollout": { + "items": ["visible controls and legacy bypass migration by feature owner"], + "numericStatus": "included_not_separately_quantified", + "explanation": "W1 combines feature migration with production additions and reports deleted legacy lines separately." + } + }, + "deduplication": { + "strategy": "single_source", + "includedSourceIds": ["P0.W1.ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": ["one-owner-per-semantic-action", "parity-fixtures-reused-by-release-e2e"], + "explanation": "Each visible action is assigned to one feature key. W1's 2,800-4,200 net range subtracts deletions and therefore does not use the parent metric; normalized gross is 5,200-8,800." + }, + "variance": { + "lowPercent": 30, + "highPercent": 35.3846, + "overTwentyPercent": true + }, + "assumptions": [ + "One owning feature is used for each semantic action.", + "Server/runtime/auth work remains in its non-W1 bucket." + ], + "sourceIds": [ + "PARENT_PLAN", + "P0.BASELINE_ESTIMATE", + "P0.W1.ESTIMATE", + "P0.W1.ACTION_INVENTORY", + "P0.W1.API_PARITY" + ] + }, + { + "bucketId": "EST-RELEASE-E2E", + "title": "Real-browser E2E, desktop regression, migration/rollout docs and tooling", + "confidence": { + "baseline": { + "rating": "medium", + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "rating": "low" + }, + "changed": true, + "reason": "No lane input splits production-shape E2E, desktop regression, release tooling, and rollout evidence within the parent range." + }, + "estimateStatus": "parent_provisional", + "allocationKeys": [ + "desktop-regression", + "migration-rollout-docs-tooling", + "production-shape-browser-e2e" + ], + "baseline": { + "low": 2500, + "high": 4000 + }, + "components": { + "implementationAdditions": { + "low": 0, + "high": 0 + }, + "testsAndEvidenceAdditions": { + "low": 0, + "high": 0 + }, + "deletedLegacyLines": { + "low": 0, + "high": 0 + }, + "unallocatedMixedScope": { + "low": 2500, + "high": 4000 + } + }, + "reconciled": { + "low": 2500, + "high": 4000 + }, + "scopePartition": { + "implementation": [], + "testsAndEvidence": [ + "production-shape real-browser E2E", + "desktop regression", + "release tooling" + ], + "migrationAndRollout": { + "items": ["migration evidence", "rollout and rollback documentation/tooling"], + "numericStatus": "mixed_parent_range_not_separable", + "explanation": "No lane input isolates the test/evidence portion from migration/rollout within the parent 2,500-4,000 range, so the full range remains visibly unallocated rather than fabricated." + } + }, + "deduplication": { + "strategy": "parent_provisional", + "includedSourceIds": ["P0.BASELINE_ESTIMATE"], + "excludedNumericSourceIds": [], + "overlapKeys": [ + "feature-fixtures", + "native-probes", + "auth-proxy-fixtures", + "renderer-fixtures" + ], + "explanation": "Only cross-feature production-shape harness, desktop regression and rollout/migration evidence remain here. Focused fixtures already counted in W1-W6 buckets are not repeated." + }, + "variance": { + "lowPercent": 0, + "highPercent": 0, + "overTwentyPercent": false + }, + "assumptions": [ + "Most deterministic lane fixtures are reusable.", + "A later release-E2E estimate must split test/evidence from migration/rollout before implementation freeze." + ], + "sourceIds": ["PARENT_PLAN", "P0.BASELINE_ESTIMATE", "P0.FREEZE_EVIDENCE_INDEX"] + } + ], + "totals": { + "baselineV1": { + "low": 28000, + "high": 45000 + }, + "knownComponents": { + "implementationAdditions": { + "low": 17300, + "high": 27000 + }, + "testsAndEvidenceAdditions": { + "low": 12550, + "high": 20250 + }, + "deletedLegacyLines": { + "low": 3300, + "high": 6750 + }, + "unallocatedMixedScope": { + "low": 5150, + "high": 8100 + } + }, + "reconciledV1": { + "low": 38300, + "high": 62100 + }, + "variance": { + "lowPercent": 36.7857, + "highPercent": 38, + "overTwentyPercent": true + }, + "confidence": { + "baseline": { + "score": 7, + "scale": 10, + "sourceId": "P0.BASELINE_ESTIMATE" + }, + "reconciled": { + "score": 5, + "scale": 10 + }, + "changed": true, + "reason": "The total now includes all characterized W4 executable scope and a disjoint W5 bucket, but the W4 allocations and W3/W5 shared-fixture bound are controller estimates pending implementation inventories." + }, + "migrationQuantification": { + "status": "included_not_separately_quantified", + "explanation": "Migration/rollout work is separately identified by bucket but lane inputs do not isolate its line count from production additions, and the release bucket does not split it from E2E/tooling. Deleted legacy lines are not used as a migration proxy." + }, + "requiresScopeDesignReview": true + }, + "deferredScope": [ + { + "scopeId": "POST-V1-T1-HOSTED-TERMINAL", + "title": "Hosted terminal parity across this repository and terminal-platform", + "estimate": { + "low": 6500, + "high": 11500 + }, + "v1Contribution": { + "low": 0, + "high": 0 + }, + "repositories": ["777genius/agent-teams-ai", "terminal-platform"], + "sourceIds": ["PARENT_PLAN"], + "reason": "ADR-35 is a separately revalidated post-v1 project. V1 contains no hosted terminal route, renderer facet, daemon, migration, store, socket, artifact or packaging line." + } + ], + "reviewTriggers": [ + { + "triggerId": "BUCKET_VARIANCE_OVER_20_PERCENT", + "active": true, + "reason": "EST-LIFECYCLE-RUNTIME, both split W3/W5 recovery buckets, EST-RENDERER-LIFECYCLE, and EST-REMAINING-PARITY exceed 20 percent at one or both endpoints." + }, + { + "triggerId": "TOTAL_PARTIALLY_OUTSIDE_PARENT_RANGE", + "active": true, + "reason": "The reconciled low endpoint 38,300 is inside the inclusive parent 28,000-45,000 interval; the high endpoint 62,100 exceeds the parent high endpoint." + }, + { + "triggerId": "CONTROLLER_ESTIMATE_REVIEW", + "active": true, + "reason": "W4 executable allocations and the W5 shared-fixture subtraction are controller estimates and require scope/design review before capacity expands." + }, + { + "triggerId": "UNALLOCATED_MIGRATION_SPLIT", + "active": true, + "reason": "Migration/rollout is identified but not numerically separable from production and the mixed release bucket in current evidence." + }, + { + "triggerId": "PHASE_7_REESTIMATE", + "active": false, + "reason": "The parent plan requires another reconciliation after Phase 7." + } + ], + "allocationReconciliation": { + "method": "Source sub-scopes are assigned by semantic ownership, not numeric endpoint containment. Included allocations contribute once through their owner bucket. Excluded shared overlap must be arithmetically recoverable with the included source allocation and name the included allocations that cover it.", + "allocations": [ + { + "allocationId": "w3-compat-catalog", + "sourceId": "P0.W3.ESTIMATE", + "ownerBucketId": "EST-RECOVERY-STATE", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 650, + "high": 1050 + }, + "testsAndEvidenceAdditions": { + "low": 450, + "high": 750 + }, + "deletedLegacyLines": { + "low": 50, + "high": 200 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "features/internal-storage", + "hosted state compatibility contracts", + "team compatibility adapters" + ], + "estimateMethod": "source_sub_bucket", + "basisSourceIds": ["P0.W3.ESTIMATE"], + "rationale": "Recorded W3 compatibility-catalog source sub-bucket.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w3-writer-coordination", + "sourceId": "P0.W3.ESTIMATE", + "ownerBucketId": "EST-RECOVERY-STATE", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 900, + "high": 1450 + }, + "testsAndEvidenceAdditions": { + "low": 650, + "high": 1000 + }, + "deletedLegacyLines": { + "low": 150, + "high": 400 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "team state coordinators", + "external observation registrations", + "provider compatibility adapters" + ], + "estimateMethod": "source_sub_bucket", + "basisSourceIds": ["P0.W3.ESTIMATE"], + "rationale": "Recorded W3 writer-coordination source sub-bucket.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w3-sqlite-backup", + "sourceId": "P0.W3.ESTIMATE", + "ownerBucketId": "EST-RECOVERY-STATE", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 350, + "high": 600 + }, + "testsAndEvidenceAdditions": { + "low": 450, + "high": 700 + }, + "deletedLegacyLines": { + "low": 0, + "high": 50 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": ["features/internal-storage worker", "backup participant adapter"], + "estimateMethod": "source_sub_bucket", + "basisSourceIds": ["P0.W3.ESTIMATE"], + "rationale": "Recorded W3 SQLite-backup source sub-bucket.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w3-backup-participants", + "sourceId": "P0.W3.ESTIMATE", + "ownerBucketId": "EST-RECOVERY-STATE", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 550, + "high": 900 + }, + "testsAndEvidenceAdditions": { + "low": 500, + "high": 800 + }, + "deletedLegacyLines": { + "low": 250, + "high": 600 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "deployment backup participants", + "legacy backup retirement/ratchet", + "restore verifier" + ], + "estimateMethod": "source_sub_bucket", + "basisSourceIds": ["P0.W3.ESTIMATE"], + "rationale": "Recorded W3 backup-participant source sub-bucket.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w5-command-event-recovery", + "sourceId": "P0.W5.ESTIMATE", + "ownerBucketId": "EST-COMMAND-EVENT-RECOVERY", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 2450, + "high": 3900 + }, + "testsAndEvidenceAdditions": { + "low": 1600, + "high": 2650 + }, + "deletedLegacyLines": { + "low": 200, + "high": 450 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "shared command descriptors/fingerprints", + "internal-storage command/effect registry", + "event journal/SSE handoff", + "renderer reconciliation", + "provider effect adapters" + ], + "estimateMethod": "controller_overlap_subtraction", + "basisSourceIds": [ + "P0.W5.ESTIMATE", + "P0.W3.ESTIMATE", + "P0.W5.COMMAND_CATALOG", + "P0.W5.EFFECT_RECOVERY", + "P0.W5.EVENT_CURSORS" + ], + "rationale": "Unique W5 range after subtracting only the bounded shared transaction/storage-fixture allowance.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w5-shared-storage-transaction-fixtures", + "sourceId": "P0.W5.ESTIMATE", + "ownerBucketId": "EST-RECOVERY-STATE", + "numericDisposition": "excluded_shared_overlap", + "components": { + "implementationAdditions": { + "low": 250, + "high": 500 + }, + "testsAndEvidenceAdditions": { + "low": 200, + "high": 450 + }, + "deletedLegacyLines": { + "low": 0, + "high": 50 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "shared internal-storage transaction fixtures", + "shared SQLite writer and backup-run storage fixtures" + ], + "estimateMethod": "controller_overlap_bound", + "basisSourceIds": ["P0.W3.ESTIMATE", "P0.W5.ESTIMATE"], + "rationale": "This 450-1,000 allowance completes W5's recorded gross source range but contributes zero again because the W3 writer/SQLite allocations already carry it.", + "coveredByAllocationIds": ["w3-writer-coordination", "w3-sqlite-backup"] + }, + { + "allocationId": "w4-workspace-guard", + "sourceId": "P0.W4.ESTIMATE", + "ownerBucketId": "EST-IDENTITY-WORKSPACE", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 450, + "high": 700 + }, + "testsAndEvidenceAdditions": { + "low": 350, + "high": 600 + }, + "deletedLegacyLines": { + "low": 50, + "high": 100 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "workspace-root containment", + "symlink and traversal rejection", + "workspace race probes" + ], + "estimateMethod": "controller_scope_estimate", + "basisSourceIds": ["PARENT_PLAN", "P0.W2.ESTIMATE", "P0.W4.ESTIMATE", "P0.W4.HOST_PROBES"], + "rationale": "Small ADR-28 native guard estimate retained inside the parent identity/workspace envelope.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w4-instance-lease", + "sourceId": "P0.W4.ESTIMATE", + "ownerBucketId": "EST-LIFECYCLE-RUNTIME", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 500, + "high": 850 + }, + "testsAndEvidenceAdditions": { + "low": 400, + "high": 700 + }, + "deletedLegacyLines": { + "low": 50, + "high": 100 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "cross-process instance lease", + "renewal and stale-owner handling", + "lease race probes" + ], + "estimateMethod": "controller_scope_estimate", + "basisSourceIds": ["PARENT_PLAN", "P0.W2.ESTIMATE", "P0.W4.ESTIMATE", "P0.W4.HOST_PROBES"], + "rationale": "W2 explicitly excludes the reusable instance lease.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w4-process-anchor", + "sourceId": "P0.W4.ESTIMATE", + "ownerBucketId": "EST-LIFECYCLE-RUNTIME", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 450, + "high": 750 + }, + "testsAndEvidenceAdditions": { + "low": 400, + "high": 650 + }, + "deletedLegacyLines": { + "low": 50, + "high": 100 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": ["process anchor identity", "stale process rejection", "anchor race probes"], + "estimateMethod": "controller_scope_estimate", + "basisSourceIds": ["PARENT_PLAN", "P0.W2.ESTIMATE", "P0.W4.ESTIMATE", "P0.W4.HOST_PROBES"], + "rationale": "W2 explicitly excludes the reusable process anchor.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w4-native-helper-build", + "sourceId": "P0.W4.ESTIMATE", + "ownerBucketId": "EST-LIFECYCLE-RUNTIME", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 650, + "high": 1100 + }, + "testsAndEvidenceAdditions": { + "low": 450, + "high": 800 + }, + "deletedLegacyLines": { + "low": 50, + "high": 100 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "native helper implementation", + "platform build and packaging", + "artifact ABI probes" + ], + "estimateMethod": "controller_scope_estimate", + "basisSourceIds": [ + "PARENT_PLAN", + "P0.W2.ESTIMATE", + "P0.W4.ESTIMATE", + "P0.W4.NATIVE_ARTIFACT" + ], + "rationale": "Production native-helper build scope excluded by W2 and W4 r3 characterization.", + "coveredByAllocationIds": [] + }, + { + "allocationId": "w4-final-image-probes", + "sourceId": "P0.W4.ESTIMATE", + "ownerBucketId": "EST-LIFECYCLE-RUNTIME", + "numericDisposition": "included_unique", + "components": { + "implementationAdditions": { + "low": 150, + "high": 300 + }, + "testsAndEvidenceAdditions": { + "low": 500, + "high": 800 + }, + "deletedLegacyLines": { + "low": 0, + "high": 0 + }, + "unallocatedMixedScope": { + "low": 0, + "high": 0 + } + }, + "scope": [ + "final-image composition checks", + "runtime ABI and permission probes", + "negative missing-artifact probes" + ], + "estimateMethod": "controller_scope_estimate", + "basisSourceIds": [ + "PARENT_PLAN", + "P0.W2.ESTIMATE", + "P0.W4.ESTIMATE", + "P0.W4.NATIVE_ARTIFACT" + ], + "rationale": "Final-image proof scope is test-heavy and excluded from W2 provider fixtures.", + "coveredByAllocationIds": [] + } + ] + } +} diff --git a/docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json b/docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json new file mode 100644 index 0000000000..087dbb0af4 --- /dev/null +++ b/docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json @@ -0,0 +1,744 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentteams.live/schemas/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.schema.json", + "title": "Hosted web Phase 0 unique-bucket estimate ledger", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "ledgerId", + "asOfCommit", + "targetHeadCommit", + "provenance", + "status", + "unit", + "accountingPolicy", + "sources", + "buckets", + "allocationReconciliation", + "totals", + "deferredScope", + "reviewTriggers" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": 1 + }, + "ledgerId": { + "const": "P0.ESTIMATE.UNIQUE_BUCKET_RECONCILIATION" + }, + "asOfCommit": { + "$ref": "#/$defs/gitSha" + }, + "targetHeadCommit": { + "$ref": "#/$defs/gitSha" + }, + "provenance": { + "$ref": "#/$defs/provenance" + }, + "status": { + "enum": ["reconciled", "current_candidate_requires_scope_design_review"] + }, + "unit": { + "const": "gross_integrated_changed_lines" + }, + "accountingPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["definition", "endpointRule", "deletionRule", "deduplicationRule", "excluded"], + "properties": { + "definition": { + "type": "string", + "minLength": 1 + }, + "endpointRule": { + "type": "string", + "minLength": 1 + }, + "deletionRule": { + "type": "string", + "minLength": 1 + }, + "deduplicationRule": { + "type": "string", + "minLength": 1 + }, + "excluded": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "sources": { + "type": "array", + "minItems": 9, + "items": { + "$ref": "#/$defs/source" + } + }, + "buckets": { + "type": "array", + "minItems": 9, + "maxItems": 9, + "items": { + "$ref": "#/$defs/bucket" + } + }, + "totals": { + "$ref": "#/$defs/totals" + }, + "deferredScope": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/deferred" + } + }, + "reviewTriggers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["triggerId", "active", "reason"], + "properties": { + "triggerId": { + "type": "string", + "pattern": "^[A-Z0-9_-]+$" + }, + "active": { + "type": "boolean" + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + } + }, + "allocationReconciliation": { + "type": "object", + "additionalProperties": false, + "required": ["method", "allocations"], + "properties": { + "method": { + "type": "string", + "minLength": 1 + }, + "allocations": { + "type": "array", + "minItems": 11, + "maxItems": 11, + "items": { + "$ref": "#/$defs/allocation" + } + } + } + } + }, + "$defs": { + "gitSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "repoPath": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)[.][.](?:/|$)).+$" + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["sourceSnapshot", "targetSnapshot", "headPolicy", "ancestryPolicy", "authority"], + "properties": { + "sourceSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["commitField", "classification"], + "properties": { + "commitField": { + "const": "asOfCommit" + }, + "classification": { + "const": "source_snapshot" + } + } + }, + "targetSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["commitField", "classification"], + "properties": { + "commitField": { + "const": "targetHeadCommit" + }, + "classification": { + "const": "target_candidate" + } + } + }, + "headPolicy": { + "const": "pinned_snapshots_no_head_equality" + }, + "ancestryPolicy": { + "const": "source_snapshot_must_be_ancestor_of_target_candidate" + }, + "authority": { + "type": "object", + "additionalProperties": false, + "required": [ + "artifactStatus", + "reviewDisposition", + "canonicalEntrypoint", + "evidenceLifecycle", + "supersededRejectedArtifact" + ], + "properties": { + "artifactStatus": { + "const": "current_candidate_not_canonical" + }, + "reviewDisposition": { + "const": "pending" + }, + "canonicalEntrypoint": { + "const": "docs/hosted-web-phases/START_HERE.md" + }, + "evidenceLifecycle": { + "const": "docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md" + }, + "supersededRejectedArtifact": { + "const": "estimate-candidate-reconcile-h4" + } + } + } + } + }, + "range": { + "type": "object", + "additionalProperties": false, + "required": ["low", "high"], + "properties": { + "low": { + "type": "integer", + "minimum": 0 + }, + "high": { + "type": "integer", + "minimum": 0 + } + } + }, + "variance": { + "type": "object", + "additionalProperties": false, + "required": ["lowPercent", "highPercent", "overTwentyPercent"], + "properties": { + "lowPercent": { + "type": "number" + }, + "highPercent": { + "type": "number" + }, + "overTwentyPercent": { + "type": "boolean" + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["sourceId", "path", "sha256", "role", "provenanceClass"], + "properties": { + "sourceId": { + "type": "string", + "pattern": "^[A-Z0-9._-]+$" + }, + "path": { + "$ref": "#/$defs/repoPath" + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "role": { + "enum": [ + "parent_plan", + "baseline", + "freeze_index", + "lane_estimate", + "supporting_evidence" + ] + }, + "provenanceClass": { + "enum": ["source_snapshot", "target_candidate"] + } + } + }, + "components": { + "type": "object", + "additionalProperties": false, + "required": [ + "implementationAdditions", + "testsAndEvidenceAdditions", + "deletedLegacyLines", + "unallocatedMixedScope" + ], + "properties": { + "implementationAdditions": { + "$ref": "#/$defs/range" + }, + "testsAndEvidenceAdditions": { + "$ref": "#/$defs/range" + }, + "deletedLegacyLines": { + "$ref": "#/$defs/range" + }, + "unallocatedMixedScope": { + "$ref": "#/$defs/range" + } + } + }, + "scopePartition": { + "type": "object", + "additionalProperties": false, + "required": ["implementation", "testsAndEvidence", "migrationAndRollout"], + "properties": { + "implementation": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "testsAndEvidence": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "migrationAndRollout": { + "type": "object", + "additionalProperties": false, + "required": ["items", "numericStatus", "explanation"], + "properties": { + "items": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "numericStatus": { + "enum": [ + "none_identified", + "included_not_separately_quantified", + "mixed_parent_range_not_separable" + ] + }, + "explanation": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "deduplication": { + "type": "object", + "additionalProperties": false, + "required": [ + "strategy", + "includedSourceIds", + "excludedNumericSourceIds", + "overlapKeys", + "explanation" + ], + "properties": { + "strategy": { + "enum": [ + "single_source", + "parent_provisional", + "controller_allocation", + "allocation_sum", + "allocation_subtraction" + ] + }, + "includedSourceIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "excludedNumericSourceIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "overlapKeys": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "explanation": { + "type": "string", + "minLength": 1 + } + } + }, + "bucket": { + "type": "object", + "additionalProperties": false, + "required": [ + "bucketId", + "title", + "confidence", + "estimateStatus", + "allocationKeys", + "baseline", + "components", + "reconciled", + "scopePartition", + "deduplication", + "variance", + "assumptions", + "sourceIds" + ], + "properties": { + "bucketId": { + "type": "string", + "pattern": "^EST-[A-Z0-9-]+$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "confidence": { + "$ref": "#/$defs/bucketConfidence" + }, + "estimateStatus": { + "enum": ["evidence_reconciled", "parent_provisional", "controller_reconciled"] + }, + "allocationKeys": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + } + }, + "baseline": { + "$ref": "#/$defs/range" + }, + "components": { + "$ref": "#/$defs/components" + }, + "reconciled": { + "$ref": "#/$defs/range" + }, + "scopePartition": { + "$ref": "#/$defs/scopePartition" + }, + "deduplication": { + "$ref": "#/$defs/deduplication" + }, + "variance": { + "$ref": "#/$defs/variance" + }, + "assumptions": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "sourceIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "totals": { + "type": "object", + "additionalProperties": false, + "required": [ + "baselineV1", + "knownComponents", + "reconciledV1", + "variance", + "confidence", + "migrationQuantification", + "requiresScopeDesignReview" + ], + "properties": { + "baselineV1": { + "$ref": "#/$defs/range" + }, + "knownComponents": { + "$ref": "#/$defs/components" + }, + "reconciledV1": { + "$ref": "#/$defs/range" + }, + "variance": { + "$ref": "#/$defs/variance" + }, + "migrationQuantification": { + "type": "object", + "additionalProperties": false, + "required": ["status", "explanation"], + "properties": { + "status": { + "const": "included_not_separately_quantified" + }, + "explanation": { + "type": "string", + "minLength": 1 + } + } + }, + "requiresScopeDesignReview": { + "type": "boolean" + }, + "confidence": { + "$ref": "#/$defs/totalConfidence" + } + } + }, + "deferred": { + "type": "object", + "additionalProperties": false, + "required": [ + "scopeId", + "title", + "estimate", + "v1Contribution", + "repositories", + "sourceIds", + "reason" + ], + "properties": { + "scopeId": { + "const": "POST-V1-T1-HOSTED-TERMINAL" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "estimate": { + "$ref": "#/$defs/range" + }, + "v1Contribution": { + "$ref": "#/$defs/range" + }, + "repositories": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "sourceIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + }, + "confidenceRating": { + "enum": ["high", "medium", "medium-low", "low"] + }, + "bucketConfidence": { + "type": "object", + "additionalProperties": false, + "required": ["baseline", "reconciled", "changed", "reason"], + "properties": { + "baseline": { + "type": "object", + "additionalProperties": false, + "required": ["rating", "sourceId"], + "properties": { + "rating": { + "$ref": "#/$defs/confidenceRating" + }, + "sourceId": { + "const": "P0.BASELINE_ESTIMATE" + } + } + }, + "reconciled": { + "type": "object", + "additionalProperties": false, + "required": ["rating"], + "properties": { + "rating": { + "$ref": "#/$defs/confidenceRating" + } + } + }, + "changed": { + "type": "boolean" + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + }, + "totalConfidence": { + "type": "object", + "additionalProperties": false, + "required": ["baseline", "reconciled", "changed", "reason"], + "properties": { + "baseline": { + "type": "object", + "additionalProperties": false, + "required": ["score", "scale", "sourceId"], + "properties": { + "score": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "scale": { + "const": 10 + }, + "sourceId": { + "const": "P0.BASELINE_ESTIMATE" + } + } + }, + "reconciled": { + "type": "object", + "additionalProperties": false, + "required": ["score", "scale"], + "properties": { + "score": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "scale": { + "const": 10 + } + } + }, + "changed": { + "type": "boolean" + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + }, + "allocation": { + "type": "object", + "additionalProperties": false, + "required": [ + "allocationId", + "sourceId", + "ownerBucketId", + "numericDisposition", + "components", + "scope", + "estimateMethod", + "basisSourceIds", + "rationale", + "coveredByAllocationIds" + ], + "properties": { + "allocationId": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "sourceId": { + "type": "string", + "pattern": "^[A-Z0-9._-]+$" + }, + "ownerBucketId": { + "type": "string", + "pattern": "^EST-[A-Z0-9-]+$" + }, + "numericDisposition": { + "enum": ["included_unique", "excluded_shared_overlap"] + }, + "components": { + "$ref": "#/$defs/components" + }, + "scope": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "estimateMethod": { + "enum": [ + "source_sub_bucket", + "controller_overlap_subtraction", + "controller_overlap_bound", + "controller_scope_estimate" + ] + }, + "basisSourceIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Z0-9._-]+$" + } + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "coveredByAllocationIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + } + } + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs b/docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs new file mode 100644 index 0000000000..024751022d --- /dev/null +++ b/docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs @@ -0,0 +1,654 @@ +#!/usr/bin/env node +/* global console, process */ + +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const artifactDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(artifactDir, '../../../../..'); +const ledger = JSON.parse(readFileSync(resolve(artifactDir, 'estimate-ledger.json'), 'utf8')); +JSON.parse(readFileSync(resolve(artifactDir, 'estimate-ledger.schema.json'), 'utf8')); + +const failures = []; +const fail = (message) => failures.push(message); +const rangeKeys = ['low', 'high']; +const componentKeys = [ + 'implementationAdditions', + 'testsAndEvidenceAdditions', + 'deletedLegacyLines', + 'unallocatedMixedScope', +]; + +const assertRange = (range, label) => { + if (!range || !Number.isInteger(range.low) || !Number.isInteger(range.high)) { + fail(label + ' is not an integer range'); + return; + } + if (range.low < 0 || range.high < range.low) { + fail(label + ' has invalid bounds ' + range.low + '-' + range.high); + } +}; + +const sumRanges = (ranges) => + Object.fromEntries( + rangeKeys.map((key) => [key, ranges.reduce((sum, range) => sum + range[key], 0)]) + ); + +const sumComponents = (components) => + Object.fromEntries( + componentKeys.map((componentKey) => [ + componentKey, + sumRanges(components.map((component) => component[componentKey])), + ]) + ); + +const assertSameRange = (actual, expected, label) => { + for (const key of rangeKeys) { + if (actual[key] !== expected[key]) { + fail(label + '.' + key + ': expected ' + expected[key] + ', found ' + actual[key]); + } + } +}; + +const assertSameComponents = (actual, expected, label) => { + for (const componentKey of componentKeys) { + assertSameRange(actual[componentKey], expected[componentKey], label + '.' + componentKey); + } +}; + +const assertSameStringSet = (actual, expected, label) => { + const actualSet = new Set(actual); + const expectedSet = new Set(expected); + const missing = [...expectedSet].filter((value) => !actualSet.has(value)); + const extra = [...actualSet].filter((value) => !expectedSet.has(value)); + if (actualSet.size !== actual.length || missing.length > 0 || extra.length > 0) { + fail( + label + ' mismatch: missing [' + missing.join(', ') + '], extra [' + extra.join(', ') + ']' + ); + } +}; + +const componentGrandTotal = (components) => + sumRanges(componentKeys.map((componentKey) => components[componentKey])); + +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const git = (args) => + execFileSync('git', args, { + cwd: repoRoot, + encoding: null, + stdio: ['ignore', 'pipe', 'pipe'], + }); + +const verifyCommit = (commit, label) => { + try { + git(['cat-file', '-e', commit + '^{commit}']); + } catch { + fail(label + ' does not resolve to a commit: ' + commit); + } +}; + +verifyCommit(ledger.asOfCommit, 'asOfCommit'); +verifyCommit(ledger.targetHeadCommit, 'targetHeadCommit'); +if (ledger.asOfCommit === ledger.targetHeadCommit) { + fail('asOfCommit and targetHeadCommit must identify distinct source and target snapshots'); +} +try { + git(['merge-base', '--is-ancestor', ledger.asOfCommit, ledger.targetHeadCommit]); +} catch { + fail('asOfCommit is not an ancestor of targetHeadCommit'); +} +if ( + ledger.provenance?.sourceSnapshot?.commitField !== 'asOfCommit' || + ledger.provenance?.sourceSnapshot?.classification !== 'source_snapshot' || + ledger.provenance?.targetSnapshot?.commitField !== 'targetHeadCommit' || + ledger.provenance?.targetSnapshot?.classification !== 'target_candidate' || + ledger.provenance?.headPolicy !== 'pinned_snapshots_no_head_equality' || + ledger.provenance?.ancestryPolicy !== 'source_snapshot_must_be_ancestor_of_target_candidate' || + ledger.provenance?.authority?.artifactStatus !== 'current_candidate_not_canonical' || + ledger.provenance?.authority?.reviewDisposition !== 'pending' || + ledger.provenance?.authority?.canonicalEntrypoint !== 'docs/hosted-web-phases/START_HERE.md' || + ledger.provenance?.authority?.evidenceLifecycle !== + 'docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md' || + ledger.provenance?.authority?.supersededRejectedArtifact !== 'estimate-candidate-reconcile-h4' +) { + fail('ledger provenance and authority policy is incomplete or claims canonical authority'); +} + +const snapshotCommitFor = (source) => + source.provenanceClass === 'source_snapshot' + ? ledger.asOfCommit + : source.provenanceClass === 'target_candidate' + ? ledger.targetHeadCommit + : null; + +const readSourceBytes = (source) => { + const commit = snapshotCommitFor(source); + if (!commit) return null; + try { + return git(['show', commit + ':' + source.path]); + } catch { + fail( + 'source cannot be reproduced from ' + + source.provenanceClass + + ' at ' + + commit + + ': ' + + source.path + ); + return null; + } +}; + +const sourceIds = new Set(); +const expectedTargetCandidateSourceIds = new Set([ + 'P0.W6.AUTH_ARTIFACT_EVIDENCE', + 'P0.W6.ARTIFACT_MANIFEST', +]); +const provenanceCounts = new Map([ + ['source_snapshot', 0], + ['target_candidate', 0], +]); +for (const source of ledger.sources) { + if (sourceIds.has(source.sourceId)) fail('duplicate sourceId ' + source.sourceId); + sourceIds.add(source.sourceId); + if (source.path.startsWith('/') || source.path.split('/').includes('..')) { + fail('source path is not repository-relative: ' + source.path); + continue; + } + if (!provenanceCounts.has(source.provenanceClass)) { + fail('source has unknown provenanceClass: ' + source.sourceId); + } else { + provenanceCounts.set(source.provenanceClass, provenanceCounts.get(source.provenanceClass) + 1); + } + const expectedClass = expectedTargetCandidateSourceIds.has(source.sourceId) + ? 'target_candidate' + : 'source_snapshot'; + if (source.provenanceClass !== expectedClass) { + fail(source.sourceId + ' must be classified ' + expectedClass); + } + const sourceBytes = readSourceBytes(source); + if (!sourceBytes) continue; + const actualHash = sha256(sourceBytes); + if (actualHash !== source.sha256) { + fail( + 'source hash mismatch for ' + + source.path + + ': expected ' + + source.sha256 + + ', found ' + + actualHash + ); + } +} +if (ledger.sources.length !== 23) fail('expected exactly 23 classified source hashes'); +if (provenanceCounts.get('source_snapshot') !== 21) { + fail('expected 21 source_snapshot hashes'); +} +if (provenanceCounts.get('target_candidate') !== 2) { + fail('expected 2 target_candidate hashes'); +} + +const expectedBucketIds = new Set([ + 'EST-CONTRACTS', + 'EST-IDENTITY-WORKSPACE', + 'EST-LIFECYCLE-RUNTIME', + 'EST-RECOVERY-STATE', + 'EST-COMMAND-EVENT-RECOVERY', + 'EST-HOSTED-OPS', + 'EST-RENDERER-LIFECYCLE', + 'EST-REMAINING-PARITY', + 'EST-RELEASE-E2E', +]); +const expectedBaselineConfidence = new Map([ + ['EST-CONTRACTS', 'high'], + ['EST-IDENTITY-WORKSPACE', 'medium'], + ['EST-LIFECYCLE-RUNTIME', 'medium'], + ['EST-RECOVERY-STATE', 'medium-low'], + ['EST-COMMAND-EVENT-RECOVERY', 'medium-low'], + ['EST-HOSTED-OPS', 'medium'], + ['EST-RENDERER-LIFECYCLE', 'medium'], + ['EST-REMAINING-PARITY', 'medium-low'], + ['EST-RELEASE-E2E', 'medium'], +]); +const bucketIds = new Set(); +const allocationOwners = new Map(); + +for (const bucket of ledger.buckets) { + if (!expectedBucketIds.has(bucket.bucketId)) fail('unexpected bucket ' + bucket.bucketId); + if (bucketIds.has(bucket.bucketId)) fail('duplicate bucket ' + bucket.bucketId); + bucketIds.add(bucket.bucketId); + assertRange(bucket.baseline, bucket.bucketId + '.baseline'); + assertRange(bucket.reconciled, bucket.bucketId + '.reconciled'); + + for (const key of componentKeys) + assertRange(bucket.components[key], bucket.bucketId + '.components.' + key); + assertSameRange( + bucket.reconciled, + componentGrandTotal(bucket.components), + bucket.bucketId + '.component sum' + ); + + for (const allocationKey of bucket.allocationKeys) { + const previous = allocationOwners.get(allocationKey); + if (previous) + fail( + 'allocation key ' + + allocationKey + + ' is owned by both ' + + previous + + ' and ' + + bucket.bucketId + ); + allocationOwners.set(allocationKey, bucket.bucketId); + } + + for (const sourceId of bucket.sourceIds) { + if (!sourceIds.has(sourceId)) fail(bucket.bucketId + ' references unknown source ' + sourceId); + } + for (const sourceId of [ + ...bucket.deduplication.includedSourceIds, + ...bucket.deduplication.excludedNumericSourceIds, + ]) { + if (!sourceIds.has(sourceId)) + fail(bucket.bucketId + ' deduplication references unknown source ' + sourceId); + } + + for (const key of rangeKeys) { + const baseline = bucket.baseline[key]; + const expectedVariance = ((bucket.reconciled[key] - baseline) / baseline) * 100; + const recordedVariance = bucket.variance[key + 'Percent']; + if (Math.abs(recordedVariance - expectedVariance) > 0.01) { + fail( + bucket.bucketId + + ' ' + + key + + ' variance expected ' + + expectedVariance + + ', found ' + + recordedVariance + ); + } + } + const expectedTrigger = + Math.abs(bucket.variance.lowPercent) > 20 || Math.abs(bucket.variance.highPercent) > 20; + if (bucket.variance.overTwentyPercent !== expectedTrigger) { + fail(bucket.bucketId + ' overTwentyPercent does not match its variance'); + } + + const baselineConfidence = expectedBaselineConfidence.get(bucket.bucketId); + if (bucket.confidence.baseline.rating !== baselineConfidence) { + fail( + bucket.bucketId + + ' baseline confidence expected ' + + baselineConfidence + + ', found ' + + bucket.confidence.baseline.rating + ); + } + if (bucket.confidence.baseline.sourceId !== 'P0.BASELINE_ESTIMATE') { + fail(bucket.bucketId + ' confidence does not cite the parent baseline'); + } + const changed = bucket.confidence.baseline.rating !== bucket.confidence.reconciled.rating; + if (bucket.confidence.changed !== changed) { + fail(bucket.bucketId + ' confidence.changed does not match its ratings'); + } + if (!bucket.confidence.reason.trim()) + fail(bucket.bucketId + ' confidence transition reason is empty'); +} + +for (const expectedBucketId of expectedBucketIds) { + if (!bucketIds.has(expectedBucketId)) fail('missing bucket ' + expectedBucketId); +} + +const byId = new Map(ledger.buckets.map((bucket) => [bucket.bucketId, bucket])); +const readJsonSource = (sourceId) => { + const source = ledger.sources.find((candidate) => candidate.sourceId === sourceId); + const sourceBytes = readSourceBytes(source); + if (!sourceBytes) return {}; + return JSON.parse(sourceBytes.toString('utf8')); +}; + +const allocationById = new Map(); +for (const allocation of ledger.allocationReconciliation.allocations) { + if (allocationById.has(allocation.allocationId)) { + fail('duplicate reconciliation allocation ' + allocation.allocationId); + } + allocationById.set(allocation.allocationId, allocation); + if (!sourceIds.has(allocation.sourceId)) + fail(allocation.allocationId + ' references unknown source ' + allocation.sourceId); + if (!bucketIds.has(allocation.ownerBucketId)) + fail(allocation.allocationId + ' references unknown owner bucket ' + allocation.ownerBucketId); + for (const sourceId of allocation.basisSourceIds) { + if (!sourceIds.has(sourceId)) + fail(allocation.allocationId + ' references unknown basis source ' + sourceId); + } + for (const componentKey of componentKeys) { + assertRange( + allocation.components[componentKey], + allocation.allocationId + '.components.' + componentKey + ); + } + if (allocation.numericDisposition === 'included_unique') { + if (!byId.get(allocation.ownerBucketId).allocationKeys.includes(allocation.allocationId)) { + fail(allocation.allocationId + ' is not an allocation key of ' + allocation.ownerBucketId); + } + if (allocation.coveredByAllocationIds.length !== 0) { + fail(allocation.allocationId + ' is included but declares overlap coverage'); + } + } else if (allocation.coveredByAllocationIds.length === 0) { + fail(allocation.allocationId + ' excludes overlap without naming included coverage'); + } +} + +for (const allocation of ledger.allocationReconciliation.allocations) { + for (const coveredId of allocation.coveredByAllocationIds) { + const covered = allocationById.get(coveredId); + if (!covered || covered.numericDisposition !== 'included_unique') { + fail(allocation.allocationId + ' has invalid overlap coverage ' + coveredId); + } + if (covered && covered.ownerBucketId !== allocation.ownerBucketId) { + fail(allocation.allocationId + ' coverage ' + coveredId + ' has a different owner bucket'); + } + } +} + +const w1 = readJsonSource('P0.W1.ESTIMATE'); +for (const sourceBucket of w1.buckets) { + const expected = { + low: + sourceBucket.productionLines.low + sourceBucket.testLines.low + sourceBucket.deletedLines.low, + high: + sourceBucket.productionLines.high + + sourceBucket.testLines.high + + sourceBucket.deletedLines.high, + }; + assertSameRange( + byId.get(sourceBucket.bucketId).reconciled, + expected, + sourceBucket.bucketId + ' W1 normalization' + ); +} + +const w2 = readJsonSource('P0.W2.ESTIMATE'); +const w2Components = { + implementationAdditions: { + low: w2.ranges.productionLines.low, + high: w2.ranges.productionLines.high, + }, + testsAndEvidenceAdditions: { low: w2.ranges.testLines.low, high: w2.ranges.testLines.high }, + deletedLegacyLines: { low: w2.ranges.deletedLines.low, high: w2.ranges.deletedLines.high }, + unallocatedMixedScope: { low: 0, high: 0 }, +}; + +const w3 = readJsonSource('P0.W3.ESTIMATE'); +const w3AllocationIds = new Map([ + ['EST-W3-COMPAT-CATALOG', 'w3-compat-catalog'], + ['EST-W3-WRITER-COORDINATION', 'w3-writer-coordination'], + ['EST-W3-SQLITE-BACKUP', 'w3-sqlite-backup'], + ['EST-W3-BACKUP-PARTICIPANTS', 'w3-backup-participants'], +]); +for (const sourceBucket of w3.buckets) { + const allocationId = w3AllocationIds.get(sourceBucket.bucketId); + const allocation = allocationById.get(allocationId); + if (!allocation) { + fail('missing W3 allocation for ' + sourceBucket.bucketId); + continue; + } + assertSameComponents( + allocation.components, + { + implementationAdditions: { + low: sourceBucket.productionLines.min, + high: sourceBucket.productionLines.max, + }, + testsAndEvidenceAdditions: { + low: sourceBucket.testLines.min, + high: sourceBucket.testLines.max, + }, + deletedLegacyLines: { + low: sourceBucket.deletedLines.min, + high: sourceBucket.deletedLines.max, + }, + unallocatedMixedScope: { low: 0, high: 0 }, + }, + allocationId + ' source normalization' + ); + assertSameStringSet( + allocation.scope, + sourceBucket.packages, + allocationId + ' source package coverage' + ); +} +const w3Components = sumComponents( + [...w3AllocationIds.values()].map((allocationId) => allocationById.get(allocationId).components) +); +assertSameComponents(byId.get('EST-RECOVERY-STATE').components, w3Components, 'W3 allocation sum'); + +const w5 = readJsonSource('P0.W5.ESTIMATE'); +const w5Unique = allocationById.get('w5-command-event-recovery'); +const w5Overlap = allocationById.get('w5-shared-storage-transaction-fixtures'); +assertSameComponents( + byId.get('EST-COMMAND-EVENT-RECOVERY').components, + w5Unique.components, + 'W5 unique bucket' +); +assertSameComponents( + sumComponents([w5Unique.components, w5Overlap.components]), + { + implementationAdditions: { low: w5.productionLines.low, high: w5.productionLines.high }, + testsAndEvidenceAdditions: { low: w5.testLines.low, high: w5.testLines.high }, + deletedLegacyLines: { low: w5.deletedLines.low, high: w5.deletedLines.high }, + unallocatedMixedScope: { low: 0, high: 0 }, + }, + 'W5 unique plus excluded overlap' +); +assertSameStringSet(w5Unique.scope, w5.packages, 'W5 unique source package coverage'); +if ( + byId.get('EST-RECOVERY-STATE').sourceIds.includes('P0.W5.ESTIMATE') || + byId.get('EST-COMMAND-EVENT-RECOVERY').sourceIds.includes('P0.W3.ESTIMATE') +) { + fail('W3 and W5 source ownership is not separated by bucket'); +} + +const semanticOverlap = (leftKeys, rightKeys) => { + const right = new Set(rightKeys); + return leftKeys.some((key) => right.has(key)); +}; +const nestedNumericNegativeFixture = { + containingRange: { low: 4000, high: 9000 }, + nestedRange: { low: 4500, high: 7500 }, + containingAllocationKeys: ['storage-backup'], + nestedAllocationKeys: ['command-journal'], +}; +const fixtureIsNumericallyNested = + nestedNumericNegativeFixture.containingRange.low <= + nestedNumericNegativeFixture.nestedRange.low && + nestedNumericNegativeFixture.containingRange.high >= + nestedNumericNegativeFixture.nestedRange.high; +if (!fixtureIsNumericallyNested) fail('nested numeric negative fixture is not nested'); +if ( + semanticOverlap( + nestedNumericNegativeFixture.containingAllocationKeys, + nestedNumericNegativeFixture.nestedAllocationKeys + ) +) { + fail('negative fixture incorrectly treats nested numeric ranges as semantic overlap'); +} + +const w4 = readJsonSource('P0.W4.ESTIMATE'); +assertSameStringSet( + w2.w4Reconciliation.w2ExcludesAsW4Owned, + [ + 'instance lease', + 'workspace guard', + 'process anchor', + 'native helper build and final-image probes', + ], + 'W2-declared W4 executable scope' +); +for (const value of Object.values(w4.admittedR3Lines)) { + if (value !== 0) fail('W4 characterized r3 source unexpectedly admits numeric lines'); +} +if (!w4.historicalR2Range.disposition.startsWith('not admitted')) { + fail('W4 historical range is no longer explicitly not admitted'); +} +const w4Ids = [ + 'w4-workspace-guard', + 'w4-instance-lease', + 'w4-process-anchor', + 'w4-native-helper-build', + 'w4-final-image-probes', +]; +for (const allocationId of w4Ids) { + const allocation = allocationById.get(allocationId); + if (!allocation || allocation.estimateMethod !== 'controller_scope_estimate') { + fail('missing controller scope estimate ' + allocationId); + } +} +const w4Allocations = w4Ids.map((allocationId) => allocationById.get(allocationId)); +assertSameRange( + componentGrandTotal(sumComponents(w4Allocations.map((entry) => entry.components))), + { low: 4500, high: 7650 }, + 'complete W4 executable scope' +); +const w4LifecycleComponents = sumComponents( + w4Allocations + .filter((allocation) => allocation.ownerBucketId === 'EST-LIFECYCLE-RUNTIME') + .map((allocation) => allocation.components) +); +assertSameComponents( + byId.get('EST-LIFECYCLE-RUNTIME').components, + sumComponents([w2Components, w4LifecycleComponents]), + 'W2 plus W4 lifecycle/runtime' +); +const w4Guard = allocationById.get('w4-workspace-guard'); +for (const componentKey of componentKeys.filter((key) => key !== 'unallocatedMixedScope')) { + assertSameRange( + byId.get('EST-IDENTITY-WORKSPACE').components[componentKey], + w4Guard.components[componentKey], + 'identity/workspace ' + componentKey + ' W4 guard' + ); +} + +const w6 = readJsonSource('P0.W6.ESTIMATE'); +assertSameRange( + byId.get('EST-HOSTED-OPS').reconciled, + { + low: w6.lines.production.low + w6.lines.test.low + w6.lines.deleted.low, + high: w6.lines.production.high + w6.lines.test.high + w6.lines.deleted.high, + }, + 'W6 gross normalization' +); + +assertSameRange( + ledger.totals.baselineV1, + sumRanges(ledger.buckets.map((bucket) => bucket.baseline)), + 'baseline total' +); +assertSameRange( + ledger.totals.reconciledV1, + sumRanges(ledger.buckets.map((bucket) => bucket.reconciled)), + 'reconciled total' +); +for (const componentKey of componentKeys) { + assertSameRange( + ledger.totals.knownComponents[componentKey], + sumRanges(ledger.buckets.map((bucket) => bucket.components[componentKey])), + 'component total ' + componentKey + ); +} +assertSameRange( + ledger.totals.reconciledV1, + componentGrandTotal(ledger.totals.knownComponents), + 'component grand total' +); +for (const key of rangeKeys) { + const baseline = ledger.totals.baselineV1[key]; + const expectedVariance = ((ledger.totals.reconciledV1[key] - baseline) / baseline) * 100; + if (Math.abs(ledger.totals.variance[key + 'Percent'] - expectedVariance) > 0.01) { + fail('total ' + key + ' variance does not match arithmetic'); + } +} +const totalConfidence = ledger.totals.confidence; +if ( + totalConfidence.baseline.score !== 7 || + totalConfidence.baseline.scale !== 10 || + totalConfidence.baseline.sourceId !== 'P0.BASELINE_ESTIMATE' +) { + fail('total baseline confidence does not preserve the parent 7/10 provenance'); +} +if (totalConfidence.reconciled.score !== 5 || totalConfidence.reconciled.scale !== 10) { + fail('total reconciled confidence must be the reviewed 5/10 transition'); +} +if ( + totalConfidence.changed !== + (totalConfidence.baseline.score !== totalConfidence.reconciled.score) +) { + fail('total confidence.changed does not match its scores'); +} +if (!totalConfidence.reason.trim()) fail('total confidence transition reason is empty'); + +for (const deferred of ledger.deferredScope) { + assertRange(deferred.estimate, deferred.scopeId + '.estimate'); + assertSameRange( + deferred.v1Contribution, + { low: 0, high: 0 }, + deferred.scopeId + '.v1Contribution' + ); +} + +const activeTriggers = ledger.reviewTriggers.filter((trigger) => trigger.active); +if (ledger.totals.requiresScopeDesignReview !== activeTriggers.length > 0) { + fail('requiresScopeDesignReview does not match active review triggers'); +} +const partialRangeTrigger = ledger.reviewTriggers.find( + (trigger) => trigger.triggerId === 'TOTAL_PARTIALLY_OUTSIDE_PARENT_RANGE' +); +const lowInsideParentInterval = + ledger.totals.reconciledV1.low >= ledger.totals.baselineV1.low && + ledger.totals.reconciledV1.low <= ledger.totals.baselineV1.high; +const highAboveParentInterval = ledger.totals.reconciledV1.high > ledger.totals.baselineV1.high; +if (!lowInsideParentInterval || !highAboveParentInterval || !partialRangeTrigger?.active) { + fail('partial parent-range semantics do not match the reconciled interval'); +} +if (ledger.reviewTriggers.some((trigger) => trigger.triggerId === 'TOTAL_OUTSIDE_PARENT_RANGE')) { + fail('obsolete total-outside-parent-range trigger is present'); +} +if (ledger.status !== 'current_candidate_requires_scope_design_review') { + fail('unexpected ledger status ' + ledger.status); +} + +if (failures.length > 0) { + for (const failure of failures) console.error('FAIL: ' + failure); + process.exit(1); +} + +console.log( + 'PASS: ' + + ledger.buckets.length + + ' unique v1 buckets, ' + + allocationOwners.size + + ' unique bucket allocations, ' + + ledger.allocationReconciliation.allocations.length + + ' W3/W4/W5 scope allocations, ' + + ledger.totals.reconciledV1.low + + '-' + + ledger.totals.reconciledV1.high + + ' gross integrated changed lines, confidence ' + + ledger.totals.confidence.baseline.score + + '/10->' + + ledger.totals.confidence.reconciled.score + + '/10; ' + + ledger.sources.length + + ' classified source hashes verified (' + + provenanceCounts.get('source_snapshot') + + ' source_snapshot, ' + + provenanceCounts.get('target_candidate') + + ' target_candidate) across ' + + ledger.asOfCommit + + ' -> ' + + ledger.targetHeadCommit +); diff --git a/docs/research/hosted-web/phase-0/evidence/acceptance-gap-register.md b/docs/research/hosted-web/phase-0/evidence/acceptance-gap-register.md new file mode 100644 index 0000000000..cd152c06ec --- /dev/null +++ b/docs/research/hosted-web/phase-0/evidence/acceptance-gap-register.md @@ -0,0 +1,64 @@ +# Phase 0 acceptance gap register + +## Method + +Each row maps a parent-plan or Phase 0 packet exit requirement to the strongest inspected evidence. +`Supported` means the current record proves the stated limited claim. `Partial` means useful evidence +exists but cannot close the requirement. `Contradicted` means a review reproduced or identified a +failure against the required contract. `Missing` means no authoritative record was found. None of these +labels claims controller adoption. + +The independent requirements audit reached the same reject-pending-correction disposition across 70 +checks: 17 satisfied, 14 partial, 28 failed, five missing, and six pending. This register is a compact +parent-exit/packet-DoD projection, not a replacement for that worktree-local machine matrix. + +## Parent-plan exit gate + +| Requirement | Strongest current evidence | Disposition | Required closure | +| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Product decisions for the first vertical slice are explicit | 0A decision register plus lane recommendations | Partial | Resolve reciprocal-review findings and freeze every required decision as accepted, narrowed, reopened, or blocked. | +| Current desktop behavior is characterized | W1 renderer/action inventories and W3/W5 current-state catalogs | Partial | Correct the rejected W1 semantic inventory/scanner and complete the requirements audit. | +| Exact-base CI is green or failures have accepted isolation | 0A baseline classifies five lint errors in two `base_owned_fix` records | Partial | Record adopted prerequisite fixes, run the packet's uncaptured commands, and run the final Phase 0 gate. | +| Architecture tests fail on deliberate negatives and pass on the initial skeleton | Producer scanners include negative fixtures | Partial | Review/adopt the valid fixtures and run combined checks against the integrated evidence; no Phase 1 skeleton exists yet. | +| Every runtime-control producer/verb has explicit ingress/outbound direction | W2 runtime-ingress inventory | Partial | Correct the rejected nested schemas/fixtures and close permission-direction, relay, authority, and credential gaps. | +| Every legacy member and visible hosted action has one disposition | W1 producer counts plus rejected semantic mapping | Contradicted | Replace heuristic JSX rows with reviewed stable semantic action IDs and reconcile W1/W2 ownership. | +| Both real execution topologies have characterization fixtures | W2 topology approved as useful; fake-runtime matrix rejected | Partial | Add every required provider matrix case and prove provider/version assumptions and disjoint child environments before capability advertisement. | +| ADR-16 lease passes final volume/container topology | W4 current-host fixture | Missing | Correct R46-04/R46-06, then run two final-image containers plus manual contender with stable-inode and descriptor-leak proof. | +| ADR-28 guard passes final-image race/exec/Git suite | W4 current-host guard and negative raw-Node control | Missing | Correct paired artifact contract and rerun in the admitted non-root init/seccomp/filesystem topology. | +| Hosted artifact has no required empty-stub or wrong-ABI dependency | W6 artifact scan | Contradicted | Build the allowlisted hosted artifact with required worker/native artifacts and rerun ABI/stub/terminal-negative probes. | +| State families have compatibility range and migration owner | W3 catalog accepted as useful by reciprocal review | Partial | Correct W3 backup characterization, complete provider/version writer evidence, and adopt a reviewed compatibility manifest. | +| External-writer operations have one safe ADR-29 class | W3 writer evidence; W5 classes | Contradicted | Reclassify W5 task/inbox effects to agree with W3 and prove any automatic recovery through durable dedupe/lookup. | +| Child environment provenance and relay canaries prove ADR-30 | W2 environment provenance rejected; relay absent | Contradicted | Discover source keys independently, require classifications, and obtain accepted fixture evidence for allowlists and relay isolation. | +| ADR-31 anchor passes final-image ownership/drain/PID-reuse | W4 process fixture | Contradicted | Correct reusable numeric-PID signaling, cleanup truth, high-FD closure, and run final-image PID/PGID reuse/drain tests. | +| SQLite Online Backup is verified with WAL and no raw copy | W3 fixture-characterized Online Backup | Partial | Add the required production-worker/final-ABI proof and retain full deployment backup as disabled until quiescence is proven. | +| ADR-33 schedules are exhaustive and gap-free | W5 scheduler | Contradicted | Make commit/compensation/publication boundaries real crash/restart transitions and rerun the negative schedules. | +| ADR-34 catalogs cover every mutation and effect class | W5 catalogs/goldens | Contradicted | Use an independent mutation census, reconcile W3 writer classes, add retained fingerprint/default vectors, and re-review. | +| ADR-7 restart/reset/proxy schedules close authority gaps | W6 auth fixture | Contradicted | Prevent revoked authority resurrection, bind reset to generation-scoped W4 `drained`, and run the final HTTPS edge matrix. | +| Unique-bucket estimate is reconciled and terminal is zero | 0A estimate plus lane estimates | Partial | Deduplicate W3/W5 and all other overlaps, resolve variances, regenerate the controller-owned ledger, and review it. | +| No new browser stub lacks a capability classification | Oversized W1 bypass projection and W6 scan | Partial | Compact/hash the W1 projection, correct semantic actions, complete cross-lane audit, and validate the integrated capability ledger. | + +## Packet Definition of Done + +| Packet requirement | Disposition | Evidence/gap | +| -------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reproducible 0A base/baseline | Partial | Base, plan, phase-start, and baseline records exist; literal 0A command coverage and final rerun remain incomplete. | +| All lanes start at `phaseStartSha` | Supported | All six producer results record `a32f509e6d9bd31ba2135940e336729bf90c3d93`. | +| All six lanes reviewed and adopted or explicitly rejected by controller decision | Missing | All three pair reviews reject outputs; the cross-lane audit holds all adoption and reports zero immediately adoptable files; no controller adoption decision exists. | +| Every parent-plan exit gate has evidence | Missing | This matrix identifies unsupported, contradicted, and missing gates; it does not close them. | +| Native/container claims ran in supported target topology | Missing | W4 explicitly lacked Docker/final-image access. | +| Estimate/salvage/parity/state/provider/artifact ledgers generated and validated | Partial | Producer ledgers exist in separate worktrees; review/adoption/audit and shared regeneration remain pending. | +| Required broad gates green or accepted isolated base failures only | Missing | No final Phase 0 broad gate was inspected. | +| No hosted product mutation or terminal implementation enabled | Supported for inspected diffs | Producers and reviews report evidence-only scope; integration/audit confirmation remains pending. | +| Integration worktree clean after Phase 0 commits | Missing | No final adoption sequence or integration cleanliness record was inspected. | +| Phase 0 completion report with residual risks | Missing | This gap register is not a completion report. | +| Phase 1 JIT packet generated from frozen decisions/current integration SHA | Blocked | The draft packet intentionally has no predecessor freeze SHA or executable lane packets. | + +## Mandatory next sequence + +1. Correct W1/W2, W3/W5, and W4/W6 findings, then repeat focused reciprocal reviews. +2. Registry-finalize and later refresh both audit records after corrections/reviews. +3. Adopt only approved evidence through the integration lifecycle; regenerate decision, estimate, + salvage, and lane ledgers. +4. Run the required target-topology probes and Phase 0 combined/final gates. +5. Freeze Phase 0 and only then replace the blocked Phase 1 draft with a revisioned ready packet and + exact non-overlapping lane ownership. diff --git a/docs/research/hosted-web/phase-0/evidence/index.md b/docs/research/hosted-web/phase-0/evidence/index.md new file mode 100644 index 0000000000..907e1f97b0 --- /dev/null +++ b/docs/research/hosted-web/phase-0/evidence/index.md @@ -0,0 +1,53 @@ +# Phase 0 evidence assembly + +## Status + +This is a documentation-only projection of the project registry and its referenced worktrees, captured +at `2026-07-11T18:25:36Z`. It is not an integration manifest, a Phase 0 freeze, or an acceptance +record. No producer or review output was copied into this worktree, and no Phase 1 implementation is +authorized. + +The authoritative machine snapshot is [`registry-snapshot.json`](./registry-snapshot.json). The +requirement-by-requirement disposition is in +[`acceptance-gap-register.md`](./acceptance-gap-register.md). + +## Supported current-state claims + +- W1-W6 producer jobs all report `done` from the same phase start + `a32f509e6d9bd31ba2135940e336729bf90c3d93`. Every producer handoff says `characterized`, not + `verified`. +- The W1/W2 reciprocal review rejects both outputs. It accepts only W1's selection invariants and W2's + source-observed execution topology, while rejecting heuristic/unstable action IDs, cross-lane + ownership, estimate arithmetic/buckets, incomplete environment discovery, shallow schemas, and + incomplete fake-runtime coverage. +- The W3/W5 reciprocal review rejects both outputs. It retains W3's catalog/writer/SQLite evidence and + W5's event inventory/estimate as useful characterization, while rejecting the required backup fault + fixture, scheduler, recovery classes, fingerprint vectors, and mutation census. +- The W4/W6 reciprocal review rejects both outputs and paired integration. Current-host W4 probes remain + characterization only. W6's artifact inventory supports the negative finding that the current + standalone artifact cannot be designated the v1 hosted artifact. +- The cross-lane audit holds all adoption: it records 24 findings, nine review-approved files held in + rejected pairs, zero immediately adoptable files, and controller ledger drift. The requirements audit + rejects Phase 0 acceptance/freeze: of 70 requirements, 17 are satisfied, 14 partial, 28 failed, five + missing, and six pending. Both audit records are worktree-local without registry latest-results at + capture time. + +## Review disposition + +| Pair | Record state | Disposition | Consequence | +| ----- | ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------ | +| W1/W2 | Worktree-local draft; no registry-final result | Reject both | Corrections and focused reciprocal re-review are required. | +| W3/W5 | Worktree-local draft; no registry-final result | Reject | Corrections and focused reciprocal re-review are required. | +| W4/W6 | Worktree-local draft; no registry-final result | Reject pending correction | Corrections, paired artifact/topology reconciliation, and final-shape probes are required. | + +Worktree-local review files are useful findings but are not relabeled as controller-adopted evidence. +Their hashes are preserved in the registry snapshot so a later assembler can detect replacement. + +## Phase conclusion + +Phase 0 is not frozen. Phase 1 remains blocked because all three review pairs reject producer evidence, +the cross-lane audit holds all adoption, the requirements audit rejects acceptance/freeze, and the predecessor +evidence index, frozen decision +register, reconciled unique-bucket estimate, adopted integration SHA, final Phase 0 gate, and completion +report do not exist in the inspected state. The Phase 1 documents in this worktree are therefore a +non-executable blocked draft derived from supported parent-plan statements and explicit gaps. diff --git a/docs/research/hosted-web/phase-0/evidence/registry-snapshot.json b/docs/research/hosted-web/phase-0/evidence/registry-snapshot.json new file mode 100644 index 0000000000..56619b066c --- /dev/null +++ b/docs/research/hosted-web/phase-0/evidence/registry-snapshot.json @@ -0,0 +1,227 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "packetRevision": "phase-00-r2", + "capturedAt": "2026-07-11T18:25:36Z", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "scope": "documentation-assembly-only", + "sourcePolicy": { + "registryRoot": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/registry-v2", + "producerResultsRoot": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs", + "note": "Registry job records identify the producer, review, and audit worktrees. Worktree-local records are treated as draft evidence until the controller records a successful result and adopts them through integration." + }, + "producerLanes": [ + { + "laneId": "w1", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w1-v1", + "registryJobSha256": "8666ccbaa376eae4a3029dcfb2cfe7e301a91403bd3f48a3e7f11867d963b9ed", + "latestResultSha256": "35bb9c270cc84c0ad429039c5e1c2b60cfbd7fb81ab83ec71339a91973db10", + "resultState": "done", + "producerStatus": "characterized", + "reviewDisposition": "reject", + "evidenceIds": [ + "P0.W1.API_PARITY_LEDGER", + "P0.W1.RENDERER_ACTIONS", + "P0.W1.LEGACY_BYPASSES", + "P0.W1.SELECTION_INVARIANTS", + "P0.W1.SCANNER", + "P0.W1.ESTIMATE" + ], + "supportedSummary": "The producer reports 86 TeamsAPI, 20 ReviewAPI, and 3 CrossTeamAPI members; it classified 833 renderer action mappings and 160 deliberate pre-mount absences with focused scanner checks.", + "gap": "The reciprocal review rejects all W1 evidence except the selection invariants. It identifies heuristic/unstable action IDs, cross-lane ownership conflicts, estimate arithmetic errors, and an adoption-size violation." + }, + { + "laneId": "w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w2-v1", + "registryJobSha256": "b37db9c1edd6b49a9465064699f4093661537bd0f50eea2e775e0cf54906eb00", + "latestResultSha256": "893c1b3ff460367af84fbd45aa5efec0916a2530578ffad6b13c79433fed7701", + "resultState": "done", + "producerStatus": "characterized", + "reviewDisposition": "reject", + "evidenceIds": [ + "P0.W2.EXECUTION_TOPOLOGY", + "P0.W2.RUNTIME_INGRESS_INVENTORY", + "P0.W2.ENVIRONMENT_PROVENANCE", + "P0.W2.CREDENTIAL_EXPOSURE_MATRIX", + "P0.W2.RUNTIME_SCANNER", + "P0.W2.ESTIMATE" + ], + "supportedSummary": "The producer distinguishes four provider identities over two execution backend families and records current runtime-ingress and ambient-environment gaps.", + "gap": "The reciprocal review approves only the source-observed execution topology. It rejects the ingress/environment/credential schemas, scanner completeness, estimate bucket IDs, and fake-runtime coverage." + }, + { + "laneId": "w3", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w3-v1", + "registryJobSha256": "bd0c26451b923367028b6c35c892cdbe81d3adbac4d68b9b20ed742b917676f7", + "latestResultSha256": "a07db789611d8d126dbf2ea5a83d1ee57b7a962e099272f8a03f7dded0a71979", + "resultState": "done", + "producerStatus": "characterized", + "reviewDisposition": "reject", + "evidenceIds": [ + "P0.W3.STATE_FAMILY_CATALOG", + "P0.W3.WRITER_COORDINATION", + "P0.W3.SCHEMA_UNKNOWN_FIELDS", + "P0.W3.BACKUP_BEHAVIOR", + "P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", + "P0.W3.ESTIMATE" + ], + "supportedSummary": "The reciprocal review accepts the state-family catalog, writer coordination, schema/unknown-field evidence, SQLite backup spike, and estimate as useful at their declared proof levels.", + "gap": "P0.W3.BACKUP_BEHAVIOR was rejected because TeamBackupService was not exercised by the required fault-injected fixture." + }, + { + "laneId": "w4", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w4-v1", + "registryJobSha256": "c7a0dae8ab2b5817abc33e639f86a7268a2fe8435ca21a2b6e58b0e621f7dce2", + "latestResultSha256": "709ae6bcf548aae73b7df33ef754bdbb3a10c71b72ca5f7775e5a6aa6abf8c4e", + "resultState": "done", + "producerStatus": "characterized", + "reviewDisposition": "reject", + "evidenceIds": [ + "P0.W4.TARGET_HOST_ENVELOPE", + "P0.W4.INSTANCE_LEASE_SPIKE", + "P0.W4.WORKSPACE_GUARD_SPIKE", + "P0.W4.PROCESS_ANCHOR_SPIKE", + "P0.W4.NATIVE_ARTIFACT_PROPOSAL", + "P0.W4.ESTIMATE" + ], + "supportedSummary": "Current-host native probes are useful characterization only; the producer correctly did not claim final-topology verification.", + "gap": "Review findings R46-03 through R46-06 identify reusable numeric-PID signaling, unproved cleanup, an incomplete high-FD close rule, and no reconciled final-image artifact contract." + }, + { + "laneId": "w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w5-v1", + "registryJobSha256": "f2f3996db1c8cfdd31e9cc251fcbf03de93e525d9ccee3459ecc6c74c35a6bf2", + "latestResultSha256": "894573155a28a3aea9d9d2978565c243fa71e1327978eadb86e146da833b38be", + "resultState": "done", + "producerStatus": "characterized", + "reviewDisposition": "reject", + "evidenceIds": [ + "P0.W5.EVENT_CURSOR_INVENTORY", + "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER", + "P0.W5.COMMAND_CATALOG", + "P0.W5.EFFECT_RECOVERY_MATRIX", + "P0.W5.FINGERPRINT_GOLDENS", + "P0.W5.ESTIMATE" + ], + "supportedSummary": "The reciprocal review accepts only the current event/cursor inventory and estimate as useful evidence.", + "gap": "The scheduler, command catalog, effect classes, and fingerprint goldens were rejected for simulated rather than real crash transitions, conflict with W3 writer evidence, incomplete version/default vectors, and a circular mutation census." + }, + { + "laneId": "w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-w6-v1", + "registryJobSha256": "e90a9d8f6253833ff170d5b35266aecb700e0f45dd69691aca0df514fde3c62d", + "latestResultSha256": "80f3835108abb9078b53a0da190389c51dbb8c5c98546ddd68a146d54320ebac", + "resultState": "done", + "producerStatus": "characterized", + "reviewDisposition": "reject", + "evidenceIds": [ + "P0.W6.AUTH_TRANSITIONS", + "P0.W6.PROXY_ORIGIN_THREAT_MATRIX", + "P0.W6.COOKIE_VERSION_EVIDENCE", + "P0.W6.ARTIFACT_INVENTORY", + "P0.W6.ABI_STUB_REPORT", + "P0.W6.TERMINAL_ABSENCE_REPORT", + "P0.W6.ESTIMATE" + ], + "supportedSummary": "The artifact scan supports the negative conclusion that the current standalone artifact is not an acceptable v1 hosted artifact.", + "gap": "Review findings R46-01, R46-02, R46-06, and R46-07 reject restart auth admission, reset/drain fencing, W4 artifact coverage, and reproducibility of ABI/native smoke claims." + } + ], + "reciprocalReviews": [ + { + "reviewId": "w1-w2", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w1-w2-v1", + "registryJobSha256": "6ee28a7e0b42b1289b3272adce900d08e472ec80a1dd497636b09964befe214a", + "recordState": "worktree_local_not_registry_final", + "disposition": "failed_reject_both", + "reviewSha256": "213836d2c6104318d3e59848a856dea14bfefc9d56840645a76cf821fc918e2b", + "handoffSha256": "c12836733a03159b434223b6ffb63d2a561a3ff946dd3704fde6f5d7f88ef48e", + "findingIds": [ + "R12-W1-001", + "R12-W1-002", + "R12-X-001", + "R12-W1-003", + "R12-W1-004", + "R12-W2-001", + "R12-W2-002", + "R12-W2-003", + "R12-W2-004", + "R12-W2-005" + ] + }, + { + "reviewId": "w3-w5", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w3-w5-v1", + "registryJobSha256": "97a44fdd4e84e61d59980da8f05609d93704add8e12aceb549a24e6eca9d8b5e", + "recordState": "worktree_local_not_registry_final", + "disposition": "rejected", + "reviewSha256": "8140261ef1cf3a2a37fc25d75f639fa9e20607e7bb1c104fe5a39b1585e38a15", + "handoffSha256": "f8d9b40c83ce4d895110162807cd8abafaac25566ef4dd32db0ab5e8afbd8beb", + "findingIds": ["RW35-001", "RW35-002", "RW35-003", "RW35-004", "RW35-005", "RW35-006"] + }, + { + "reviewId": "w4-w6", + "jobId": "agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v1", + "registryJobSha256": "ba034befb24cc39fce60874758ec39cd5320728f0b3d9e2152341bcddd3d9173", + "recordState": "worktree_local_not_registry_final", + "disposition": "rejected_pending_correction", + "reviewSha256": "4a509551fdf2466f1be2f6cc9e9b8c9c1058fb3c5c695f8706f78af42e47e5bf", + "handoffSha256": "7bf1e3e6f1c02ff352909cead19dd0a344f4277d3bbd7ac4e897dfee2a45a175", + "findingIds": ["R46-01", "R46-02", "R46-03", "R46-04", "R46-05", "R46-06", "R46-07", "R46-08"] + } + ], + "audits": [ + { + "auditId": "cross-lane", + "jobId": "agent-teams-hosted-web-refactor-phase-00-audit-cross-lane-v1", + "registryJobSha256": "4360a73e424819f01128798272a2a44dcc8cf854353567a4c06b87b96706b024", + "recordState": "worktree_local_not_registry_final", + "disposition": "hold_all_adoption_pending_owner_correction_and_repeat_review", + "reportSha256": "fb7d76e7645f36f400306fb3aea565e653a1fc212b1acd69290475a0dda0019c", + "manifestSha256": "d6a727dcc93a9f1df64304a8f136b53bd8f061d99a525a4f52d9dd49d7cbda7b", + "handoffSha256": "3480903df109c1a2525f743af1255740e38ac5ea39fc5c73956507372036b1e4", + "summary": { + "findings": 24, + "reviewApprovedFilesHeld": 9, + "immediatelyAdoptableFiles": 0 + }, + "gap": "The audit identifies controller ledger drift and holds every producer file until correction and repeated reciprocal review; no registry latest-result exists at capture time." + }, + { + "auditId": "requirements", + "jobId": "agent-teams-hosted-web-refactor-phase-00-audit-requirements-v1", + "registryJobSha256": "ec95946743f7b005fdd769ef5fce93059e3f8d93234d477741454578c344e67c", + "recordState": "worktree_local_not_registry_final", + "disposition": "reject_pending_correction", + "reportSha256": "2a4de34d267bda6f7d92988ed863fae390ac70cd6a8d2531eea43e34fe1e4723", + "matrixSha256": "406545d7881ee9f09df1068187d1b9e613e0e359f862fbc90a2edffa982424fe", + "registrySnapshotSha256": "ff14175a3bd201aa150cc5417201855de06c843462a9434315414f5b6b1c1b9c", + "handoffSha256": "df152965944784e32c59d1daa20b7bd9d00654755d5ba303b7c0db50511cab42", + "summary": { + "requirements": 70, + "satisfied": 17, + "partial": 14, + "failed": 28, + "missing": 5, + "pending": 6, + "completedProducerJobs": 6, + "completedReviewJobs": 3, + "approvedProducerLanes": 0, + "rejectedProducerLanes": 6, + "pendingCorrectionAndRereview": 6 + }, + "gap": "The full audit rejects Phase 0 acceptance and freeze pending correction/re-review; no registry latest-result exists at capture time." + } + ], + "conclusion": { + "phase0State": "not_frozen", + "phase1PacketState": "blocked_draft_only", + "acceptanceClaim": false, + "reasons": [ + "All three reciprocal review pairs reject producer evidence pending correction.", + "The W3/W5 and W4/W6 reciprocal reviews reject producer evidence pending correction.", + "The cross-lane audit holds all adoption and the requirements audit rejects acceptance/freeze pending correction and reciprocal re-review.", + "No reviewed adoption, regenerated decision/estimate ledgers, final Phase 0 broad gate, completion report, or predecessor integration SHA is available." + ] + } +} diff --git a/docs/research/hosted-web/phase-0/final-gate/README.md b/docs/research/hosted-web/phase-0/final-gate/README.md new file mode 100644 index 0000000000..f76c0540fc --- /dev/null +++ b/docs/research/hosted-web/phase-0/final-gate/README.md @@ -0,0 +1,68 @@ +# Phase 0 normalized final gate + +This gate is pinned to integration candidate `3bc0dfa7c00261785c0c752270cb302a9294e751`. +It makes the repository-wide TypeScript result actionable without changing or concealing the seven +inherited diagnostics present at that commit. + +## Normalization contract + +The allowlist records the exact file, line, column, TypeScript code, and complete message for each +inherited diagnostic. The evaluator consumes each allowlisted entry at most once. A removed +diagnostic is reported as resolved and is not a failure. A moved, changed, duplicated, global, or new +diagnostic is unexpected and fails the gate. Compiler crashes, unparsed output, signals, and exit +codes other than the TypeScript diagnostic exit (`2`) or clean exit (`0`) also fail. + +The targeted configuration has five explicit roots: the three inherited-diagnostic tests, the +normalizer test, and the parity-renderer scanner test, together with their import graphs. It is the +fast iteration gate: + +```text +bash scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh targeted +``` + +The milestone mode executes all three stages represented by `pnpm typecheck:workspace` in canonical +order: root, MCP source, and MCP tests. The known root diagnostics are normalized; both MCP stages +must exit cleanly. Each stage has a five-minute timeout and preserves its raw output independently: + +```text +bash scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh milestone +``` + +Normalization does not edit the three inherited sources, weaken compiler options, add `skip` rules, +or turn an unknown failure into a pass. This work is gate-only and contains no Phase 1 +implementation. + +## Gate matrix + +The completed commands, classifications, durations, and results are recorded in +`gate-matrix.json`. Fast checks are suitable for every edit. The broad workspace typecheck is a +milestone check and must not be used as the inner development loop. + +`typecheck-evidence-reconciliation.json` records the explicit source classification for the inherited +baseline on the updated canonical commit. It proves that the three diagnostic-bearing source blobs, +root TypeScript configuration, and toolchain manifests are byte-identical to the prior observation, +then ties that continuity to a fresh targeted compiler observation. This evidence verifies the exact +seven inherited diagnostics; it is deliberately not a substitute for the one workspace milestone +run. + +The reconciliation is re-derived with: + +```text +bash scripts/hosted-web/phase-0/final-gate/run-reconciliation-check.sh +``` + +Milestone mode preserves all three raw compiler streams and the normalized report under this +directory. Every stage records its command, precise disposition (`exited`, `timeout`, or `signal`), +raw exit or signal, timeout, duration, output byte count, and SHA-256 digest. Timeout, signal, and +runner-error dispositions always fail and are never reported as ordinary exits. The production +runner's shell-level disposition fixtures are exercised with: + +```text +bash test/architecture/hosted-web/phase-0/final-gate/run-normalized-typecheck.test.sh +``` + +The authorized candidate milestone was invoked exactly once with a 300,000 ms timeout per stage. +It passed in 218,250 ms of measured stage time: root exited `2` after 207,899 ms and reproduced the +exact seven inherited diagnostics, MCP source exited `0` after 4,362 ms, and MCP tests exited `0` +after 5,989 ms. The shell wall time was 219 seconds. All committed captures replace the candidate +checkout root with ``; they contain no producer-worktree or host-specific absolute path. diff --git a/docs/research/hosted-web/phase-0/final-gate/gate-matrix.json b/docs/research/hosted-web/phase-0/final-gate/gate-matrix.json new file mode 100644 index 0000000000..fb42fbe619 --- /dev/null +++ b/docs/research/hosted-web/phase-0/final-gate/gate-matrix.json @@ -0,0 +1,125 @@ +{ + "schemaVersion": 1, + "workKey": "phase0/final-gate/3bc0dfa7/6be51a/candidate-reconcile/r3", + "baseSha": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "status": "passed", + "workspaceTypecheckMilestoneInvocationCount": 1, + "workspaceTypecheckMilestoneRetryCount": 0, + "normalizationPolicy": { + "baselineClassification": "inherited_source_observed", + "allowRemovalOfInheritedDiagnostic": true, + "allowChangedOrNewDiagnostic": false, + "allowDuplicateInheritedDiagnostic": false, + "allowCompilerCrashSignalTimeoutRunnerErrorOrUnparsedOutput": false + }, + "gates": [ + { + "id": "normalizer-falsification-tests", + "class": "targeted", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.test.ts --maxWorkers=1 --minWorkers=1", + "status": "passed", + "result": "1 file and 16 tests passed; runner-error has focused fail-closed coverage" + }, + { + "id": "production-runner-disposition-tests", + "class": "targeted-shell", + "command": "bash test/architecture/hosted-web/phase-0/final-gate/run-normalized-typecheck.test.sh", + "status": "passed", + "result": "production runner classified timeout, signal, and runner-error and failed closed for all three" + }, + { + "id": "targeted-normalized-typecheck", + "class": "targeted", + "command": "bash scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh targeted", + "status": "passed", + "durationMs": 8799, + "result": { + "rawExitCode": 2, + "processDisposition": "exited", + "signal": null, + "timeoutMs": 300000, + "compilerOutputBytes": 1815, + "compilerOutputSha256": "46821e45d10298369490fd11ddc4046f16e38a15b0cf4e1c0532e36de26c43aa", + "observedDiagnostics": 7, + "normalizedInheritedDiagnostics": 7, + "resolvedInheritedDiagnostics": 0, + "unexpectedDiagnostics": 0, + "effectiveDiagnostics": 0 + } + }, + { + "id": "candidate-source-reconciliation", + "class": "targeted", + "command": "bash scripts/hosted-web/phase-0/final-gate/run-reconciliation-check.sh", + "status": "passed", + "result": "candidate source and root typecheck inputs match the superseded base blobs; fresh candidate compilation reproduced the exact 5/1/1 diagnostic distribution" + }, + { + "id": "workspace-normalized-typecheck", + "class": "authorized-milestone", + "command": "PHASE0_TYPECHECK_TIMEOUT_MS=300000 bash scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh milestone", + "status": "passed", + "attemptCount": 1, + "retryCount": 0, + "wallSeconds": 219, + "durationMs": 218250, + "normalizedReportSha256": "53769bbc62166f27840a74b5495ea14d20fad588dcd456638b952530ad1a97d5", + "stages": [ + { + "id": "root", + "status": "passed_after_exact_normalization", + "durationMs": 207899, + "processDisposition": "exited", + "rawExitCode": 2, + "signal": null, + "timeoutMs": 300000, + "compilerOutputBytes": 1815, + "compilerOutputSha256": "46821e45d10298369490fd11ddc4046f16e38a15b0cf4e1c0532e36de26c43aa", + "observedDiagnostics": 7, + "normalizedInheritedDiagnostics": 7, + "unexpectedDiagnostics": 0, + "effectiveDiagnostics": 0, + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck.raw.txt" + }, + { + "id": "mcp-source", + "status": "passed", + "durationMs": 4362, + "processDisposition": "exited", + "rawExitCode": 0, + "signal": null, + "timeoutMs": 300000, + "compilerOutputBytes": 0, + "compilerOutputSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-source.raw.txt" + }, + { + "id": "mcp-tests", + "status": "passed", + "durationMs": 5989, + "processDisposition": "exited", + "rawExitCode": 0, + "signal": null, + "timeoutMs": 300000, + "compilerOutputBytes": 0, + "compilerOutputSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-tests.raw.txt" + } + ] + }, + { + "id": "lint-format-shell-json", + "class": "final", + "status": "passed", + "result": "focused fast lint, Prettier check, shell syntax, and JSON parsing passed" + }, + { + "id": "scope-secret-path-and-patch", + "class": "final", + "status": "passed", + "result": "owned-path scope, secret patterns, host-specific path scan, and no-index patch whitespace checks passed" + } + ], + "partialResultMayPass": false, + "phase1Implementation": false +} diff --git a/docs/research/hosted-web/phase-0/final-gate/inherited-typescript-diagnostics.json b/docs/research/hosted-web/phase-0/final-gate/inherited-typescript-diagnostics.json new file mode 100644 index 0000000000..f0b11bd101 --- /dev/null +++ b/docs/research/hosted-web/phase-0/final-gate/inherited-typescript-diagnostics.json @@ -0,0 +1,79 @@ +{ + "schemaVersion": 1, + "baseSha": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "compiler": "typescript", + "sourceClassification": { + "classification": "inherited_source_observed", + "priorObservedBaseSha": "42ec333848e29e97c41699b9fed73ed199740e3f", + "observedAtBaseSha": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "observationMethod": "fresh targeted TypeScript compilation of the integration candidate", + "sourceBlobContinuity": [ + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "priorBlob": "f876c667faef5870b59fd1ab896151b223e30441", + "updatedBaseBlob": "f876c667faef5870b59fd1ab896151b223e30441" + }, + { + "path": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "priorBlob": "91cf2560b416e14c0f8a7d60d92a6a3ccf83ce12", + "updatedBaseBlob": "91cf2560b416e14c0f8a7d60d92a6a3ccf83ce12" + }, + { + "path": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "priorBlob": "cacd88f7d9b3e34b583a99be96f7a7587f2fb0d2", + "updatedBaseBlob": "cacd88f7d9b3e34b583a99be96f7a7587f2fb0d2" + } + ] + }, + "diagnostics": [ + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 25, + "column": 8, + "code": 7016, + "message": "Could not find a declaration file for module '../../../../../scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs'. '/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 66, + "column": 31, + "code": 7031, + "message": "Binding element 'code' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 117, + "column": 68, + "code": 18046, + "message": "'session' is of type 'unknown'." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 413, + "column": 48, + "code": 7031, + "message": "Binding element 'operation' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 733, + "column": 10, + "code": 7031, + "message": "Binding element 'artifactId' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "line": 12, + "column": 8, + "code": 7016, + "message": "Could not find a declaration file for module '../../../../../scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs'. '/scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "line": 162, + "column": 44, + "code": 2352, + "message": "Conversion of type '{ providerBackends: { codex: \"fixture-codex\"; gemini: \"fixture-gemini\"; }; }' to type 'RuntimeConfig' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.\n The types of 'providerBackends.gemini' are incompatible between these types.\n Type '\"fixture-gemini\"' is not comparable to type '\"auto\" | \"api\" | \"cli-sdk\"'." + } + ] +} diff --git a/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-source.raw.txt b/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-source.raw.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-tests.raw.txt b/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-tests.raw.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-report.json b/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-report.json new file mode 100644 index 0000000000..a961000243 --- /dev/null +++ b/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-report.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": 1, + "gate": "phase-0-final-gate-typescript-workspace-milestone", + "mode": "milestone", + "baseSha": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "workspaceScript": "pnpm typecheck:workspace", + "executionPolicy": "run the three canonical workspace stages in order; normalize only the inherited root diagnostics and require clean MCP exits", + "timeoutMsPerStage": 300000, + "durationMs": 218250, + "passed": true, + "stages": [ + { + "id": "root", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck.raw.txt", + "schemaVersion": 1, + "gate": "phase-0-final-gate-typescript-milestone", + "mode": "milestone", + "baseSha": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "durationMs": 207899, + "compilerCommand": "pnpm --silent typecheck", + "processDisposition": "exited", + "signal": null, + "timeoutMs": 300000, + "compilerOutputBytes": 1815, + "compilerOutputSha256": "46821e45d10298369490fd11ddc4046f16e38a15b0cf4e1c0532e36de26c43aa", + "passed": true, + "rawExitCode": 2, + "observedDiagnosticCount": 7, + "normalizedInheritedCount": 7, + "resolvedInheritedCount": 0, + "effectiveDiagnosticCount": 0, + "inheritedDiagnostics": [ + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 25, + "column": 8, + "code": 7016, + "message": "Could not find a declaration file for module '../../../../../scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs'. '/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 66, + "column": 31, + "code": 7031, + "message": "Binding element 'code' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 117, + "column": 68, + "code": 18046, + "message": "'session' is of type 'unknown'." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 413, + "column": 48, + "code": 7031, + "message": "Binding element 'operation' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "line": 733, + "column": 10, + "code": 7031, + "message": "Binding element 'artifactId' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "line": 12, + "column": 8, + "code": 7016, + "message": "Could not find a declaration file for module '../../../../../scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs'. '/scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs' implicitly has an 'any' type." + }, + { + "file": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "line": 162, + "column": 44, + "code": 2352, + "message": "Conversion of type '{ providerBackends: { codex: \"fixture-codex\"; gemini: \"fixture-gemini\"; }; }' to type 'RuntimeConfig' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.\n The types of 'providerBackends.gemini' are incompatible between these types.\n Type '\"fixture-gemini\"' is not comparable to type '\"auto\" | \"api\" | \"cli-sdk\"'." + } + ], + "resolvedDiagnostics": [], + "unexpectedDiagnostics": [], + "unparsedOutput": [] + }, + { + "id": "mcp-source", + "command": "pnpm --silent --filter agent-teams-mcp typecheck", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-source.raw.txt", + "durationMs": 4362, + "processDisposition": "exited", + "rawExitCode": 0, + "signal": null, + "timeoutMs": 300000, + "compilerOutputBytes": 0, + "compilerOutputSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "passed": true + }, + { + "id": "mcp-tests", + "command": "pnpm --silent --filter agent-teams-mcp typecheck:test", + "rawCapturePath": "docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-tests.raw.txt", + "durationMs": 5989, + "processDisposition": "exited", + "rawExitCode": 0, + "signal": null, + "timeoutMs": 300000, + "compilerOutputBytes": 0, + "compilerOutputSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "passed": true + } + ] +} diff --git a/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck.raw.txt b/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck.raw.txt new file mode 100644 index 0000000000..bc864b2686 --- /dev/null +++ b/docs/research/hosted-web/phase-0/final-gate/milestone-typecheck.raw.txt @@ -0,0 +1,9 @@ +test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts(25,8): error TS7016: Could not find a declaration file for module '../../../../../scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs'. '/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs' implicitly has an 'any' type. +test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts(66,31): error TS7031: Binding element 'code' implicitly has an 'any' type. +test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts(117,68): error TS18046: 'session' is of type 'unknown'. +test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts(413,48): error TS7031: Binding element 'operation' implicitly has an 'any' type. +test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts(733,10): error TS7031: Binding element 'artifactId' implicitly has an 'any' type. +test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts(12,8): error TS7016: Could not find a declaration file for module '../../../../../scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs'. '/scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs' implicitly has an 'any' type. +test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts(162,44): error TS2352: Conversion of type '{ providerBackends: { codex: "fixture-codex"; gemini: "fixture-gemini"; }; }' to type 'RuntimeConfig' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. + The types of 'providerBackends.gemini' are incompatible between these types. + Type '"fixture-gemini"' is not comparable to type '"auto" | "api" | "cli-sdk"'. diff --git a/docs/research/hosted-web/phase-0/final-gate/typecheck-evidence-reconciliation.json b/docs/research/hosted-web/phase-0/final-gate/typecheck-evidence-reconciliation.json new file mode 100644 index 0000000000..8d2bd378ee --- /dev/null +++ b/docs/research/hosted-web/phase-0/final-gate/typecheck-evidence-reconciliation.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "canonicalHead": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "status": "updated_base_source_classified_and_targeted_verified", + "priorBaseline": { + "baseSha": "42ec333848e29e97c41699b9fed73ed199740e3f", + "reviewDecision": "REJECT", + "usableFinding": "Historical only; no report or runtime result from this superseded base is current evidence for the candidate." + }, + "baselineSourceClassification": { + "classification": "inherited_source_observed", + "classificationBasis": "All three diagnostic-bearing source blobs are byte-identical between the superseded base and integration candidate, and a fresh targeted compiler run at the candidate reproduced the exact seven tuples.", + "sourceBlobs": [ + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "priorBlob": "f876c667faef5870b59fd1ab896151b223e30441", + "updatedBaseBlob": "f876c667faef5870b59fd1ab896151b223e30441", + "diagnosticCount": 5 + }, + { + "path": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "priorBlob": "91cf2560b416e14c0f8a7d60d92a6a3ccf83ce12", + "updatedBaseBlob": "91cf2560b416e14c0f8a7d60d92a6a3ccf83ce12", + "diagnosticCount": 1 + }, + { + "path": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "priorBlob": "cacd88f7d9b3e34b583a99be96f7a7587f2fb0d2", + "updatedBaseBlob": "cacd88f7d9b3e34b583a99be96f7a7587f2fb0d2", + "diagnosticCount": 1 + } + ] + }, + "unchangedRootTypecheckInputs": [ + { + "path": "tsconfig.json", + "priorBlob": "2a0d056bbb0795556bb9009bc736d7402c028ecf", + "updatedBaseBlob": "2a0d056bbb0795556bb9009bc736d7402c028ecf" + }, + { + "path": "package.json", + "priorBlob": "2917eb28b1bf05d50109fb605b392b7396f93782", + "updatedBaseBlob": "2917eb28b1bf05d50109fb605b392b7396f93782" + }, + { + "path": "pnpm-lock.yaml", + "priorBlob": "3c72d1fa1a24bc8771424781bf0802e8f9ae3229", + "updatedBaseBlob": "3c72d1fa1a24bc8771424781bf0802e8f9ae3229" + } + ], + "freshTargetedObservation": { + "command": "bash scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh targeted", + "durationMs": 8799, + "rawExitCode": 2, + "compilerOutputBytes": 1815, + "compilerOutputSha256": "46821e45d10298369490fd11ddc4046f16e38a15b0cf4e1c0532e36de26c43aa", + "observedDiagnosticCount": 7, + "normalizedInheritedCount": 7, + "resolvedInheritedCount": 0, + "unexpectedDiagnosticCount": 0, + "effectiveDiagnosticCount": 0 + }, + "scopeBoundary": { + "targetedProof": "The exact inherited diagnostic set is source-observed at the updated base.", + "notClaimed": "The targeted run does not cover the updated base's complete TypeScript workspace and is not a substitute for the one three-stage workspace milestone invocation." + } +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/README.md b/docs/research/hosted-web/phase-0/freeze/current-canonical/README.md new file mode 100644 index 0000000000..0c5933bee7 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/README.md @@ -0,0 +1,55 @@ +# Phase 0 accepted canonical freeze + +This directory is the controller-owned current-state authority for Phase 0. It resolves +`P0.C1.IDENTITY.001` and `P0.C1.STALE.001` without rewriting historical producer, reviewer, audit, +baseline, or ledger records. + +The one true `phaseStartSha` is `a32f509e6d9bd31ba2135940e336729bf90c3d93`. Phase 0 is accepted and +frozen at exact candidate `f4fa24aac9615a4ce10632965a2244a2e11a273e`. Its accepted supporting +authorities are orchestration `1587615c751c3cb12b5078ab4b7264b6e9fd42ad`, bounded navigation +`f32be6a6fcb2da7a47ef3553476430ef8052e19a`, and estimate reconciliation +`f4fa24aac9615a4ce10632965a2244a2e11a273e`. A lane source base, review base, phase start, evidence +integration commit, or freeze commit must not be substituted for another provenance role. + +Current authority is split by concern: + +- `lane-identity-index.json` records every original and later lane integration commit. +- `review-disposition-index.json` records lane dispositions plus exact hashes and commits for the + accepted target-image, final-gate, orchestration, navigation, and estimate authorities. +- `decision-index.json` records the controller's frozen decisions and the S0-only Phase 1 transition. +- `evidence-index.json` gives every current evidence byte an exact lane, path, SHA-256, proof level, + byte state, disposition, and true integration commit. +- `supersession-index.json` identifies historical claims that no longer represent current state. + Supersession preserves the historical bytes but cannot revive their rejection or hold conclusions. +- `hash-reconciliation.md` distinguishes exact evidence-byte commits from the accepted freeze. +- `acceptance-and-completion.md` records completion, removed blockers, and implementation risks. +- `handoff-census.json` inventories the six canonical lane handoffs without treating stale registry + progress as current authority. + +The W1 raw bypass projection is intentionally external because it exceeds the evidence budget. Its +checked-in envelope stores only the stable pack-relative name `legacy-bypass-raw.json`, its digest and +record count, and a deterministic repository command. The corrected bytes are integrated at +`a6bd7a39aebb4d822f57707c96c5e071b2aecb2b`; no task-local path is recorded. + +W2 correction is also explicit: historical files use +`phaseStartSha=c72fd201867b9bcd1ef77d5e0f95ba379adb4fca`. That SHA is a W2 source +base, not the Phase 0 start. The later omission-sensitive W2 bytes are integrated at +`6d54e7c60d29812de5b96e471761486fbbc0842c`. Target-image provider execution remains unverified as a +later implementation risk, not a Phase 0 research blocker. + +Run the repository-portable gate with: + +```bash +node docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs +``` + +The controller environment can additionally re-hash external review records with: + +```bash +node docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs --include-controller-external +``` + +The gate validates all five indexes against `canonical-index.schema.json`, re-hashes repository and +Git-commit bytes, checks the exact accepted authorities and freeze provenance, enforces pack-relative +W1 raw evidence, verifies explicit later-byte dispositions, checks the S0-only router, and proves the +omission, stale-hash, and duplicate-ID fixtures fail with their expected diagnostics. diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/acceptance-and-completion.md b/docs/research/hosted-web/phase-0/freeze/current-canonical/acceptance-and-completion.md new file mode 100644 index 0000000000..2c7d0bc1ec --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/acceptance-and-completion.md @@ -0,0 +1,43 @@ +# Phase 0 acceptance and completion record + +Phase 0 started at `a32f509e6d9bd31ba2135940e336729bf90c3d93` and is accepted/frozen at +`f4fa24aac9615a4ce10632965a2244a2e11a273e`. No evidence byte is attributed to a commit unless +`git show` at the stated integration commit produces that exact byte. + +Current lane dispositions are: + +- W1: original v9 adopted; the `0d1a82fe…` renderer census narrowly adopted as characterization; + pack-relative raw-evidence serialization remains source-observed and is integrated at `a6bd7a39…`. +- W2: original A1 approved; the `6d54e7c6…` omission-sensitive census narrowly adopted, with final + target-image provider behavior and credential canaries unproved. +- W3/W5: compatible only within the evidence boundary; the `5d723407…` command-ownership correction + is narrowly adopted and does not implement hosted recovery. +- W4/W6: characterization only. The `c958c872…` artifact authority and `3bc0dfa7…` fail-closed + target-image narrowing are adopted without admitting hosted mutation or final-image readiness. + +Historical rejected-pair, held-adoption, failed-audit, h7, and h8 conclusions remain immutable +historical evidence. They are not adopted and are not current blockers. The accepted target-image +decision, final-gate reconciliation, orchestration authority, bounded navigation contract, and +estimate reconciliation resolve the former Phase 0 transition blockers. + +Phase 1 is current only for `P1.S0` serial bootstrap. Its one authorized worker may freeze exact +packet identity, ownership, paths, fixtures, baselines, commands, and start SHA; it may not implement +product source. `P1.S1` and every later subphase remain blocked until S0 is integrated and the router +is explicitly advanced. + +## Remaining implementation risks + +- Exact target-image/profile construction, provider canaries, production composition, and + terminal-negative admission remain fail closed for their owning later phases. +- The accepted non-terminal v1 estimate is 38,300-62,100 gross changed lines. Its upper endpoint, + W4 controller allocations, W3/W5 overlap bound, and unallocated migration split require planning + discipline during implementation. +- These limitations may narrow or stop a dependent later-phase capability. They do not authorize + repeating Phase 0 research. + +## Typecheck normalization rule + +The accepted final gate compares diagnostics against the inherited seven-diagnostic set: five in +`auth-artifacts-spike.test.ts`, one in `host-primitives/evidence-scanner.test.ts`, and one in +`provider-runtime/scan-runtime-surfaces.test.ts`. The normalized comparison does not waive the +repo-wide typecheck or convert an inherited failure into a pass. diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/canonical-index.schema.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/canonical-index.schema.json new file mode 100644 index 0000000000..856f8347d9 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/canonical-index.schema.json @@ -0,0 +1,271 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://agent-teams.local/schemas/hosted-web-phase-0-current-canonical-index-v1.json", + "title": "Controller-owned Phase 0 current canonical index", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "indexType", + "controllerOwner", + "phaseId", + "phaseStartSha", + "currentIntegrationCommit", + "freezeCandidate", + "generatedAt" + ], + "additionalProperties": false, + "properties": { + "$schema": { "const": "./canonical-index.schema.json" }, + "schemaVersion": { "const": 1 }, + "indexType": { + "enum": ["lane-identity", "review-disposition", "decision", "evidence", "supersession"] + }, + "controllerOwner": { "const": "agent-teams-hosted-web-refactor-controller-v2" }, + "phaseId": { "const": "phase-00" }, + "phaseStartSha": { "const": "a32f509e6d9bd31ba2135940e336729bf90c3d93" }, + "currentIntegrationCommit": { "const": "f4fa24aac9615a4ce10632965a2244a2e11a273e" }, + "freezeCandidate": { "$ref": "#/definitions/freezeCandidate" }, + "generatedAt": { "type": "string", "format": "date-time" }, + "lanes": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "items": { "$ref": "#/definitions/lane" } + }, + "reviews": { + "type": "array", + "minItems": 4, + "items": { "$ref": "#/definitions/review" } + }, + "acceptedAuthorities": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "items": { "$ref": "#/definitions/acceptedAuthority" } + }, + "decisions": { + "type": "array", + "minItems": 13, + "items": { "$ref": "#/definitions/decision" } + }, + "evidence": { + "type": "array", + "minItems": 40, + "items": { "$ref": "#/definitions/evidence" } + }, + "supersessions": { + "type": "array", + "minItems": 8, + "items": { "$ref": "#/definitions/supersession" } + } + }, + "oneOf": [ + { + "properties": { "indexType": { "const": "lane-identity" } }, + "required": ["lanes"] + }, + { + "properties": { "indexType": { "const": "review-disposition" } }, + "required": ["reviews", "acceptedAuthorities"] + }, + { + "properties": { "indexType": { "const": "decision" } }, + "required": ["decisions"] + }, + { + "properties": { "indexType": { "const": "evidence" } }, + "required": ["evidence"] + }, + { + "properties": { "indexType": { "const": "supersession" } }, + "required": ["supersessions"] + } + ], + "definitions": { + "sha1": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "laneId": { "type": "string", "enum": ["w1", "w2", "w3", "w4", "w5", "w6"] }, + "freezeCandidate": { + "type": "object", + "required": ["baseCommit", "status", "integrationCommit"], + "additionalProperties": false, + "properties": { + "baseCommit": { "const": "f4fa24aac9615a4ce10632965a2244a2e11a273e" }, + "status": { "const": "accepted-frozen" }, + "integrationCommit": { "const": "f4fa24aac9615a4ce10632965a2244a2e11a273e" } + } + }, + "pathHash": { + "type": "object", + "required": ["path", "sha256", "scope"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/definitions/sha256" }, + "scope": { "enum": ["repository", "controller-external"] } + } + }, + "commitDisposition": { + "type": "object", + "required": ["commit", "disposition", "scope", "paths"], + "additionalProperties": false, + "properties": { + "commit": { "$ref": "#/definitions/sha1" }, + "disposition": { "enum": ["adopted", "narrowed", "rejected"] }, + "scope": { "type": "string", "minLength": 1 }, + "paths": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "acceptedAuthority": { + "type": "object", + "required": ["authorityId", "commit", "disposition", "role", "source"], + "additionalProperties": false, + "properties": { + "authorityId": { "type": "string", "pattern": "^P0\\.CURRENT\\.AUTHORITY\\." }, + "commit": { "$ref": "#/definitions/sha1" }, + "disposition": { "const": "accepted" }, + "role": { + "enum": [ + "target-image-narrowing", + "final-gate", + "orchestration", + "navigation", + "estimate" + ] + }, + "source": { "$ref": "#/definitions/pathHash" } + } + }, + "lane": { + "type": "object", + "required": [ + "laneId", + "producerJobId", + "packetRevision", + "phaseStartSha", + "sourceBaseSha", + "integratedAtCommit", + "integrationHistory", + "currentState", + "handoff" + ], + "additionalProperties": false, + "properties": { + "laneId": { "$ref": "#/definitions/laneId" }, + "producerJobId": { "type": "string", "minLength": 1 }, + "packetRevision": { "enum": ["phase-00-r2", "phase-00-r3"] }, + "phaseStartSha": { "const": "a32f509e6d9bd31ba2135940e336729bf90c3d93" }, + "sourceBaseSha": { "$ref": "#/definitions/sha1" }, + "integratedAtCommit": { "$ref": "#/definitions/sha1" }, + "integrationHistory": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/commitDisposition" } + }, + "currentState": { "const": "integrated-current" }, + "handoff": { "$ref": "#/definitions/pathHash" }, + "identityCorrection": { "type": "string", "minLength": 1 }, + "pendingCandidatePaths": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "review": { + "type": "object", + "required": ["reviewId", "lanes", "disposition", "scope", "sources", "commitDispositions"], + "additionalProperties": false, + "properties": { + "reviewId": { "type": "string", "pattern": "^P0\\.CURRENT\\.REVIEW\\." }, + "lanes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/laneId" } + }, + "disposition": { + "enum": ["controller-adopted", "approve", "compatible-narrowed"] + }, + "scope": { "type": "string", "minLength": 1 }, + "sources": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/pathHash" } + }, + "commitDispositions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/commitDisposition" } + } + } + }, + "decision": { + "type": "object", + "required": ["decisionId", "status", "decision", "authorityPaths"], + "additionalProperties": false, + "properties": { + "decisionId": { "type": "string", "pattern": "^P0\\." }, + "status": { "enum": ["resolved", "accepted", "narrowed", "blocked"] }, + "decision": { "type": "string", "minLength": 1 }, + "authorityPaths": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "evidence": { + "type": "object", + "required": ["evidenceId", "laneId", "path", "sha256", "proofLevel", "integratedAtCommit"], + "additionalProperties": false, + "properties": { + "evidenceId": { "type": "string", "pattern": "^P0\\.W[1-6]\\." }, + "laneId": { "$ref": "#/definitions/laneId" }, + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/definitions/sha256" }, + "proofLevel": { "type": "string", "minLength": 1 }, + "byteState": { "enum": ["integrated", "pending-integration"] }, + "integratedAtCommit": { + "oneOf": [{ "$ref": "#/definitions/sha1" }, { "type": "null" }] + }, + "derivedFromCommit": { "$ref": "#/definitions/sha1" }, + "adoptionDisposition": { "enum": ["adopted", "narrowed", "rejected"] }, + "adoptionDecisionId": { "type": "string", "pattern": "^P0\\." } + } + }, + "supersession": { + "type": "object", + "required": [ + "supersessionId", + "sources", + "supersededClaims", + "replacementIndex", + "preservationRule" + ], + "additionalProperties": false, + "properties": { + "supersessionId": { "type": "string", "pattern": "^P0\\.CURRENT\\.SUPERSESSION\\." }, + "sources": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/pathHash" } + }, + "supersededClaims": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "replacementIndex": { "type": "string", "minLength": 1 }, + "preservationRule": { + "const": "historical bytes remain immutable; only current interpretation is superseded" + }, + "correction": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json new file mode 100644 index 0000000000..b4dea0681f --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json @@ -0,0 +1,157 @@ +{ + "$schema": "./canonical-index.schema.json", + "schemaVersion": 1, + "indexType": "decision", + "controllerOwner": "agent-teams-hosted-web-refactor-controller-v2", + "phaseId": "phase-00", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "currentIntegrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "freezeCandidate": { + "baseCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "status": "accepted-frozen", + "integrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e" + }, + "generatedAt": "2026-07-12T10:28:17Z", + "decisions": [ + { + "decisionId": "P0.C1.IDENTITY.001", + "status": "resolved", + "decision": "The controller-owned lane identity index is the sole current identity authority for W1-W6. All lanes share phaseStartSha a32f509e6d9bd31ba2135940e336729bf90c3d93. The accepted and frozen Phase 0 candidate is f4fa24aac9615a4ce10632965a2244a2e11a273e.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json" + ] + }, + { + "decisionId": "P0.C1.STALE.001", + "status": "resolved", + "decision": "Historical producer, review, audit, ledger and baseline records remain immutable evidence but no longer supply current lane identity, disposition, decision, integration status, or readiness blockers.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.PHASE_START", + "status": "accepted", + "decision": "a32f509e6d9bd31ba2135940e336729bf90c3d93 is the one true Phase 0 phaseStartSha. A lane source base, remediation base, review base, predecessor, or eventual freeze commit is never a substitute.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.W1.ADOPTION", + "status": "accepted", + "decision": "Adopt W1 v9 at 89c1358925033d480bcfe3bdfee6c899df556431 as the original bundle.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.W1.LATER_BYTES", + "status": "narrowed", + "decision": "Narrowly adopt the later W1 renderer census and scanner semantics integrated at 0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3. The legacy-bypass artifact remains source-observed; a6bd7a39aebb4d822f57707c96c5e071b2aecb2b integrates its pack-relative raw-output locator and matching scanner bytes without increasing proof level.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md" + ] + }, + { + "decisionId": "P0.CURRENT.W2.ADOPTION", + "status": "accepted", + "decision": "Adopt the approved W2 targeted A1 evidence bundle at 0bf8f2d105def1fa34dd8dedfb8d345d720dc35e while superseding its incorrect phaseStartSha claim.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.W2.LATER_BYTES", + "status": "narrowed", + "decision": "Narrowly adopt the omission-sensitive W2 environment census integrated at 6d54e7c60d29812de5b96e471761486fbbc0842c. It remains source-observed and does not prove target-image provider behavior or credential canaries.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md" + ] + }, + { + "decisionId": "P0.CURRENT.W3_W5.ADOPTION", + "status": "narrowed", + "decision": "Adopt W3 and W5 evidence as a compatible evidence-only pair; retain all recorded later-phase and final-topology limitations.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.W5.LATER_BYTES", + "status": "narrowed", + "decision": "Narrowly adopt the W5 command-ownership and formatting-stable evidence corrections integrated at 5d723407f287767c0f30f3d708459fb943256eaf. They remain Phase 0 evidence and do not implement or authorize hosted recovery.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md" + ] + }, + { + "decisionId": "P0.CURRENT.W4_W6.ADOPTION", + "status": "narrowed", + "decision": "Adopt W4/W6 as characterization only, including W6 artifact authority at c958c872fa22edf9b2d6a0741d7781b00957903c and fail-closed target-image narrowing at 3bc0dfa7c00261785c0c752270cb302a9294e751. Exact-image/profile construction, provider canaries, production composition, and terminal-negative admission remain later implementation risks; they do not require more Phase 0 research.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.HISTORICAL_PRESERVATION", + "status": "accepted", + "decision": "No historical file is rewritten. Supersession changes only which controller-owned record is authoritative for current state and interpretation. Historical rejection and hold conclusions cannot become current blockers without a new current decision.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.PHASE0_FREEZE", + "status": "accepted", + "decision": "Phase 0 is accepted and frozen at f4fa24aac9615a4ce10632965a2244a2e11a273e. The target-image decision at 3bc0dfa7, final gate at 63ff349e, orchestration authority at 1587615c, navigation at f32be6a6, and estimate reconciliation at f4fa24aa resolve the former transition blockers. Remaining limitations are implementation risks owned by later phases.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/acceptance-and-completion.md", + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.PHASE1_AUTHORITY", + "status": "narrowed", + "decision": "Phase 1 authorization is limited to the serial P1.S0 bootstrap. P1.S1 and every later subphase remain blocked and proposal-only until S0 is integrated and the compact router is explicitly advanced. No product source implementation is authorized by this freeze.", + "authorityPaths": [ + "docs/hosted-web-phases/phase-01/README.md", + "docs/hosted-web-phases/phase-01/controller-packet.md", + "docs/hosted-web-phases/phase-01/execution-dag.md" + ] + }, + { + "decisionId": "P0.CURRENT.ORCHESTRATION_AUTHORITY", + "status": "accepted", + "decision": "Commit 1587615c751c3cb12b5078ab4b7264b6e9fd42ad is the accepted orchestration and evidence-supersession authority inherited by the frozen candidate.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json" + ] + }, + { + "decisionId": "P0.CURRENT.NAVIGATION_AUTHORITY", + "status": "accepted", + "decision": "Commit f32be6a6fcb2da7a47ef3553476430ef8052e19a is the accepted bounded navigation contract inherited by the frozen candidate and updated here to route only P1.S0.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "docs/hosted-web-phases/EXECUTION_INDEX.json" + ] + }, + { + "decisionId": "P0.CURRENT.ESTIMATE_AUTHORITY", + "status": "accepted", + "decision": "Commit f4fa24aac9615a4ce10632965a2244a2e11a273e is the accepted estimate authority: 38,300-62,100 gross non-terminal v1 changed lines, with the upper endpoint and controller allocations retained as implementation planning risks.", + "authorityPaths": [ + "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json" + ] + } + ] +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json new file mode 100644 index 0000000000..cf022fe6e5 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json @@ -0,0 +1,391 @@ +{ + "$schema": "./canonical-index.schema.json", + "schemaVersion": 1, + "indexType": "evidence", + "controllerOwner": "agent-teams-hosted-web-refactor-controller-v2", + "phaseId": "phase-00", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "currentIntegrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "freezeCandidate": { + "baseCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "status": "accepted-frozen", + "integrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e" + }, + "generatedAt": "2026-07-12T10:28:17Z", + "evidence": [ + { + "evidenceId": "P0.W1.API_PARITY_LEDGER", + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "sha256": "a11f469d9c981c3e05be57fed7a6303772349a79297a0f1887db97d54e84c0a3", + "proofLevel": "source_observed", + "integratedAtCommit": "89c1358925033d480bcfe3bdfee6c899df556431" + }, + { + "evidenceId": "P0.W1.RENDERER_ACTIONS", + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "sha256": "9ff8164fb83dd235b69aef81168d03ed5c6dd84f5ffd25fab25bacd594d175b4", + "proofLevel": "fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W1.LATER_BYTES" + }, + { + "evidenceId": "P0.W1.RENDERER_CHILD_CONTROLS", + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json", + "sha256": "833200abb1218a8b8596c65614f1f8d407ed55f2a030139178b1d57196ed5a7e", + "proofLevel": "fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W1.LATER_BYTES" + }, + { + "evidenceId": "P0.W1.LEGACY_BYPASSES", + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "sha256": "674859e100839256f86869b8a72ddf65153001f399d7ba08ba135fdf22b2d0d5", + "proofLevel": "source_observed", + "byteState": "integrated", + "integratedAtCommit": "a6bd7a39aebb4d822f57707c96c5e071b2aecb2b", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W1.LATER_BYTES" + }, + { + "evidenceId": "P0.W1.SELECTION_INVARIANTS", + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md", + "sha256": "b9f9a3b24720fbac112a1b97e40725ecf484e18c15f1d7b6596d2446b4f4eefe", + "proofLevel": "source_observed", + "integratedAtCommit": "89c1358925033d480bcfe3bdfee6c899df556431" + }, + { + "evidenceId": "P0.W1.SCANNER", + "laneId": "w1", + "path": "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts", + "sha256": "a91bcbdcf383bb06c07517d12ca4c0985591f66057e0a60bd7388c156dc7ad8b", + "proofLevel": "fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "a6bd7a39aebb4d822f57707c96c5e071b2aecb2b", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W1.LATER_BYTES" + }, + { + "evidenceId": "P0.W1.ESTIMATE", + "laneId": "w1", + "path": "docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json", + "sha256": "af3fe8edc17ac5f3ca77533a949625b5b97c61c3ebab111726f72ebdc9adf669", + "proofLevel": "source_observed", + "integratedAtCommit": "89c1358925033d480bcfe3bdfee6c899df556431" + }, + { + "evidenceId": "P0.W2.EXECUTION_TOPOLOGY", + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sha256": "40650b7ec3549a30419932fa43330b1484468c393ddef0555438f9f308c97e3a", + "proofLevel": "source_observed", + "integratedAtCommit": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e" + }, + { + "evidenceId": "P0.W2.RUNTIME_INGRESS_INVENTORY", + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "sha256": "531ae8746d48e527c4e1a857e8b6077c5c9d58cf1ca0ca6523cd629a98c0e807", + "proofLevel": "source_observed", + "integratedAtCommit": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e" + }, + { + "evidenceId": "P0.W2.ENVIRONMENT_PROVENANCE", + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "sha256": "302cc6081598a182df58478f6d50288228e037db5f14c33df72c512fcc5b3d81", + "proofLevel": "source_observed_per_key_with_executed_routing_semantics_target_unverified", + "byteState": "integrated", + "integratedAtCommit": "6d54e7c60d29812de5b96e471761486fbbc0842c", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W2.LATER_BYTES" + }, + { + "evidenceId": "P0.W2.CREDENTIAL_EXPOSURE_MATRIX", + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "sha256": "5f039a47a65ced6556f5c096e4135d18aeea3377db1f21fb347025c1af5b36e1", + "proofLevel": "source_observed_per_key_target_unverified", + "byteState": "integrated", + "integratedAtCommit": "6d54e7c60d29812de5b96e471761486fbbc0842c", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W2.LATER_BYTES" + }, + { + "evidenceId": "P0.W2.RUNTIME_SCANNER", + "laneId": "w2", + "path": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "sha256": "92a28a82c279467f844a48743c3c87ef4bea89c991bb3d0e06424052a3738f7c", + "proofLevel": "executable_fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "6d54e7c60d29812de5b96e471761486fbbc0842c", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W2.LATER_BYTES" + }, + { + "evidenceId": "P0.W2.ESTIMATE", + "laneId": "w2", + "path": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "sha256": "6316c642472506f51638673aa3ede535dc358264c77b4cff43ca83364d879625", + "proofLevel": "source_observed", + "integratedAtCommit": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e" + }, + { + "evidenceId": "P0.W3.STATE_FAMILY_CATALOG", + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json", + "sha256": "f987dfb942445d3ac9ebfeb1a4f931291b7b834af45e5979d45518d59975e90a", + "proofLevel": "source_observed", + "integratedAtCommit": "7f23e7b628b09e8fbed71c914af5e665f14dab25" + }, + { + "evidenceId": "P0.W3.WRITER_COORDINATION", + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/writer-coordination.json", + "sha256": "7c7c572e068b04c9b84886d2a7c47aa3e04ba714311b2f8c98b0fe066c7d52ef", + "proofLevel": "fixture_characterized", + "integratedAtCommit": "7f23e7b628b09e8fbed71c914af5e665f14dab25" + }, + { + "evidenceId": "P0.W3.SCHEMA_UNKNOWN_FIELDS", + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json", + "sha256": "aec02d87f8657ec91d51f252d0dcf652dc04cae2ae047cebef6e3455820906e3", + "proofLevel": "source_observed", + "integratedAtCommit": "7f23e7b628b09e8fbed71c914af5e665f14dab25" + }, + { + "evidenceId": "P0.W3.BACKUP_BEHAVIOR", + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/backup-behavior.json", + "sha256": "a9cff35be387062de2ce908a98aa0613cf460bd43cb5e1b3eeeb7d1c704f0623", + "proofLevel": "fixture_characterized", + "integratedAtCommit": "7f23e7b628b09e8fbed71c914af5e665f14dab25" + }, + { + "evidenceId": "P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json", + "sha256": "7b9e2a03da2f972239b36b248319d36d420917596f03d20467dbabc8dc0ee4f5", + "proofLevel": "fixture_characterized", + "integratedAtCommit": "7f23e7b628b09e8fbed71c914af5e665f14dab25" + }, + { + "evidenceId": "P0.W3.ESTIMATE", + "laneId": "w3", + "path": "docs/research/hosted-web/phase-0/state-writers/estimate-input.json", + "sha256": "e115aa10ab1ad3842e8b44db03a07541cae9b6abdbeb1207ba3f01bd6d5ff7ae", + "proofLevel": "source_observed", + "integratedAtCommit": "7f23e7b628b09e8fbed71c914af5e665f14dab25" + }, + { + "evidenceId": "P0.W4.TARGET_HOST_ENVELOPE", + "laneId": "w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md", + "sha256": "83fede2fb9ce5e663963af951f7bac0fb24ee636024ac767108a7045114a9eec", + "proofLevel": "source_observed", + "integratedAtCommit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + { + "evidenceId": "P0.W4.INSTANCE_LEASE_SPIKE", + "laneId": "w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md", + "sha256": "5d6f766eade0133481e673ba692828ce5ab02da9067542869a70d37b90cf984f", + "proofLevel": "fixture_characterized", + "integratedAtCommit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + { + "evidenceId": "P0.W4.WORKSPACE_GUARD_SPIKE", + "laneId": "w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md", + "sha256": "55c7e21f8b33e8ea8b01c9286dc7ed153066d1ae4071a02d65b1c53544307dc1", + "proofLevel": "fixture_characterized", + "integratedAtCommit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + { + "evidenceId": "P0.W4.PROCESS_ANCHOR_SPIKE", + "laneId": "w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md", + "sha256": "5c09310193e18a1e972903c8604c977634b72ede6b87c11807e7b8c86fa6e42e", + "proofLevel": "fixture_characterized", + "integratedAtCommit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + { + "evidenceId": "P0.W4.NATIVE_ARTIFACT_PROPOSAL", + "laneId": "w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md", + "sha256": "629e3c2db97be5555cb9ad1a45d8e5d9713ef8c807be52ecf1f1bd44e1ea1f83", + "proofLevel": "source_observed", + "integratedAtCommit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + { + "evidenceId": "P0.W4.ESTIMATE", + "laneId": "w4", + "path": "docs/research/hosted-web/phase-0/host-primitives/estimate-input.json", + "sha256": "a1b527c3f9ecd1863223eed43bc6e0a3ea720af9138114b9924d0e8acf062f7c", + "proofLevel": "source_observed", + "integratedAtCommit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + { + "evidenceId": "P0.W5.EVENT_CURSOR_INVENTORY", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json", + "sha256": "2e296692f0415c3b82f3ce15cf1683bbd0aee846d2b571a5dff31bff406ddc15", + "proofLevel": "source_observed", + "integratedAtCommit": "ffaecae3fc70a42df1ac49c65469f84515ea5ed8" + }, + { + "evidenceId": "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json", + "sha256": "f19fcbefb7fe673156f07e3bf5c680b0d10bc4664466bd529b9c65e3ec7639f1", + "proofLevel": "fixture_characterized", + "integratedAtCommit": "ffaecae3fc70a42df1ac49c65469f84515ea5ed8" + }, + { + "evidenceId": "P0.W5.COMMAND_CATALOG", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "sha256": "a40fd876f0a9ffc63e5932985c969ab867411716a48718586abf1552a35780ff", + "proofLevel": "source_observed_plus_executable_omission_proof", + "byteState": "integrated", + "integratedAtCommit": "5d723407f287767c0f30f3d708459fb943256eaf", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W5.LATER_BYTES" + }, + { + "evidenceId": "P0.W5.EFFECT_RECOVERY_MATRIX", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "sha256": "c7166ca1ac43f72591fd1c56221dd3f2b6ffe8bebdbdca2b9fb89288874a79bc", + "proofLevel": "fresh_process_fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "5d723407f287767c0f30f3d708459fb943256eaf", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W5.LATER_BYTES" + }, + { + "evidenceId": "P0.W5.FINGERPRINT_GOLDENS", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json", + "sha256": "819206353712839fd915c124d4108dc1c462f5f531b6f18ecf63e473125eaa1c", + "proofLevel": "immutable_oracle_fixture_characterized", + "integratedAtCommit": "ffaecae3fc70a42df1ac49c65469f84515ea5ed8" + }, + { + "evidenceId": "P0.W5.ESTIMATE", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/estimate-input.json", + "sha256": "03f41142c1845f913bd0dfbd59499cfb6390454c36f4fec596432e7faf714341", + "proofLevel": "source_observed", + "integratedAtCommit": "ffaecae3fc70a42df1ac49c65469f84515ea5ed8" + }, + { + "evidenceId": "P0.W5.SUPPORTING.MUTATION_CENSUS", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "sha256": "5db5e282d358d4fad77b812cdec59c96bbe395ff04e6433ea9497d18126c0f98", + "proofLevel": "source_observed_plus_executable_omission_proof", + "byteState": "integrated", + "integratedAtCommit": "5d723407f287767c0f30f3d708459fb943256eaf", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W5.LATER_BYTES" + }, + { + "evidenceId": "P0.W5.SUPPORTING.MUTATION_SURFACE_MANIFEST", + "laneId": "w5", + "path": "docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json", + "sha256": "8922576490c0a9496a105923502008bc0d5c361af47282fbe5834e07b40d8130", + "proofLevel": "review_input", + "byteState": "integrated", + "integratedAtCommit": "5d723407f287767c0f30f3d708459fb943256eaf", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W5.LATER_BYTES" + }, + { + "evidenceId": "P0.W6.AUTH_TRANSITIONS", + "laneId": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "sha256": "082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a", + "proofLevel": "fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W4_W6.ADOPTION" + }, + { + "evidenceId": "P0.W6.PROXY_ORIGIN_THREAT_MATRIX", + "laneId": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "sha256": "082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a", + "proofLevel": "fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W4_W6.ADOPTION" + }, + { + "evidenceId": "P0.W6.COOKIE_VERSION_EVIDENCE", + "laneId": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "sha256": "082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a", + "proofLevel": "source_observed", + "byteState": "integrated", + "integratedAtCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W4_W6.ADOPTION" + }, + { + "evidenceId": "P0.W6.ARTIFACT_INVENTORY", + "laneId": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "sha256": "3cf85823fc79522f36520911991a134ee7557cbd715e839137a61e610a54d22f", + "proofLevel": "source_observed", + "byteState": "integrated", + "integratedAtCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W4_W6.ADOPTION" + }, + { + "evidenceId": "P0.W6.ABI_STUB_REPORT", + "laneId": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "sha256": "082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a", + "proofLevel": "fixture_characterized", + "byteState": "integrated", + "integratedAtCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W4_W6.ADOPTION" + }, + { + "evidenceId": "P0.W6.TERMINAL_ABSENCE_REPORT", + "laneId": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "sha256": "082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a", + "proofLevel": "source_observed", + "byteState": "integrated", + "integratedAtCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W4_W6.ADOPTION" + }, + { + "evidenceId": "P0.W6.ESTIMATE", + "laneId": "w6", + "path": "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json", + "sha256": "1309dd32d3ebf57447dc181b802fa5e625f1b7bb069fc040c08fef74513750b8", + "proofLevel": "source_observed", + "byteState": "integrated", + "integratedAtCommit": "c958c872fa22edf9b2d6a0741d7781b00957903c", + "adoptionDisposition": "narrowed", + "adoptionDecisionId": "P0.CURRENT.W4_W6.ADOPTION" + } + ] +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/duplicate-id.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/duplicate-id.json new file mode 100644 index 0000000000..f6d1e86e24 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/duplicate-id.json @@ -0,0 +1,6 @@ +{ + "fixtureId": "P0.CURRENT.NEGATIVE.DUPLICATE_ID", + "targetIndex": "decision-index.json", + "mutation": { "type": "duplicate-first-id", "collection": "decisions", "idField": "decisionId" }, + "expectedCode": "DUPLICATE_ID" +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/omission.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/omission.json new file mode 100644 index 0000000000..3dc1a6e8c8 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/omission.json @@ -0,0 +1,10 @@ +{ + "fixtureId": "P0.CURRENT.NEGATIVE.OMISSION", + "targetIndex": "supersession-index.json", + "mutation": { + "type": "omit-supersession-source", + "supersessionId": "P0.CURRENT.SUPERSESSION.W2_INCORRECT_PHASE_START", + "path": "docs/research/hosted-web/phase-0/provider-runtime/README.md" + }, + "expectedCode": "MISSING_PROVENANCE" +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/stale-hash.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/stale-hash.json new file mode 100644 index 0000000000..f2ee9ed77d --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/fixtures/stale-hash.json @@ -0,0 +1,10 @@ +{ + "fixtureId": "P0.CURRENT.NEGATIVE.STALE_HASH", + "targetIndex": "evidence-index.json", + "mutation": { + "type": "replace-evidence-hash", + "evidenceId": "P0.W2.EXECUTION_TOPOLOGY", + "replacementSha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "expectedCode": "STALE_HASH" +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/handoff-census.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/handoff-census.json new file mode 100644 index 0000000000..d9da06af37 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/handoff-census.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "canonicalPredecessorCommit": "c958c872fa22edf9b2d6a0741d7781b00957903c", + "freezeCandidate": { + "status": "accepted-frozen", + "integrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e" + }, + "authority": "lane-identity-index.json", + "lanes": [ + { "laneId": "w1", "path": ".codex-handoff/phase-00-w1.json", "state": "integrated-current" }, + { "laneId": "w2", "path": ".codex-handoff/phase-00-w2.json", "state": "integrated-current" }, + { "laneId": "w3", "path": ".codex-handoff/phase-00-w3.json", "state": "integrated-current" }, + { + "laneId": "w4", + "path": ".codex-handoff/phase-00-w4.json", + "state": "integrated-current-characterization" + }, + { "laneId": "w5", "path": ".codex-handoff/phase-00-w5.json", "state": "integrated-current" }, + { + "laneId": "w6", + "path": ".codex-handoff/phase-00-w6.json", + "state": "integrated-current-characterization" + } + ], + "policy": "Current indexes override stale registry progress. Historical handoffs remain immutable. The accepted freeze is f4fa24aac9615a4ce10632965a2244a2e11a273e; rejected h7/h8 outputs are not adopted." +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md b/docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md new file mode 100644 index 0000000000..38688e000f --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/hash-reconciliation.md @@ -0,0 +1,26 @@ +# Phase 0 canonical-byte reconciliation + +Accepted freeze candidate: `f4fa24aac9615a4ce10632965a2244a2e11a273e`. Phase start: +`a32f509e6d9bd31ba2135940e336729bf90c3d93`. + +The evidence index contains the complete lane-level byte inventory. This projection records the bytes +that changed after the former `c958c872…` predecessor and the accepted cross-cutting authorities. The +verifier re-hashes the worktree byte and every stated `git show :` byte. + +| Concern | Path | Current SHA-256 | Exact byte commit | Disposition | +| -------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------ | +| W1 raw locator | `docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json` | `674859e100839256f86869b8a72ddf65153001f399d7ba08ba135fdf22b2d0d5` | `a6bd7a39aebb4d822f57707c96c5e071b2aecb2b` | Narrowly integrated; source-observed proof level unchanged. | +| W1 scanner | `scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts` | `a91bcbdcf383bb06c07517d12ca4c0985591f66057e0a60bd7388c156dc7ad8b` | `a6bd7a39aebb4d822f57707c96c5e071b2aecb2b` | Pack-relative raw locator integrated. | +| W6 authority | `docs/research/hosted-web/phase-0/auth-artifacts/evidence.json` | `082f9deced2bf21b5b15c14f9f8f786198e61eceb52b9007605949f45ebb503a` | `3bc0dfa7c00261785c0c752270cb302a9294e751` | Fail-closed target-image narrowing accepted. | +| W6 scan | `docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json` | `3cf85823fc79522f36520911991a134ee7557cbd715e839137a61e610a54d22f` | `3bc0dfa7c00261785c0c752270cb302a9294e751` | Final image/provider limitations retained as implementation risks. | +| Estimate | `docs/research/hosted-web/phase-0/estimate-reconciliation/estimate-ledger.json` | `4a499adb3dceab512f9011aef355ded611045b25b72fc0e0c2142a54bb6563f5` | `f4fa24aac9615a4ce10632965a2244a2e11a273e` | Accepted 38,300-62,100 non-terminal v1 range. | + +| Accepted authority | Commit | Manifest path | Manifest SHA-256 | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------ | +| Target-image narrowing | `3bc0dfa7c00261785c0c752270cb302a9294e751` | `.codex-handoff/target-image-decision-h2.json` | `fa6a5c9a7386eb202fdb247d59c4fa6e30c048f90b73b8148c643c1c2c2e39d8` | +| Final gate | `63ff349e14e44a83d363ccbcdd756af935555aa9` | `.codex-handoff/final-gate-candidate-reconcile-h4.json` | `f45033d339fca0f436ab52226aa5528416967f1000f80ee9e42c2caf38d30754` | +| Orchestration | `1587615c751c3cb12b5078ab4b7264b6e9fd42ad` | `.codex-handoff/orchestration-authority-remediation-h5.json` | `32c0a04bb321bd94f80bddc5e1eb2d749d781c9fb5e9ff85785e2510b1b5e3df` | +| Navigation | `f32be6a6fcb2da7a47ef3553476430ef8052e19a` | `.codex-handoff/document-navigation-h6.json` | `58e025f6dfe691b3151bdc2119955f87e303423910a14375354068f126663bcb` | +| Estimate | `f4fa24aac9615a4ce10632965a2244a2e11a273e` | `.codex-handoff/estimate-semantics-h6.json` | `246dac62c31f015ec3601cfb2cc3a6435bfa6f027aff192f05c9e03b06bf5f16` | + +No row adopts the rejected h7 or h8 freeze outputs. No row authorizes work beyond Phase 1 `P1.S0`. diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json new file mode 100644 index 0000000000..a5e5553369 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json @@ -0,0 +1,212 @@ +{ + "$schema": "./canonical-index.schema.json", + "schemaVersion": 1, + "indexType": "lane-identity", + "controllerOwner": "agent-teams-hosted-web-refactor-controller-v2", + "phaseId": "phase-00", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "currentIntegrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "freezeCandidate": { + "baseCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "status": "accepted-frozen", + "integrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e" + }, + "generatedAt": "2026-07-12T10:28:17Z", + "lanes": [ + { + "laneId": "w1", + "producerJobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "sourceBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "integratedAtCommit": "a6bd7a39aebb4d822f57707c96c5e071b2aecb2b", + "integrationHistory": [ + { + "commit": "89c1358925033d480bcfe3bdfee6c899df556431", + "disposition": "adopted", + "scope": "Original reviewed W1 v9 bundle.", + "paths": [".codex-handoff/phase-00-w1.json"] + }, + { + "commit": "0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3", + "disposition": "narrowed", + "scope": "Adopt the later external-callback renderer census and scanner semantics; legacy raw output remains source-observed and pack-relative only.", + "paths": [ + "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json", + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts" + ] + }, + { + "commit": "a6bd7a39aebb4d822f57707c96c5e071b2aecb2b", + "disposition": "narrowed", + "scope": "Integrate the pack-relative legacy raw locator and deterministic scanner bytes into the accepted Phase 0 candidate.", + "paths": [ + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts" + ] + } + ], + "currentState": "integrated-current", + "handoff": { + "path": ".codex-handoff/phase-00-w1.json", + "sha256": "0cc95df4fe153e046788d8592dda638cfd2ebf199133f670917db22bab32a10e", + "scope": "repository" + }, + "identityCorrection": "The producer field laneId=w1-remediation-v9 is historical job identity; the controller canonical lane is w1. The pack-relative correction is integrated at a6bd7a39." + }, + { + "laneId": "w2", + "producerJobId": "agent-teams-hosted-web-refactor-phase-00-w2-targeted-fix-a1", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "sourceBaseSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "integratedAtCommit": "6d54e7c60d29812de5b96e471761486fbbc0842c", + "integrationHistory": [ + { + "commit": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e", + "disposition": "adopted", + "scope": "Original approved W2 targeted A1 bundle.", + "paths": [".codex-handoff/phase-00-w2.json"] + }, + { + "commit": "6d54e7c60d29812de5b96e471761486fbbc0842c", + "disposition": "narrowed", + "scope": "Adopt the omission-sensitive environment census while retaining final target-image and credential-canary limits.", + "paths": [ + "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts" + ] + } + ], + "currentState": "integrated-current", + "handoff": { + "path": ".codex-handoff/phase-00-w2.json", + "sha256": "d5064a798c6322642f26542214e317cd16ec2b38594efada6d0c8a481430d98f", + "scope": "repository" + }, + "identityCorrection": "The producer incorrectly labeled its source base c72fd201... as phaseStartSha; a32f509e... is the sole Phase 0 start." + }, + { + "laneId": "w3", + "producerJobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w3-v1", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "sourceBaseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "integratedAtCommit": "7f23e7b628b09e8fbed71c914af5e665f14dab25", + "integrationHistory": [ + { + "commit": "7f23e7b628b09e8fbed71c914af5e665f14dab25", + "disposition": "adopted", + "scope": "Current W3 evidence bundle.", + "paths": [".codex-handoff/phase-00-w3.json"] + } + ], + "currentState": "integrated-current", + "handoff": { + "path": ".codex-handoff/phase-00-w3.json", + "sha256": "02108287b2f1c1a25ce1cd80ffe5b7808cc5afc0f51f6aec15c46c8bd7b28816", + "scope": "repository" + } + }, + { + "laneId": "w4", + "producerJobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "packetRevision": "phase-00-r3", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "sourceBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "integratedAtCommit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "integrationHistory": [ + { + "commit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "disposition": "narrowed", + "scope": "Current-host characterization only.", + "paths": [".codex-handoff/phase-00-w4.json"] + } + ], + "currentState": "integrated-current", + "handoff": { + "path": ".codex-handoff/phase-00-w4.json", + "sha256": "87492c0695d241e07b9a6a9ea11c0dca744af2c816e64f47e4f12fc1d058b7a6", + "scope": "repository" + } + }, + { + "laneId": "w5", + "producerJobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w5-v3", + "packetRevision": "phase-00-r2", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "sourceBaseSha": "648bebed68f5a64c984e83b441e14dd7c587c403", + "integratedAtCommit": "5d723407f287767c0f30f3d708459fb943256eaf", + "integrationHistory": [ + { + "commit": "ffaecae3fc70a42df1ac49c65469f84515ea5ed8", + "disposition": "narrowed", + "scope": "Original evidence-only W5 adoption.", + "paths": [".codex-handoff/phase-00-w5.json"] + }, + { + "commit": "5d723407f287767c0f30f3d708459fb943256eaf", + "disposition": "narrowed", + "scope": "Adopt command-ownership reconciliation and formatting-stable projections inside the evidence-only boundary.", + "paths": [ + "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json" + ] + } + ], + "currentState": "integrated-current", + "handoff": { + "path": ".codex-handoff/phase-00-w5.json", + "sha256": "6b33b0323d928c3bdd63e4cba5d33bf82806a3f205dfbf2a31d3fcf497ddb051", + "scope": "repository" + } + }, + { + "laneId": "w6", + "producerJobId": "agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7", + "packetRevision": "phase-00-r3", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "sourceBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "integratedAtCommit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "integrationHistory": [ + { + "commit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "disposition": "narrowed", + "scope": "Original W6 characterization bundle.", + "paths": [".codex-handoff/phase-00-w6.json"] + }, + { + "commit": "c958c872fa22edf9b2d6a0741d7781b00957903c", + "disposition": "narrowed", + "scope": "Adopt current-commit artifact authority and preserve standalone rejection, terminal absence failure, and final-image limits.", + "paths": [ + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json" + ] + }, + { + "commit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "disposition": "narrowed", + "scope": "Accept fail-closed target-image narrowing: exact image admission is deferred to Phase 5 and is an implementation risk rather than a Phase 0 blocker.", + "paths": [ + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json" + ] + } + ], + "currentState": "integrated-current", + "handoff": { + "path": ".codex-handoff/phase-00-w6.json", + "sha256": "61d8ff2016dbc117cfbea60e8fbe7de519b18a38b06f62706a6f6e16356c8001", + "scope": "repository" + } + } + ] +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/negative-fixture.schema.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/negative-fixture.schema.json new file mode 100644 index 0000000000..c98de6201c --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/negative-fixture.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["fixtureId", "targetIndex", "mutation", "expectedCode"], + "additionalProperties": false, + "properties": { + "fixtureId": { "type": "string", "pattern": "^P0\\.CURRENT\\.NEGATIVE\\." }, + "targetIndex": { + "enum": [ + "lane-identity-index.json", + "decision-index.json", + "evidence-index.json", + "supersession-index.json" + ] + }, + "mutation": { + "oneOf": [ + { + "type": "object", + "required": ["type", "laneId"], + "additionalProperties": false, + "properties": { + "type": { "const": "omit-lane" }, + "laneId": { "enum": ["w1", "w2", "w3", "w4", "w5", "w6"] } + } + }, + { + "type": "object", + "required": ["type", "evidenceId", "replacementSha256"], + "additionalProperties": false, + "properties": { + "type": { "const": "replace-evidence-hash" }, + "evidenceId": { "type": "string" }, + "replacementSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + }, + { + "type": "object", + "required": ["type", "collection", "idField"], + "additionalProperties": false, + "properties": { + "type": { "const": "duplicate-first-id" }, + "collection": { "enum": ["decisions", "evidence"] }, + "idField": { "enum": ["decisionId", "evidenceId"] } + } + }, + { + "type": "object", + "required": ["type", "supersessionId", "path"], + "additionalProperties": false, + "properties": { + "type": { "const": "omit-supersession-source" }, + "supersessionId": { "type": "string" }, + "path": { "type": "string" } + } + } + ] + }, + "expectedCode": { + "enum": ["MISSING_LANE", "MISSING_PROVENANCE", "STALE_HASH", "DUPLICATE_ID"] + } + } +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json new file mode 100644 index 0000000000..3c5bff9fce --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json @@ -0,0 +1,255 @@ +{ + "$schema": "./canonical-index.schema.json", + "schemaVersion": 1, + "indexType": "review-disposition", + "controllerOwner": "agent-teams-hosted-web-refactor-controller-v2", + "phaseId": "phase-00", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "currentIntegrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "freezeCandidate": { + "baseCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "status": "accepted-frozen", + "integrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e" + }, + "generatedAt": "2026-07-12T10:28:17Z", + "reviews": [ + { + "reviewId": "P0.CURRENT.REVIEW.W1", + "lanes": ["w1"], + "disposition": "controller-adopted", + "scope": "The v9 bundle is adopted. Commit 0d1a82fe adds the reviewed external-callback census and is narrowed to renderer characterization. Commit a6bd7a39 integrates the pack-relative raw reference without raising the legacy-bypass proof level.", + "sources": [ + { + "path": ".codex-handoff/phase-00-w1.json", + "sha256": "0cc95df4fe153e046788d8592dda638cfd2ebf199133f670917db22bab32a10e", + "scope": "repository" + }, + { + "path": ".codex-handoff/phase-00-freeze-fix-w1-f4.json", + "sha256": "4274fe8b9cbe8a1bf84b49801d1c16bd47694940c0158a47193ec4833858d196", + "scope": "repository" + } + ], + "commitDispositions": [ + { + "commit": "89c1358925033d480bcfe3bdfee6c899df556431", + "disposition": "adopted", + "scope": "Original W1 v9 evidence bundle.", + "paths": [".codex-handoff/phase-00-w1.json"] + }, + { + "commit": "0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3", + "disposition": "narrowed", + "scope": "Later renderer census and scanner correction, with legacy bypasses remaining source-observed.", + "paths": [ + "docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json", + "docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json", + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts" + ] + }, + { + "commit": "a6bd7a39aebb4d822f57707c96c5e071b2aecb2b", + "disposition": "narrowed", + "scope": "Pack-relative raw locator and deterministic scanner integration.", + "paths": [ + "docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json", + "scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts" + ] + } + ] + }, + { + "reviewId": "P0.CURRENT.REVIEW.W2", + "lanes": ["w2"], + "disposition": "approve", + "scope": "The targeted A1 bundle is approved and commit 6d54e7c6 is narrowed to its omission-sensitive environment census; target-image provider execution and credential canaries remain unproved.", + "sources": [ + { + "path": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-w2-targeted-fix-review-a1-r9/.codex-handoff/phase-00-w2-targeted-fix-review-a1-r9.json", + "sha256": "ffb2d7173cff698deed1dbc7c93517f4926894a5eb3af9b923d3546fe1374abf", + "scope": "controller-external" + }, + { + "path": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-w2-targeted-fix-review-a1-r9/agent-teams-hosted-web-refactor-phase-00-w2-targeted-fix-review-a1-r9.latest-result.json", + "sha256": "ff9d800de6a827aa479f988c8b70b546f9a93ffe0dbf1dea9405130564adbf30", + "scope": "controller-external" + }, + { + "path": ".codex-handoff/phase-00-freeze-fix-w2-d1.json", + "sha256": "5f3ef80c62a4a3a5d894a1dc2339ffecbd84fa9a157037d5de0b64e925c85ca0", + "scope": "repository" + } + ], + "commitDispositions": [ + { + "commit": "0bf8f2d105def1fa34dd8dedfb8d345d720dc35e", + "disposition": "adopted", + "scope": "Original approved W2 targeted A1 bundle.", + "paths": [".codex-handoff/phase-00-w2.json"] + }, + { + "commit": "6d54e7c60d29812de5b96e471761486fbbc0842c", + "disposition": "narrowed", + "scope": "Omission-sensitive environment and credential census; no target-image readiness claim.", + "paths": [ + "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts" + ] + } + ] + }, + { + "reviewId": "P0.CURRENT.REVIEW.W3_W5", + "lanes": ["w3", "w5"], + "disposition": "compatible-narrowed", + "scope": "The adopted W3/W5 pair remains compatible only within the Phase 0 evidence boundary; commit 5d723407 is narrowed to its approved command-ownership and stable-format corrections.", + "sources": [ + { + "path": "docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5-current-canonical.md", + "sha256": "7bc2ded46924f18e28b0b85d3dafbd09e17662cf7e95c11b139bfe799a4f1564", + "scope": "repository" + }, + { + "path": ".codex-handoff/phase-00-freeze-fix-w5-format-f4.json", + "sha256": "de86b44dc836f4342cb486d24d59a86fd866b9e24805957774baaca5e17b19d0", + "scope": "repository" + } + ], + "commitDispositions": [ + { + "commit": "7f23e7b628b09e8fbed71c914af5e665f14dab25", + "disposition": "adopted", + "scope": "Current W3 evidence bundle.", + "paths": [".codex-handoff/phase-00-w3.json"] + }, + { + "commit": "ffaecae3fc70a42df1ac49c65469f84515ea5ed8", + "disposition": "narrowed", + "scope": "Original evidence-only W5 adoption.", + "paths": [".codex-handoff/phase-00-w5.json"] + }, + { + "commit": "5d723407f287767c0f30f3d708459fb943256eaf", + "disposition": "narrowed", + "scope": "Approved command ownership and formatting-stable evidence projections.", + "paths": [ + "docs/research/hosted-web/phase-0/recovery-events/command-catalog.json", + "docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-census.json", + "docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json" + ] + } + ] + }, + { + "reviewId": "P0.CURRENT.REVIEW.W4_W6", + "lanes": ["w4", "w6"], + "disposition": "approve", + "scope": "W4/W6 remains characterization only. Commit c958c872 supplies artifact authority, and 3bc0dfa7 accepts fail-closed target-image narrowing. Exact-image admission and provider canaries remain later implementation risks, not Phase 0 blockers.", + "sources": [ + { + "path": "/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-review-w4-w6-v6/.codex-handoff/phase-00-review-w4-w6-v6.json", + "sha256": "5c4c0ed2792df575dfd74c3a197ff00af6ed2abcc001dd815c39e70a87f7ed7a", + "scope": "controller-external" + }, + { + "path": "/var/data/agent-teams-hosted-web-refactor/worker-jobs/jobs/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6/agent-teams-hosted-web-refactor-phase-00-review-w4-w6-v6.latest-result.json", + "sha256": "b29b4188a98de25cead5f299e9fab0e16160d8f3f0252023050d8fc73cbe52cc", + "scope": "controller-external" + }, + { + "path": ".codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json", + "sha256": "507a6c280cc2f3cd82906111033653dd857ae5a89a875f191c67825f011f62b5", + "scope": "repository" + } + ], + "commitDispositions": [ + { + "commit": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "disposition": "narrowed", + "scope": "Original paired W4/W6 characterization.", + "paths": [".codex-handoff/phase-00-w4.json", ".codex-handoff/phase-00-w6.json"] + }, + { + "commit": "c958c872fa22edf9b2d6a0741d7781b00957903c", + "disposition": "narrowed", + "scope": "Current-commit W6 artifact authority; no final-image, mutation, or terminal admission.", + "paths": [ + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json" + ] + }, + { + "commit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "disposition": "narrowed", + "scope": "Fail-closed target-image narrowing accepted for the Phase 0 exit; exact-image admission remains Phase 5 scope.", + "paths": [ + "docs/research/hosted-web/phase-0/auth-artifacts/evidence.json", + "docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json", + "docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json" + ] + } + ] + } + ], + "acceptedAuthorities": [ + { + "authorityId": "P0.CURRENT.AUTHORITY.ESTIMATE", + "commit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "disposition": "accepted", + "role": "estimate", + "source": { + "path": ".codex-handoff/estimate-semantics-h6.json", + "sha256": "246dac62c31f015ec3601cfb2cc3a6435bfa6f027aff192f05c9e03b06bf5f16", + "scope": "repository" + } + }, + { + "authorityId": "P0.CURRENT.AUTHORITY.FINAL_GATE", + "commit": "63ff349e14e44a83d363ccbcdd756af935555aa9", + "disposition": "accepted", + "role": "final-gate", + "source": { + "path": ".codex-handoff/final-gate-candidate-reconcile-h4.json", + "sha256": "f45033d339fca0f436ab52226aa5528416967f1000f80ee9e42c2caf38d30754", + "scope": "repository" + } + }, + { + "authorityId": "P0.CURRENT.AUTHORITY.NAVIGATION", + "commit": "f32be6a6fcb2da7a47ef3553476430ef8052e19a", + "disposition": "accepted", + "role": "navigation", + "source": { + "path": ".codex-handoff/document-navigation-h6.json", + "sha256": "58e025f6dfe691b3151bdc2119955f87e303423910a14375354068f126663bcb", + "scope": "repository" + } + }, + { + "authorityId": "P0.CURRENT.AUTHORITY.ORCHESTRATION", + "commit": "1587615c751c3cb12b5078ab4b7264b6e9fd42ad", + "disposition": "accepted", + "role": "orchestration", + "source": { + "path": ".codex-handoff/orchestration-authority-remediation-h5.json", + "sha256": "32c0a04bb321bd94f80bddc5e1eb2d749d781c9fb5e9ff85785e2510b1b5e3df", + "scope": "repository" + } + }, + { + "authorityId": "P0.CURRENT.AUTHORITY.TARGET_IMAGE", + "commit": "3bc0dfa7c00261785c0c752270cb302a9294e751", + "disposition": "accepted", + "role": "target-image-narrowing", + "source": { + "path": ".codex-handoff/target-image-decision-h2.json", + "sha256": "fa6a5c9a7386eb202fdb247d59c4fa6e30c048f90b73b8148c643c1c2c2e39d8", + "scope": "repository" + } + } + ] +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json b/docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json new file mode 100644 index 0000000000..a7988e1064 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/supersession-index.json @@ -0,0 +1,240 @@ +{ + "$schema": "./canonical-index.schema.json", + "schemaVersion": 1, + "indexType": "supersession", + "controllerOwner": "agent-teams-hosted-web-refactor-controller-v2", + "phaseId": "phase-00", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "currentIntegrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "freezeCandidate": { + "baseCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "status": "accepted-frozen", + "integrationCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e" + }, + "generatedAt": "2026-07-12T10:28:17Z", + "supersessions": [ + { + "supersessionId": "P0.CURRENT.SUPERSESSION.BASE_PENDING_IDENTITY", + "sources": [ + { + "path": "docs/research/hosted-web/phase-0/base.json", + "sha256": "1c8e69a8588084825cfd5b181ac8f13c8c02021b75dc23c4557cf7b083c42104", + "scope": "repository" + } + ], + "supersededClaims": [ + "phaseStartSha=null", + "phaseStartResolution.status=pending_external_integration_record" + ], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded", + "correction": "The external integration record now exists: a32f509e6d9bd31ba2135940e336729bf90c3d93 is the Phase 0 start." + }, + { + "supersessionId": "P0.CURRENT.SUPERSESSION.LANE_LEDGER", + "sources": [ + { + "path": "docs/research/hosted-web/phase-0/lane-ledger.json", + "sha256": "133910e497f35d4e8e27080e3304af08991283c737f1c9e856852f00852bc418", + "scope": "repository" + } + ], + "supersededClaims": [ + "lane startState values", + "lane current status", + "lane request identity" + ], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded" + }, + { + "supersessionId": "P0.CURRENT.SUPERSESSION.DECISION_REGISTER", + "sources": [ + { + "path": "docs/research/hosted-web/phase-0/decision-register.md", + "sha256": "b26e363fc9c591b4122143eb8777242782b52ad3b443407b09cdf7d5dec0ee81", + "scope": "repository" + } + ], + "supersededClaims": [ + "P0.D.PHASE_START pending external resolution", + "pre-integration lane decision status" + ], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/decision-index.json", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded" + }, + { + "supersessionId": "P0.CURRENT.SUPERSESSION.PHASE_INDEX", + "sources": [ + { + "path": "docs/research/hosted-web/phase-0/index.md", + "sha256": "5ebf1904d67e372a680c0de2560d7cad31fc070853b54dceb3862042f9b6991c", + "scope": "repository" + } + ], + "supersededClaims": [ + "current lane disposition", + "current evidence selection", + "current review selection" + ], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/README.md", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded" + }, + { + "supersessionId": "P0.CURRENT.SUPERSESSION.ORIGINAL_PAIR_REVIEWS", + "sources": [ + { + "path": "docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md", + "sha256": "213836d2c6104318d3e59848a856dea14bfefc9d56840645a76cf821fc918e2b", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md", + "sha256": "efd65a976bd0644abd38f0067fa84cf941bd93467d01f583e2737c5a5e6fb308", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/reciprocal-review-w4-w6.md", + "sha256": "4a509551fdf2466f1be2f6cc9e9b8c9c1058fb3c5c695f8706f78af42e47e5bf", + "scope": "repository" + } + ], + "supersededClaims": ["original producer pair disposition", "unremediated finding status"], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded" + }, + { + "supersessionId": "P0.CURRENT.SUPERSESSION.W1_W2_REMEDIATION_REVIEW", + "sources": [ + { + "path": "docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2-remediation-v3.md", + "sha256": "b3d38bdc576474024a8aa96281723362465f939d8af70361bfcccdbfe35db77f", + "scope": "repository" + } + ], + "supersededClaims": [ + "pre-v9 W1 disposition", + "pre-targeted-A1 W2 disposition", + "pair remediation next action" + ], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/review-disposition-index.json", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded" + }, + { + "supersessionId": "P0.CURRENT.SUPERSESSION.PRODUCER_STATUS_SNAPSHOTS", + "sources": [ + { + "path": ".codex-handoff/phase-00-w1.json", + "sha256": "0cc95df4fe153e046788d8592dda638cfd2ebf199133f670917db22bab32a10e", + "scope": "repository" + }, + { + "path": ".codex-handoff/phase-00-w3.json", + "sha256": "02108287b2f1c1a25ce1cd80ffe5b7808cc5afc0f51f6aec15c46c8bd7b28816", + "scope": "repository" + }, + { + "path": ".codex-handoff/phase-00-w4.json", + "sha256": "87492c0695d241e07b9a6a9ea11c0dca744af2c816e64f47e4f12fc1d058b7a6", + "scope": "repository" + }, + { + "path": ".codex-handoff/phase-00-w5.json", + "sha256": "6b33b0323d928c3bdd63e4cba5d33bf82806a3f205dfbf2a31d3fcf497ddb051", + "scope": "repository" + }, + { + "path": ".codex-handoff/phase-00-w6.json", + "sha256": "61d8ff2016dbc117cfbea60e8fbe7de519b18a38b06f62706a6f6e16356c8001", + "scope": "repository" + } + ], + "supersededClaims": [ + "producer status", + "producer nextAction", + "producer-local lane identity as current controller identity" + ], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded" + }, + { + "supersessionId": "P0.CURRENT.SUPERSESSION.W2_INCORRECT_PHASE_START", + "sources": [ + { + "path": ".codex-handoff/phase-00-w2.json", + "sha256": "d5064a798c6322642f26542214e317cd16ec2b38594efada6d0c8a481430d98f", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/README.md", + "sha256": "31773c1442d1db34cae604c23cefa458c0eada3635d124c0e8a5581776b20408", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json", + "sha256": "40650b7ec3549a30419932fa43330b1484468c393ddef0555438f9f308c97e3a", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json", + "sha256": "531ae8746d48e527c4e1a857e8b6077c5c9d58cf1ca0ca6523cd629a98c0e807", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "sha256": "302cc6081598a182df58478f6d50288228e037db5f14c33df72c512fcc5b3d81", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json", + "sha256": "5f039a47a65ced6556f5c096e4135d18aeea3377db1f21fb347025c1af5b36e1", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json", + "sha256": "7f2e1f1964117da529be7ed89b5b76bf6a2d7a8b41e2824e7fae03beb8fbeea0", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json", + "sha256": "6316c642472506f51638673aa3ede535dc358264c77b4cff43ca83364d879625", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json", + "sha256": "d378d02bc7eb49be040dfff2af06f11ba81cbca548da640989ff28388792e03a", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json", + "sha256": "2744548b35627c09cc26cbd6398435a125151165855a3ffb4f14be91ac764550", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json", + "sha256": "1a6d23b175a81b43e107873f720f045033a56ff8be5e6dbcc665f57ab83f9426", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json", + "sha256": "00f6bcdf75f9f14310701ddff09e0a008362f59a2cbfa8b131333d2533a73191", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json", + "sha256": "6fd55e710ef5ded818da08475e24b6815dd213d11775b3374001c1b71ed7c8bd", + "scope": "repository" + }, + { + "path": "docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json", + "sha256": "5b2208902c512a19f77ca0ca5643c893339861d5cc08ab71c0cf694337a3fec7", + "scope": "repository" + } + ], + "supersededClaims": ["phaseStartSha=c72fd201867b9bcd1ef77d5e0f95ba379adb4fca"], + "replacementIndex": "docs/research/hosted-web/phase-0/freeze/current-canonical/lane-identity-index.json", + "preservationRule": "historical bytes remain immutable; only current interpretation is superseded", + "correction": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca is the W2 source/integration base, not the Phase 0 start. The only current phaseStartSha is a32f509e6d9bd31ba2135940e336729bf90c3d93." + } + ] +} diff --git a/docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs b/docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs new file mode 100644 index 0000000000..ac3baf9671 --- /dev/null +++ b/docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs @@ -0,0 +1,589 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const requireFromFastify = createRequire(require.resolve('fastify/package.json')); +const Ajv = requireFromFastify('ajv'); + +const indexDir = dirname(fileURLToPath(import.meta.url)); +let repoRoot = indexDir; +while (!existsSync(resolve(repoRoot, 'package.json'))) { + const parent = dirname(repoRoot); + if (parent === repoRoot) throw new Error('repository root not found'); + repoRoot = parent; +} + +const includeControllerExternal = process.argv.includes('--include-controller-external'); +const indexFiles = [ + 'lane-identity-index.json', + 'review-disposition-index.json', + 'decision-index.json', + 'evidence-index.json', + 'supersession-index.json', +]; +const fixtureFiles = ['omission.json', 'stale-hash.json', 'duplicate-id.json']; +const readJson = (path) => JSON.parse(readFileSync(path, 'utf8')); +const clone = (value) => JSON.parse(JSON.stringify(value)); +const indexes = new Map(indexFiles.map((name) => [name, readJson(resolve(indexDir, name))])); + +const ajv = new Ajv({ allErrors: true, jsonPointers: true }); +const validateIndex = ajv.compile(readJson(resolve(indexDir, 'canonical-index.schema.json'))); +const validateFixture = ajv.compile(readJson(resolve(indexDir, 'negative-fixture.schema.json'))); + +class ValidationFailure extends Error { + constructor(code, message) { + super(`${code}: ${message}`); + this.code = code; + } +} + +const fail = (code, message) => { + throw new ValidationFailure(code, message); +}; + +const assertUnique = (rows, field, collection) => { + const seen = new Set(); + for (const row of rows) { + if (seen.has(row[field])) fail('DUPLICATE_ID', `${collection} repeats ${row[field]}`); + seen.add(row[field]); + } +}; + +const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex'); +const sha256Bytes = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +const hashAtCommit = (commit, path) => { + try { + return sha256Bytes( + execFileSync('git', ['show', `${commit}:${path}`], { + cwd: repoRoot, + encoding: null, + maxBuffer: 128 * 1024 * 1024, + }) + ); + } catch (error) { + if (error?.status === 0 && error?.stdout) return sha256Bytes(error.stdout); + fail('GIT_PROVENANCE_MISSING', `${commit}:${path}`); + } +}; + +const checkPathHash = ({ path, sha256: expected, scope = 'repository' }) => { + if (scope === 'controller-external' && !includeControllerExternal) return; + const absolute = isAbsolute(path) ? path : resolve(repoRoot, path); + if (!existsSync(absolute)) fail('PATH_MISSING', path); + const actual = sha256(absolute); + if (actual !== expected) fail('STALE_HASH', `${path}: expected ${expected}, received ${actual}`); +}; + +const validateSemantics = (allIndexes) => { + const laneIndex = allIndexes.get('lane-identity-index.json'); + const reviewIndex = allIndexes.get('review-disposition-index.json'); + const decisionIndex = allIndexes.get('decision-index.json'); + const evidenceIndex = allIndexes.get('evidence-index.json'); + const supersessionIndex = allIndexes.get('supersession-index.json'); + + const expectedCurrentCommit = 'f4fa24aac9615a4ce10632965a2244a2e11a273e'; + for (const [name, index] of allIndexes) { + if (index.currentIntegrationCommit !== expectedCurrentCommit) { + fail('INTEGRATION_COMMIT_MISMATCH', `${name} does not pin accepted freeze f4fa24aa`); + } + if ( + index.freezeCandidate?.baseCommit !== expectedCurrentCommit || + index.freezeCandidate?.status !== 'accepted-frozen' || + index.freezeCandidate?.integrationCommit !== expectedCurrentCommit + ) { + fail('CANDIDATE_PROVENANCE_MISMATCH', `${name} does not freeze the accepted candidate`); + } + } + + const expectedLanes = ['w1', 'w2', 'w3', 'w4', 'w5', 'w6']; + assertUnique(laneIndex.lanes, 'laneId', 'lanes'); + const actualLanes = laneIndex.lanes.map(({ laneId }) => laneId).sort(); + if (JSON.stringify(actualLanes) !== JSON.stringify(expectedLanes)) { + fail('MISSING_LANE', `expected ${expectedLanes.join(',')}; received ${actualLanes.join(',')}`); + } + + const expectedLaneIdentity = { + w1: { + producerJobId: 'agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9', + packetRevision: 'phase-00-r2', + sourceBaseSha: 'f7d98790eb868714e536f77bd796072ea706911a', + integratedAtCommit: 'a6bd7a39aebb4d822f57707c96c5e071b2aecb2b', + }, + w2: { + producerJobId: 'agent-teams-hosted-web-refactor-phase-00-w2-targeted-fix-a1', + packetRevision: 'phase-00-r2', + sourceBaseSha: 'c72fd201867b9bcd1ef77d5e0f95ba379adb4fca', + integratedAtCommit: '6d54e7c60d29812de5b96e471761486fbbc0842c', + }, + w3: { + producerJobId: 'agent-teams-hosted-web-refactor-phase-00-remediation-w3-v1', + packetRevision: 'phase-00-r2', + sourceBaseSha: '0e8431b1935c71a2e77bea1384b134ee25c8aa12', + integratedAtCommit: '7f23e7b628b09e8fbed71c914af5e665f14dab25', + }, + w4: { + producerJobId: 'agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7', + packetRevision: 'phase-00-r3', + sourceBaseSha: 'f7d98790eb868714e536f77bd796072ea706911a', + integratedAtCommit: 'c72fd201867b9bcd1ef77d5e0f95ba379adb4fca', + }, + w5: { + producerJobId: 'agent-teams-hosted-web-refactor-phase-00-remediation-w5-v3', + packetRevision: 'phase-00-r2', + sourceBaseSha: '648bebed68f5a64c984e83b441e14dd7c587c403', + integratedAtCommit: '5d723407f287767c0f30f3d708459fb943256eaf', + }, + w6: { + producerJobId: 'agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7', + packetRevision: 'phase-00-r3', + sourceBaseSha: 'f7d98790eb868714e536f77bd796072ea706911a', + integratedAtCommit: '3bc0dfa7c00261785c0c752270cb302a9294e751', + }, + }; + + for (const lane of laneIndex.lanes) { + if (lane.phaseStartSha !== laneIndex.phaseStartSha) { + fail('PHASE_START_MISMATCH', `${lane.laneId} does not use the controller phase start`); + } + for (const [field, expected] of Object.entries(expectedLaneIdentity[lane.laneId])) { + if (lane[field] !== expected) { + fail('LANE_IDENTITY_MISMATCH', `${lane.laneId}.${field} differs from integration history`); + } + } + checkPathHash(lane.handoff); + if (!lane.integrationHistory.some(({ commit }) => commit === lane.integratedAtCommit)) { + fail( + 'INTEGRATION_COMMIT_MISMATCH', + `${lane.laneId} latest commit is absent from its history` + ); + } + } + + const expectedEvidenceById = new Map(); + for (const lane of laneIndex.lanes) { + const handoff = readJson(resolve(repoRoot, lane.handoff.path)); + for (const row of handoff.evidence) { + if (expectedEvidenceById.has(row.id)) { + fail('DUPLICATE_ID', `lane handoffs repeat ${row.id}`); + } + expectedEvidenceById.set(row.id, { + laneId: lane.laneId, + path: row.path, + proofLevel: row.proofLevel, + }); + } + } + + assertUnique(reviewIndex.reviews, 'reviewId', 'reviews'); + for (const review of reviewIndex.reviews) review.sources.forEach(checkPathHash); + + const expectedAuthorities = { + 'P0.CURRENT.AUTHORITY.ESTIMATE': { + commit: 'f4fa24aac9615a4ce10632965a2244a2e11a273e', + role: 'estimate', + }, + 'P0.CURRENT.AUTHORITY.FINAL_GATE': { + commit: '63ff349e14e44a83d363ccbcdd756af935555aa9', + role: 'final-gate', + }, + 'P0.CURRENT.AUTHORITY.NAVIGATION': { + commit: 'f32be6a6fcb2da7a47ef3553476430ef8052e19a', + role: 'navigation', + }, + 'P0.CURRENT.AUTHORITY.ORCHESTRATION': { + commit: '1587615c751c3cb12b5078ab4b7264b6e9fd42ad', + role: 'orchestration', + }, + 'P0.CURRENT.AUTHORITY.TARGET_IMAGE': { + commit: '3bc0dfa7c00261785c0c752270cb302a9294e751', + role: 'target-image-narrowing', + }, + }; + assertUnique(reviewIndex.acceptedAuthorities, 'authorityId', 'acceptedAuthorities'); + if (reviewIndex.acceptedAuthorities.length !== Object.keys(expectedAuthorities).length) { + fail('MISSING_PROVENANCE', 'accepted authority count differs from the frozen set'); + } + for (const authority of reviewIndex.acceptedAuthorities) { + const expected = expectedAuthorities[authority.authorityId]; + if ( + !expected || + authority.commit !== expected.commit || + authority.role !== expected.role || + authority.disposition !== 'accepted' + ) { + fail('ADOPTION_PROVENANCE_MISMATCH', authority.authorityId); + } + checkPathHash(authority.source); + if (hashAtCommit(authority.commit, authority.source.path) !== authority.source.sha256) { + fail('GIT_PROVENANCE_MISMATCH', `${authority.authorityId} manifest`); + } + try { + execFileSync( + 'git', + ['merge-base', '--is-ancestor', authority.commit, expectedCurrentCommit], + { + cwd: repoRoot, + stdio: 'ignore', + } + ); + } catch { + fail( + 'CANDIDATE_PROVENANCE_MISMATCH', + `${authority.authorityId} is not in the freeze ancestry` + ); + } + } + + assertUnique(decisionIndex.decisions, 'decisionId', 'decisions'); + for (const decision of decisionIndex.decisions) { + for (const path of decision.authorityPaths) { + if (!existsSync(resolve(repoRoot, path))) fail('PATH_MISSING', path); + } + } + + assertUnique(evidenceIndex.evidence, 'evidenceId', 'evidence'); + const indexedEvidenceIds = evidenceIndex.evidence.map(({ evidenceId }) => evidenceId).sort(); + const handoffEvidenceIds = [...expectedEvidenceById.keys()].sort(); + if (JSON.stringify(indexedEvidenceIds) !== JSON.stringify(handoffEvidenceIds)) { + fail('MISSING_PROVENANCE', 'evidence IDs differ from the six hashed lane handoffs'); + } + const laneById = new Map(laneIndex.lanes.map((lane) => [lane.laneId, lane])); + for (const laneId of expectedLanes) { + if (!evidenceIndex.evidence.some((row) => row.laneId === laneId)) { + fail('MISSING_LANE', `evidence index omits ${laneId}`); + } + } + for (const row of evidenceIndex.evidence) { + const handoffRow = expectedEvidenceById.get(row.evidenceId); + if ( + row.laneId !== handoffRow.laneId || + row.path !== handoffRow.path || + row.proofLevel !== handoffRow.proofLevel + ) { + fail('EVIDENCE_PROVENANCE_MISMATCH', row.evidenceId); + } + checkPathHash({ path: row.path, sha256: row.sha256, scope: 'repository' }); + const lane = laneById.get(row.laneId); + const laneCommits = new Set(lane.integrationHistory.map(({ commit }) => commit)); + if (row.byteState === 'pending-integration') { + if ( + row.integratedAtCommit !== null || + !laneCommits.has(row.derivedFromCommit) || + !lane.pendingCandidatePaths?.includes(row.path) + ) { + fail('CANDIDATE_PROVENANCE_MISMATCH', row.evidenceId); + } + } else { + if (!laneCommits.has(row.integratedAtCommit)) { + fail( + 'INTEGRATION_COMMIT_MISMATCH', + `${row.evidenceId} is absent from ${row.laneId} history` + ); + } + const committedHash = hashAtCommit(row.integratedAtCommit, row.path); + if (committedHash !== row.sha256) { + fail( + 'GIT_PROVENANCE_MISMATCH', + `${row.evidenceId}: ${row.integratedAtCommit} has ${committedHash}, index has ${row.sha256}` + ); + } + } + } + + const expectedLaterBytes = { + 'P0.W1.RENDERER_ACTIONS': { + commit: '0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W1.LATER_BYTES', + }, + 'P0.W1.RENDERER_CHILD_CONTROLS': { + commit: '0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W1.LATER_BYTES', + }, + 'P0.W1.LEGACY_BYPASSES': { + commit: 'a6bd7a39aebb4d822f57707c96c5e071b2aecb2b', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W1.LATER_BYTES', + }, + 'P0.W1.SCANNER': { + commit: 'a6bd7a39aebb4d822f57707c96c5e071b2aecb2b', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W1.LATER_BYTES', + }, + 'P0.W2.ENVIRONMENT_PROVENANCE': { + commit: '6d54e7c60d29812de5b96e471761486fbbc0842c', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W2.LATER_BYTES', + }, + 'P0.W2.CREDENTIAL_EXPOSURE_MATRIX': { + commit: '6d54e7c60d29812de5b96e471761486fbbc0842c', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W2.LATER_BYTES', + }, + 'P0.W2.RUNTIME_SCANNER': { + commit: '6d54e7c60d29812de5b96e471761486fbbc0842c', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W2.LATER_BYTES', + }, + 'P0.W5.COMMAND_CATALOG': { + commit: '5d723407f287767c0f30f3d708459fb943256eaf', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W5.LATER_BYTES', + }, + 'P0.W5.EFFECT_RECOVERY_MATRIX': { + commit: '5d723407f287767c0f30f3d708459fb943256eaf', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W5.LATER_BYTES', + }, + 'P0.W5.SUPPORTING.MUTATION_CENSUS': { + commit: '5d723407f287767c0f30f3d708459fb943256eaf', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W5.LATER_BYTES', + }, + 'P0.W5.SUPPORTING.MUTATION_SURFACE_MANIFEST': { + commit: '5d723407f287767c0f30f3d708459fb943256eaf', + disposition: 'narrowed', + decisionId: 'P0.CURRENT.W5.LATER_BYTES', + }, + }; + const decisionIds = new Set(decisionIndex.decisions.map(({ decisionId }) => decisionId)); + for (const [evidenceId, expected] of Object.entries(expectedLaterBytes)) { + const row = evidenceIndex.evidence.find((candidate) => candidate.evidenceId === evidenceId); + if ( + row?.integratedAtCommit !== expected.commit || + row?.adoptionDisposition !== expected.disposition || + row?.adoptionDecisionId !== expected.decisionId || + !decisionIds.has(expected.decisionId) + ) { + fail('ADOPTION_PROVENANCE_MISMATCH', evidenceId); + } + } + + const laterReviewByDecision = { + 'P0.CURRENT.W1.LATER_BYTES': { + reviewId: 'P0.CURRENT.REVIEW.W1', + commit: '0d1a82fe2fb0c8d73b62cd3b5996b853bef2d7c3', + }, + 'P0.CURRENT.W2.LATER_BYTES': { + reviewId: 'P0.CURRENT.REVIEW.W2', + commit: '6d54e7c60d29812de5b96e471761486fbbc0842c', + }, + 'P0.CURRENT.W5.LATER_BYTES': { + reviewId: 'P0.CURRENT.REVIEW.W3_W5', + commit: '5d723407f287767c0f30f3d708459fb943256eaf', + }, + }; + for (const [decisionId, expected] of Object.entries(laterReviewByDecision)) { + const review = reviewIndex.reviews.find(({ reviewId }) => reviewId === expected.reviewId); + const disposition = review?.commitDispositions.find(({ commit }) => commit === expected.commit); + const expectedPaths = evidenceIndex.evidence + .filter(({ adoptionDecisionId }) => adoptionDecisionId === decisionId) + .map(({ path }) => path); + if ( + disposition?.disposition !== 'narrowed' || + expectedPaths.some((path) => !disposition.paths.includes(path)) + ) { + fail('ADOPTION_PROVENANCE_MISMATCH', `${decisionId} review projection`); + } + } + for (const [reviewId, commit] of [ + ['P0.CURRENT.REVIEW.W1', 'a6bd7a39aebb4d822f57707c96c5e071b2aecb2b'], + ['P0.CURRENT.REVIEW.W4_W6', '3bc0dfa7c00261785c0c752270cb302a9294e751'], + ]) { + const disposition = reviewIndex.reviews + .find((review) => review.reviewId === reviewId) + ?.commitDispositions.find((candidate) => candidate.commit === commit); + if (disposition?.disposition !== 'narrowed') { + fail('ADOPTION_PROVENANCE_MISMATCH', `${reviewId} ${commit}`); + } + } + + const legacyBypasses = readJson( + resolve( + repoRoot, + 'docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json' + ) + ); + if ( + legacyBypasses.rawArtifact?.externalPath !== 'legacy-bypass-raw.json' || + legacyBypasses.rawArtifact?.pathScope !== 'artifact-pack-relative' || + /(^|\/)tmp\//.test(legacyBypasses.rawArtifact?.externalPath ?? '') || + !legacyBypasses.rawArtifact?.reproductionCommand?.includes( + 'scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts' + ) + ) { + fail( + 'NON_PORTABLE_PROVENANCE', + 'legacy bypass raw artifact is not pack-relative and reproducible' + ); + } + + const requiredDecisions = { + 'P0.CURRENT.PHASE0_FREEZE': 'accepted', + 'P0.CURRENT.PHASE1_AUTHORITY': 'narrowed', + 'P0.CURRENT.ORCHESTRATION_AUTHORITY': 'accepted', + 'P0.CURRENT.NAVIGATION_AUTHORITY': 'accepted', + 'P0.CURRENT.ESTIMATE_AUTHORITY': 'accepted', + }; + for (const [decisionId, status] of Object.entries(requiredDecisions)) { + if ( + decisionIndex.decisions.find((decision) => decision.decisionId === decisionId)?.status !== + status + ) { + fail('READINESS_AUTHORITY_MISMATCH', decisionId); + } + } + + const phase1Readme = readFileSync( + resolve(repoRoot, 'docs/hosted-web-phases/phase-01/README.md'), + 'utf8' + ); + const phase1Packet = readFileSync( + resolve(repoRoot, 'docs/hosted-web-phases/phase-01/controller-packet.md'), + 'utf8' + ); + const phase1Dag = readFileSync( + resolve(repoRoot, 'docs/hosted-web-phases/phase-01/execution-dag.md'), + 'utf8' + ); + const executionIndex = readJson(resolve(repoRoot, 'docs/hosted-web-phases/EXECUTION_INDEX.json')); + const revivedHistoricalClaim = [ + 'Both rejected', + 'Pair rejected with RW35', + 'Pair rejected with R46', + 'Holds all adoption', + ].find((claim) => `${phase1Readme}\n${phase1Packet}\n${phase1Dag}`.includes(claim)); + if ( + revivedHistoricalClaim || + executionIndex.acceptedPhase0Freeze?.commit !== expectedCurrentCommit || + executionIndex.acceptedPhase0Freeze?.status !== 'accepted-frozen' + ) { + fail('SUPERSESSION_PROJECTION_MISMATCH', revivedHistoricalClaim ?? 'accepted freeze missing'); + } + if ( + executionIndex.currentExecutablePhase !== 'phase-01' || + executionIndex.currentExecutableSubphase !== 'P1.S0' || + JSON.stringify(executionIndex.authorization?.authorized) !== JSON.stringify(['P1.S0']) || + executionIndex.authorization?.productSourceImplementationAuthorized !== false || + !phase1Readme.includes('current for `P1.S0` serial bootstrap only') || + !phase1Packet.includes('current execution authority for serial `P1.S0` only') || + !phase1Packet.includes('Later-subphase producer target: **zero**') || + !phase1Dag.includes('`-X->` is a blocked transition') + ) { + fail('PHASE1_AUTHORITY_MISMATCH', 'Phase 1 is not restricted to serial P1.S0'); + } + + assertUnique(supersessionIndex.supersessions, 'supersessionId', 'supersessions'); + for (const row of supersessionIndex.supersessions) { + row.sources.forEach(checkPathHash); + if (!existsSync(resolve(repoRoot, row.replacementIndex))) { + fail('PATH_MISSING', row.replacementIndex); + } + } + + const w2 = laneById.get('w2'); + if ( + w2.phaseStartSha !== 'a32f509e6d9bd31ba2135940e336729bf90c3d93' || + w2.sourceBaseSha !== 'c72fd201867b9bcd1ef77d5e0f95ba379adb4fca' + ) { + fail('PHASE_START_MISMATCH', 'W2 phase start/source-base correction is absent'); + } + const w2Supersession = supersessionIndex.supersessions.find( + ({ supersessionId }) => supersessionId === 'P0.CURRENT.SUPERSESSION.W2_INCORRECT_PHASE_START' + ); + if ( + !w2Supersession?.supersededClaims.includes( + 'phaseStartSha=c72fd201867b9bcd1ef77d5e0f95ba379adb4fca' + ) + ) { + fail('PHASE_START_MISMATCH', 'W2 historical claim is not explicitly superseded'); + } + const expectedW2PhaseStartSources = [ + '.codex-handoff/phase-00-w2.json', + 'docs/research/hosted-web/phase-0/provider-runtime/README.md', + 'docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json', + 'docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json', + 'docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json', + 'docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json', + 'docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json', + 'docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json', + 'docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json', + 'docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json', + 'docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json', + 'docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json', + 'docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json', + 'docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json', + ].sort(); + const actualW2PhaseStartSources = w2Supersession.sources.map(({ path }) => path).sort(); + if (JSON.stringify(actualW2PhaseStartSources) !== JSON.stringify(expectedW2PhaseStartSources)) { + fail('MISSING_PROVENANCE', 'W2 incorrect phase-start source set is incomplete or excessive'); + } +}; + +for (const [name, value] of indexes) { + if (!validateIndex(value)) { + fail('SCHEMA', `${name}: ${JSON.stringify(validateIndex.errors)}`); + } +} +validateSemantics(indexes); + +const applyMutation = (target, mutation) => { + if (mutation.type === 'omit-lane') { + target.lanes = target.lanes.filter(({ laneId }) => laneId !== mutation.laneId); + return; + } + if (mutation.type === 'replace-evidence-hash') { + const row = target.evidence.find(({ evidenceId }) => evidenceId === mutation.evidenceId); + if (!row) fail('FIXTURE_INVALID', mutation.evidenceId); + row.sha256 = mutation.replacementSha256; + return; + } + if (mutation.type === 'duplicate-first-id') { + target[mutation.collection].push(clone(target[mutation.collection][0])); + return; + } + if (mutation.type === 'omit-supersession-source') { + const row = target.supersessions.find( + ({ supersessionId }) => supersessionId === mutation.supersessionId + ); + if (!row) fail('FIXTURE_INVALID', mutation.supersessionId); + row.sources = row.sources.filter(({ path }) => path !== mutation.path); + return; + } + fail('FIXTURE_INVALID', mutation.type); +}; + +for (const fixtureName of fixtureFiles) { + const fixture = readJson(resolve(indexDir, 'fixtures', fixtureName)); + if (!validateFixture(fixture)) { + fail('SCHEMA', `${fixtureName}: ${JSON.stringify(validateFixture.errors)}`); + } + const mutated = new Map([...indexes].map(([name, value]) => [name, clone(value)])); + applyMutation(mutated.get(fixture.targetIndex), fixture.mutation); + let observedCode = 'NO_FAILURE'; + try { + validateSemantics(mutated); + } catch (error) { + if (!(error instanceof ValidationFailure)) throw error; + observedCode = error.code; + } + if (observedCode !== fixture.expectedCode) { + fail( + 'NEGATIVE_FALSE_GREEN', + `${fixture.fixtureId}: expected ${fixture.expectedCode}, received ${observedCode}` + ); + } +} + +process.stdout.write( + `Phase 0 current canonical indexes passed: 5 schemas, ${indexes.get('evidence-index.json').evidence.length} evidence IDs, 5 accepted authorities, 3 focused negatives${includeControllerExternal ? ', controller-external hashes checked' : ''}.\n` +); diff --git a/docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json b/docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json new file mode 100644 index 0000000000..f0bf83fda2 --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json @@ -0,0 +1,149 @@ +{ + "$schema": "./probe-results.schema.json", + "cleanup": { + "markerRemoved": true, + "ownedProcessGroupsTracked": 106, + "ownedProcessIdentitiesTracked": 146, + "ownedResidualMounts": 0, + "ownedResidualProcesses": 0, + "performedBeforeEmission": true + }, + "cleanupProbes": { + "actualOwnedResourcesCleanupExecutions": 3, + "liveOwnedProcessTerminated": true, + "negativeMarkerRemovalRejected": true, + "negativeResidualProcessRejected": true, + "negativeResidualsObserved": 1, + "positiveCleanup": { + "markerRemoved": true, + "ownedProcessGroupsTracked": 1, + "ownedProcessIdentitiesTracked": 1, + "ownedResidualMounts": 0, + "ownedResidualProcesses": 0, + "performedBeforeEmission": true + } + }, + "host": { + "architecture": "x86_64", + "dockerDaemonReachable": false, + "filesystem": "ext4", + "finalShapeContainer": false, + "kernel": "6.8.0-124-generic", + "noNewPrivs": 1, + "node": "v24.16.0", + "osRelease": "PRETTY_NAME=\"Ubuntu 24.04.4 LTS\"", + "seccompMode": 2, + "uid": 0 + }, + "instanceLease": { + "duplicateCloseOrdering": true, + "killedOwnerCleanHandoff": true, + "mutualExclusion": true, + "outsideEffects": 0, + "pathReplacementRejectedByIdentity": true, + "pausedOwnerNotStealable": true, + "status": "passed_current_host" + }, + "markerOwnership": "all fixture paths, mounts, process identities, and process groups are invocation-owned and tracked before use", + "nativeArtifactFeasibility": { + "artifacts": [ + { + "artifactId": "agent-teams-instance-lock", + "currentHostUnstrippedBinarySha256": "d58f8efb668aa6e9e171780068f9e04099b6976d77869158405c71c19d3763ab", + "protocolSha256": "ded8949371646d490ba5175cf2992cb308df6fdca44537d91d728345be3b139f", + "sourceSha256": "d9cc83ae82e3a1c11e654db39ecf6ddceea78f23f455db707cb5876652118c57" + }, + { + "artifactId": "agent-teams-process-anchor", + "currentHostUnstrippedBinarySha256": "c1429828e7be36efb9d86c9ca69a4bd66240835065b1375a7aa29ea80e4751dc", + "protocolSha256": "17e8b63b294a88959b7907cb931ea828a23b139abbbb3959b36ff77966cf41de", + "sourceSha256": "8fd3414ca172a2947eb2545457417f42b16b76612e91162f74a51e8728ebff20" + }, + { + "artifactId": "agent-teams-workspace-guard", + "currentHostUnstrippedBinarySha256": "8fbefcea19270edf420b06cb4f53ca79d5605d87e0cfaff5cf0439975f30c7e1", + "protocolSha256": "d73abe4570fb87f42824c22e84a184fd617bd42384f097e4fae341c1d33b02c3", + "sourceSha256": "66f7c58d1188fd8d4be09bdd890458002dbbe40bf94b153155f1f9a54366ab43" + } + ], + "buildRecipeId": "w4-native-c17-v1", + "status": "passed_current_host_target_unverified" + }, + "probeCommand": "/usr/bin/python3 scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "processAnchor": { + "controlDescriptorLeaks": 0, + "controllerEofDrained": true, + "drainDtoGenerationBound": true, + "drainDtoKind": "process_drain_outcome_v1", + "drainDtoSamples": { + "drained": { + "classificationId": "anchor-process-anchor-generation-normal-88", + "deploymentGeneration": "deployment-generation-fixture", + "kind": "process_drain_outcome_v1", + "outcome": "drained", + "processAnchorGeneration": "process-anchor-generation-normal", + "protocolVersion": 1, + "purpose": "host_reset", + "resetGeneration": 7, + "residuals": [] + }, + "ready": { + "anchorIdentity": "anchor-88", + "deploymentGeneration": "deployment-generation-fixture", + "mainPidfdReady": true, + "ownedProcessGroupReady": true, + "processAnchorGeneration": "process-anchor-generation-normal", + "protocolVersion": 1, + "purpose": "host_reset", + "resetGeneration": 7, + "spawnNonceHash": "spawn-nonce-hash-normal-stop" + }, + "unclassified": { + "classificationId": "anchor-process-anchor-generation-escape-171", + "containerReplacementRequired": true, + "deploymentGeneration": "deployment-generation-fixture", + "kind": "process_drain_outcome_v1", + "outcome": "unclassified", + "processAnchorGeneration": "process-anchor-generation-escape", + "protocolVersion": 1, + "purpose": "host_reset", + "reason": "unclassified_identity", + "resetGeneration": 7, + "residuals": ["escaped_group"] + } + }, + "highFdCanariesClosed": true, + "nonceReadyBound": true, + "numericPgidSignals": 0, + "numericPidSignals": 0, + "ownedProcessGroupSignals": false, + "pidReuseDeterministicallyForced": false, + "pidfd": true, + "pidfdDescendantSignals": true, + "rapidPidPgidReuseNegativeSchedule": true, + "status": "passed_current_host", + "subreaperDoubleFork": true, + "termKillEscalation": true, + "typedStopDrained": true, + "typedUnclassified": true, + "unrelatedProcessesSignaled": 0 + }, + "recordId": "P0.W4.CURRENT_HOST_PROBE_RESULTS", + "schemaVersion": 1, + "status": "characterized", + "workspaceGuard": { + "bindMount": "rejected_zero_effect", + "execDescriptorLeaks": 0, + "fakeProviderCwdBound": true, + "finalSymlinkOutsideEffects": 0, + "gitHookDisabled": true, + "highFdCanariesClosed": true, + "openat2": true, + "parentSymlinkOutsideEffects": 0, + "rawNodeNegativeControlOutsideEffects": 1, + "rootRenameOutsideEffects": 0, + "staleGenerationOutsideEffects": 0, + "status": "passed_current_host", + "statxMountId": 1237 + } +} diff --git a/docs/research/hosted-web/phase-0/host-primitives/estimate-input.json b/docs/research/hosted-web/phase-0/host-primitives/estimate-input.json new file mode 100644 index 0000000000..e40442a613 --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/estimate-input.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "evidenceId": "P0.W4.ESTIMATE", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "canonicalBaseSha": "f7d98790eb868714e536f77bd796072ea706911a", + "packetRevision": "phase-00-r3", + "status": "characterized", + "canonicalBucketId": "EST-LIFECYCLE-RUNTIME", + "ownerContribution": "W4 current-host characterization and read-only projections only", + "admittedR3Lines": { + "production": 0, + "test": 0, + "deleted": 0 + }, + "historicalR2Range": { + "lowNetLines": 11600, + "highNetLines": 20370, + "disposition": "not admitted; retained only as historical controller input" + }, + "w2Reconciliation": { + "sharedCanonicalBucket": "EST-LIFECYCLE-RUNTIME", + "overlapRule": "W4 contributes no admitted r3 production scope; never add the historical r2 range to W2 or the baseline." + }, + "excludedCapabilities": [ + "hosted mutation/runtime readiness", + "workspace effects", + "provider launch", + "production native artifacts", + "final-image composition" + ], + "uncertainty": [ + "Any later executable scope requires a separately reviewed packet and a new estimate." + ] +} diff --git a/docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md b/docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md new file mode 100644 index 0000000000..79681cf07c --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/instance-lease-spike.md @@ -0,0 +1,26 @@ +# P0.W4.INSTANCE_LEASE_SPIKE + +Status: `characterized`. + +Under `phase-00-r3`, this current-host fixture does not admit hosted mutation/runtime behavior. + +`instance_lock_spike.c` opens a stable root-owned regular anchor beneath a non-writable deployment +parent, verifies expected dev/inode and mode, obtains nonblocking `flock`, duplicates the same open +file description into a controller fixture, and prevents that descriptor from reaching exec children. +Metadata never grants or steals ownership. + +Current-host results: + +- a second start returned typed exit 73 before its effect marker; +- `SIGSTOP` of the owner did not make the lease stealable; +- killing the launcher caused controller EOF/exit, after which exactly one clean handoff succeeded; +- closing the launcher duplicate left exclusion held by the controller duplicate; +- rename/recreate of `instance.lock` was rejected by expected inode identity and produced no effect. + +Negative control: pathname replacement would allow a new `flock` on a different inode without the +provisioned-parent and expected-identity checks. The spike deliberately treats that replacement as +anchor error, not as a lease opportunity. + +Not proven: two final-image containers or a manual start sharing the final volume, runtime-UID inability +to replace the root-owned anchor, Node's retained descriptor, launcher/controller crash ordering under +container init, NFS/CIFS refusal in the release image, and descendant FD scans through real adapters. diff --git a/docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json b/docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json new file mode 100644 index 0000000000..2bca11852b --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json @@ -0,0 +1,41 @@ +{ + "$schema": "./native-protocol.schema.json", + "artifactId": "agent-teams-instance-lock", + "failClosed": [ + "anchor identity, type, ownership or mode differs from the provisioned manifest", + "deployment parent is runtime-writable", + "lease is busy or the filesystem is unsupported", + "controller readiness or lifecycle handoff is incomplete" + ], + "owner": "w4", + "protocolVersion": 1, + "request": { + "channel": "fixed argv plus inherited lifecycle descriptors", + "fields": [ + "deploymentId", + "anchorRelativeName", + "expectedAnchorDevice", + "expectedAnchorInode", + "controllerExecutable" + ], + "maximumBytes": 4096, + "shellAllowed": false + }, + "responses": [ + { + "fields": ["protocolVersion", "deploymentId", "leaseId", "anchorIdentity"], + "maximumBytes": 1024, + "type": "ready" + }, + { + "exitCode": 73, + "type": "lease_busy" + }, + { + "exitCode": 74, + "type": "anchor_rejected" + } + ], + "schemaVersion": 1, + "status": "proposed_characterized" +} diff --git a/docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json b/docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json new file mode 100644 index 0000000000..193248e12a --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "contractId": "P0.W4.NATIVE_ARTIFACT_PROJECTION.V1", + "owner": "w4", + "status": "read_only_projection_current_host_characterized", + "controllerContractPath": "docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json", + "controllerContractSha256": "4e8f2b43f47f94586692530cba2f5198f6de3740225b51dfba627f36fc82812f", + "artifacts": [ + { + "artifactId": "agent-teams-instance-lock", + "finalImagePath": "/app/bin/agent-teams-instance-lock", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json", + "protocolSha256": "ded8949371646d490ba5175cf2992cb308df6fdca44537d91d728345be3b139f", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "spikeSourceSha256": "d9cc83ae82e3a1c11e654db39ecf6ddceea78f23f455db707cb5876652118c57", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "pre_node_contract_only" + }, + { + "artifactId": "agent-teams-process-anchor", + "finalImagePath": "/app/bin/agent-teams-process-anchor", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "protocolSha256": "17e8b63b294a88959b7907cb931ea828a23b139abbbb3959b36ff77966cf41de", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c", + "spikeSourceSha256": "8fd3414ca172a2947eb2545457417f42b16b76612e91162f74a51e8728ebff20", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "after_lease_contract_only" + }, + { + "artifactId": "agent-teams-workspace-guard", + "finalImagePath": "/app/bin/agent-teams-workspace-guard", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json", + "protocolSha256": "d73abe4570fb87f42824c22e84a184fd617bd42384f097e4fae341c1d33b02c3", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "spikeSourceSha256": "66f7c58d1188fd8d4be09bdd890458002dbbe40bf94b153155f1f9a54366ab43", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "per_effect_contract_only" + } + ], + "capabilityClaims": { + "hostedMutationReady": false, + "workspaceEffectsReady": false, + "providerLaunchReady": false, + "productionArtifactReady": false + } +} diff --git a/docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md b/docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md new file mode 100644 index 0000000000..1d545db2b3 --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md @@ -0,0 +1,16 @@ +# P0.W4.NATIVE_ARTIFACT_PROPOSAL + +Status: `characterized`. + +Under `phase-00-r3`, this is a read-only projection of the controller-owned artifact authority at +`docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json`. W4 does not own a +second schema, path layout, or hash table. `native-artifact-contract.json` records the controller path +and SHA-256 and projects the three rows byte-for-byte for cross-lane comparison. + +The three C programs remain current-host feasibility spikes only. They are not production artifacts, +and this lane makes no hosted mutation/runtime readiness, workspace-effect, provider-launch, +production-composition, or final-image claim. The `/app/bin/*` paths are contract identifiers only; +they do not assert that an admitted image exists. + +Any later executable or packaging work requires a separately reviewed packet and must not infer +admission from this Phase 0 characterization. diff --git a/docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json b/docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json new file mode 100644 index 0000000000..4513117b0a --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "P0.W4.NATIVE_PROTOCOL_SCHEMA.V1", + "x-processAnchorDrainEvidence": { + "authority": "phase-00-controller", + "envelopeId": "P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1", + "schemaPath": "docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json", + "schemaSha256": "764e707edc1148e45f18f1ac70a30b681e2a794b55d6f8946fb5b50601bc099f", + "readyResponseType": "ready", + "drainedResponseType": "drained", + "projection": "exact_required_fields_no_lane_owned_wrapper" + }, + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "artifactId", + "owner", + "status", + "protocolVersion", + "request", + "responses", + "failClosed" + ], + "properties": { + "$schema": { "const": "./native-protocol.schema.json" }, + "schemaVersion": { "const": 1 }, + "artifactId": { "type": "string", "minLength": 1 }, + "owner": { "const": "w4" }, + "status": { "const": "proposed_characterized" }, + "protocolVersion": { "const": 1 }, + "request": { "type": "object" }, + "responses": { "type": "array", "minItems": 1 }, + "failClosed": { "type": "array", "minItems": 1 }, + "sharedDrainDto": { "type": "object" }, + "signalSemantics": { "type": "object" } + } +} diff --git a/docs/research/hosted-web/phase-0/host-primitives/probe-results.schema.json b/docs/research/hosted-web/phase-0/host-primitives/probe-results.schema.json new file mode 100644 index 0000000000..b983a55e1e --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/probe-results.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "P0.W4.PROBE_RESULTS_SCHEMA.V1", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "recordId", + "status", + "probeCommand", + "markerOwnership", + "host", + "nativeArtifactFeasibility", + "cleanupProbes", + "instanceLease", + "workspaceGuard", + "processAnchor", + "cleanup" + ], + "properties": { + "$schema": { "const": "./probe-results.schema.json" }, + "schemaVersion": { "const": 1 }, + "recordId": { "const": "P0.W4.CURRENT_HOST_PROBE_RESULTS" }, + "status": { "const": "characterized" }, + "probeCommand": { "type": "string", "minLength": 1 }, + "markerOwnership": { "type": "string", "minLength": 1 }, + "host": { "type": "object" }, + "nativeArtifactFeasibility": { "type": "object" }, + "cleanupProbes": { + "type": "object", + "required": [ + "actualOwnedResourcesCleanupExecutions", + "liveOwnedProcessTerminated", + "negativeResidualsObserved", + "negativeResidualProcessRejected", + "negativeMarkerRemovalRejected", + "positiveCleanup" + ] + }, + "instanceLease": { "type": "object" }, + "workspaceGuard": { "type": "object" }, + "processAnchor": { "type": "object" }, + "cleanup": { + "type": "object", + "required": [ + "performedBeforeEmission", + "markerRemoved", + "ownedProcessIdentitiesTracked", + "ownedProcessGroupsTracked", + "ownedResidualProcesses", + "ownedResidualMounts" + ] + } + } +} diff --git a/docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md b/docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md new file mode 100644 index 0000000000..7939c8bf9d --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md @@ -0,0 +1,31 @@ +# P0.W4.PROCESS_ANCHOR_SPIKE + +Status: `characterized`. + +Under `phase-00-r3`, this current-host fixture does not admit runtime or provider-launch readiness. + +`process_anchor_spike.c` binds a caller-provided nonce hash into `ready`, enables +`PR_SET_CHILD_SUBREAPER` and +`PR_SET_NO_NEW_PRIVS`, opens a pidfd for the main child before readiness, keeps typed stdin/stdout +control/status separate from provider output, and reports TERM, KILL, `drained`, `protocol_error` or +`unclassified_residual`. It discovers only descendants of its own marker-owned anchor. Every TERM or +KILL target is opened as a pidfd, checked against the same pre/post `/proc` start-time identity, and +signaled with `pidfd_send_signal`; the process group is classification evidence only. The spike issues +no numeric PID or PGID signal, so an empty/reused process group cannot receive a late escalation. + +Current-host cases proved normal typed stop, control EOF, a double-forked descendant, TERM-ignore to +KILL escalation, and an escaped process-group fixture. Normal/ignore/double cases ended `drained` with +zero descendants. The intentional group escape returned `unclassified_residual` even though the +fixture performed exact owned cleanup. A simultaneously running unrelated marker process remained +alive in every case. `/proc//fd` evidence contained no control/status pipe. + +The emitted `ready` and `drained` records carry the exact field sets consumed by W6. Both equality-bind +purpose, reset generation, deployment generation, process-anchor generation and protocol version; +`ready` additionally binds nonce hash, anchor identity, pidfd readiness and process-group readiness, +while the drain outcome binds classification identity and residuals. A +drained result requires an empty residual list; escape or identity ambiguity is unclassified and +requires container replacement. The pressure schedule still cannot force kernel PID reuse +deterministically, so it proves stable pidfd targeting and unrelated-process survival rather than a +numeric recycle event. Final container init behavior, +anchor crash/whole-container replacement, real stdout flood/backpressure, real relay bootstrap FDs, +artifact protocol hashes and non-root final seccomp remain unverified. diff --git a/docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json b/docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json new file mode 100644 index 0000000000..5f9ae3a167 --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json @@ -0,0 +1,90 @@ +{ + "$schema": "./native-protocol.schema.json", + "artifactId": "agent-teams-process-anchor", + "failClosed": [ + "spawn nonce, workspace evidence or protocol version is stale or mismatched", + "pidfd, pidfd_send_signal, subreaper, process-group classification or complete descriptor closure is unavailable", + "a descendant escapes the owned process group or cannot be classified", + "control EOF or stop cannot end in a generation-bound typed drain result" + ], + "owner": "w4", + "protocolVersion": 1, + "request": { + "channel": "fixed control descriptor", + "fields": [ + "protocolVersion", + "spawnNonce", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "stopMode", + "graceMs" + ], + "maximumBytes": 4096, + "numericPidTargetsAllowed": false + }, + "responses": [ + { + "fields": [ + "protocolVersion", + "spawnNonceHash", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "anchorIdentity", + "mainPidfdReady", + "ownedProcessGroupReady" + ], + "maximumBytes": 4096, + "type": "ready" + }, + { + "fields": [ + "protocolVersion", + "kind", + "outcome", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "classificationId", + "residuals" + ], + "maximumBytes": 1024, + "type": "drained" + }, + { + "fields": [ + "protocolVersion", + "kind", + "outcome", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "classificationId", + "residuals", + "reason", + "containerReplacementRequired" + ], + "maximumBytes": 2048, + "type": "unclassified_residual" + } + ], + "sharedDrainDto": { + "kind": "process_drain_outcome_v1", + "outcomes": ["drained", "unclassified"], + "residualsEmptyWhenDrained": true, + "w6FieldMapping": "exact_field_names_no_adapter_downcast" + }, + "signalSemantics": { + "descendantTargets": "pidfd_open plus stable pre/post start-time identity, then pidfd_send_signal", + "numericPidSignalsAllowed": false, + "numericProcessGroupSignalsAllowed": false, + "processGroupUse": "classification only; escape forces unclassified and container replacement" + }, + "schemaVersion": 1, + "status": "proposed_characterized" +} diff --git a/docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md b/docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md new file mode 100644 index 0000000000..103e5ba010 --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md @@ -0,0 +1,44 @@ +# P0.W4.TARGET_HOST_ENVELOPE + +Status: `characterized` (not `verified`). Phase start SHA was +`a32f509e6d9bd31ba2135940e336729bf90c3d93` and matched HEAD before edits. + +`phase-00-r3` narrows W4 to current-host characterization and read-only projections. This historical +target envelope is not a hosted readiness claim: mutation/runtime admission, workspace effects, +provider launch and production composition all remain absent. + +## Required admitted envelope + +- Node 24 on the release Debian-slim image, x86_64 initially, non-root fixed UID/GID, read-only image, + `no-new-privileges`, explicit capability drop and the final seccomp profile. +- Linux kernel 5.6+ with working `openat2`, `statx` mount identity, pidfds, + `PR_SET_CHILD_SUBREAPER`, `flock`, `renameat2`, directory/file `fsync`, `/proc` FD evidence and a + minimal init in the final PID namespace. +- One pre-provisioned local filesystem deployment root: root-owned non-runtime-writable parent and + stable `instance.lock`, with a separately runtime-writable `state/` child. NFS, CIFS and unknown + network filesystems remain unsupported. +- Registered local workspace mounts with explicit generation and root dev/inode/mount-ID evidence. + Nested mounts are separate registrations; default resolution uses `RESOLVE_NO_XDEV`. +- Two final-image containers plus a manual start must be able to share the same admitted local volume + for the competing-writer proof. The runtime must not have the Docker socket, host PID namespace, + privileged mode or a broad home mount. + +## Observed worker envelope + +The runnable probe observed Ubuntu 24.04.4, kernel `6.8.0-124-generic`, x86_64, Node `v24.16.0`, ext4, +UID 0, `NoNewPrivs=1`, and seccomp mode 2. `openat2`, `statx` mount ID, bind-mount rejection, +`flock`, pidfd and subreaper probes passed. Docker client `29.6.1` exists, but daemon access failed: +`permission denied while trying to connect to the docker API at unix:///var/run/docker.sock`. + +This is not the admitted topology: it is root Ubuntu, not the final Debian-slim non-root image; the +checked-in Dockerfile has no ADR-16 launcher, pre-provisioned volume layout, workspace guard, process +anchor, init, non-root user or final seccomp declaration. Therefore every W4 result is bounded to the +current host and cannot close the target-container gate. + +## Unverified assumptions + +Final image digest/build manifest; non-root volume ownership; two-container/manual-start sharing; +launcher-before-Node ordering; duplicate lease FD integration with Node; final init/PID namespace; +final seccomp allowlist; read-only root/tmpfs/capability settings; child FD policy through real Node, +Git and provider adapters; output backpressure; container stop grace; image ABI/strip reproducibility; +and clean whole-container replacement after an ambiguous anchor outcome. diff --git a/docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md b/docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md new file mode 100644 index 0000000000..d5bbbb7731 --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/workspace-guard-spike.md @@ -0,0 +1,24 @@ +# P0.W4.WORKSPACE_GUARD_SPIKE + +Status: `characterized`. + +Under `phase-00-r3`, this current-host fixture does not admit workspace effects or provider launch. + +`workspace_guard_spike.c` opens the registered root as `O_PATH|O_DIRECTORY|O_NOFOLLOW`, checks +dev/inode plus `statx` mount ID and a generation marker, resolves mutation parents/cwd with +`openat2(RESOLVE_BENEATH|RESOLVE_NO_MAGICLINKS|RESOLVE_NO_SYMLINKS|RESOLVE_NO_XDEV)`, binds create to +the opened parent, fsyncs file and directory, and enters exec cwd with `fchdir` before closing all +non-stdio descriptors and calling `execve` with a clean environment. + +The marker-owned suite produced zero outside effects for parent and final symlinks, canonical-root +rename/replacement, stale generation and a bind-mounted subdirectory. A fake provider wrote only from +the descriptor-entered cwd. Raw Node `realpathSync` followed by delayed pathname write was the required +failing control: after root replacement it wrote one marker-owned byte effect outside the original +workspace. Raw Git `worktree add` ran a fixture `post-checkout` hook; guarded Git with the fixed +no-hook/no-helper policy did not. Explicit inherited lease/control canaries were absent from fake +provider, Git-helper and generic-helper `/proc//fd` evidence. + +The spike's exec argv is test-only and intentionally not an application protocol. Production must use +typed bounded envelopes and fixed artifact/argv/environment policy IDs. Relative read-only symlink +policy, atomic replace/rename/remove verbs, output framing, PTY inherited-FD handoff, final-image +seccomp and real provider/Git integration remain unverified. diff --git a/docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json b/docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json new file mode 100644 index 0000000000..fbee2ea14c --- /dev/null +++ b/docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json @@ -0,0 +1,38 @@ +{ + "$schema": "./native-protocol.schema.json", + "artifactId": "agent-teams-workspace-guard", + "failClosed": [ + "envelope is oversized, trailing, unknown-version or contains an unsupported verb", + "workspace dev, inode, mount ID or generation differs from the grant", + "openat2 or statx is unavailable under the final seccomp profile", + "descriptor closure or bounded exec-error acknowledgement cannot be proved" + ], + "owner": "w4", + "protocolVersion": 1, + "request": { + "channel": "one fixed inherited request descriptor", + "families": ["bounded_file_effect", "descriptor_bound_exec"], + "maximumBytes": 16384, + "rawAbsolutePathsAllowed": false, + "shellAllowed": false + }, + "responses": [ + { + "fields": ["protocolVersion", "operationId", "workspaceEvidence", "effectReceipt"], + "maximumBytes": 4096, + "type": "completed" + }, + { + "fields": ["protocolVersion", "operationId", "errorCode", "effectState"], + "maximumBytes": 4096, + "type": "rejected" + }, + { + "fields": ["protocolVersion", "operationId", "execEvidence"], + "maximumBytes": 4096, + "type": "exec_ready" + } + ], + "schemaVersion": 1, + "status": "proposed_characterized" +} diff --git a/docs/research/hosted-web/phase-0/index.md b/docs/research/hosted-web/phase-0/index.md new file mode 100644 index 0000000000..39f2d571da --- /dev/null +++ b/docs/research/hosted-web/phase-0/index.md @@ -0,0 +1,64 @@ +# Hosted Web Phase 0 evidence + +This directory contains the serialized Phase 0A baseline record for packet `phase-00-r2`. It does not +claim that Phase 0, the six evidence lanes, or the Phase 0 freeze is complete. No hosted product or +terminal behavior is enabled by these files. + +## Pinned lineage + +| Fact | Value | +| --- | --- | +| Canonical repository | `https://github.com/777genius/agent-teams-ai.git` | +| Base branch | `refactor/team-provisioning-round2-reapply` | +| Pinned base | `cbe501ad0f1fa0e51a038e832ad35fce4120321b` | +| Source plan bundle | `16c156db8a85e75a6b679f6919e1013af74fb112` | +| Adopted plan bundle | `f1ad7a8cba2f26abf5f42ddd206937c24d143f77` | +| Baseline integration head | `c1b8e3fe69e1c05ad94ec0c0301def25c8a464b5` | +| Packet revision | `phase-00-r2` | + +The source plan commit is review provenance, not an ancestor of the implementation branch. The +content-equivalent reviewed bundle was adopted as `f1ad7a8cba2f26abf5f42ddd206937c24d143f77`, which +descends from the pinned base. The baseline integration head descends from both the base and the +adopted bundle. + +## Artifacts + +- [`base.json`](./base.json) is the machine-readable pin, plan hash, toolchain, controller, cache and + external-evidence record. +- [`baseline.md`](./baseline.md) records the reproducible gate and the complete inherited-failure + classification. +- [`lane-ledger.json`](./lane-ledger.json) reserves one unique slot for each Phase 0 lane. All remain + unstarted until the evidence commit SHA is recorded externally as `phaseStartSha`. +- [`estimate-ledger.md`](./estimate-ledger.md) records the non-duplicated pre-inventory v1 estimate. +- [`salvage-ledger.md`](./salvage-ledger.md) records that no closed-PR production asset was salvaged in + 0A. +- [`decision-register.md`](./decision-register.md) records only decisions closed or narrowed by 0A. + +## Current gate state + +`pnpm check:ci` ran against `c1b8e3fe69e1c05ad94ec0c0301def25c8a464b5` and exited `1` after +2,211 seconds. Type checking, workspace tests, workspace builds and MCP E2E passed. The sole failing +stage was full root lint: five errors already present at the pinned base, grouped into two +`base_owned_fix` records. There are no `unknown`, `base_blocker`, or environment failures in the +captured run. + +The packet's separate `lint:fast` and `standalone:build` invocations were not rerun for this evidence +write. The supplied broad run proves its nested typecheck, test, workspace build and MCP E2E stages, +but a workspace build is not relabeled as `standalone:build`. See `baseline.md` for the exact coverage +and limitation. + +## `phaseStartSha` resolution + +A Git commit cannot contain its own SHA. Therefore `base.json` and every unstarted lane slot contain +`phaseStartSha: null`. The integration controller must adopt exactly these seven reviewed files, +record the resulting immutable evidence commit in its external integration-attempt record, and inject +that SHA into every lane job, prompt and runtime lane-ledger overlay. That external commit is the only +valid lane `phaseStartSha`; `c1b8e3fe69e1c05ad94ec0c0301def25c8a464b5` is only the pre-evidence +integration head. + +## Operational incident + +The first baseline evidence job had `prewarmOnStart=true` and entered a runaway prewarm failure loop: +22 attempts in under five minutes, ending with `subscription_worker_prewarm_failed` and +`codex_app_server_exited:1` without changing files. The replacement v2 baseline job and clean v2 jobs +use `prewarmOnStart=false`. This is an orchestration incident, not a repository gate failure. diff --git a/docs/research/hosted-web/phase-0/lane-ledger.json b/docs/research/hosted-web/phase-0/lane-ledger.json new file mode 100644 index 0000000000..cf05b9cb30 --- /dev/null +++ b/docs/research/hosted-web/phase-0/lane-ledger.json @@ -0,0 +1,138 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-00", + "packetRevision": "phase-00-r2", + "phaseStartSha": null, + "phaseStartResolution": "Integration controller records the seven-file evidence commit externally, then injects that immutable SHA into every lane job and prompt before changing a slot from unstarted.", + "capacityEpoch": "bootstrap", + "producerTarget": 0, + "slots": [ + { + "laneId": "w1", + "epoch": "evidence", + "attempt": 0, + "jobId": null, + "worktreeId": null, + "phaseStartSha": null, + "packetRevision": "phase-00-r2", + "ownedEvidenceIds": [ + "P0.W1.API_PARITY_LEDGER", + "P0.W1.ESTIMATE", + "P0.W1.LEGACY_BYPASSES", + "P0.W1.RENDERER_ACTIONS", + "P0.W1.SCANNER", + "P0.W1.SELECTION_INVARIANTS" + ], + "state": "unstarted", + "lastUsefulProgress": null, + "handoffHash": null, + "supersededJobId": null + }, + { + "laneId": "w2", + "epoch": "evidence", + "attempt": 0, + "jobId": null, + "worktreeId": null, + "phaseStartSha": null, + "packetRevision": "phase-00-r2", + "ownedEvidenceIds": [ + "P0.W2.CREDENTIAL_EXPOSURE_MATRIX", + "P0.W2.ENVIRONMENT_PROVENANCE", + "P0.W2.ESTIMATE", + "P0.W2.EXECUTION_TOPOLOGY", + "P0.W2.RUNTIME_INGRESS_INVENTORY", + "P0.W2.RUNTIME_SCANNER" + ], + "state": "unstarted", + "lastUsefulProgress": null, + "handoffHash": null, + "supersededJobId": null + }, + { + "laneId": "w3", + "epoch": "evidence", + "attempt": 0, + "jobId": null, + "worktreeId": null, + "phaseStartSha": null, + "packetRevision": "phase-00-r2", + "ownedEvidenceIds": [ + "P0.W3.BACKUP_BEHAVIOR", + "P0.W3.ESTIMATE", + "P0.W3.SCHEMA_UNKNOWN_FIELDS", + "P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", + "P0.W3.STATE_FAMILY_CATALOG", + "P0.W3.WRITER_COORDINATION" + ], + "state": "unstarted", + "lastUsefulProgress": null, + "handoffHash": null, + "supersededJobId": null + }, + { + "laneId": "w4", + "epoch": "evidence", + "attempt": 0, + "jobId": null, + "worktreeId": null, + "phaseStartSha": null, + "packetRevision": "phase-00-r2", + "ownedEvidenceIds": [ + "P0.W4.ESTIMATE", + "P0.W4.INSTANCE_LEASE_SPIKE", + "P0.W4.NATIVE_ARTIFACT_PROPOSAL", + "P0.W4.PROCESS_ANCHOR_SPIKE", + "P0.W4.TARGET_HOST_ENVELOPE", + "P0.W4.WORKSPACE_GUARD_SPIKE" + ], + "state": "unstarted", + "lastUsefulProgress": null, + "handoffHash": null, + "supersededJobId": null + }, + { + "laneId": "w5", + "epoch": "evidence", + "attempt": 0, + "jobId": null, + "worktreeId": null, + "phaseStartSha": null, + "packetRevision": "phase-00-r2", + "ownedEvidenceIds": [ + "P0.W5.COMMAND_CATALOG", + "P0.W5.EFFECT_RECOVERY_MATRIX", + "P0.W5.ESTIMATE", + "P0.W5.EVENT_CURSOR_INVENTORY", + "P0.W5.FINGERPRINT_GOLDENS", + "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER" + ], + "state": "unstarted", + "lastUsefulProgress": null, + "handoffHash": null, + "supersededJobId": null + }, + { + "laneId": "w6", + "epoch": "evidence", + "attempt": 0, + "jobId": null, + "worktreeId": null, + "phaseStartSha": null, + "packetRevision": "phase-00-r2", + "ownedEvidenceIds": [ + "P0.W6.ABI_STUB_REPORT", + "P0.W6.ARTIFACT_INVENTORY", + "P0.W6.AUTH_TRANSITIONS", + "P0.W6.COOKIE_VERSION_EVIDENCE", + "P0.W6.ESTIMATE", + "P0.W6.PROXY_ORIGIN_THREAT_MATRIX", + "P0.W6.TERMINAL_ABSENCE_REPORT" + ], + "state": "unstarted", + "lastUsefulProgress": null, + "handoffHash": null, + "supersededJobId": null + } + ] +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/README.md b/docs/research/hosted-web/phase-0/parity-renderer/README.md new file mode 100644 index 0000000000..d99c0a93a2 --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/README.md @@ -0,0 +1,58 @@ +# Phase 0 W1 parity and renderer evidence + +This remediation replaces the rejected per-JSX heuristic inventory with reviewed semantic contracts. +Action identity is an explicit, canonical ID such as `team.lifecycle.stop` or +`provider.management.credentials.edit`. A source file, normalized source hash, and occurrence count +are refreshable evidence only; inserting unrelated lines cannot change an action ID. + +The checked-in evidence is intentionally compact: + +- `api-parity-ledger.json` gives every pinned `TeamsAPI`, `ReviewAPI`, and `CrossTeamAPI` member exactly + one explicit owner, disposition, security class, semantic evidence obligation, action ID, and work + package. Renderer caller paths omit source line numbers. +- `renderer-child-control-catalog.json` is the reviewed, omission-sensitive catalog for the complete + relative/renderer and change-review feature-alias import closure rooted at `TeamListView`, + `TeamDetailView`, the globally mounted approval sheet and task-detail dialog, and the provider + management panel. Its 191 declared + source files must exactly equal the recursively discovered closure; 720 stable source keys cover + every non-root child control occurrence. The scanner also proves the `App` and `TabbedLayout` mount + chains for the two global roots. +- `renderer-action-inventory.json` maps all 877 scanner-visible sites in that closure to 516 semantic + actions or six deliberate absence classes. Multiple AST sites for keyboard/click parity or a Select + trigger/item/change widget may map to one action. Interaction discovery covers the complete React + event families (including capture variants) and every `on*` callback on directly imported external + components, rather than a selective event-property allowlist. This includes both mounted + `DndContext.onDragCancel` sites and `Sheet.onClose`/`Sheet.onSnap`. The separately mounted + approval/task roots include 29 sites in + `ToolApprovalSheet`, `ToolApprovalDiffPreview`, `ToolApprovalSettingsPanel`, and + `GlobalTaskDetailDialog`. Pure containment, dialog state, and local presentation handlers are + explicit non-actions; mixed containment/action handlers map to the semantic effect. All + eight other production team TSX files are recorded with their 15 interaction sites and excluded only + because no import path from a mounted W1 root exists. +- `legacy-bypass-inventory.json` retains counts and a hash of the deterministic raw projection. The raw + rows are generated outside Git at the recorded `/tmp` path so the adoption remains focused. +- `selection-reconciliation-invariants.md` records the already-approved selection/race constraints. +- `estimate-input.json` records the arithmetic as calculated; it does not suppress the two >20% changes. + +Run: + +```bash +node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts +pnpm exec vitest run test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts +``` + +The scanner fails closed for missing/extra closure files, broken global mount chains, omitted +immediate-child mappings, missing/duplicate API dispositions, missing/duplicate semantic mappings, +stale source references, legacy-child/API owner conflicts, and unannotated dynamic API dispatch. The +focused fixture mutates the real +`TeamListFilterPopover.tsx` closure and mapping, and also covers omitted onBlur/onPaste/onContextMenu/ +onDragStart families, capture variants, a mixed containment/action negative, the real TaskDetailDialog +subject-save paths, and repository-backed removal negatives for `DndContext.onDragCancel`, +`Sheet.onClose`, and `Sheet.onSnap`. It also covers an event-containment handler, provider credential +input, multi-part Select, semantic missing/duplicate mappings, and root/child line-shift ID stability. + +The generator follows both relative imports and the team/provider renderer aliases used by production +components. A file outside the closure is not silently ignored: it is emitted in `excludedSourceFiles` +and its exclusion is regenerated from the current import graph. The direct renderer API caller scan is +repository-wide, so all IPC semantics remain tied to the 119-member parity ledger even when a control +delegates through a child callback. diff --git a/docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json b/docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json new file mode 100644 index 0000000000..dd82ffcc27 --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json @@ -0,0 +1,2301 @@ +{ + "schemaId": "p0-w1-api-parity-ledger", + "evidenceId": "P0.W1.API_PARITY_LEDGER", + "schemaVersion": 2, + "packetRevision": "phase-00-r2", + "pinnedBaseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "counts": { + "TeamsAPI": 82, + "ReviewAPI": 34, + "CrossTeamAPI": 3 + }, + "historicalCountDifference": "ReviewAPI +14 since the phase-start AST: current 82/34/3 versus historical 86/20/3", + "members": [ + { + "stableId": "P0.W1.API.TeamsAPI.list", + "source": "TeamsAPI", + "sourceMember": "list", + "legacySignature": "list: () => Promise;", + "sourceSignatureHash": "sha256:197a6d482f868175ee5b05eaa6b020e34c85392c4b5aab7e6ff298446b4b1b1b", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamDirectoryTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.list", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getData", + "source": "TeamsAPI", + "sourceMember": "getData", + "legacySignature": "getData: (teamName: string, options?: TeamGetDataOptions) => Promise;", + "sourceSignatureHash": "sha256:560f1214f397a0b0ef7e65c62e9865a5a79a2f2c164739b4c4e44a804aa5e6cc", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamViewDataTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.get-data", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.deleteTeam", + "source": "TeamsAPI", + "sourceMember": "deleteTeam", + "legacySignature": "deleteTeam: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:37bfd9599d287139544c086097b365668dd501e857e73e8599135cf523f2bcd4", + "rendererCallers": ["src/renderer/composition/team/createTeamLifecycleMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.delete-team", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.restoreTeam", + "source": "TeamsAPI", + "sourceMember": "restoreTeam", + "legacySignature": "restoreTeam: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:33bc7f7d0b01df7b1840240d3a3ca0a9081c9ea9a40a581d2bdd3c4524d83052", + "rendererCallers": ["src/renderer/composition/team/createTeamLifecycleMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.restore-team", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.permanentlyDeleteTeam", + "source": "TeamsAPI", + "sourceMember": "permanentlyDeleteTeam", + "legacySignature": "permanentlyDeleteTeam: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:415e50c33235af8605b33cfe5e8f24b0021f40ebc10585ca9d7b44e5ca577362", + "rendererCallers": ["src/renderer/composition/team/createTeamLifecycleMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.permanently-delete-team", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.deleteDraft", + "source": "TeamsAPI", + "sourceMember": "deleteDraft", + "legacySignature": "deleteDraft: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:1d2066c4c8fae7f0947b75c7e284f364e7da5e84952c0d20bd9a4fce93db995e", + "rendererCallers": [], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.delete-draft", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.prepareProvisioning", + "source": "TeamsAPI", + "sourceMember": "prepareProvisioning", + "legacySignature": "prepareProvisioning: (cwd?: string, providerId?: TeamLaunchRequest['providerId'], providerIds?: TeamLaunchRequest['providerId'][], selectedModels?: string[], limitContext?: boolean, modelVerificationMode?: TeamProvisioningModelVerificationMode, selectedModelChecks?: TeamProvisioningModelCheckRequest[]) => Promise;", + "sourceSignatureHash": "sha256:3c6ec55173f7e4333a90e3655e12eb20ba050d9618c4b9ea79fe0a99528cf999", + "rendererCallers": [ + "src/renderer/composition/team/createRuntimeProviderProvisioningReadinessTransport.ts", + "src/renderer/composition/team/createTeamProvisioningPreparationTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.prepare-provisioning", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.createTeam", + "source": "TeamsAPI", + "sourceMember": "createTeam", + "legacySignature": "createTeam: (request: TeamCreateRequest) => Promise;", + "sourceSignatureHash": "sha256:60e205f2d42f3051dbe5b7c55f16b2b79893e6d75163ba6e9542c94d89b6261f", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningLaunchTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.create-team", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getProvisioningStatus", + "source": "TeamsAPI", + "sourceMember": "getProvisioningStatus", + "legacySignature": "getProvisioningStatus: (runId: string) => Promise;", + "sourceSignatureHash": "sha256:02060efebcc16f296da415f7a8dafa8da03e99d9fb5e92db1e88ce4d529493f2", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningControlTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.get-provisioning-status", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getLaunchFailureDiagnostics", + "source": "TeamsAPI", + "sourceMember": "getLaunchFailureDiagnostics", + "legacySignature": "getLaunchFailureDiagnostics: (teamName: string, runId?: string) => Promise;", + "sourceSignatureHash": "sha256:6df9e83e5e2862b9acbe4925950d189d69f8e65959026689894b47bd717f3f85", + "rendererCallers": [ + "src/renderer/composition/team/createTeamProvisioningDiagnosticsTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.get-launch-failure-diagnostics", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.cancelProvisioning", + "source": "TeamsAPI", + "sourceMember": "cancelProvisioning", + "legacySignature": "cancelProvisioning: (runId: string) => Promise;", + "sourceSignatureHash": "sha256:bb1702df9457f93956a0e4b78634e62e9084764f5f2c108ff9f9cb2968b8c86c", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningControlTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.cancel-provisioning", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.stop", + "source": "TeamsAPI", + "sourceMember": "stop", + "legacySignature": "stop: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:a791df4bdaeff3ef08915d5beda2efb03d5df34b5639949818a3ed0e9727c689", + "rendererCallers": [], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.stop", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.createConfig", + "source": "TeamsAPI", + "sourceMember": "createConfig", + "legacySignature": "createConfig: (request: TeamCreateConfigRequest) => Promise;", + "sourceSignatureHash": "sha256:2e22d2fe303b09fe6d8b2961e0fb00d27b9faccf1287a2dc01e7fa6e1f30df91", + "rendererCallers": ["src/renderer/composition/team/createTeamConfigurationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.create-config", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.launchTeam", + "source": "TeamsAPI", + "sourceMember": "launchTeam", + "legacySignature": "launchTeam: (request: TeamLaunchRequest) => Promise;", + "sourceSignatureHash": "sha256:2b43671dd020abe85989c48efa41fe172595c8b25150b397dbc8b09f335ff68c", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningLaunchTransport.ts" + ], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.launch-team", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.updateConfig", + "source": "TeamsAPI", + "sourceMember": "updateConfig", + "legacySignature": "updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise;", + "sourceSignatureHash": "sha256:8a8065e43e3783d42c31be29d5c513955308ded3bac78e7d7cdbd2d84c485672", + "rendererCallers": ["src/renderer/composition/team/createTeamConfigurationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.update-config", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.addMember", + "source": "TeamsAPI", + "sourceMember": "addMember", + "legacySignature": "addMember: (teamName: string, request: AddMemberRequest) => Promise;", + "sourceSignatureHash": "sha256:214452e08e9bfdcd5f85b73ebf0567202f210b10a40d87baaa72e2896ac8ec80", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.add-member", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.replaceMembers", + "source": "TeamsAPI", + "sourceMember": "replaceMembers", + "legacySignature": "replaceMembers: (teamName: string, request: ReplaceMembersRequest) => Promise;", + "sourceSignatureHash": "sha256:1b9f8bd097da2d3d8e7c9bb261331a4d31ddfe515bddd742843bdecc64048e80", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.replace-members", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.removeMember", + "source": "TeamsAPI", + "sourceMember": "removeMember", + "legacySignature": "removeMember: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:7d640478c265e3d7c7407b2ef628bacac60087fc98bbc0e89d3d3272480c5e0f", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.remove-member", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.restoreMember", + "source": "TeamsAPI", + "sourceMember": "restoreMember", + "legacySignature": "restoreMember: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:696acc681f5e6135af3dadca517d7ed37c834e65f47e13d83825bdcce35bdcd5", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.restore-member", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.updateMemberRole", + "source": "TeamsAPI", + "sourceMember": "updateMemberRole", + "legacySignature": "updateMemberRole: (teamName: string, memberName: string, role: string | undefined) => Promise;", + "sourceSignatureHash": "sha256:10b06ebae4c9d33b513dfec41030fb3acadcdb55a31fd3d53cfe30b9f4421ff5", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.update-member-role", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getMemberSpawnStatuses", + "source": "TeamsAPI", + "sourceMember": "getMemberSpawnStatuses", + "legacySignature": "getMemberSpawnStatuses: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:e7c8aba54fd2089320a245bd6a0fae9c54475a0559c0f3d5e03f545a3853dae9", + "rendererCallers": [], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.get-member-spawn-statuses", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.restartMember", + "source": "TeamsAPI", + "sourceMember": "restartMember", + "legacySignature": "restartMember: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:c160272ed94ac6ee0009376af58c0f946d91f9e624246bc9ed45d5fe22df8de8", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.restart-member", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.skipMemberForLaunch", + "source": "TeamsAPI", + "sourceMember": "skipMemberForLaunch", + "legacySignature": "skipMemberForLaunch: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:7ce3099a50dcb94d67ceafbad1b9cb959e3d13da3fc515693d9cffecf047b26a", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"], + "owningFeature": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.lifecycle.skip-member-for-launch", + "targetWorkPackage": "WP-7-TEAM-LIFECYCLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getWorktreeGitStatus", + "source": "TeamsAPI", + "sourceMember": "getWorktreeGitStatus", + "legacySignature": "getWorktreeGitStatus: (projectPath: string) => Promise;", + "sourceSignatureHash": "sha256:b21ed55fb4422b8723f5d8f6ad4bca3cffff171480cd2319d5bdacf6039ebb43", + "rendererCallers": [ + "src/renderer/composition/team/createTeamWorktreeGitReadinessTransport.ts" + ], + "owningFeature": "workspace-registry", + "disposition": "decomposed", + "securityClass": "workspace-authorization", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "workspace.registry.get-worktree-git-status", + "targetWorkPackage": "WP-3-WORKSPACE-REGISTRY" + }, + { + "stableId": "P0.W1.API.TeamsAPI.initializeGitRepository", + "source": "TeamsAPI", + "sourceMember": "initializeGitRepository", + "legacySignature": "initializeGitRepository: (projectPath: string) => Promise;", + "sourceSignatureHash": "sha256:2c2947a9ca831ecc4a3a16e115680a4052d0477c05dca4902c4d383f24fa759c", + "rendererCallers": [ + "src/renderer/composition/team/createTeamWorktreeGitReadinessTransport.ts" + ], + "owningFeature": "workspace-registry", + "disposition": "decomposed", + "securityClass": "workspace-authorization", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "workspace.registry.initialize-git-repository", + "targetWorkPackage": "WP-3-WORKSPACE-REGISTRY" + }, + { + "stableId": "P0.W1.API.TeamsAPI.createInitialGitCommit", + "source": "TeamsAPI", + "sourceMember": "createInitialGitCommit", + "legacySignature": "createInitialGitCommit: (projectPath: string) => Promise;", + "sourceSignatureHash": "sha256:896c2d2d5c8dfece0b920a8b4c5aa38b158051401f5e872621550c68bf888e7d", + "rendererCallers": [ + "src/renderer/composition/team/createTeamWorktreeGitReadinessTransport.ts" + ], + "owningFeature": "workspace-registry", + "disposition": "decomposed", + "securityClass": "workspace-authorization", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "workspace.registry.create-initial-git-commit", + "targetWorkPackage": "WP-3-WORKSPACE-REGISTRY" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getProjectBranch", + "source": "TeamsAPI", + "sourceMember": "getProjectBranch", + "legacySignature": "getProjectBranch: (projectPath: string) => Promise;", + "sourceSignatureHash": "sha256:775efe0ae331a88b808ecc468089a7b39b829799b340d834287f2e18da260819", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamDirectoryTransport.ts" + ], + "owningFeature": "workspace-registry", + "disposition": "decomposed", + "securityClass": "workspace-authorization", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "workspace.registry.get-project-branch", + "targetWorkPackage": "WP-3-WORKSPACE-REGISTRY" + }, + { + "stableId": "P0.W1.API.TeamsAPI.setProjectBranchTracking", + "source": "TeamsAPI", + "sourceMember": "setProjectBranchTracking", + "legacySignature": "setProjectBranchTracking: (projectPath: string, enabled: boolean) => Promise;", + "sourceSignatureHash": "sha256:41fdc0c01e59319f4740d7ad854c7a20b80bb77760f5153717ee87d139d720fc", + "rendererCallers": ["src/renderer/composition/team/createTeamBranchTrackingTransport.ts"], + "owningFeature": "workspace-registry", + "disposition": "decomposed", + "securityClass": "workspace-authorization", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "workspace.registry.set-project-branch-tracking", + "targetWorkPackage": "WP-3-WORKSPACE-REGISTRY" + }, + { + "stableId": "P0.W1.API.TeamsAPI.onProjectBranchChange", + "source": "TeamsAPI", + "sourceMember": "onProjectBranchChange", + "legacySignature": "onProjectBranchChange: (callback: (event: unknown, data: ProjectBranchChangeEvent) => void) => () => void;", + "sourceSignatureHash": "sha256:7c1530305799697e44c0da6c6e59a53767415a58908a2d783d8a267f0a95b4b3", + "rendererCallers": [], + "owningFeature": "workspace-registry", + "disposition": "decomposed", + "securityClass": "workspace-authorization", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "workspace.registry.on-project-branch-change", + "targetWorkPackage": "WP-3-WORKSPACE-REGISTRY" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskChangePresence", + "source": "TeamsAPI", + "sourceMember": "getTaskChangePresence", + "legacySignature": "getTaskChangePresence: (teamName: string) => Promise>;", + "sourceSignatureHash": "sha256:858c59addc9b58fff0336ecb124649d27afc7c0209d70126df7ed5f8047a00dd", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.get-task-change-presence", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.setChangePresenceTracking", + "source": "TeamsAPI", + "sourceMember": "setChangePresenceTracking", + "legacySignature": "setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise;", + "sourceSignatureHash": "sha256:e64795b2f5451732f48c836454524f4595214d69514406a7eeb9c08527171719", + "rendererCallers": [], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.set-change-presence-tracking", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.createTask", + "source": "TeamsAPI", + "sourceMember": "createTask", + "legacySignature": "createTask: (teamName: string, request: CreateTaskRequest) => Promise;", + "sourceSignatureHash": "sha256:de010025eaa10aeae3ee1bb67e98b66f9c704477e0db5f03ea4c97cf5575e9fc", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.create-task", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTask", + "source": "TeamsAPI", + "sourceMember": "getTask", + "legacySignature": "getTask: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:33d7e015ff76a9bbacbad8d5618a57ff79a914e3d7b4d2fa8961ae9be2957588", + "rendererCallers": ["src/renderer/composition/team/createTeamTaskDetailTransport.ts"], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.get-task", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.requestReview", + "source": "TeamsAPI", + "sourceMember": "requestReview", + "legacySignature": "requestReview: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:f80a971e3f7aa5a6ef284e15280e6fa62b3b2ed5110a062a09004c5c0c9d8f8b", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.request-review", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.updateKanban", + "source": "TeamsAPI", + "sourceMember": "updateKanban", + "legacySignature": "updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise;", + "sourceSignatureHash": "sha256:d7493e6df6d61ab332266f5fa9ac35fd0cced9a49d77f2f93ea4c9e19dd47cf5", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.update-kanban", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.updateKanbanColumnOrder", + "source": "TeamsAPI", + "sourceMember": "updateKanbanColumnOrder", + "legacySignature": "updateKanbanColumnOrder: (teamName: string, columnId: KanbanColumnId, orderedTaskIds: string[]) => Promise;", + "sourceSignatureHash": "sha256:e00a04bb858ab3b7694fff117b495d2febe9226cf9ba5dfe5f574c53a4a5cf6b", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.update-kanban-column-order", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.updateTaskStatus", + "source": "TeamsAPI", + "sourceMember": "updateTaskStatus", + "legacySignature": "updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise;", + "sourceSignatureHash": "sha256:77ac2c6b39bd7d5c50f7f44c315e8750bcf48c22a93768d660f82cab5d5be6f4", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.update-task-status", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.updateTaskOwner", + "source": "TeamsAPI", + "sourceMember": "updateTaskOwner", + "legacySignature": "updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise;", + "sourceSignatureHash": "sha256:0b5787cf04cd3e42063b6e06c25d2564db1a36433e79dca1c5c99f1558d7dfef", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.update-task-owner", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.updateTaskFields", + "source": "TeamsAPI", + "sourceMember": "updateTaskFields", + "legacySignature": "updateTaskFields: (teamName: string, taskId: string, fields: { subject?: string; description?: string; }) => Promise;", + "sourceSignatureHash": "sha256:b048f696224491e1d07c275142c5a882eedbcb190c6d09595af157d1c2d5f0d2", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.update-task-fields", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.startTask", + "source": "TeamsAPI", + "sourceMember": "startTask", + "legacySignature": "startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean; }>;", + "sourceSignatureHash": "sha256:32639d78c33054b134ee23ce2e793250d18fa071207cbb4d1b9475bb8c190c5e", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.start-task", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.startTaskByUser", + "source": "TeamsAPI", + "sourceMember": "startTaskByUser", + "legacySignature": "startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean; }>;", + "sourceSignatureHash": "sha256:9c9902e65355146ddf2f8689d4b714996f103ce4d6d08c746c508e33ffd64e34", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.start-task-by-user", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getAllTasks", + "source": "TeamsAPI", + "sourceMember": "getAllTasks", + "legacySignature": "getAllTasks: () => Promise;", + "sourceSignatureHash": "sha256:a3c182e90ebd8187d0df0267d0cae28c4dcbe95630f8a8c37c73f8e32890344a", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamDirectoryTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.get-all-tasks", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.addTaskComment", + "source": "TeamsAPI", + "sourceMember": "addTaskComment", + "legacySignature": "addTaskComment: (teamName: string, taskId: string, request: AddTaskCommentRequest) => Promise;", + "sourceSignatureHash": "sha256:265f0c37ef64a3eeef611a0419e8773e2d64367fbf07467091ef88c564f07473", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.add-task-comment", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.setTaskClarification", + "source": "TeamsAPI", + "sourceMember": "setTaskClarification", + "legacySignature": "setTaskClarification: (teamName: string, taskId: string, value: 'lead' | 'user' | null) => Promise;", + "sourceSignatureHash": "sha256:4ea788fcf20d8658c6f590fb08c942daf7c1e2a4693029d2ef5f5da36ad0263b", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.set-task-clarification", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.softDeleteTask", + "source": "TeamsAPI", + "sourceMember": "softDeleteTask", + "legacySignature": "softDeleteTask: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:fa8aa7d8a3989754460572f2b5b3bcd7ffb4d9c267991cf253fffb6b99c53dea", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.soft-delete-task", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.restoreTask", + "source": "TeamsAPI", + "sourceMember": "restoreTask", + "legacySignature": "restoreTask: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:53ebd72882501bb9c0857f14addffc79732d3c301fafecce26fde52313f22fbc", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.restore-task", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getDeletedTasks", + "source": "TeamsAPI", + "sourceMember": "getDeletedTasks", + "legacySignature": "getDeletedTasks: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:ac2d570c540d9f7959d5889757e028fdb7d2e1d2677f4cd84bd57d4c81fab54f", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.get-deleted-tasks", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.addTaskRelationship", + "source": "TeamsAPI", + "sourceMember": "addTaskRelationship", + "legacySignature": "addTaskRelationship: (teamName: string, taskId: string, targetId: string, type: 'blockedBy' | 'blocks' | 'related') => Promise;", + "sourceSignatureHash": "sha256:2b7c43bef9c0bc42952dbb5f745809dce319709a40b491b2dadbe5f9f4f6b7b9", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.add-task-relationship", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.removeTaskRelationship", + "source": "TeamsAPI", + "sourceMember": "removeTaskRelationship", + "legacySignature": "removeTaskRelationship: (teamName: string, taskId: string, targetId: string, type: 'blockedBy' | 'blocks' | 'related') => Promise;", + "sourceSignatureHash": "sha256:8f5ce7a86285405b3068a46bd074054808b4ae837d1f7d082fde89bb57e00ece", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ], + "owningFeature": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.task.remove-task-relationship", + "targetWorkPackage": "WP-8-TASK-BOARD" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getSavedRequest", + "source": "TeamsAPI", + "sourceMember": "getSavedRequest", + "legacySignature": "getSavedRequest: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:102b654062e8f4a764ef4cd15b663ee1688c240072dc23c5c48bf83b691a35fc", + "rendererCallers": ["src/renderer/composition/team/createTeamConfigurationTransport.ts"], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.get-saved-request", + "targetWorkPackage": "WP-8-MESSAGING" + }, + { + "stableId": "P0.W1.API.TeamsAPI.sendMessage", + "source": "TeamsAPI", + "sourceMember": "sendMessage", + "legacySignature": "sendMessage: (teamName: string, request: SendMessageRequest) => Promise;", + "sourceSignatureHash": "sha256:0acc38208962cd7f676984ec9c314dfdc3d698f937853b797609245e927207c4", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.send-message", + "targetWorkPackage": "WP-8-MESSAGING" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getOpenCodeRuntimeDeliveryStatus", + "source": "TeamsAPI", + "sourceMember": "getOpenCodeRuntimeDeliveryStatus", + "legacySignature": "getOpenCodeRuntimeDeliveryStatus: (teamName: string, messageId: string) => Promise;", + "sourceSignatureHash": "sha256:5de5ca6827bc448762a72b598d259ac369a7f85fa8d804c3c1af22263d8e3805", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.get-open-code-runtime-delivery-status", + "targetWorkPackage": "WP-8-MESSAGING" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getMessagesPage", + "source": "TeamsAPI", + "sourceMember": "getMessagesPage", + "legacySignature": "getMessagesPage: (teamName: string, options?: { cursor?: string | null; limit?: number; }) => Promise;", + "sourceSignatureHash": "sha256:507d113d41e23153db26a475fc1cacdec2fc09038df861e44e6ecd8d1554be44", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamMessageFeedTransport.ts" + ], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.get-messages-page", + "targetWorkPackage": "WP-8-MESSAGING" + }, + { + "stableId": "P0.W1.API.TeamsAPI.processSend", + "source": "TeamsAPI", + "sourceMember": "processSend", + "legacySignature": "processSend: (teamName: string, message: string) => Promise;", + "sourceSignatureHash": "sha256:e406f4da7997eb7c81652925e4aa460687374fb686b18c1daa959c8faa17d374", + "rendererCallers": [ + "src/renderer/composition/team/createTeamGraphTaskNotificationTransport.ts", + "src/renderer/composition/team/createTeamTaskDetailTransport.ts" + ], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.process-send", + "targetWorkPackage": "WP-8-MESSAGING" + }, + { + "stableId": "P0.W1.API.TeamsAPI.setToolActivityTracking", + "source": "TeamsAPI", + "sourceMember": "setToolActivityTracking", + "legacySignature": "setToolActivityTracking: (teamName: string, enabled: boolean) => Promise;", + "sourceSignatureHash": "sha256:0cd1e362a6ecb37f98add43d43d429e8a7be6e459a0315fc51ce7e0b6c706a4d", + "rendererCallers": [], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.set-tool-activity-tracking", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.setTaskLogStreamTracking", + "source": "TeamsAPI", + "sourceMember": "setTaskLogStreamTracking", + "legacySignature": "setTaskLogStreamTracking: (teamName: string, enabled: boolean) => Promise;", + "sourceSignatureHash": "sha256:2767b767c5a57be6f001fe9c069664fcd29288eb8820c1741a87fb5ce9e5c4b3", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.set-task-log-stream-tracking", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getClaudeLogs", + "source": "TeamsAPI", + "sourceMember": "getClaudeLogs", + "legacySignature": "getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise;", + "sourceSignatureHash": "sha256:deb98bd89a38b022c43da428f08af19e1e00de60fbb136cc38521ed1ec7aff41", + "rendererCallers": ["src/renderer/composition/team/createTeamOperationalReadTransport.ts"], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-claude-logs", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getMemberActivityMeta", + "source": "TeamsAPI", + "sourceMember": "getMemberActivityMeta", + "legacySignature": "getMemberActivityMeta: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:cf6a316403c772df7b5fee26970d5ccad9b75fce3067a4cec529cf80f36dcaa8", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamMessageFeedTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-member-activity-meta", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.processAlive", + "source": "TeamsAPI", + "sourceMember": "processAlive", + "legacySignature": "processAlive: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:f8c68284be508f2f2e4af70052d34565f2847c0c368b0173706c366d6a174900", + "rendererCallers": [], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.process-alive", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.aliveList", + "source": "TeamsAPI", + "sourceMember": "aliveList", + "legacySignature": "aliveList: () => Promise;", + "sourceSignatureHash": "sha256:4c670dfc5985eb5e77628bd42916c83117da1f07198390253e4d3bc2aa252c01", + "rendererCallers": ["src/renderer/composition/team/createTeamAliveListReadPort.ts"], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.alive-list", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getMemberLogs", + "source": "TeamsAPI", + "sourceMember": "getMemberLogs", + "legacySignature": "getMemberLogs: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:2687c2e90d7508c4ca677fd480992352b527f199a41d67dc98b0093d342e5bc2", + "rendererCallers": [ + "src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-member-logs", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getLogsForTask", + "source": "TeamsAPI", + "sourceMember": "getLogsForTask", + "legacySignature": "getLogsForTask: (teamName: string, taskId: string, options?: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string; }[]; since?: string; }) => Promise;", + "sourceSignatureHash": "sha256:ee03990c1a0f089d1f6be1c496b0e6be833c2aa3dfae0ec120c3825edde157e4", + "rendererCallers": [ + "src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-logs-for-task", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskActivity", + "source": "TeamsAPI", + "sourceMember": "getTaskActivity", + "legacySignature": "getTaskActivity: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:aa49880b4e7690b17736248868c9c265488a5dc19b6e2a608ce59d9b89f61810", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-task-activity", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskActivityDetail", + "source": "TeamsAPI", + "sourceMember": "getTaskActivityDetail", + "legacySignature": "getTaskActivityDetail: (teamName: string, taskId: string, activityId: string) => Promise;", + "sourceSignatureHash": "sha256:2d2b8b84760344f696fa482b249759b1fee4c5da88616f7ea51c2184c604ba45", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-task-activity-detail", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskLogStreamSummary", + "source": "TeamsAPI", + "sourceMember": "getTaskLogStreamSummary", + "legacySignature": "getTaskLogStreamSummary: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:b915ad90b3252c3449866d2b6469dbe30f7781945d293f4df6c2d8f66f187d9a", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-task-log-stream-summary", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskLogStream", + "source": "TeamsAPI", + "sourceMember": "getTaskLogStream", + "legacySignature": "getTaskLogStream: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:640634d0b1ec490eb19d3b0dc155c08d106dc3d5143a445088ddd04d177e228b", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-task-log-stream", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskExactLogSummaries", + "source": "TeamsAPI", + "sourceMember": "getTaskExactLogSummaries", + "legacySignature": "getTaskExactLogSummaries: (teamName: string, taskId: string) => Promise;", + "sourceSignatureHash": "sha256:7c06e5ad8d56341ad385039a5ad15b07eed22fb73c4679a51ec07b0d172b46e4", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-task-exact-log-summaries", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskExactLogDetail", + "source": "TeamsAPI", + "sourceMember": "getTaskExactLogDetail", + "legacySignature": "getTaskExactLogDetail: (teamName: string, taskId: string, exactLogId: string, expectedSourceGeneration: string) => Promise;", + "sourceSignatureHash": "sha256:a674904b4668d3d574d63ece7e44eae2c90b3a493765a76da2335c915e559efb", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-task-exact-log-detail", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getMemberStats", + "source": "TeamsAPI", + "sourceMember": "getMemberStats", + "legacySignature": "getMemberStats: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:b3dac9f3b3f64ebb175992db59a1495fac4ea7b7fd657e8c0134fefb54cab63c", + "rendererCallers": ["src/renderer/composition/team/createTeamOperationalReadTransport.ts"], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-member-stats", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.killProcess", + "source": "TeamsAPI", + "sourceMember": "killProcess", + "legacySignature": "killProcess: (teamName: string, pid: number) => Promise;", + "sourceSignatureHash": "sha256:b600b0ddfc744cec2c74734719d120fd432b0f89dd6e68582b4b5f737f107c79", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.kill-process", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getLeadActivity", + "source": "TeamsAPI", + "sourceMember": "getLeadActivity", + "legacySignature": "getLeadActivity: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:7f6ad0ae8c60b5533852d336564df434919d638afc913c95213543eb86c6477c", + "rendererCallers": [], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-lead-activity", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getLeadContext", + "source": "TeamsAPI", + "sourceMember": "getLeadContext", + "legacySignature": "getLeadContext: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:198a8c8e3d809b82af819a9e2082bfada79fb00e08feee56678af5ca60b53857", + "rendererCallers": [], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-lead-context", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTeamAgentRuntime", + "source": "TeamsAPI", + "sourceMember": "getTeamAgentRuntime", + "legacySignature": "getTeamAgentRuntime: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:7edfc120d2cf6a6071867fff1621c0c0904e80a3fca65953326c98557ad0ae56", + "rendererCallers": [], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.get-team-agent-runtime", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.retryFailedOpenCodeSecondaryLanes", + "source": "TeamsAPI", + "sourceMember": "retryFailedOpenCodeSecondaryLanes", + "legacySignature": "retryFailedOpenCodeSecondaryLanes: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:aa7737c7a6288bd4fad8a97bf2d3b02bf13153265409ac9ec57fa6a5305bedfe", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"], + "owningFeature": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.runtime.retry-failed-open-code-secondary-lanes", + "targetWorkPackage": "WP-5-RUNTIME-CONTROL" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getAttachments", + "source": "TeamsAPI", + "sourceMember": "getAttachments", + "legacySignature": "getAttachments: (teamName: string, messageId: string) => Promise;", + "sourceSignatureHash": "sha256:1dc6b0fdeadf3475d283334f30b6b9940cc461a30a0217a5559dd8d37197576d", + "rendererCallers": [ + "src/renderer/composition/team/createTeamMessageAttachmentReadTransport.ts" + ], + "owningFeature": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "agent.attachment.get-attachments", + "targetWorkPackage": "WP-9-ATTACHMENTS" + }, + { + "stableId": "P0.W1.API.TeamsAPI.saveTaskAttachment", + "source": "TeamsAPI", + "sourceMember": "saveTaskAttachment", + "legacySignature": "saveTaskAttachment: (teamName: string, taskId: string, attachmentId: string, filename: string, mimeType: string, base64Data: string) => Promise;", + "sourceSignatureHash": "sha256:c34e0689f3de941e79461da031eef31830e70c10d8504830213ab381373ddb57", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ], + "owningFeature": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "agent.attachment.save-task-attachment", + "targetWorkPackage": "WP-9-ATTACHMENTS" + }, + { + "stableId": "P0.W1.API.TeamsAPI.getTaskAttachment", + "source": "TeamsAPI", + "sourceMember": "getTaskAttachment", + "legacySignature": "getTaskAttachment: (teamName: string, taskId: string, attachmentId: string, mimeType: string) => Promise;", + "sourceSignatureHash": "sha256:8ce5eaa8e85927b19caab30dc64f60e5f44935f8339d214d6e31dd9bcb5af65a", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ], + "owningFeature": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "agent.attachment.get-task-attachment", + "targetWorkPackage": "WP-9-ATTACHMENTS" + }, + { + "stableId": "P0.W1.API.TeamsAPI.deleteTaskAttachment", + "source": "TeamsAPI", + "sourceMember": "deleteTaskAttachment", + "legacySignature": "deleteTaskAttachment: (teamName: string, taskId: string, attachmentId: string, mimeType: string) => Promise;", + "sourceSignatureHash": "sha256:9c92fcd967776054bc1c7e973304cf6e6350d3de2dc6e215a90316944b2391ef", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ], + "owningFeature": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "agent.attachment.delete-task-attachment", + "targetWorkPackage": "WP-9-ATTACHMENTS" + }, + { + "stableId": "P0.W1.API.TeamsAPI.validateCliArgs", + "source": "TeamsAPI", + "sourceMember": "validateCliArgs", + "legacySignature": "validateCliArgs: (rawArgs: string) => Promise;", + "sourceSignatureHash": "sha256:d16fc957be949353db7ab539010b41b70ad1fc23ea0d984b39bc95f8a5436b2b", + "rendererCallers": [ + "src/renderer/composition/team/createTeamProvisioningDiagnosticsTransport.ts" + ], + "owningFeature": "team-approvals", + "disposition": "decomposed", + "securityClass": "approval-policy", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.approval.validate-cli-args", + "targetWorkPackage": "WP-9-APPROVALS" + }, + { + "stableId": "P0.W1.API.TeamsAPI.showMessageNotification", + "source": "TeamsAPI", + "sourceMember": "showMessageNotification", + "legacySignature": "showMessageNotification: (data: TeamMessageNotificationData) => Promise;", + "sourceSignatureHash": "sha256:0186618de3b3efae1473d60b2fbce9c89548f94ead0ff4f6642bfa1e9185c9aa", + "rendererCallers": [], + "owningFeature": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.console.show-message-notification", + "targetWorkPackage": "WP-7-TEAM-CONSOLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.onTeamChange", + "source": "TeamsAPI", + "sourceMember": "onTeamChange", + "legacySignature": "onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;", + "sourceSignatureHash": "sha256:60c7faa8746e3d3323394b141b898385bf0aa539a65b07d42e9e85efb80a89de", + "rendererCallers": [ + "src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts", + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ], + "owningFeature": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.console.on-team-change", + "targetWorkPackage": "WP-7-TEAM-CONSOLE" + }, + { + "stableId": "P0.W1.API.TeamsAPI.onProvisioningProgress", + "source": "TeamsAPI", + "sourceMember": "onProvisioningProgress", + "legacySignature": "onProvisioningProgress: (callback: (event: unknown, data: TeamProvisioningProgress) => void) => () => void;", + "sourceSignatureHash": "sha256:7a223a2b873b9b82dbb61dc78e9782eecb8af95ab36fe5b064b76e3ced6a44f5", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningControlTransport.ts" + ], + "owningFeature": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.console.on-provisioning-progress", + "targetWorkPackage": "WP-7-TEAM-CONSOLE" + }, + { + "stableId": "P0.W1.API.ReviewAPI.getAgentChanges", + "source": "ReviewAPI", + "sourceMember": "getAgentChanges", + "legacySignature": "getAgentChanges: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:e87d4fa7ef29ed1c97612def49e5666d6b7482da3cdf6f7d2e00b56927e19fad", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.get-agent-changes", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.getTaskChanges", + "source": "ReviewAPI", + "sourceMember": "getTaskChanges", + "legacySignature": "getTaskChanges: (teamName: string, taskId: string, options?: TaskChangeRequestOptions) => Promise;", + "sourceSignatureHash": "sha256:01f2a9b0781628b287c3882b6aae8a6c8e2cc656d7877b022dedf0a936e6188f", + "rendererCallers": [ + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "src/renderer/store/slices/changeReviewSlice.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.get-task-changes", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.getTeamTaskChangeSummaries", + "source": "ReviewAPI", + "sourceMember": "getTeamTaskChangeSummaries", + "legacySignature": "getTeamTaskChangeSummaries: (teamName: string, requests: TeamTaskChangeSummaryRequest[]) => Promise;", + "sourceSignatureHash": "sha256:d0b8a2735672117faf807ca8f482df02a7b9ec05a45b2ef7f884ddbe2b425c11", + "rendererCallers": ["src/renderer/components/team/useTeamChangesSummaries.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.get-team-task-change-summaries", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.invalidateTaskChangeSummaries", + "source": "ReviewAPI", + "sourceMember": "invalidateTaskChangeSummaries", + "legacySignature": "invalidateTaskChangeSummaries: (teamName: string, taskIds: string[]) => Promise;", + "sourceSignatureHash": "sha256:58594b444b247d9a16a2b133ec16915eb8b019651422561f70d77ae743247325", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamViewDataTransport.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.invalidate-task-change-summaries", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.getChangeStats", + "source": "ReviewAPI", + "sourceMember": "getChangeStats", + "legacySignature": "getChangeStats: (teamName: string, memberName: string) => Promise;", + "sourceSignatureHash": "sha256:ad47ddeb9ca6b0a4ca5d2624e3e7b922b4b9251d96dd9a14c8e4382b438feb5f", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.get-change-stats", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.getFileContent", + "source": "ReviewAPI", + "sourceMember": "getFileContent", + "legacySignature": "getFileContent: (teamName: string, memberName: string | undefined, filePath: string, snippets?: SnippetDiff[]) => Promise;", + "sourceSignatureHash": "sha256:5e6c3970f084c2ab3ef28c1923d4ee515718a04a0678f887dfe3c16ea44125b6", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.get-file-content", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.applyDecisions", + "source": "ReviewAPI", + "sourceMember": "applyDecisions", + "legacySignature": "applyDecisions: (request: ApplyReviewRequest) => Promise;", + "sourceSignatureHash": "sha256:d644df5c763f03d62aea6515b8e95ddd607f2fb80eb7581520b8e8f4573446ba", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.apply-decisions", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.executeMutation", + "source": "ReviewAPI", + "sourceMember": "executeMutation", + "legacySignature": "executeMutation: (request: ExecuteReviewMutationRequest) => Promise;", + "sourceSignatureHash": "sha256:2c9bc1b401269033f830b4e71564836fdbf2b416bdd7e505646c7e1ca04ca45c", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewFileDecisionPorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewFileDraftPorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.execute-mutation", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.retryMutationRecovery", + "source": "ReviewAPI", + "sourceMember": "retryMutationRecovery", + "legacySignature": "retryMutationRecovery: (request: RetryReviewMutationRecoveryRequest) => Promise;", + "sourceSignatureHash": "sha256:c4145fe4a3ac29382d8fde44b70507e9c96f7cd93b7e3901490bd035504e082e", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDialogLifecyclePorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.retry-mutation-recovery", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.restoreHistory", + "source": "ReviewAPI", + "sourceMember": "restoreHistory", + "legacySignature": "restoreHistory: (request: RestoreReviewHistoryRequest) => Promise;", + "sourceSignatureHash": "sha256:c18ae82e0476fcfe3177258989510c108d1173f50291b032c383045e72a1eb10", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.restore-history", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.checkConflict", + "source": "ReviewAPI", + "sourceMember": "checkConflict", + "legacySignature": "checkConflict: (scope: ReviewFileScope, filePath: string, expectedModified: string) => Promise;", + "sourceSignatureHash": "sha256:30fbf403bc90071dcf528cb50c73a9ecdace09c65ffba9d964005473a067f60b", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts", + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts", + "src/features/change-review/renderer/adapters/createChangeReviewFileDecisionPorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewFileDraftPorts.ts", + "src/renderer/components/team/review/ChangeReviewDialog.tsx" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.check-conflict", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.rejectHunks", + "source": "ReviewAPI", + "sourceMember": "rejectHunks", + "legacySignature": "rejectHunks: (scope: ReviewFileScope, filePath: string, hunkIndices: number[]) => Promise;", + "sourceSignatureHash": "sha256:4b728f7955495f31375c868956649f108d7f09fec70fb727693a68f52d597ac1", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.reject-hunks", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.rejectFile", + "source": "ReviewAPI", + "sourceMember": "rejectFile", + "legacySignature": "rejectFile: (scope: ReviewFileScope, filePath: string) => Promise;", + "sourceSignatureHash": "sha256:9551694f09775b0da45d681ea4babc754de84c7cc363f780a41f43253fc46022", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.reject-file", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.previewReject", + "source": "ReviewAPI", + "sourceMember": "previewReject", + "legacySignature": "previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise<{ preview: string; hasConflicts: boolean; }>;", + "sourceSignatureHash": "sha256:b9b8b990261426de61748fa7d1746270073eb0888a50ef798f44e22a24a45dd8", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.preview-reject", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.saveEditedFile", + "source": "ReviewAPI", + "sourceMember": "saveEditedFile", + "legacySignature": "saveEditedFile: (scope: ReviewFileScope, filePath: string, content: string, expectedCurrentContent: string | null) => Promise<{ success: boolean; }>;", + "sourceSignatureHash": "sha256:a1b57037833b89da5b0f5008e0b20c0e269dc613c523b9224235b25784b29655", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.save-edited-file", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.deleteEditedFile", + "source": "ReviewAPI", + "sourceMember": "deleteEditedFile", + "legacySignature": "deleteEditedFile: (scope: ReviewFileScope, filePath: string, expectedCurrentContent: string) => Promise<{ success: boolean; }>;", + "sourceSignatureHash": "sha256:f6a7bb25103609050cfbaf16962113ca9dead02d22c6e2eccdad315b5b652150", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.delete-edited-file", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.restoreRejectedRename", + "source": "ReviewAPI", + "sourceMember": "restoreRejectedRename", + "legacySignature": "restoreRejectedRename: (scope: ReviewFileScope, filePath: string, expectation: ReviewRenameRecoveryExpectation) => Promise<{ success: boolean; }>;", + "sourceSignatureHash": "sha256:bddbcd802e876d4b5a2dd6e784d1769021931c999cc7579d393ceb50b5533e8f", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.restore-rejected-rename", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.reapplyRejectedRename", + "source": "ReviewAPI", + "sourceMember": "reapplyRejectedRename", + "legacySignature": "reapplyRejectedRename: (scope: ReviewFileScope, filePath: string, expectation: ReviewRenameRecoveryExpectation) => Promise<{ success: boolean; }>;", + "sourceSignatureHash": "sha256:ee5c9578b7b3c9eeacdd23572f27fa1b29facdd4bfd3bbc9d0951b152a8a8806", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.reapply-rejected-rename", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.watchFiles", + "source": "ReviewAPI", + "sourceMember": "watchFiles", + "legacySignature": "watchFiles: (projectPath: string, filePaths: string[]) => Promise;", + "sourceSignatureHash": "sha256:145f58a08654db239da6e77af49645c1f230584511b3e275a5e6da4f7a2777cb", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.watch-files", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.unwatchFiles", + "source": "ReviewAPI", + "sourceMember": "unwatchFiles", + "legacySignature": "unwatchFiles: () => Promise;", + "sourceSignatureHash": "sha256:eda8bc65fe2d15e1bb7b24173678b5bdfc8e5dd84123f4a9cb2e1f86d8167f5b", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.unwatch-files", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.onExternalFileChange", + "source": "ReviewAPI", + "sourceMember": "onExternalFileChange", + "legacySignature": "onExternalFileChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;", + "sourceSignatureHash": "sha256:7a6850a8167b90818ebd6b565a65edb56d41373d49c1ca1e5310065f774bb0bb", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.on-external-file-change", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.loadDecisions", + "source": "ReviewAPI", + "sourceMember": "loadDecisions", + "legacySignature": "loadDecisions: (teamName: string, scopeKey: string, scopeToken?: string) => Promise<{ hunkDecisions: Record; fileDecisions: Record; hunkContextHashesByFile?: Record>; reviewActionHistory: ReviewUndoAction[]; reviewRedoHistory: ReviewRedoAction[]; revision: number; } | null>;", + "sourceSignatureHash": "sha256:6ab13b98b0479ffd09a31b50d9c5e8cda9eb75d409c40c8298f2dbc769e455b1", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.load-decisions", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.saveDecisions", + "source": "ReviewAPI", + "sourceMember": "saveDecisions", + "legacySignature": "saveDecisions: (teamName: string, scopeKey: string, scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile?: Record>, reviewActionHistory?: ReviewUndoAction[], expectedRevision?: number, reviewRedoHistory?: ReviewRedoAction[]) => Promise;", + "sourceSignatureHash": "sha256:dc8031eb68120cf43e03ac98e2c5c74d111ceab9b81a908bfbe194ad69ef214b", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.save-decisions", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.clearDecisions", + "source": "ReviewAPI", + "sourceMember": "clearDecisions", + "legacySignature": "clearDecisions: (teamName: string, scopeKey: string, scopeToken?: string, expectedRevision?: number) => Promise<{ revision: number; }>;", + "sourceSignatureHash": "sha256:fbbb25abafcae84386d6b51bb49167c699b3e5a4a92c3c377103bb3415b5a625", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.clear-decisions", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.loadDecisionConflictCandidates", + "source": "ReviewAPI", + "sourceMember": "loadDecisionConflictCandidates", + "legacySignature": "loadDecisionConflictCandidates: (teamName: string, scopeKey: string, scopeToken: string) => Promise;", + "sourceSignatureHash": "sha256:487c7b74b8def41e642a9796c0bc9c271c07ec742abce59547ae0fcf63ee59fb", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.load-decision-conflict-candidates", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.resolveDecisionConflictCandidate", + "source": "ReviewAPI", + "sourceMember": "resolveDecisionConflictCandidate", + "legacySignature": "resolveDecisionConflictCandidate: (teamName: string, scopeKey: string, scopeToken: string, candidateId: string, resolution: ReviewConflictResolution, expectedCurrentRevision: number) => Promise<{ revision: number; }>;", + "sourceSignatureHash": "sha256:22485faf53bdcca33f4cf19bd2dd7691db4c0c68e1072b9d34fd50ee25a29e77", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.resolve-decision-conflict-candidate", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.loadDraftHistory", + "source": "ReviewAPI", + "sourceMember": "loadDraftHistory", + "legacySignature": "loadDraftHistory: (teamName: string, scopeKey: string, scopeToken: string) => Promise;", + "sourceSignatureHash": "sha256:1db8053abb2608392cc092faed24b49ed027c0cfaa460f91497678d8dc17abab", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.load-draft-history", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.saveDraftHistoryEntry", + "source": "ReviewAPI", + "sourceMember": "saveDraftHistoryEntry", + "legacySignature": "saveDraftHistoryEntry: (teamName: string, scopeKey: string, scopeToken: string, entry: Omit, expectedRevision: number, expectedGeneration: string | null) => Promise;", + "sourceSignatureHash": "sha256:5533291b443626b87f68f5a4dba237e5189ce784c0531cc21e4b8fadab3c84c0", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.save-draft-history-entry", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.clearDraftHistory", + "source": "ReviewAPI", + "sourceMember": "clearDraftHistory", + "legacySignature": "clearDraftHistory: (teamName: string, scopeKey: string, scopeToken: string, filePath?: string, expectedRevision?: number, expectedGeneration?: string | null) => Promise;", + "sourceSignatureHash": "sha256:af609ad313105105ada4771728fc5bf267d354117b874514baf510875807b31f", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.clear-draft-history", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.loadDraftHistoryConflictCandidates", + "source": "ReviewAPI", + "sourceMember": "loadDraftHistoryConflictCandidates", + "legacySignature": "loadDraftHistoryConflictCandidates: (teamName: string, scopeKey: string, scopeToken: string) => Promise;", + "sourceSignatureHash": "sha256:51dba683a1f73607227a6b9354b319f5dfec2c76870b5a5889913be9b5211a3f", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.load-draft-history-conflict-candidates", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.resolveDraftHistoryConflictCandidate", + "source": "ReviewAPI", + "sourceMember": "resolveDraftHistoryConflictCandidate", + "legacySignature": "resolveDraftHistoryConflictCandidate: (teamName: string, scopeKey: string, scopeToken: string, candidateId: string, resolution: ReviewConflictResolution, expectedCurrentRevision: number, expectedCurrentGeneration: string | null) => Promise;", + "sourceSignatureHash": "sha256:e8f5bd39436667d1fe32017b0a5e1750f4501206427345b82de025f6c7721d33", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.resolve-draft-history-conflict-candidate", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.replaceDraftHistoryConflictCandidate", + "source": "ReviewAPI", + "sourceMember": "replaceDraftHistoryConflictCandidate", + "legacySignature": "replaceDraftHistoryConflictCandidate: (teamName: string, scopeKey: string, scopeToken: string, expectedEntry: Omit, replacementEntry: Omit, expectedCurrentRevision: number, expectedCurrentGeneration: string | null) => Promise;", + "sourceSignatureHash": "sha256:fc7c025aff2e1ee4f838e0aae9212d624d3547b78a6a0dae89f8bd0ce9d45219", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ], + "owningFeature": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.replace-draft-history-conflict-candidate", + "targetWorkPackage": "WP-9-REVIEW" + }, + { + "stableId": "P0.W1.API.ReviewAPI.onCmdN", + "source": "ReviewAPI", + "sourceMember": "onCmdN", + "legacySignature": "onCmdN?: (callback: () => void) => (() => void) | undefined;", + "sourceSignatureHash": "sha256:c5c6a285bba1b0f85cdf316bf4991e4dda2a5d0c3fcffb99a9deed58b6bf06ed", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.on-cmd-n", + "targetWorkPackage": "DEFERRED-DESKTOP-ONLY" + }, + { + "stableId": "P0.W1.API.ReviewAPI.getGitFileLog", + "source": "ReviewAPI", + "sourceMember": "getGitFileLog", + "legacySignature": "getGitFileLog: (projectPath: string, filePath: string) => Promise<{ hash: string; timestamp: string; message: string; }[]>;", + "sourceSignatureHash": "sha256:1fb572924942c52ad42e07e91f0d61b105c3faa0dc08cad15188288dbd431e9e", + "rendererCallers": [], + "owningFeature": "team-review", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.review.get-git-file-log", + "targetWorkPackage": "DEFERRED-DESKTOP-ONLY" + }, + { + "stableId": "P0.W1.API.CrossTeamAPI.send", + "source": "CrossTeamAPI", + "sourceMember": "send", + "legacySignature": "send: (request: CrossTeamSendRequest) => Promise;", + "sourceSignatureHash": "sha256:1e1a96bb77e65ae5ff7c671f18cab1a912285823d300f32ba355e69156966e55", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "cross-team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.cross-team.send", + "targetWorkPackage": "WP-8-MESSAGING" + }, + { + "stableId": "P0.W1.API.CrossTeamAPI.listTargets", + "source": "CrossTeamAPI", + "sourceMember": "listTargets", + "legacySignature": "listTargets: (excludeTeam?: string) => Promise<{ teamName: string; displayName: string; description?: string; color?: string; leadName?: string; leadColor?: string; isOnline?: boolean; }[]>;", + "sourceSignatureHash": "sha256:0a984b7fdfe6efb69668eaddfbc7b17f2d1ada4ce2f939c4f061019845863617", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "cross-team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.cross-team.list-targets", + "targetWorkPackage": "WP-8-MESSAGING" + }, + { + "stableId": "P0.W1.API.CrossTeamAPI.getOutbox", + "source": "CrossTeamAPI", + "sourceMember": "getOutbox", + "legacySignature": "getOutbox: (teamName: string) => Promise;", + "sourceSignatureHash": "sha256:25a849660cb588763067ce6e5ec44cd76cc55e27fb4cca7e69efac5e7b131c16", + "rendererCallers": [], + "owningFeature": "team-messaging", + "disposition": "decomposed", + "securityClass": "cross-team-resource", + "requiredSemanticEvidence": [ + "normalized success/error contract", + "support distinct from resource allowance", + "revision/idempotency/event obligation" + ], + "actionId": "team.message.cross-team.get-outbox", + "targetWorkPackage": "WP-8-MESSAGING" + } + ] +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json b/docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json new file mode 100644 index 0000000000..cfe61526f2 --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json @@ -0,0 +1,130 @@ +{ + "schemaId": "p0-w1-estimate-input", + "evidenceId": "P0.W1.ESTIMATE", + "schemaVersion": 2, + "packetRevision": "phase-00-r2", + "pinnedBaseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "unit": "net integrated source lines; aligned low/high = production + test - deleted", + "buckets": [ + { + "bucketId": "EST-CONTRACTS", + "packages": ["shared capability/action contracts", "ADR-19 parity gate"], + "productionLines": { + "low": 1200, + "high": 1800 + }, + "testLines": { + "low": 800, + "high": 1200 + }, + "deletedLines": { + "low": 0, + "high": 0 + }, + "netLines": { + "low": 2000, + "high": 3000 + }, + "excludedGeneratedVendorLines": ["Phase 0 evidence", "lockfiles", "vendor"], + "overlap": "W1 parity contracts only.", + "confidence": "high", + "assumptions": ["No replacement mega-interface."], + "evidenceRefs": ["P0.W1.API_PARITY_LEDGER", "P0.W1.SCANNER"] + }, + { + "bucketId": "EST-RENDERER-LIFECYCLE", + "packages": ["team-console", "team lifecycle renderer composition"], + "productionLines": { + "low": 1800, + "high": 2800 + }, + "testLines": { + "low": 1200, + "high": 2000 + }, + "deletedLines": { + "low": 900, + "high": 1600 + }, + "netLines": { + "low": 2100, + "high": 3200 + }, + "excludedGeneratedVendorLines": ["Phase 0 evidence", "format churn"], + "overlap": "Task/message/review/provider actions stay with their canonical owners.", + "confidence": "medium", + "assumptions": ["Desktop-only controls are absent before hosted mount."], + "evidenceRefs": ["P0.W1.RENDERER_ACTIONS", "P0.W1.SELECTION_INVARIANTS"] + }, + { + "bucketId": "EST-REMAINING-PARITY", + "packages": [ + "team-task-board", + "team-messaging", + "team-review", + "team-approvals", + "agent-attachments" + ], + "productionLines": { + "low": 2500, + "high": 3900 + }, + "testLines": { + "low": 1500, + "high": 2600 + }, + "deletedLines": { + "low": 1200, + "high": 2300 + }, + "netLines": { + "low": 2800, + "high": 4200 + }, + "excludedGeneratedVendorLines": ["Phase 0 evidence", "post-v1 terminal"], + "overlap": "Server/runtime/auth work remains in its owning non-W1 bucket.", + "confidence": "medium-low", + "assumptions": ["One owning feature per semantic action."], + "evidenceRefs": ["P0.W1.API_PARITY_LEDGER", "P0.W1.RENDERER_ACTIONS", "P0.W1.LEGACY_BYPASSES"] + } + ], + "varianceAssessment": { + "parentRangeStillSupported": false, + "uniqueBucketOverTwentyPercent": true, + "scopeReviewRequired": true, + "changes": [ + { + "bucketId": "EST-RENDERER-LIFECYCLE", + "baseline": { + "low": 3000, + "high": 5000 + }, + "recomputed": { + "low": 2100, + "high": 3200 + }, + "variancePercent": { + "low": -30, + "high": -36 + } + }, + { + "bucketId": "EST-REMAINING-PARITY", + "baseline": { + "low": 4000, + "high": 6500 + }, + "recomputed": { + "low": 2800, + "high": 4200 + }, + "variancePercent": { + "low": -30, + "high": -35.38 + } + } + ], + "controllerDisposition": "scope review required before estimate freeze; W1 does not suppress or self-approve either variance" + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json b/docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json new file mode 100644 index 0000000000..8b065d68ea --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json @@ -0,0 +1,23 @@ +{ + "schemaId": "p0-w1-legacy-bypass-inventory", + "evidenceId": "P0.W1.LEGACY_BYPASSES", + "schemaVersion": 2, + "packetRevision": "phase-00-r2", + "pinnedBaseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "summary": { + "direct-electron-global": 1, + "global-mega-client-call": 100, + "structural-capability-check": 128, + "fabricated-browser-success": 429 + }, + "rawArtifact": { + "format": "deterministically sorted compact JSON", + "recordCount": 658, + "sha256": "sha256:54eac1d0e82970f830047c8b69c355050d4687224f855fbdd7d1a393f6e741ce", + "externalPath": "legacy-bypass-raw.json", + "pathScope": "artifact-pack-relative", + "reproductionCommand": "W1_RAW_EVIDENCE_ROOT= node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts" + }, + "requiredDisposition": "Supported hosted actions use a real feature facet; unavailable and desktop-only controls are absent before mount. No optional-method check or fabricated success is capability proof." +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json b/docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json new file mode 100644 index 0000000000..64c9645261 --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json @@ -0,0 +1,12088 @@ +{ + "schemaId": "p0-w1-renderer-action-inventory", + "evidenceId": "P0.W1.RENDERER_ACTIONS", + "schemaVersion": 2, + "packetRevision": "phase-00-r2", + "pinnedBaseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "identityRule": "Semantic IDs are reviewed contract identifiers; source hashes, handler text, counts, file paths, and line positions are refreshable references and never enter identity.", + "roots": [ + "src/renderer/components/team/TeamListView.tsx", + "src/renderer/components/team/TeamDetailView.tsx", + "src/renderer/components/team/ToolApprovalSheet.tsx", + "src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx" + ], + "mountProofs": [ + { + "root": "src/renderer/components/team/ToolApprovalSheet.tsx", + "mountChain": ["src/renderer/App.tsx#ToolApprovalSheet"] + }, + { + "root": "src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx", + "mountChain": [ + "src/renderer/components/layout/TabbedLayout.tsx#GlobalTaskDetailDialogSlot", + "src/renderer/components/layout/GlobalTaskDetailDialogSlot.tsx#GlobalTaskDetailDialog" + ] + } + ], + "sourceFiles": [ + { + "path": "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx", + "sha256": "sha256:245f5c24aaee53d6690f1acb2b863aaeb3898116286b638720638969a36b7a76", + "interactionSiteCount": 5 + }, + { + "path": "src/features/change-review/renderer/ui/ChangeReviewSidebar.tsx", + "sha256": "sha256:eb22fc4ff4c62dc7fe2196e6ffadede53816b1f5498ac8e3136c1cc5a65ca70a", + "interactionSiteCount": 1 + }, + { + "path": "src/features/change-review/renderer/ui/FileEditTimeline.tsx", + "sha256": "sha256:7748c9d3e69b9aae91516a01984b4754e1d5535da97ab4009b50c6035bd868b3", + "interactionSiteCount": 1 + }, + { + "path": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sha256": "sha256:748fa84ac099a262c26940d71685ed43657659b6cf55b3bebc416f0ffc481007", + "interactionSiteCount": 7 + }, + { + "path": "src/features/change-review/renderer/ui/TaskChangesEmptyState.tsx", + "sha256": "sha256:154f433ec233980743f0856045e5a9f52b8f9d2464d51a87b5338ac37009c62e", + "interactionSiteCount": 0 + }, + { + "path": "src/features/runtime-provider-management/renderer/ui/LegacyConfiguredModelsPanel.tsx", + "sha256": "sha256:536064fefcbfe06c992ee5eaa801a57637c5c110b6c354d661a02205559df7e9", + "interactionSiteCount": 2 + }, + { + "path": "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx", + "sha256": "sha256:35d7e94424c7820a5d800f3a522a047a5547ed5b1ee877cb580a2c7118feb051", + "interactionSiteCount": 4 + }, + { + "path": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sha256": "sha256:ab2e2cb674c7e5d5b3ac80c76bd21b95cd8bdb84acd9b84f78d8c10a95bce106", + "interactionSiteCount": 50 + }, + { + "path": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderModelTestResult.tsx", + "sha256": "sha256:cc00e02d9b59218f790e322ce157120339ed11651a53d2883e6bbcaf2ba2968f", + "interactionSiteCount": 0 + }, + { + "path": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx", + "sha256": "sha256:7807687d8e2af0a5e5617fad8c37678548fc8f1949ec05d501392a77695c8713", + "interactionSiteCount": 4 + }, + { + "path": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderSupplementalStatus.tsx", + "sha256": "sha256:b3a7b58dfc0d0318e99a76cefd83e077fc853e403d678a33eb19a4b0b6fcbeae", + "interactionSiteCount": 1 + }, + { + "path": "src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx", + "sha256": "sha256:95046c7a6f8b5be3d52641de9449f498788301d84ed2b99219b0878011a4077d", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sha256": "sha256:d89af15caa878d70c9cddcf61dabeb98b58c58007dfe660deca36d4a73480d2c", + "interactionSiteCount": 9 + }, + { + "path": "src/renderer/components/team/ClaudeLogsPanel.tsx", + "sha256": "sha256:463ec93f8a03a03f1fa7d959da968c6dcd454ab44e23f5f6c2c33417b27c3d03", + "interactionSiteCount": 6 + }, + { + "path": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sha256": "sha256:099cd771bebd552b938da791062c842a03d45a4f27eb7ef80a47335eaf1bc4fb", + "interactionSiteCount": 8 + }, + { + "path": "src/renderer/components/team/CliLogsRichView.tsx", + "sha256": "sha256:629fe29ab03113f27293f6dcc69f02e17caa059a64f5246f9eaa021e2ecf752b", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/CollapsibleTeamSection.tsx", + "sha256": "sha256:c587ea6a628be171a92fa9930825e3297848118ba725d2b15aab8ebf3a51b968", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/LeadSessionDetailGate.tsx", + "sha256": "sha256:ba39d55de225cef9606ac708b3e5c113af74f34fbfe98f49764a4e5245db5cf1", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/LiveRuntimeStatusBridge.tsx", + "sha256": "sha256:3e85594e7cdbbc83ed46f928d9eb47a76a81509787bf81dd166a4089de96ddd7", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/LiveRuntimeStatusSection.tsx", + "sha256": "sha256:9426bbd3f0ccc1c5e2e490fba0171076d8e26d3ef6cd30f0c084d70788e10ae6", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/MemberBadge.tsx", + "sha256": "sha256:0dbb1dfe8f56dcb6e795f006126a3a8fec52c8f9f26d89d51ba5aa368fe7f549", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/ProcessesSection.tsx", + "sha256": "sha256:c0dc0ac93b7d109ad3a3b7543b1d46d3d9e722fc6e083e7af5c131f3ec5f9343", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "sha256": "sha256:87e6475f61bca83312d94f5310a6d323eb39e8e51a4eb430a3b36bfc8303803e", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/RoleSelect.tsx", + "sha256": "sha256:e477de9942e25d25d0e3972507b7aae951555c5d9611930312c0ec1e17cc8fb1", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/StepProgressBar.tsx", + "sha256": "sha256:f7d7d8a0ab4b07a3e088444eccb4f98bec215112bf0f41c24996de76e3f21e85", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/TaskTooltip.tsx", + "sha256": "sha256:50ae1c7c87bda8243a5d89973db74ab94f31cb6bf055122309113c87bc037c02", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/TeamChangesSection.tsx", + "sha256": "sha256:2f8577f6849517878cd6d5f063ec7803cb92dcedaa8bd90587e79aa2a66663dd", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/TeamDetailView.tsx", + "sha256": "sha256:8833fa11cdd095bf2f9826a797e72e38fa123b503efb3ad7f0a85bc23c51aa2b", + "interactionSiteCount": 22 + }, + { + "path": "src/renderer/components/team/TeamEmptyState.tsx", + "sha256": "sha256:554c8e3e2a02fa6bc257228ad92a9636c3c6eed61f52a2d3d034bea7c746374c", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/TeamListFilterPopover.tsx", + "sha256": "sha256:e1e23de2e28d81be9735c9703a707d4765bda5b8c76a7465eba3b785586623a8", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/TeamListView.tsx", + "sha256": "sha256:09dcc3cb5afe9d831bd4e973204f257bb750000d3db0758adbeb95121b447492", + "interactionSiteCount": 16 + }, + { + "path": "src/renderer/components/team/TeamLoadingSkeleton.tsx", + "sha256": "sha256:205ccca7f1ce9a8e7cc0d02a4bb1e04a6d1c074ac27aea8853a82fe7672008c2", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/TeamProvisioningBanner.tsx", + "sha256": "sha256:461c8333c3eefe3a6d991571faf92baa998fd76a6cd651d87fdbf9f1e766c5d2", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/TeamProvisioningPanel.tsx", + "sha256": "sha256:7343b9136c8b307f0e56025e5ef83aec42ca00f99d5a91aa9363e65d1a65a87b", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/TeamSessionsSection.tsx", + "sha256": "sha256:3f3dd7e1089cb3d757017421a87d9e84f4a248844f3246c43e4d50b5d36a1eab", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/TeamTaskStatusSummary.tsx", + "sha256": "sha256:b7c56388d1315598155a3f7cf1b626d4afe75a4e6400a55d717ebd6a79e969c4", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/ToolApprovalDiffPreview.tsx", + "sha256": "sha256:eba87c5902fa1379b7516ce7c78298ef45b09a126fa4c8f32ffd1ca1b2ddbc32", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sha256": "sha256:2791ad9a209aef8cef69ad2e9a7d1ccea12db55bbed0c80f1535ef3009a95afd", + "interactionSiteCount": 11 + }, + { + "path": "src/renderer/components/team/UnreadCommentsBadge.tsx", + "sha256": "sha256:6e27dbbaff5da937777a7569dc0999d7e7d0d26aff7ab52a47a9d9174c46f55a", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/activity/ActiveTasksBlock.tsx", + "sha256": "sha256:96d4d9d14abb6398390156e5af1023416bd26bd27ee921cd3a2f21586f381003", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/activity/ActivityItem.tsx", + "sha256": "sha256:71a29333decd126ac2e19fdbdd5c1f160bc4c494a4d48516463afa3eb65c231d", + "interactionSiteCount": 31 + }, + { + "path": "src/renderer/components/team/activity/ActivityTimeline.tsx", + "sha256": "sha256:3a25f7475084d5a7f41065002f0116f0b51d2f91586fb481fa976b05b91a504b", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/activity/AnimatedHeightReveal.tsx", + "sha256": "sha256:fe9bcbe631387f0440a1b9206c0e0bd7099d226122b6e279e0bacbb185bc8d08", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sha256": "sha256:98170d4dcc3243ce26f2b015d7a8e8956ea3cb8bcdcf8e1faf72cdc58ea2b81f", + "interactionSiteCount": 11 + }, + { + "path": "src/renderer/components/team/activity/MessageExpandDialog.tsx", + "sha256": "sha256:66430d5f447d717634ab112fb05e0889fe6967da40df1c0193e6912568cc6283", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/activity/PendingRepliesBlock.tsx", + "sha256": "sha256:f824707d08ef4dbe309b896cc0c867ec970ff1d5e805cd649ad60307bb0179a7", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/activity/ReplyQuoteBlock.tsx", + "sha256": "sha256:f842168510400723d68c36f6da96e1e8d42573096439671d56523c03ea943b91", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/activity/ThoughtBodyContent.tsx", + "sha256": "sha256:9245fb456082ef547dbd855310e46d1bc1fe3ff41e8bc6e99caf73f86ed429cb", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/activity/TimelineHeaderAccent.tsx", + "sha256": "sha256:666ade7889c350f51694b081c793dad813f7e41c9b1b8b802188f47fce542b9c", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/attachments/AttachmentDisplay.tsx", + "sha256": "sha256:0e13ad93523da7ce62255b834d03127a53677df5e03dbd99e5dc44d795fb2fa4", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/attachments/AttachmentPreviewItem.tsx", + "sha256": "sha256:2d19b18c823707fc567e197f66d5db530d97fe1d0a0f5bfb5f0401696d7bfa43", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/attachments/AttachmentPreviewList.tsx", + "sha256": "sha256:f6a8613f6f70410212f8214215a32edb0b8cf5b7b472b5f6f207cc5004e7b7d8", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/attachments/AttachmentThumbnail.tsx", + "sha256": "sha256:de1248cfa78ea10be2d149b83915c2024f307ba88970c8bb2c5cea7685c8be62", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/attachments/DropZoneOverlay.tsx", + "sha256": "sha256:db375e9502d96a496d1fbc8d2accd2b52548ba865f77a0d759773fb1cffb0036", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/attachments/ImageLightbox.tsx", + "sha256": "sha256:80f2d8029a5d5a74d2a5b16aa56b5d78141bd91c49832b1e8bd6e8e89211d619", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/attachments/SourceMessageAttachments.tsx", + "sha256": "sha256:12f6a4834e7575cdfb47f786a464df5b1a6a01fd1e5c30b9bc79894d8933adc4", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/composer/ComposerSurface.tsx", + "sha256": "sha256:92107db1ace06e912529deac561dfa3b3ead2e0c8660a74e906ddd288de7d5c1", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/dialogs/AddMemberDialog.tsx", + "sha256": "sha256:5b0dca18a4837ec84c5675f4d2b21c1e43f7478d4497c9e84e97e3b4601c88dc", + "interactionSiteCount": 4 + }, + { + "path": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sha256": "sha256:d2b4ff885909a009f2b1eca476e6170a5e4c1fce9a53ece27ce7a97495861290", + "interactionSiteCount": 8 + }, + { + "path": "src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx", + "sha256": "sha256:9a409a5ecd3dbcfb863b9d56a254cb6bdc65fd9ccce1ad01d64835343f85a14d", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx", + "sha256": "sha256:f7f4469863e9aa066735a1dd9a4a6047b77d8db021e89f42b55f799a0179af73", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/CodexFastModeSelector.tsx", + "sha256": "sha256:621673c76ab47b3bd544834686e3bbd16a3f903edbd163bf2cadc263837d4b6a", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/CodexModelCatalogFallbackNotice.tsx", + "sha256": "sha256:799c375d07d070bbfcc28bf810897ab9ecf51c8a58904cc6d1fe0e7b306da70f", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx", + "sha256": "sha256:eb653444151c650734658798e30150bc82112f7354798921252054859e0149a1", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sha256": "sha256:b075bbe09e3349e0750c55e2d010d720b0a1fc22017bf2aba555b554f9d5dea5", + "interactionSiteCount": 14 + }, + { + "path": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sha256": "sha256:24ab369e7a23211a57e8b1c9b7a2540fa44270208611daace3adce1ef88e57ff", + "interactionSiteCount": 22 + }, + { + "path": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sha256": "sha256:8f3de4f2f53d3aff75c231fbf8dd7adf208b66fcc24f612c21bc590ceefa6b5f", + "interactionSiteCount": 8 + }, + { + "path": "src/renderer/components/team/dialogs/EffortLevelSelector.tsx", + "sha256": "sha256:ed7edcd5ac6f259add979b91e499981fbd761f31536d3289f32e24ef3ad2e7d7", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/ExperimentalLocalModelOverride.tsx", + "sha256": "sha256:0abbb0daa709c289ed1bde5145d84345d4ff0b36a5414703abf93efef4094944", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx", + "sha256": "sha256:447eb5ef2617c09c2f96db727db85d659a1c08a430ff9a40065bd43dc5dc021a", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sha256": "sha256:28b0d6fd6b9a0a36f93a67c72c2a3dc91df2fcf2992e58116ff39271ed77952c", + "interactionSiteCount": 21 + }, + { + "path": "src/renderer/components/team/dialogs/LaunchTeamDialogLoadingFallback.tsx", + "sha256": "sha256:f61e0caf67231780c14d5e00d1e5dc8f121f99e41e48b594263e2c970dfe7b78", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/LimitContextCheckbox.tsx", + "sha256": "sha256:d42e0c71d7ab4d14f07afa7cb376587e216a6d670982b25cf44639c04375a610", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/MembersJsonEditor.tsx", + "sha256": "sha256:347bd5476bb3a0532ce11bb4970cdfd6a7c3ecbd1424361d158dbcb98bd252c5", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx", + "sha256": "sha256:1299d1bc6cd4e4d16e4093d06b12af4f934407f38e29b94b51af77af53b52377", + "interactionSiteCount": 4 + }, + { + "path": "src/renderer/components/team/dialogs/OpenCodeLocalModelStatus.tsx", + "sha256": "sha256:671d534cc61241053512f7f6f1e23e98b67e932164bcfdcb788310ca04436308", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/dialogs/OpenCodeLocalModelsFeedback.tsx", + "sha256": "sha256:f721c35baed37aaa4c0f42b7f189445577e5573b060d2acf1bb8230fece887ce", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/OptionalSettingsSection.tsx", + "sha256": "sha256:e7c12f42fec939c9799efc3c56f3c15f2e4374af54c0a973318b1f1c94c3fdb2", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "sha256": "sha256:6b8366d04fa0e1790cf4ba3ed319a766792ce13abd2db472756ba3e94fdb3d6d", + "interactionSiteCount": 6 + }, + { + "path": "src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx", + "sha256": "sha256:e5a0004f0530f121b0500d9f2872a105e46b2593eabd782d0fd6df77e0cd7dd0", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx", + "sha256": "sha256:e9fda7ddbe026ce8d32ee6876ea873067e41d81e563f48169e3288c782dd8f14", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/dialogs/ReviewDialog.tsx", + "sha256": "sha256:b3437cbfc7ead65cbc7b6568a80c907054872b5a6ae2edbac47ea28bc8efcc66", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sha256": "sha256:c13773bf0b1739de654baee83d32cde5f61f79ff0b021ee2d03a30873d52e445", + "interactionSiteCount": 14 + }, + { + "path": "src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx", + "sha256": "sha256:6a1aec39d3613a1a540f9de7586b8f23ddc8322ef0c4a7f5eade20d1a75df95f", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx", + "sha256": "sha256:dd7419db632ee597c23c84c5240f4fc10b52b221a5f20a3ca8f91ed54a0e6624", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sha256": "sha256:0c188aa534faa261ec5649113df4e21ae690aaa172451a11a8ccadbd932a025f", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx", + "sha256": "sha256:1fd9d265ed2cf17f2fb9689b773b285fde81dc679fe4a2962329ca2d233923ac", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sha256": "sha256:8a0483dd45fd2839f07cc013deb0c45900562cb11b86568661960c3a841c087b", + "interactionSiteCount": 10 + }, + { + "path": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sha256": "sha256:1e28e0a2e9c82c9d6ec7b73625160dbfa3432234992b9381524b4da542bb4537", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sha256": "sha256:ecc2404b75e1c8de25c4feb9275ffa7d157d6a5885de6732b41bd3a8960ae3f7", + "interactionSiteCount": 28 + }, + { + "path": "src/renderer/components/team/dialogs/TeamModelBrandIcon.tsx", + "sha256": "sha256:eea34c4a4014c36c313f9e4d14530623c45bea11141e1b344c8e527a5126f938", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sha256": "sha256:4c3f37e2bae34726bb9592f056083333841da1026cc300d0ad82abb7ffdbec94", + "interactionSiteCount": 23 + }, + { + "path": "src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx", + "sha256": "sha256:9d366041a60ab19a86b5558213e380f2890a073cb2822c99ed7edd773434d10f", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sha256": "sha256:169d8a085cbf992adaaed77ac86cba1b8cab39b69ab53aa56230b4d38549f417", + "interactionSiteCount": 14 + }, + { + "path": "src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx", + "sha256": "sha256:173d5be0ab7793b4c206bd384784892dadf9fcf7986d2959f198b713355446e9", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx", + "sha256": "sha256:41783acea2876e4180a01c69a771579ba12714e7e4d7a33bbf82e3e83e3248bd", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/editor/CodeMirrorEditor.tsx", + "sha256": "sha256:6ecdf1ff266ac59cd098fb0988558ed827892641205b89619c17722eda55f2d6", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx", + "sha256": "sha256:295ea08e0d32767e0ce78859d39848473020d449430a19a371056bdb19b0ac2d", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/editor/EditorBinaryState.tsx", + "sha256": "sha256:3d93f0da77a51d96c4f958235a51745c0fc2f5310b2c9acdb2833c3d04d30b40", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sha256": "sha256:d6b792f3c255daa7ed239ee2333d24df6dbb95c85ec736eff0e27dc5f4f64617", + "interactionSiteCount": 10 + }, + { + "path": "src/renderer/components/team/editor/EditorEmptyState.tsx", + "sha256": "sha256:f1504d6e2f0826b729520053189718665d03bc3dfc7885a3c81ce2718baa7268", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/editor/EditorErrorBoundary.tsx", + "sha256": "sha256:043ed2c9d94b54cd5ae38add8bc1c79300a88aa4ee36ee009fbeb999b4a6aadc", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/editor/EditorErrorState.tsx", + "sha256": "sha256:75d45205b9c9237dfc59c44e416bfd351efe31583b2f52205d5d29716634ead0", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sha256": "sha256:1bd56d4a55cd46e9d0ce6fc1e753646f3544342ecfaa59d082b55217d06686fd", + "interactionSiteCount": 16 + }, + { + "path": "src/renderer/components/team/editor/EditorImagePreview.tsx", + "sha256": "sha256:c345cd2557fcc0f0f3cb3fa763cb71b6860247692557f6fba4a57bc321552369", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sha256": "sha256:cb25793820f8692ba459c1f0b063a98dbd154cb00bfcda0f75a076a9f3440803", + "interactionSiteCount": 14 + }, + { + "path": "src/renderer/components/team/editor/EditorSelectionMenu.tsx", + "sha256": "sha256:f5a5a78d9b2aaa582f8165abb87cc621a34a18a35fd63da299727dd328364bc7", + "interactionSiteCount": 4 + }, + { + "path": "src/renderer/components/team/editor/EditorShortcutsHelp.tsx", + "sha256": "sha256:162380bdf4fd7534fa0b9ea7b0af814f3e366a6b819746de017b423227636724", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/editor/EditorStatusBar.tsx", + "sha256": "sha256:fd62fe6b1bcd37822020c1456facf28f63299067f93a9540216af674b6295d82", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sha256": "sha256:a606f3b1fd5acf42c542798d1a3db7de214cd47ee9500af7ec7d113252e544c6", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/editor/EditorTabContextMenu.tsx", + "sha256": "sha256:9af7aa60eeb782a0e19d1b46fec47641490f6a68b1c8b9045a802598cad18c6a", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sha256": "sha256:1abf0015bfdac2bc1cda726f49ac3fed642121e647c9c57c0eb34feac4ce9a17", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/editor/FileIcon.tsx", + "sha256": "sha256:c58cb88731fe1182824a1ff3ac4b3686b18cd4e0c8f5d6ed41c3c4afc0d6e00c", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/editor/GitStatusBadge.tsx", + "sha256": "sha256:5b1000124d23929c72515912034624283314dbc01a315e7b726b89167a04647a", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/editor/GoToLineDialog.tsx", + "sha256": "sha256:b3c9412342c5a1b3c287a20a325ad819fe78597b843f93932fd8ec2961ce76b5", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/editor/MarkdownPreviewPane.tsx", + "sha256": "sha256:ab02393c8219e164ccd30a60c4406db283f0015d8dab7731b101a3b8499807e2", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/editor/MarkdownSplitView.tsx", + "sha256": "sha256:8300366484cd028e80e3e67355993eb2abb0632a73503069ce324c34bb5ab9f5", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/editor/NewFileDialog.tsx", + "sha256": "sha256:ea9277927af8ca18a1638c00a8f802b2d3c39bfcbc00f3ef18b86bf02d1cd0bd", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sha256": "sha256:ada08ddb1269007349c39677c16e4ae7e315d9bc8b67ff5333e648f1be2d0ee0", + "interactionSiteCount": 22 + }, + { + "path": "src/renderer/components/team/editor/QuickOpenDialog.tsx", + "sha256": "sha256:ce7b3ad9301b1cd69f4e0d9ba8bd35b5d386e0a29776828b5bf533c09461d4d3", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "sha256": "sha256:774bd58ef89295a11a8f8c7a2ac992b15917737a93273728f1dcd1f53eaea024", + "interactionSiteCount": 6 + }, + { + "path": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sha256": "sha256:1b3b6596dae81e7265037bbcc2396838016cd5a9847cabad4a7c5b806a482363", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/kanban/KanbanColumn.tsx", + "sha256": "sha256:0247b305b187d83bd1f367d0a8f845a3a6a6401a31a63bdfd9f30a4a33c44645", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sha256": "sha256:cd2bf6195cdc1a86d4485e88795ede86c4103599b7829ad517aba2666b6427d0", + "interactionSiteCount": 8 + }, + { + "path": "src/renderer/components/team/kanban/KanbanGridLayout.tsx", + "sha256": "sha256:4e6c6c5844a4ce425a443615f949543f6fe2c56a2013f50bc9c3feccbdb3a9d0", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "sha256": "sha256:533c3b1712b65f6039473ebb31fcfcb3c2f19f05fdea271c00ed51e23b2af6df", + "interactionSiteCount": 6 + }, + { + "path": "src/renderer/components/team/kanban/KanbanSortPopover.tsx", + "sha256": "sha256:f9366454380e3e45aa7a348490a991425191c88f339f7e3350c818804acb5563", + "interactionSiteCount": 4 + }, + { + "path": "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx", + "sha256": "sha256:5049526b5be482f89a574c0053765cfcfec316f6d87533e9dac9d1b1c3716ab9", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sha256": "sha256:4d4d038c2ee208894b231a260b6ba48bc713a1e2e9604a4fe89874d439943e65", + "interactionSiteCount": 25 + }, + { + "path": "src/renderer/components/team/kanban/KanbanTaskCardSkeleton.tsx", + "sha256": "sha256:b04a185bd4ba39b23a8a9435e06e64490ddcf2c73fc5ed3ab89982172a014ebe", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/kanban/TrashDialog.tsx", + "sha256": "sha256:f517ec04fbe1ddd14ea6ba1043febacee9ee9f4d74162aa44de74359d704bef5", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/members/CurrentTaskIndicator.tsx", + "sha256": "sha256:7920582f344c783cc9b254b58d25a7b91b22b5f2bab86fadb135ad8d3d3e7338", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/members/LeadModelRow.tsx", + "sha256": "sha256:9e04434079d9ce765f4da257c87c8d743173892274f31a3841cf7caea8060428", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/members/MemberCard.tsx", + "sha256": "sha256:b44c224c0442e704b457e4b684316eadcf9f230a80be301006b5911d7fa3316d", + "interactionSiteCount": 13 + }, + { + "path": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sha256": "sha256:d779a0ceb44776b155ee156b26dba1bd5dbdc4c85c6bc0395433b5c51f76baa3", + "interactionSiteCount": 10 + }, + { + "path": "src/renderer/components/team/members/MemberDetailHeader.tsx", + "sha256": "sha256:7eff7de48cf43047556f4f169de3e8f2baebedcaefd65be980752f5217620b77", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/members/MemberDetailStats.tsx", + "sha256": "sha256:7d4ad21216af9efca34156369e503a1696c33f71f53a64d8b5a64391e59e87b7", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sha256": "sha256:a344cf4fdd281d0c010c66e32fd3661b81fc2bda57d8dab04f0c8c57920ed72a", + "interactionSiteCount": 21 + }, + { + "path": "src/renderer/components/team/members/MemberExecutionLog.tsx", + "sha256": "sha256:76a82d5bfd8b8747b2187e3b969cf5378c141781e2c555cb24304dbc3a97f1e8", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/members/MemberHoverCard.tsx", + "sha256": "sha256:609720049ddddf52c27cc30ac006eb96d52b3568e62dc5fb467a8d868096bdc4", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/members/MemberLaunchDiagnosticsButton.tsx", + "sha256": "sha256:604cce4455aa8e07cc693b69198be5d0f6d25cc6e2436898754492d57f3d85a5", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/members/MemberList.tsx", + "sha256": "sha256:7924220d0ac9c5e5ae01316611da35988095fb5000e4fe52800863774e4884e7", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx", + "sha256": "sha256:ed6d858a519b3a78f7e9fb02c9c28aa7311a7478e3b0c32a6e500ec4cd962518", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/members/MemberLogsTab.tsx", + "sha256": "sha256:d66919f281078b1d1cb6f0c70c048961341d5a854a732de52b1c013277498631", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/members/MemberMessagesTab.tsx", + "sha256": "sha256:36e533858ac2e5265e4003ae89687780836dd1719d6cdb4a58f85fc5bf7c7292", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/members/MemberPresenceDot.tsx", + "sha256": "sha256:8384392aecfc302ffd41935b71797fa733a251542e1d77d0e27b904a566377aa", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/members/MemberQuickActions.tsx", + "sha256": "sha256:c6cd315cce31a1e8fdfc50d992e5e6691c2b233d4ebed83d4e0bd234c546e9e3", + "interactionSiteCount": 6 + }, + { + "path": "src/renderer/components/team/members/MemberStatsTab.tsx", + "sha256": "sha256:f395b26faab4e7e3dc2a2b16f4a7bd1079ca90fee8d4578cebe1e42c09813089", + "interactionSiteCount": 4 + }, + { + "path": "src/renderer/components/team/members/MemberTasksTab.tsx", + "sha256": "sha256:9245e5520ec1981a0a3d1ad0c9a7f8d6a4968f68578bb824825cdb20ff52f57c", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/members/MembersEditorSection.tsx", + "sha256": "sha256:02120838733f59c44605cd7cc169f2ce66cb8cf09ddc4bd059f067946bfe804c", + "interactionSiteCount": 5 + }, + { + "path": "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx", + "sha256": "sha256:10918d8d9e88a25b0b3660545bbc8545f790dc140dad2d28fad99fd046cb9fab", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/members/TeamRosterEditorSection.tsx", + "sha256": "sha256:4f97ea476c98e266451c8e6f71ce835d3804a64eda4a2e643cc7669945456511", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/messages/ActionModeSelector.tsx", + "sha256": "sha256:3b97166c679cdd9ae2109b7d193a808a4f79d7c6159c5fce46438562e916de2e", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/messages/MessageComposer.tsx", + "sha256": "sha256:6c248293beac872d4650bf88f59fa77d858aa5955abb0c31bd937de70e92c9b0", + "interactionSiteCount": 22 + }, + { + "path": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sha256": "sha256:3bfade3a7832fa32ed8c118f8291a24865e23ef35519cb570347b61dfd5f0990", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sha256": "sha256:fcaad13443eeddb5e8bf42cd1f4af943e9962499e4a16765edc23ff48c49e873", + "interactionSiteCount": 35 + }, + { + "path": "src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx", + "sha256": "sha256:2c2b64da726298017fdc680cb55c6b45abb218f3234f2b5061a728a1f573cb31", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/messages/StatusBlock.tsx", + "sha256": "sha256:3db0c6b828efd2cc99ef9f33e52309ee57deedabee9a9485670a5994b8c2f8bd", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/review/ChangeReviewDialog.tsx", + "sha256": "sha256:9a8f53cf152e14ea640920a4696da2bdc495efaa55e62cf6ddf732a3d80861ef", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/ChangeReviewDialogView.tsx", + "sha256": "sha256:dfe403d4fad23ddfe9b430c0fbaa9d23c9800a24b41df24f9b4b41bc43e161e2", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/review/ChangeStatsBadge.tsx", + "sha256": "sha256:da06962fa36755ff311364abd1aa9a9fc45918e70e716684bf00557dda2ef3e6", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/ChangesLoadingAnimation.tsx", + "sha256": "sha256:95d3c6957c440d1613ed19a16dfd8c7ba448f211996fd2f93406134e8723fec5", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "sha256": "sha256:3e0857c601f17ab0ae834d9e8604b41bf6d10171951d3a82dd7ed0299d64200b", + "interactionSiteCount": 6 + }, + { + "path": "src/renderer/components/team/review/ConfidenceBadge.tsx", + "sha256": "sha256:f75871b7d7dfb265d974fbeda0b57a2907f716a0fee5d029e41ff1674587e2f1", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/ContinuousScrollView.tsx", + "sha256": "sha256:56d39343b12c08f14036040ffab6b17cfac72220ad35b86d88cc45b99c22d2dc", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/DiffErrorBoundary.tsx", + "sha256": "sha256:4660d94d30749b294ee2399892a9bbff42e93c2e5cccedcfba4561cc42189e22", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/review/FileSectionDiff.tsx", + "sha256": "sha256:1c18ad1a016dd8e78133e13577b3ebb3c437b72a514a54c595230b66644c2a46", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sha256": "sha256:7719c32391b5fea9831b91c984be189e716ac46d535abb703943ab896d365806", + "interactionSiteCount": 9 + }, + { + "path": "src/renderer/components/team/review/FileSectionPlaceholder.tsx", + "sha256": "sha256:3bfb3707af1d53dbf13f794cd026203bfc69f21e05a0b804c475b4618be3e4aa", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/FullDiffLoadingBanner.tsx", + "sha256": "sha256:e06eb52bfbd7f5923c76dcdf733c4154441a0485a369c7a861b43ba3d1a31ed1", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/KeyboardShortcutsHelp.tsx", + "sha256": "sha256:5c8b9b20dec2162041844a57fdedda6b53883ac8a9aeb002a2c0de79dec87f5f", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sha256": "sha256:2d6f4060d9e2b2ba0fe9214f6246a06c634b313ae3f2f0dcf1d149baa3348237", + "interactionSiteCount": 10 + }, + { + "path": "src/renderer/components/team/review/ReviewDiffContent.tsx", + "sha256": "sha256:e5cb7d2044cf7763cf62a63186d9d870c8e09cfd97333832411c6fff7cb47f1b", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/review/ReviewToolbar.tsx", + "sha256": "sha256:7f4ba2d35f50a8e79bc465bebe7fa5391d8d542a107b8f40a0530b51527ff373", + "interactionSiteCount": 6 + }, + { + "path": "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx", + "sha256": "sha256:77c341a7c3a75f6704c4b84eb287f89c967b817e42ea8939bf944cf21a0d5fb5", + "interactionSiteCount": 4 + }, + { + "path": "src/renderer/components/team/review/ScopeWarningBanner.tsx", + "sha256": "sha256:d88c6571bec1665732bc21db8a7a14eb5200fa97b9b451ffeb6bf96ed42b303b", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/review/ViewedProgressBar.tsx", + "sha256": "sha256:06b6e83668be8191dbc0839c319f4d8555dd9effe7e85e1de7430f6aa77ff103", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sha256": "sha256:9b76810b129565584b3874f96d43e263b57aad8676257b3ff0adac9e0ca7d174", + "interactionSiteCount": 8 + }, + { + "path": "src/renderer/components/team/schedule/ScheduleEmptyState.tsx", + "sha256": "sha256:deea7aded341a182059dc998dbf0337d89fdfddde66130d605a32327b06b8ac6", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx", + "sha256": "sha256:dc6a975c627d768d48ffa9b16fadb0f3e3fa50b7585a47b2de8d903d6422d532", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/schedule/ScheduleRunRow.tsx", + "sha256": "sha256:3be7772630ece22298ce46425424f7f1a8c266441f74bf85f82ad879d87823ae", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sha256": "sha256:e8e98a2715c2026be7b3c26c8d87c5eab7ba648d303b9683b77a3e1a0029de8f", + "interactionSiteCount": 10 + }, + { + "path": "src/renderer/components/team/schedule/ScheduleStatusBadge.tsx", + "sha256": "sha256:650ffe52ab0d47fc1dc94842e3609dbfaf854c11d687f09e4386c8232c031361", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/sidebar/TeamSidebarHost.tsx", + "sha256": "sha256:bdc7c8ec7f094b5966b9ce1214c08979a49a67b5d3eb5122d7a90ca65c81be7c", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx", + "sha256": "sha256:f5d8e174c0d8960fe3c74d92a95c00f653a43eaeb93bdfc01305ddca37a9f86c", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/sidebar/TeamSidebarRail.tsx", + "sha256": "sha256:673a4dd7455e481cc94e763d14e5c187eba229839437522dfffecebc5afc5559", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx", + "sha256": "sha256:cc095aea8199d013debc134ef298ddf2068a31b115e29499ebd2394eae1b5106", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/taskLogs/TaskActivityLinkedToolCard.tsx", + "sha256": "sha256:185099dd7bdb08f522ac664f69d222919f9796cfabeae84688594efc9b46220f", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/taskLogs/TaskActivitySection.tsx", + "sha256": "sha256:704587855ef4a222af06ff716c5108c2ea014dac8d4b373aa2b2b70e2923559e", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx", + "sha256": "sha256:de07c3ab6c9f465ff6db0dfc8fa8e51508492bcee1d2eb3e8d67d26143a02b7a", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx", + "sha256": "sha256:23206025b7392581d6e1c3d5ee312ceb789dad8dca4be327d2372709d717c92d", + "interactionSiteCount": 4 + } + ], + "excludedSourceFiles": [ + { + "path": "src/renderer/components/team/HostedTeamWorkspace.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 0 + }, + { + "path": "src/renderer/components/team/editor/EditorBreadcrumb.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/members/MemberRoleEditor.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 3 + }, + { + "path": "src/renderer/components/team/review/ConflictDialog.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 7 + }, + { + "path": "src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 1 + }, + { + "path": "src/renderer/components/team/tasks/TaskList.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 2 + }, + { + "path": "src/renderer/components/team/tasks/TaskRow.tsx", + "reason": "No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.", + "interactionSiteCount": 0 + } + ], + "transitiveActionCoverage": "The checked-in child-control catalog exactly matches the recursively discovered team/change-review/provider renderer closure. Every scanner-visible site maps once to a reviewed semantic action or deliberate absence; direct renderer IPC callers remain bound to the 119-member parity ledger. Every other production team TSX file is listed as excluded and rechecked as unreachable from these roots.", + "actions": [ + { + "id": "team.console.select", + "owner": "team-console", + "disposition": "direct", + "securityClass": "team-read", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["keyboard/click equivalence", "selection generation"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:7ae3e9868fb0d4a2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:3d61968c3b27192f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:a41ca0eb05d0e44d", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.launch", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-operator", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["idempotent accepted run", "runtime outcome"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:6316b98384b52106", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:57315f4848354f04", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:b0f60eaa09e35acc", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.stop", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-operator", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["lifecycle generation", "runtime stop outcome"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:9e5a0346eee1dc22", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:f24d2defb60f97d3", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.copy-draft", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-mutation", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["new stable team identity"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:caca7b02e0fe8e0c", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.delete", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "destructive-team-operator", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["tombstone generation", "partial cleanup outcome"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:31aff054fdea44fc", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:7d3361a6fdf74099", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:b8f7b322927174be", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.restore", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-operator", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["tombstone generation check"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:4aed9e873872f4c9", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.permanently-delete", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "destructive-team-operator", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["explicit irreversible confirmation"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:f94e171aad27f966", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.list.refresh", + "owner": "team-lifecycle", + "disposition": "direct", + "securityClass": "team-read", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["bounded freshness generation"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:e51dbba28734b4f3", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.create-draft", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-mutation", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["draft identity before provisioning"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:51e06c68cf47389f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:4fb257c290cbb747", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.delete-draft", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "destructive-team-operator", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["draft-only deletion"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:19f7011fed6875b2", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.edit-config", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-mutation", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["config revision conflict"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:d0df23591a37fda1", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.add-member", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-mutation", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["roster generation"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:2822e8d54b308717", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.remove-member", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "destructive-team-operator", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["roster generation and runtime outcome"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:e39a599d1bf8f481", + "siteCount": 1 + } + ] + }, + { + "id": "team.task.create", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-mutation", + "target": "WP-8-TASK-BOARD", + "evidence": ["task revision and delivery outcome"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:f6be75adbc83db1b", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:f2ce29562f9b62f6", + "siteCount": 1 + } + ] + }, + { + "id": "team.task.request-changes", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-mutation", + "target": "WP-8-TASK-BOARD", + "evidence": ["task revision and review reference"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:9e2ce9a330923369", + "siteCount": 1 + } + ] + }, + { + "id": "team.console.local-view", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["no server authority"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:c40241bfedec76c5", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:bfb04384019ff8a2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:4fd19e07a723a058", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:7253b728045c5be7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:495711757cb25a3d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:93356b94c86b9a31", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:60524c5c20d1e755", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:082021ccdae5c5a7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:5391ea0fc7047eb7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:8ae85785caeac217", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:bbd620637e74d07a", + "siteCount": 1 + } + ] + }, + { + "id": "team.console.desktop-editor", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": ["hosted chunk import exclusion"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:4d51206ae5cc9e46", + "siteCount": 1 + } + ] + }, + { + "id": "team.lifecycle.draft.configure", + "owner": "team-lifecycle", + "disposition": "direct", + "securityClass": "renderer-local-draft", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["server revalidates accepted draft"], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:d5fe5d760046156d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:6d8853fe55f75739", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:fed55a4d724f8753", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:6eb6d60bd8a2b4f9", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:460a6a2c6167d111", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:b595fc47dbad28f4", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:cdb8b3b29e5bd5dc", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:f57186dc2c15e899", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:2def27cf5dcb15c3", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:1a7ffd59f07eefa3", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:4f195bffd0ffd15b", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:f021d7f085c99bfa", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:d07a1c16e844146d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:f4d5123b4759ab0a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:efe1b616c01dac38", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:9c184401f5913a9b", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.credentials.edit", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "secret-local-input", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["credential never enters renderer evidence"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:475f2078f99114db", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:3816eb21807a0310", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:9bfec2d3b0f3b701", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:c6ce0f9d97c1db56", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:8e6244151887f85f", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:0866a69dd465cea2", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:32a0b93f9100520a", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:90b5386bbc48f0ef", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:e61e6699ac805f3d", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:2e96c75568c654f7", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.connect", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "provider-credential", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["provider availability/auth result"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:69d499064c5acd68", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:6bc3452b02286516", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:0762178282badb9e", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:29c4895caee155e7", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:e05b1f72353d30ba", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:d168672245c63043", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:3ad8822f863003a5", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:1495ee29bd7c3e90", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:ac9b52278c788f42", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.connect.cancel", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["no team lifecycle effect"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:ad293317df8ded48", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.refresh", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "provider-read", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["fresh availability projection"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:fe93b4de9d313bdb", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:4db1c0b6fb7b9ffb", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:08bc5dc7c332dd08", + "siteCount": 2 + } + ] + }, + { + "id": "provider.management.forget", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "destructive-provider-operator", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["credential deletion outcome"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:022abe36d0ffbc03", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:2a7c06563fa1b014", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.provider.select", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["keyboard/click equivalence"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:ac11e711a5ad0984", + "siteCount": 2 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:70a068044f5b9d78", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:884c2ef7b9d904ef", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.model.select", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["keyboard/click equivalence"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx", + "sourceHash": "sha256:a389fafd087613a4", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx", + "sourceHash": "sha256:b10ea2885a389036", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.model.test", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "provider-execution-probe", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["bounded test result"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:a64b1f2f3678ae66", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/LegacyConfiguredModelsPanel.tsx", + "sourceHash": "sha256:3ca82c0a17e2c6b8", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.model.set-default", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "provider-setting", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["setting scope"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/LegacyConfiguredModelsPanel.tsx", + "sourceHash": "sha256:cb4da3f063e872fb", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:c8eec5619ffb86e6", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.model.page", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "provider-read", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["bounded cursor and pagination"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:c5c2275db5ceba29", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.directory.search", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "provider-read", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["bounded query and pagination"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:4e64864b4b16cd70", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:6b69132fab97a1a0", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:b22672f4d62f3453", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.local-view", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": ["no team lifecycle effect"], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:1cecb00a557a2370", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:1e310b0bae09ff97", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:ea696b218060f2e5", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:abb468deaa0c78f4", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:4e45b33a345a189b", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:f9923ff85d4eee62", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx", + "sourceHash": "sha256:5ae058cd41c9af09", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx", + "sourceHash": "sha256:0a0021d660c78354", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx", + "sourceHash": "sha256:620ec57979b46e1d", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx", + "sourceHash": "sha256:6e5654d9504c1a58", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx", + "sourceHash": "sha256:cf5c09d33d6c7f49", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderSupplementalStatus.tsx", + "sourceHash": "sha256:947f48b23ad4a7dd", + "siteCount": 1 + } + ] + }, + { + "id": "provider.management.model.clear-project-default", + "owner": "runtime-provider-management", + "disposition": "direct", + "securityClass": "provider-setting", + "target": "WP-4-PROVIDER-AVAILABILITY", + "evidence": [ + "Clears the project-scoped default while retaining the inherited all-projects model." + ], + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx", + "sourceHash": "sha256:12f518d37b2905ba", + "siteCount": 1 + } + ] + }, + { + "id": "team.approval.respond-to-tool-approval", + "owner": "team-approvals", + "disposition": "decomposed", + "securityClass": "approval-policy", + "target": "WP-9-APPROVALS", + "evidence": [ + "Reviewed mounted approval response control; the direct TeamsAPI action and owner are pinned by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:bc7f650e270a2b3a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:f619abd9d2f06a2b", + "siteCount": 1 + } + ] + }, + { + "id": "team.approval.update-tool-approval-settings", + "owner": "team-approvals", + "disposition": "decomposed", + "securityClass": "approval-policy", + "target": "WP-9-APPROVALS", + "evidence": [ + "Reviewed mounted approval policy control; the direct TeamsAPI action and owner are pinned by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:14b1fad488bf44d0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:38304436e7d56f95", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:5d82cf9b106c6afe", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:7fe8a34c246e2291", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:85f56bd2ce48476a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:979be4ab3421ec63", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:99c4c86dfa822b94", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:a7fe6352ba186223", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:c8d1ffc5278c260d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:cf3ee774403f7d17", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:9d3a55a830af01c7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.active.tasks.block.on-member-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActiveTasksBlock.tsx", + "sourceHash": "sha256:776c8ad348a81495", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.active.tasks.block.on-task-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActiveTasksBlock.tsx", + "sourceHash": "sha256:65239ab1e502cfef", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.active.tasks.block.set-collapsed", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActiveTasksBlock.tsx", + "sourceHash": "sha256:4421fe061f0fa5e3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.create-task", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": ["The contained click creates a task from activity context."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:2a2f9994cdddf0d2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.expand-item", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained click expands an activity item."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:9b1cd0dbc06e2b98", + "siteCount": 3 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.handle-header-toggle", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:21d37e2405406467", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:3f0d324d42e9187f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.on-member-name-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:64877a4886f6d509", + "siteCount": 9 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.on-team-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:96cd94c3da2fbb43", + "siteCount": 4 + }, + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:db8f2250885aaef2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.open-task-link", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Captured task-link activation is a semantic navigation action even though the handler also contains the parent click event." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:0abbe7e35bc3e74d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:3597114d854da9e9", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:ab652cf88de6aa42", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:e05f22c26512f818", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:fd94940e350e840a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.reply-message", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": ["The contained click enters message reply."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:5c3bf89cda708003", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.restart-team", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": ["The contained click restarts the failed team flow."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:3c184571207a875b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.item.revise-message", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": ["The contained click enters message revision."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:3ee5a263eec43eec", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.timeline.handle-show-all", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityTimeline.tsx", + "sourceHash": "sha256:152181cf86666501", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.activity.timeline.handle-show-more", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityTimeline.tsx", + "sourceHash": "sha256:1c5ae926aba4b86e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.lead.thoughts.group.expand-item", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained click expands a lead-thought activity item."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sourceHash": "sha256:9b1cd0dbc06e2b98", + "siteCount": 3 + } + ] + }, + { + "id": "team.legacy-control.activity.lead.thoughts.group.handle-body-toggle", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sourceHash": "sha256:760064b4e0e491ee", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sourceHash": "sha256:79d283e138e8aeb1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sourceHash": "sha256:88576fbb278b499d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sourceHash": "sha256:c3d65956de4b8afa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.pending.replies.block.on-member-click", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/PendingRepliesBlock.tsx", + "sourceHash": "sha256:559a0e684ecb6d7a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.reply.quote.block.set-expanded", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ReplyQuoteBlock.tsx", + "sourceHash": "sha256:6154ba301d82e191", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.thought.body.content.handle-reply", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ThoughtBodyContent.tsx", + "sourceHash": "sha256:3df432be4adb4daa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.activity.thought.body.content.open-task-link", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": ["Captured task-link activation is a semantic navigation action."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ThoughtBodyContent.tsx", + "sourceHash": "sha256:436f1ae0b82487d7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.attachments.attachment.display.reveal-file", + "owner": "agent-attachments", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-9-ATTACHMENTS", + "evidence": ["The contained click reveals the attachment in the editor."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/attachments/AttachmentDisplay.tsx", + "sourceHash": "sha256:4549841228813945", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.attachments.attachment.display.set-lightbox-index", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/attachments/AttachmentDisplay.tsx", + "sourceHash": "sha256:d86a3ee9a8d0f707", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.attachments.attachment.preview.item.on-preview", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/attachments/AttachmentPreviewItem.tsx", + "sourceHash": "sha256:8f6db31e809ba621", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.attachments.attachment.preview.item.on-remove", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/attachments/AttachmentPreviewItem.tsx", + "sourceHash": "sha256:ad91c210ee511e02", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.attachments.attachment.preview.list.on-dismiss-error", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/attachments/AttachmentPreviewList.tsx", + "sourceHash": "sha256:c42f7c0bd5709b16", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.attachments.attachment.thumbnail.button-on-click", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/attachments/AttachmentThumbnail.tsx", + "sourceHash": "sha256:9d8dd10677000fbf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.filter.popover.button-implicit-action", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:2060ed5e48f06cfa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.filter.popover.handle-reset", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:860fed20d5d1788f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.filter.popover.handle-save", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:89f4f78e8ef143ef", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.filter.popover.toggle-kind", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:1a03106d2f7f1b9f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:65ac8be6bc9b1e0f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:c5d93359810b859e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.filter.popover.toggle-stream", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:2437052668d35413", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:f98f9e02b9cf40c4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.panel.apply-pending", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsPanel.tsx", + "sourceHash": "sha256:ce7f5b87c7280207", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.panel.load-older-logs", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsPanel.tsx", + "sourceHash": "sha256:c8b33959d668022e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.panel.set-search-query", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsPanel.tsx", + "sourceHash": "sha256:4e22903fa28a7450", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsPanel.tsx", + "sourceHash": "sha256:f14d8f464ef8f114", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.section.on-source-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:67599cbbaba93945", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.section.open-dialog", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained click opens the selected logs dialog."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:f01a74e48ab0db88", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.section.selected-member", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:c5f8ddc5de0fb528", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.claude.logs.section.set-selected-source-key", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:38f45e36f541f950", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.cli.logs.rich.view.on-toggle", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/CliLogsRichView.tsx", + "sourceHash": "sha256:2646e344df8b7d3a", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/CliLogsRichView.tsx", + "sourceHash": "sha256:92d41522fa881272", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/CliLogsRichView.tsx", + "sourceHash": "sha256:c7498daea8a3e729", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/CliLogsRichView.tsx", + "sourceHash": "sha256:d8284ab9c9fb0eb5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.collapsible.team.section.set-open", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/CollapsibleTeamSection.tsx", + "sourceHash": "sha256:ed30a2c92627299f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.add.member.dialog.handle-submit", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed add-member submission; the scanner maps this legacy child ID to team.lifecycle.add-member and enforces the API owner." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AddMemberDialog.tsx", + "sourceHash": "sha256:4fb257c290cbb747", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.add.member.dialog.on-close", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed add-member dialog control; ownership follows the team lifecycle operation composed by this child." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AddMemberDialog.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.add.member.dialog.set-members", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed add-member draft control; ownership follows the team lifecycle operation composed by this child." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AddMemberDialog.tsx", + "sourceHash": "sha256:18314de50ac15794", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.advanced.cli.section.commit-worktree-name", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["Blur commits the selected worktree name to renderer-local history."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:c1c7f5fbfb81fd53", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.advanced.cli.section.handle-custom-args-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:0d3bc93ca9be5a98", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.advanced.cli.section.handle-validate", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:d153c061599f89bd", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.advanced.cli.section.on-worktree-enabled-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:a1a930afc781506a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.advanced.cli.section.on-worktree-name-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:8d34f712f4b4025a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.advanced.cli.section.select-worktree-history", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Mouse selection updates the worktree name and closes its history popover; default suppression is only containment." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:e6a6ac571f32e01f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.advanced.cli.section.set-is-open", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:ca1f60e0b25ae80d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.anthropic.extra.usage.warning.a-navigate", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx", + "sourceHash": "sha256:d54edf670b7f3f5d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.anthropic.fast.mode.selector.button-on-click", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx", + "sourceHash": "sha256:dd1d63ca0667a1ad", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.codex.fast.mode.selector.button-on-click", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CodexFastModeSelector.tsx", + "sourceHash": "sha256:dd1d63ca0667a1ad", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.codex.model.catalog.fallback.notice.on-update", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CodexModelCatalogFallbackNotice.tsx", + "sourceHash": "sha256:72a8e38b31f78583", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.codex.reconnect.prompt.on-device-code-reconnect", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx", + "sourceHash": "sha256:ec5ccd285d814dfb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.codex.reconnect.prompt.open-external", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx", + "sourceHash": "sha256:868be2c5570a8df0", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.handle-submit", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:22d0f0395703c5e0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:4fb257c290cbb747", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.on-close", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.set-blocked-by-search", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:640f0fd45311b25a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.set-owner", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:5e5913be8e02e68e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.set-related-search", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:74ba7e5481ee2aaf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.set-show-optional-fields", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:762fde2a6e3d4e73", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.set-start-immediately", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:ddfcf6a1ee7d0dee", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.set-subject", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:b84dc8559ca2f4cc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.set-value", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:a0d21a43a409f198", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:b595fc47dbad28f4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.toggle-blocked-by", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:8d0c842df9b86dbd", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.create.task.dialog.toggle-related", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:0a40439e96fda7b1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.edit.team.dialog.clear-transient-errors", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:3c674be7bfd5ed09", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:60b36aa10e62d143", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:cdfbccdf13077442", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:ff5c8716807a3bda", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.edit.team.dialog.handle-save", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:147f6cc5b33ebefa", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:89f4f78e8ef143ef", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.edit.team.dialog.on-close", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.effort.level.selector.button-on-click", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/EffortLevelSelector.tsx", + "sourceHash": "sha256:864641f3781829a5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.experimental.local.model.override.checkbox-on-checked-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Records the explicit experimental local-model override selected for this team launch." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ExperimentalLocalModelOverride.tsx", + "sourceHash": "sha256:a39765a59375118b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.global.task.detail.open-team", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed globally mounted task-detail navigation control; no remote mutation is implied." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx", + "sourceHash": "sha256:159ec492291198df", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.a-navigate", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:dd72b478da5dc495", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.close-dialog", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:ec5979de08611b7f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.handle-submit", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:7a8bb7d6691ae182", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-allow-experimental-local-models", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Updates the reviewed launch draft's explicit experimental local-model override." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:9c184401f5913a9b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-conflict-dismissed", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:8ae85785caeac217", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-max-budget-usd", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:d9c62f7bc0b7316b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-max-turns", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:6f36c61f588155f7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-sched-expanded", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:6b0010b808dbf984", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-sched-label", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:c906db2f343959c1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-selected-effort", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:69d44025e3159010", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-selected-fast-mode", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:460a6a2c6167d111", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:6eb6d60bd8a2b4f9", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-selected-model", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:475c27e450b1966c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-selected-team-name", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:63d5e65eadb10c41", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-skip-permissions", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:cdb8b3b29e5bd5dc", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.dialogs.launch.team.dialog.set-value", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:b595fc47dbad28f4", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.dialogs.limit.context.checkbox.checkbox-on-checked-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/LimitContextCheckbox.tsx", + "sourceHash": "sha256:d559eeaff913da1d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.members.json.editor.on-close", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/MembersJsonEditor.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.approve", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Forwards an explicitly approved private-network local-model target to the provider-management renderer boundary for testing." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx", + "sourceHash": "sha256:9a1c55d9a2c0a180", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.cancel", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Closes the private-network approval prompt and clears its renderer-local approval state." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx", + "sourceHash": "sha256:56a483faf0535891", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx", + "sourceHash": "sha256:ec1bcf4ca573ce82", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.set-approved", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Records explicit renderer-local consent for the reviewed private-network target." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx", + "sourceHash": "sha256:51efb043d08e8d3c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.open.code.local.models.feedback.retry", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Retries the local-model lookup through the provider-management renderer boundary." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/OpenCodeLocalModelsFeedback.tsx", + "sourceHash": "sha256:3c7874e3acc27459", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.optional.settings.section.handle-toggle-open", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/OptionalSettingsSection.tsx", + "sourceHash": "sha256:1dadf3ee2133253f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.project.path.selector.on-custom-cwd-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "sourceHash": "sha256:57837e7119ca8908", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.project.path.selector.on-cwd-mode-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "sourceHash": "sha256:9a314fed16f46386", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "sourceHash": "sha256:f396779d53c48a31", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.project.path.selector.on-selected-project-path-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "sourceHash": "sha256:972d9a703667be0a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.project.path.selector.select-folders", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "sourceHash": "sha256:2733ef581ae6be7c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.provisioning.provider.status.list.copy-support-diagnostics", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx", + "sourceHash": "sha256:1c33ab1082d34d7a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.provisioning.provider.status.list.on-open-provider-settings", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx", + "sourceHash": "sha256:ad15c8016c689996", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.review.dialog.handle-submit", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ReviewDialog.tsx", + "sourceHash": "sha256:b0a5b6551de6254e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.review.dialog.set-value", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ReviewDialog.tsx", + "sourceHash": "sha256:0f993adb0364f137", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.button-on-click", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:23575b99a3ec0245", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.handle-drop-wrapper", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:0aa522480c988f84", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.handle-file-input-change", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:ecf725a5bf914c68", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.handle-paste-wrapper", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": ["Clipboard files enter the bounded message attachment flow."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:5c60d794af536bea", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.handle-submit", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:b0a5b6551de6254e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.set-action-mode", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:1083d5415080aece", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.set-member", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:338157eede87f7f7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.set-quote", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:d4dcdb7cda23aebe", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.set-quote-expanded", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:044656db8bb51d6c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.send.message.dialog.set-value", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:2769f62c04b77f24", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.skip.permissions.checkbox.checkbox-on-checked-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx", + "sourceHash": "sha256:d559eeaff913da1d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.attachments.button-on-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sourceHash": "sha256:5394e9ac9cf0f993", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.attachments.handle-drop", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sourceHash": "sha256:cc484c1a462a5936", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.attachments.handle-file-select", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sourceHash": "sha256:1c1709b687a3831f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.attachments.on-preview", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sourceHash": "sha256:baa1d5f971ad5ab1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.attachments.remove", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": ["The contained click removes a task attachment."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sourceHash": "sha256:337463bd9f0728f8", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.add-files", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:49c2025f629fc6a5", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:ce17f7b90a4ed042", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.button-on-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:23575b99a3ec0245", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.handle-submit", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:c4711728543e3896", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.on-clear-reply", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:d459482f74869f10", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.open-external", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:1930cef4ef48b7aa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.remove-attachment", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": ["The contained click removes a pending comment attachment."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:3b1bf79189c59b8e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.set-lightbox-index", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:ed413735bae6df87", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.set-quote-expanded", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:044656db8bb51d6c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comment.input.set-value", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "sourceHash": "sha256:78dafed7fc648f8e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comments.section.handle-submit", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sourceHash": "sha256:c4711728543e3896", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comments.section.on-preview", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sourceHash": "sha256:5063f7f70094b11d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comments.section.on-reply", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sourceHash": "sha256:f66bdfce139d045e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comments.section.open-task-link", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Captured task-link activation is a semantic navigation action even though the handler also contains the parent click event." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sourceHash": "sha256:5e15e3064027e405", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comments.section.set-reply-to", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sourceHash": "sha256:751319ed97a12502", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comments.section.set-value", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sourceHash": "sha256:0f993adb0364f137", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.comments.section.set-visible-count", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "sourceHash": "sha256:6ed15ef5d220029b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.clear-clarification", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": ["The contained click clears the task clarification state."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:948ebb56a04f9668", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.handle-dependency-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:021705b23ece0331", + "siteCount": 4 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.on-delete-task", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:cc8553b286400412", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.on-owner-change", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:3c4ae868ca1c6f66", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.on-preview", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:ce4b111e83de5309", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.on-view-changes", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:0f19fa7312b191e4", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:4cecf1aa0432a3f8", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:e6859407fcfa8f46", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.open-file-in-editor", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained click opens the changed file in the editor."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:3396c30df3ddab56", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.open-task-link", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Captured description task-link activation is a semantic navigation action even though the handler also contains the parent click event." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:e8f41755b35aa23e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.refresh-changes", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["The contained click refreshes task changes."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:4acf2eae8f586d13", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.save-description", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:3829d019ae6f70fa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.save-subject", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Enter and blur both commit the edited task subject through updateTaskFields; propagation containment on Enter is not the semantic effect." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:b834d684e97eb77c", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:ffbee3e9b15abf4b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.set-description-draft", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:05cead1401b5923a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.set-editing-description", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:93962f93c60e163c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.set-subject-draft", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:67400a38bd5f5f82", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.start-edit-description", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:34ac5ab7760542b1", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.dialogs.task.detail.dialog.start-edit-subject", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:9efc0f174eb61fbf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.button-on-click", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:810a54f86c6a89c3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.clear-open-code-filters", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Clears every reviewed OpenCode model filter without changing provider or model ownership." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:d9547002c6b51b05", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.input-on-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:3252000e85dcf19c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.refresh-open-code-local-providers", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Refreshes the local OpenCode provider catalog through the provider-management renderer boundary." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:fa4de5c4343b2a0e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.select-local-models", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Selects the reviewed local-models surface while retaining the model selector's provider boundary." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:89f66ae52d695ad1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.selected-provider-id", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:aacf73143639013c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.selector", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:c48b6df013f974bb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.set-free-only", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:6dc1bd60808700a5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.set-inspected-provider-id", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:c0ebb76b460439cb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.set-model-query", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:10fb7a27563b057f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:7c1f38bc7952f185", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.set-new-only", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["Toggles the reviewed recently-released model filter."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:9d43df1c40e5bd13", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.set-open-code-source-query", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:df0ce05ac83533a4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.set-recommended-only", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:40fd58216316d4b1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.set-selected-open-code-source-ids", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:e89d93d04c9671db", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.start", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:2bb2bdfee1c697b5", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:50a3c0db14930221", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.toggle-open-code-route-tag", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Toggles a reviewed OpenCode route-status filter independently of source filtering." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:17a904c69575ab0b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.team.model.selector.toggle-open-code-source-filter", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:0fc2b993439e07e7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:572e2b448b909358", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.teammate.runtime.compatibility.notice.on-open-dashboard", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx", + "sourceHash": "sha256:66e95fdb697761b9", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.tool.approval.settings.update-timeout-seconds", + "owner": "team-approvals", + "disposition": "decomposed", + "securityClass": "approval-policy", + "target": "WP-9-APPROVALS", + "evidence": ["Blur validates and persists the tool approval timeout setting."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:fa074c37a7cd5cec", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.worktree.git.readiness.banner.button-on-click", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx", + "sourceHash": "sha256:8335aa5d0ff87b9e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.dialogs.worktree.git.readiness.banner.create-initial-commit", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx", + "sourceHash": "sha256:3e0fc8827976633a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.binary.placeholder.handle-open-external", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx", + "sourceHash": "sha256:a834d7f422c4709a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.capture-target", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "The context-menu event selects the file target used by the visible editor menu." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:f681bb4cde566ba3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.context-menu-item-on-select", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:50ff16eddeee89d8", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:620a7348fb2eed2a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:a8028e4c9f679ba2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.on-create-task", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:e6fc5c43f303fbd6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.on-delete", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:84e4d2a23b7bb9c7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.on-new-file", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:a88e14ec390c4ec4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.on-new-folder", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:c2164068ef21b97d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.on-rename", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:4e329b524c8af1f8", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.context.menu.on-send-message", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorContextMenu.tsx", + "sourceHash": "sha256:b9fa2dee3e6146d1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.error.boundary.handle-retry", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorErrorBoundary.tsx", + "sourceHash": "sha256:1c1a80b7bc1db652", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.error.state.on-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorErrorState.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.error.state.on-retry", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorErrorState.tsx", + "sourceHash": "sha256:3c7874e3acc27459", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-cancel-delete", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:97e749305880f84b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-click", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:6205a0482f291e19", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-confirm-delete", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:5d4b4cefb14d7039", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-drag-end", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:70b73e5fc93691a2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-drag-start", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Drag start establishes the editor file-tree item for the subsequent move action." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:73a07f14b212f146", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-key-down", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:70a068044f5b9d78", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-new-item-submit", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:151747a5e64177ec", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:f689c4c30d5eb6d3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.handle-node-click", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:0ffa016838d1496f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.on-rename-submit", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:3426659605f1217f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.file.tree.set-value", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:6e39a42add585b77", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.image.preview.handle-open-external", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorImagePreview.tsx", + "sourceHash": "sha256:a834d7f422c4709a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.image.preview.set-lightbox-open", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorImagePreview.tsx", + "sourceHash": "sha256:a897c4d1494f0ccc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.button-on-click", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:9d8dd10677000fbf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.handle-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:bdae76e1dc7a313e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.handle-find-next", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:21fcc1874d599637", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.handle-find-prev", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:bb41769acc35383d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.handle-replace-all", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:a4c29c0480bee6f2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.handle-replace-key-down", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:3d3975f2adb533ce", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.handle-replace-next", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:e5fc3eaf5485bb35", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.handle-search-key-down", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:6b67d8251b7eecec", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.set-case-sensitive", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:715ab701362654cc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.set-replace-text", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:e7230a3dcf929fa4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.set-search-text", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:8142b4a576157a2e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.set-show-replace", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:cc24b856a5d25b9c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.set-use-regexp", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:a168081b6de3a100", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.search.panel.set-whole-word", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "sourceHash": "sha256:9b3e4f35054cfb3e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.selection.menu.button-on-click", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSelectionMenu.tsx", + "sourceHash": "sha256:150fa828171f33aa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.selection.menu.on-create-task", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSelectionMenu.tsx", + "sourceHash": "sha256:4750d3247bd0ceaa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.selection.menu.on-send-message", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorSelectionMenu.tsx", + "sourceHash": "sha256:c1d11540b3a81f2a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.status.bar.toggle-watcher", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorStatusBar.tsx", + "sourceHash": "sha256:e1be7bb0cf5e76eb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.bar.handle-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sourceHash": "sha256:3bea81922bfdd4ec", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sourceHash": "sha256:6e2e253c731c77d9", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.bar.handle-drag-end", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sourceHash": "sha256:70b73e5fc93691a2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.bar.handle-drag-start", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["Drag start establishes the editor tab for the subsequent reorder action."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sourceHash": "sha256:73a07f14b212f146", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.bar.on-activate", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sourceHash": "sha256:72cd053d873aedfd", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.context.menu.on-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabContextMenu.tsx", + "sourceHash": "sha256:a7542898e4f99a43", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.context.menu.on-close-all", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabContextMenu.tsx", + "sourceHash": "sha256:de977b2d4e46245d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.context.menu.on-close-others", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabContextMenu.tsx", + "sourceHash": "sha256:97820edd3e82af53", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.context.menu.on-close-to-left", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabContextMenu.tsx", + "sourceHash": "sha256:824f098c5f112f97", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.tab.context.menu.on-close-to-right", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorTabContextMenu.tsx", + "sourceHash": "sha256:a72ace722d9c2bfa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.toolbar.button-on-click", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sourceHash": "sha256:150fa828171f33aa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.toolbar.handle-redo", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sourceHash": "sha256:98165effca6b82e6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.toolbar.handle-save", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sourceHash": "sha256:e5a231e61a272d8a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.toolbar.handle-undo", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sourceHash": "sha256:8d18187a49d8ef63", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.toolbar.on-toggle-full-preview", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sourceHash": "sha256:787e87f75857f9c6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.toolbar.on-toggle-split", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sourceHash": "sha256:c112d0b1acbac05c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.editor.toolbar.toggle-line-wrap", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/EditorToolbar.tsx", + "sourceHash": "sha256:60dd93072558fe4d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.go.to.line.dialog.handle-go", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/GoToLineDialog.tsx", + "sourceHash": "sha256:6e492356fd0ee852", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.go.to.line.dialog.handle-key-down", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/GoToLineDialog.tsx", + "sourceHash": "sha256:d98d81e48a8a91fb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.go.to.line.dialog.on-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/GoToLineDialog.tsx", + "sourceHash": "sha256:c361e9d9fef1bf78", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/GoToLineDialog.tsx", + "sourceHash": "sha256:e71c1e77d477b5eb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.go.to.line.dialog.set-value", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/GoToLineDialog.tsx", + "sourceHash": "sha256:bb2b3396f7083929", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.markdown.split.view.resize", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["Pointer-driven divider resizing changes the mounted editor layout."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/MarkdownSplitView.tsx", + "sourceHash": "sha256:f0dc8db4d8cbe64b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.new.file.dialog.handle-change", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/NewFileDialog.tsx", + "sourceHash": "sha256:2e02a1335684a63c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.new.file.dialog.handle-key-down", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/NewFileDialog.tsx", + "sourceHash": "sha256:2258dda53fa6812c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.close-editor-tab", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:c1605279efb6db43", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-cancel-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:d5c5b6b0421253e3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-cancel-close-tab", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:73e831c94e2e1c0d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-cancel-conflict", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:27d7e9814e1ff86a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-close-request", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:ebba9c9acb4d6dc6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-discard-and-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:1235bde8107f252f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-discard-and-close-tab", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:fe5c782fdbbf6b1f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-discard-draft", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:c4bfe84a733abe3b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-dismiss-draft-banner", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:b9663750abcbb441", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-force-overwrite", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:287e3b76153b3658", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-keep-mine", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:9aa5289f0d604bed", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-manual-refresh", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:71c886f00da9175f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-reload-external-change", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:97078b48034972dc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-save-and-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:24054be910ff4d63", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.handle-save-and-close-tab", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:705e41726415c3e4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.save-file", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:b016dc6e58840710", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.set-shortcuts-help-visible", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:cffd52e07b4fcccc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.project.editor.overlay.toggle-sidebar", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:f9b330ea2ff28fb4", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.editor.quick.open.dialog.handle-select", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/QuickOpenDialog.tsx", + "sourceHash": "sha256:417c894eb9d816e7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.quick.open.dialog.on-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/QuickOpenDialog.tsx", + "sourceHash": "sha256:c361e9d9fef1bf78", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/QuickOpenDialog.tsx", + "sourceHash": "sha256:e71c1e77d477b5eb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.search.in.files.panel.handle-case-sensitive-toggle", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "sourceHash": "sha256:61980f57ee660bc6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.search.in.files.panel.handle-query-change", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "sourceHash": "sha256:8f91af26ba6781a2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.search.in.files.panel.on-close", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.search.in.files.panel.on-select-match", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "sourceHash": "sha256:d4947db6cc28af4b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.editor.search.in.files.panel.on-toggle", + "owner": "team-console", + "disposition": "desktop-only", + "securityClass": "desktop-shell", + "target": "ABSENT-BEFORE-HOSTED-MOUNT", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "sourceHash": "sha256:2646e344df8b7d3a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "sourceHash": "sha256:714ae75d2984398e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.board.add-handler", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sourceHash": "sha256:dacd98373c3111cc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.board.button-on-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sourceHash": "sha256:9aae3a3e7b342bd2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sourceHash": "sha256:a163939d4a5176f9", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sourceHash": "sha256:da08b879ddc9545c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.board.handle-drag-end", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sourceHash": "sha256:70b73e5fc93691a2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.board.handle-props", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sourceHash": "sha256:a2d89139281b9aa7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.board.on-open-trash", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanBoard.tsx", + "sourceHash": "sha256:eb738d2bbc359be5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.filter.popover.button-implicit-action", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:037750eaf41a30f4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.filter.popover.handle-clear-all", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:9a50d22e5652ab4f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.filter.popover.handle-column-toggle", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:ef686c0ff19f2c10", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.filter.popover.handle-owner-toggle", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:6eb102b1932fed12", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:f00ae16ba221cb25", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.filter.popover.handle-session-select", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:a565f8e4fc4606a7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:ddf7fff0fe4a6b23", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.search.input.button-on-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "sourceHash": "sha256:37c877908aed431d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "sourceHash": "sha256:6b4931aa98305003", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.search.input.handle-key-down", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "sourceHash": "sha256:2258dda53fa6812c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.search.input.input-on-change", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "sourceHash": "sha256:542445918a83612c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.search.input.select-task", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "sourceHash": "sha256:9e0fcad7e020c7c8", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.sort.popover.button-implicit-action", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanSortPopover.tsx", + "sourceHash": "sha256:1ff14bc8954ca063", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.sort.popover.on-sort-change", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanSortPopover.tsx", + "sourceHash": "sha256:0ae61a8c32e918cb", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanSortPopover.tsx", + "sourceHash": "sha256:18f7ca3782abe410", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.attachment.mosaic.open-lightbox", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": ["Opens the reviewed task attachment in the bounded image lightbox."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx", + "sourceHash": "sha256:76a6214da3b9b5dd", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.approve-task", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["The contained click approves the task review."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:74c135b70a736526", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.button-on-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:150fa828171f33aa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.complete-task", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": ["The contained click completes the task."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:7d9fe604f5cad108", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.delete-task", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": ["The contained click deletes the task."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:b9baa31930ee1747", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.move-back-to-done", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": ["The contained click returns the task to done."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:449c22d1f2d8cdfd", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.on-task-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:82cb75d1d6a964b7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:bcfa4c958fadb421", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.open-task", + "owner": "team-task-board", + "disposition": "direct", + "securityClass": "team-read", + "target": "WP-8-TASK-BOARD", + "evidence": ["The contained click opens the selected task."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:09fe3d215bf39d4d", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.request-changes", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["The contained click requests review changes."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:381d345194f1b837", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.request-review", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["The contained click requests task review."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:1758cd0fcf33fefe", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.set-open", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:51dcd3b14062cfd7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:f171c6da12f3b1c0", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.start-task", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": ["The contained click starts the task."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:b1e9a1605b3b9e37", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.kanban.task.card.view-changes", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["The contained click opens task changes."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:f9bd2634aa08123e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.trash.dialog.on-close", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/TrashDialog.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.kanban.trash.dialog.on-restore", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/kanban/TrashDialog.tsx", + "sourceHash": "sha256:bce23b6a20b21a35", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.member.badge.select-member", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained badge click selects a member."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/MemberBadge.tsx", + "sourceHash": "sha256:b9d68bc2ebfb27be", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.current.task.indicator.open-task", + "owner": "team-task-board", + "disposition": "direct", + "securityClass": "team-read", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Click and keyboard activation open the current task; containment prevents opening the member card." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/CurrentTaskIndicator.tsx", + "sourceHash": "sha256:0f418a37e20a9ecc", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/CurrentTaskIndicator.tsx", + "sourceHash": "sha256:85ca85b4fcc679d7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.lead.model.row.on-effort-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/LeadModelRow.tsx", + "sourceHash": "sha256:85a6275b58d8b03c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.lead.model.row.on-limit-context-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/LeadModelRow.tsx", + "sourceHash": "sha256:f81035079dd04423", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.lead.model.row.on-model-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/LeadModelRow.tsx", + "sourceHash": "sha256:36dae0755851e898", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.lead.model.row.on-sync-models-with-teammates-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/LeadModelRow.tsx", + "sourceHash": "sha256:a5c8d5429dd56f8c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.lead.model.row.set-model-expanded", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/LeadModelRow.tsx", + "sourceHash": "sha256:51a531385d10fa64", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.card.div-on-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:be6067847d21e9e4", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:e20ed8d3ae6565d5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.card.handle-restart-member", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed member restart control; the scanner maps this legacy child ID to team.lifecycle.restart-member and enforces the API owner." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:398cdac18d991269", + "siteCount": 5 + } + ] + }, + { + "id": "team.legacy-control.members.member.card.handle-restore-member", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed member restore control; the scanner maps this legacy child ID to team.lifecycle.restore-member and enforces the API owner." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:d812381411a0d780", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.card.handle-skip-failed-launch", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:a4cbc87c805bfc2f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.card.on-assign-task", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberQuickActions.tsx", + "sourceHash": "sha256:39b93e3350569795", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.card.on-send-message", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberQuickActions.tsx", + "sourceHash": "sha256:4ab707f37dcd3028", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.dialog.on-assign-task", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:36ce0e6dd1a64091", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.dialog.on-remove-member", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:fafda63dc7df6242", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.dialog.on-send-message", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:edc25a3cdb7a3aeb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.dialog.set-active-tab", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:5fbf5c1083e7c1db", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.dialog.set-restart-error", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:3bf46ee1ab140cfb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.dialog.tabs-trigger-implicit-action", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:03a2b14dd9b57efb", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:8ef2a6693be1702c", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:fa3ce9971f2cc036", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:ffb516b4f710f1ec", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.header.set-editing", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailHeader.tsx", + "sourceHash": "sha256:54cdcd170ef061a8", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberQuickActions.tsx", + "sourceHash": "sha256:1dfcdfb03494f078", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.stats.button-on-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailStats.tsx", + "sourceHash": "sha256:9d8dd10677000fbf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.detail.stats.on-tab-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDetailStats.tsx", + "sourceHash": "sha256:0fd437022800aece", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDetailStats.tsx", + "sourceHash": "sha256:95c7da16da6519f6", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDetailStats.tsx", + "sourceHash": "sha256:bb6c5452f1f6a5d0", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.button-on-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:d3c336e38e12ff7f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.handle-mcp-mode-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:405e61ef2011df5a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.handle-workflow-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:bedfd56f09ded7b5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-effort-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:4ae4e5b96633aad3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-limit-context-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:f81035079dd04423", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-model-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:c8a21020554a2ac0", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-name-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:48c5933667a9260a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-remove", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:83624c5bdfe3250d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-restore", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:2957ba1dbd202651", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-role-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:cb3756948f68ad2a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.on-worktree-isolation-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:b018bee76d11f9a8", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.select-item-implicit-action", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:1358b8f49e2055a4", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:1dbb3c9aceefe984", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:87ce702a82465df4", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:9fe23e46dc233b3f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.select-trigger-implicit-action", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:98c755a43f46007f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.set-mcp-expanded", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:50c1c59d2be63237", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.set-model-expanded", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:51a531385d10fa64", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.toggle-workflow-expanded", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:fd2bf08005b68d00", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.update-mcp-scope", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:335b01914dc27564", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.draft.row.update-mcp-server-names", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberDraftRow.tsx", + "sourceHash": "sha256:0ca42b585cb8c596", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.execution.log.on-toggle-expanded", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberExecutionLog.tsx", + "sourceHash": "sha256:fcbe1525bd6993af", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.execution.log.set-agent-info-open", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberExecutionLog.tsx", + "sourceHash": "sha256:ad74f7f671a163e9", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.hover.card.open-profile", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained click opens the member profile."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberHoverCard.tsx", + "sourceHash": "sha256:73444aad60936a64", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.launch.diagnostics.button.copy-diagnostics", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberLaunchDiagnosticsButton.tsx", + "sourceHash": "sha256:dfc9faec5a25d4c8", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.list.handle-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberList.tsx", + "sourceHash": "sha256:1c81f9dccb20814c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.logs.tab.on-toggle", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberLogsTab.tsx", + "sourceHash": "sha256:2646e344df8b7d3a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberLogsTab.tsx", + "sourceHash": "sha256:99d9eed00c4b8c0f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.messages.tab.load-older-messages", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberMessagesTab.tsx", + "sourceHash": "sha256:fa11e9014eafa4cf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.messages.tab.set-activity-filter", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberMessagesTab.tsx", + "sourceHash": "sha256:8f2bd003298bd023", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.messages.tab.set-expanded-item", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberMessagesTab.tsx", + "sourceHash": "sha256:400d43a4e8a45239", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberMessagesTab.tsx", + "sourceHash": "sha256:48b58103af4e195f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.quick.actions.invoke", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Invokes the reviewed member quick-action callback while containing the parent member-row interaction." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberQuickActions.tsx", + "sourceHash": "sha256:81f3be0b253f385b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.stats.tab.on-file-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberStatsTab.tsx", + "sourceHash": "sha256:31a5e7dbbee7b2c3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.stats.tab.on-show-all", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberStatsTab.tsx", + "sourceHash": "sha256:865603edb68b6353", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.stats.tab.open-kiro-usage", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": ["Opens the fixed Kiro account usage URL without exposing runtime credentials."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberStatsTab.tsx", + "sourceHash": "sha256:8c9371333b6ec662", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.stats.tab.set-expanded", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberStatsTab.tsx", + "sourceHash": "sha256:51ec50ac130b54b2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.member.tasks.tab.on-task-click", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MemberTasksTab.tsx", + "sourceHash": "sha256:c256c129aaaa73da", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.members.editor.section.add-member", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed add-member entry control; ownership follows the team lifecycle operation opened by this child." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MembersEditorSection.tsx", + "sourceHash": "sha256:c3013f5d5ebe67d6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.members.editor.section.handle-json-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MembersEditorSection.tsx", + "sourceHash": "sha256:cd907d90933afe16", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.members.editor.section.toggle-json-editor", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MembersEditorSection.tsx", + "sourceHash": "sha256:6bb7038047952f63", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.members.editor.section.update-agent-teams-mcp-lock", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MembersEditorSection.tsx", + "sourceHash": "sha256:adb298abc9104b41", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.members.editor.section.update-teammate-worktree-default", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/MembersEditorSection.tsx", + "sourceHash": "sha256:8ca9646e8a8e4bce", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.subagent.recent.messages.preview.on-load-more", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx", + "sourceHash": "sha256:d772a27952143aff", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.subagent.recent.messages.preview.set-expanded-all", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx", + "sourceHash": "sha256:54a9503f22264083", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx", + "sourceHash": "sha256:fea85ba602133582", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.members.team.roster.editor.section.on-members-change", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/members/TeamRosterEditorSection.tsx", + "sourceHash": "sha256:88303c06462179a9", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.action.mode.selector.button-on-click", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/ActionModeSelector.tsx", + "sourceHash": "sha256:05df411a51c7253b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.button-implicit-action", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:1ff82e5efc2af2fa", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:3b6f0923ffdae25d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.button-on-click", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:23575b99a3ec0245", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.handle-drop-wrapper", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:af143ae5254dade2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.handle-file-input-change", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:ecf725a5bf914c68", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.handle-paste-wrapper", + "owner": "agent-attachments", + "disposition": "decomposed", + "securityClass": "bounded-content", + "target": "WP-9-ATTACHMENTS", + "evidence": ["Clipboard files enter the bounded composer attachment flow."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:6a4111d7d2940c97", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.handle-revision-cancel", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:b7ff1bf34c32df73", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.handle-send", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:f65cafeb004180cc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.open-external", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:1930cef4ef48b7aa", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.set-action-mode", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:1083d5415080aece", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.set-recipient", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:14690bc95522a1a0", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.set-recipient-search", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:af2027a7b3199457", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.set-selected-team", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:03f4e77a3c55e14e", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:8cf19f761556fd08", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.message.composer.set-text", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:eb9a0907ae47120a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.filter.popover.button-implicit-action", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sourceHash": "sha256:a9b5d3f2e0548916", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.filter.popover.handle-reset", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sourceHash": "sha256:860fed20d5d1788f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.filter.popover.handle-save", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sourceHash": "sha256:89f4f78e8ef143ef", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.filter.popover.set-draft", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sourceHash": "sha256:e66ddec95e72fee3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.filter.popover.toggle-from", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sourceHash": "sha256:76fe2fcf5e57caff", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.filter.popover.toggle-to", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sourceHash": "sha256:802f5b26b7601d04", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.dropdown-menu-item-on-select", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:0b09dcc73d8b7739", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:ce549a4b7bc1899a", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:d33115a046c3962e", + "siteCount": 3 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:da2daa4f6443829e", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.handle-mark-all-read", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:289b970d7f1e0295", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:b7133cf15aba7f05", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:c19a25c09f1236ae", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.move-to-floating-composer", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained click moves messages to the floating composer."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:9565b8e4aadca5cc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.move-to-inline", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Closing the mounted message sheet moves the message surface to its inline position." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:5f5910daf98c9ae0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:b0c71590e72d19c1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.move-to-sidebar", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The contained click moves messages to the sidebar."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:8a54843df3f8b1c2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.on-load-older-messages", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:eecf7f8123e239b0", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.open", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:38505ab577d21079", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:58504e372676f6e2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:aeb8574611b5a88c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.set-messages-collapsed", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:7842557d5bb4a34c", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:c894f06cdd1bdfb4", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:fa1e8f6a9b40c16b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.set-messages-search-bar-visible", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:4721402883141c58", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:8e6a1a8844194273", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.set-messages-search-query", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:1d94ff167be9bd42", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:5ccfc493b026f1f7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.messages.panel.toggle-bottom-sheet-expansion", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:23a4eea97b4a2f9f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:cf4e7b94acde4094", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.open.code.delivery.warning.handle-copy", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx", + "sourceHash": "sha256:e9803b74743f3e17", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.open.code.delivery.warning.set-expanded-key", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx", + "sourceHash": "sha256:bfcd3b1308f435e5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.messages.status.block.set-collapsed", + "owner": "team-messaging", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-MESSAGING", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/messages/StatusBlock.tsx", + "sourceHash": "sha256:8371900221f4de19", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.processes.section.kill-process", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProcessesSection.tsx", + "sourceHash": "sha256:57f6a5d8adf996c3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.processes.section.open-external", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProcessesSection.tsx", + "sourceHash": "sha256:cf382cc6bff2d6bb", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.provisioning.progress.block.copy-diagnostics", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "sourceHash": "sha256:2329304f8b156d8c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.provisioning.progress.block.on-cancel", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "sourceHash": "sha256:5ae058cd41c9af09", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.provisioning.progress.block.on-dismiss", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "sourceHash": "sha256:8a9fbf13ec55a744", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.provisioning.progress.block.set-diagnostics-open", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "sourceHash": "sha256:6339dc9f894411f7", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.provisioning.progress.block.set-live-output-open", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "sourceHash": "sha256:359e0bd630aa01c4", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.provisioning.progress.block.set-logs-open", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "sourceHash": "sha256:18c30c4853e71f2d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.change.review.conflicts.open-discard-confirmation", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Opens the explicit confirmation gate for deleting a conflicting recovery branch without mutating either branch." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx", + "sourceHash": "sha256:c482b3d393efcafe", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.change.review.conflicts.recover-candidate", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Switches to the selected compatible recovery branch while preserving the current branch as a recoverable copy." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx", + "sourceHash": "sha256:362691d41ec59619", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.change.review.conflicts.retry-load", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Retries verification of saved recovery branches while review mutations remain locked." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx", + "sourceHash": "sha256:f0fc4d947e39cc24", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.change.review.conflicts.run-discard", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Runs only the confirmed deletion of the selected conflicting recovery branch without changing project files." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx", + "sourceHash": "sha256:bf0baea67e85504a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.change.review.dialog.button-on-click", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ChangeReviewDialogView.tsx", + "sourceHash": "sha256:6d9df3d79cb041c0", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.change.review.dialog.set-timeline-open", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ChangeReviewSidebar.tsx", + "sourceHash": "sha256:e4bafd44818fcf8a", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.code.mirror.diff.view.accept-active-chunk", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Mouse activation accepts the active review chunk; default suppression only preserves editor focus." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "sourceHash": "sha256:8d1e667f3f7399a2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.code.mirror.diff.view.move-between-chunks", + "owner": "team-review", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-9-REVIEW", + "evidence": [ + "Mouse activation navigates between review chunks; default suppression only preserves editor focus." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "sourceHash": "sha256:4119204bd4815774", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "sourceHash": "sha256:b4834fa4d85f7d1b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.code.mirror.diff.view.reject-active-chunk", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Mouse activation rejects the active review chunk; default suppression only preserves editor focus." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "sourceHash": "sha256:668b838dfd60b27c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.diff.error.boundary.set-state", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/DiffErrorBoundary.tsx", + "sourceHash": "sha256:da065a6c807cfa60", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.edit.timeline.on-event-click", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/FileEditTimeline.tsx", + "sourceHash": "sha256:1e060a0df3b36305", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.handle-header-click", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:9e05499709a8493d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.handle-header-key-down", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:662276bb914ba803", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.on-accept-file", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:d47840d46a76f203", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.on-discard", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:1f5af573b4283064", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.on-keep-draft", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:b014b06093f49267", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.on-reject-file", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:d78b87a9cd22ca9e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.on-reload-from-disk", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:9ec5878ca549c8c9", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.on-restore-missing-file", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:b009f291508abc15", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.file.section.header.on-save", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/FileSectionHeader.tsx", + "sourceHash": "sha256:1580b468000c02a8", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.keyboard.shortcuts.help.button-on-click", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/KeyboardShortcutsHelp.tsx", + "sourceHash": "sha256:191efb3695441fbf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.action.history.popover.navigate-to-action", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["Navigates to the file-backed review action selected from retained history."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:c512b00e999e336e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.action.history.popover.open-history", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["Opens the retained review action history surface."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:f5daf8bf356c6a2b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.action.history.popover.request-restore", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Prepares a checkpoint restore preview before any review or disk transition is applied." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:37870a899a708ea1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:900f949becc50693", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.action.history.popover.retry-persistence", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Retries persistence of the latest review action through the owning review workflow." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:8c820300e854ec66", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.action.history.popover.run-restore", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["Runs only the confirmed, revalidated review-history restore request."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:bd6fc14c2953a704", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.action.history.popover.show-older", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["Expands the retained history window without mutating review decisions."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:af982a72ee143ef2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.file.tree.on-select-file", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sourceHash": "sha256:bb19d502ae3f85ab", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.file.tree.on-toggle-folder", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sourceHash": "sha256:26dffc67e10890fd", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.file.tree.set-filter-new", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sourceHash": "sha256:090e131a1e7420a5", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.file.tree.set-filter-rejected", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sourceHash": "sha256:be159ef6bc193e23", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.file.tree.set-filter-unresolved", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sourceHash": "sha256:1dbfec2f05aca127", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.file.tree.set-query", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sourceHash": "sha256:30adde618d9d3f90", + "siteCount": 1 + }, + { + "file": "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "sourceHash": "sha256:c8fb366849e90b1d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.toolbar.on-accept-all", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewToolbar.tsx", + "sourceHash": "sha256:f729ef93a109fa39", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.toolbar.on-apply", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewToolbar.tsx", + "sourceHash": "sha256:f66b2cf52cdfc405", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.toolbar.on-auto-viewed-change", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewToolbar.tsx", + "sourceHash": "sha256:57e516abbfff82c3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.toolbar.on-redo", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["Reapplies the next reviewed action from the bounded redo history."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewToolbar.tsx", + "sourceHash": "sha256:c48bb67e828f6d21", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.toolbar.on-reject-all", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewToolbar.tsx", + "sourceHash": "sha256:6d6a27e74c73c9d6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.review.toolbar.on-undo", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ReviewToolbar.tsx", + "sourceHash": "sha256:78813f4b1bcf062e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.saved.review.state.recovery.gate.open-discard-confirmation", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Opens the explicit confirmation gate without deleting saved review recovery data." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx", + "sourceHash": "sha256:5a825749b237ee5d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.saved.review.state.recovery.gate.retry-load", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Retries loading unreadable review recovery state while leaving the stored copy untouched." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx", + "sourceHash": "sha256:3c7874e3acc27459", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.saved.review.state.recovery.gate.run-discard", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Runs the confirmed deletion of unreadable review recovery data without changing project files." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx", + "sourceHash": "sha256:71f805080d3f440f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.scope.warning.banner.on-dismiss", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ScopeWarningBanner.tsx", + "sourceHash": "sha256:0d11da2c9aa8ee58", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.review.scope.warning.banner.set-expanded", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/review/ScopeWarningBanner.tsx", + "sourceHash": "sha256:51ec50ac130b54b2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.role.select.handle-custom-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/RoleSelect.tsx", + "sourceHash": "sha256:94555a767bd3d768", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.role.select.handle-value-change", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/RoleSelect.tsx", + "sourceHash": "sha256:2513be99882acade", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.cron.schedule.input.on-cron-expression-change", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sourceHash": "sha256:4ceb2e3faf7c3e83", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sourceHash": "sha256:84e9852c762fda6e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.cron.schedule.input.on-timezone-change", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sourceHash": "sha256:17d8b5f046508240", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.cron.schedule.input.on-warm-up-minutes-change", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sourceHash": "sha256:67b1cd2ea566b4c9", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.cron.schedule.input.select-item-implicit-action", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sourceHash": "sha256:3d52cc1ed779b3f9", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sourceHash": "sha256:ac2bf2d47ed95393", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.cron.schedule.input.select-trigger-implicit-action", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "sourceHash": "sha256:1a7ffd59f07eefa3", + "siteCount": 2 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.run.log.dialog.on-close", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx", + "sourceHash": "sha256:2ac17d45d6b87776", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.run.row.div-on-click", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleRunRow.tsx", + "sourceHash": "sha256:4608ad394dda92b8", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/schedule/ScheduleRunRow.tsx", + "sourceHash": "sha256:74d0b7ee50c0aacc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.button-implicit-action", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:81e89f82cc61da9d", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.handle-create", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:0a3965c0c9464c85", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.handle-expand", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:adcbc16e6f23ed52", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.on-delete", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:9fdc23f915926061", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.on-edit", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:4783618710bf26f6", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.on-pause", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:087933c406a1ecac", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:6429bf3743988762", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.on-resume", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:20dace1dfa824ff1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.on-trigger-now", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:0c5fe0978fcb11bc", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.schedule.schedule.section.set-selected-run", + "owner": "team-schedule", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-SCHEDULES", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/schedule/ScheduleSection.tsx", + "sourceHash": "sha256:a7c5c148395f97eb", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.sidebar.team.sidebar.rail.resize-logs", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["Mouse-down begins resizing the mounted logs panel."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/sidebar/TeamSidebarRail.tsx", + "sourceHash": "sha256:473f548b65cd06b2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.sidebar.team.sidebar.rail.resize-sidebar", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["Mouse-down begins resizing the mounted team sidebar."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/sidebar/TeamSidebarRail.tsx", + "sourceHash": "sha256:16913658139e8b59", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.task.logs.task.activity.section.on-toggle", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/taskLogs/TaskActivitySection.tsx", + "sourceHash": "sha256:2646e344df8b7d3a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/taskLogs/TaskActivitySection.tsx", + "sourceHash": "sha256:89b9575674feeb83", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.task.logs.task.logs.panel.set-active-tab", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx", + "sourceHash": "sha256:38cf62e7cce76a2e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.task.logs.task.logs.panel.tabs-trigger-implicit-action", + "owner": "team-task-board", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-8-TASK-BOARD", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx", + "sourceHash": "sha256:2cefd5b62ce5e9a2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx", + "sourceHash": "sha256:b3dd66172834da8f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx", + "sourceHash": "sha256:df3e2d3a29f24aee", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.changes.section.on-open-task", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamChangesSection.tsx", + "sourceHash": "sha256:461d1c5d470d48d3", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.changes.section.on-view-changes", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamChangesSection.tsx", + "sourceHash": "sha256:525bc3f0efb44e02", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamChangesSection.tsx", + "sourceHash": "sha256:75a0eb88c9577b60", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamChangesSection.tsx", + "sourceHash": "sha256:a1d006c56aa7ff68", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamChangesSection.tsx", + "sourceHash": "sha256:c37418c8c5a72dcf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.changes.section.refresh", + "owner": "team-review", + "disposition": "decomposed", + "securityClass": "review-change-set", + "target": "WP-9-REVIEW", + "evidence": ["The contained click refreshes the visible change set."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamChangesSection.tsx", + "sourceHash": "sha256:34b70081a41b3c78", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.empty.state.on-create-team", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamEmptyState.tsx", + "sourceHash": "sha256:685d8d76af0d2b0b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.empty.state.on-import-team", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": ["The empty-state import control opens the reviewed local team-import workflow."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamEmptyState.tsx", + "sourceHash": "sha256:4eda11d99450e79c", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.list.filter.popover.button-implicit-action", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListFilterPopover.tsx", + "sourceHash": "sha256:05fe8602224eab1e", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.list.filter.popover.handle-clear-all", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListFilterPopover.tsx", + "sourceHash": "sha256:9a50d22e5652ab4f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.list.filter.popover.handle-project-toggle", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListFilterPopover.tsx", + "sourceHash": "sha256:a56e210a56971e71", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.list.filter.popover.handle-status-toggle", + "owner": "team-console", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-7-TEAM-CONSOLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListFilterPopover.tsx", + "sourceHash": "sha256:94368e8568421121", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamListFilterPopover.tsx", + "sourceHash": "sha256:c10868f460ab8710", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.provisioning.panel.retry-failed-open-code-secondary-lanes", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamProvisioningPanel.tsx", + "sourceHash": "sha256:9f2d4fd8ee026f7f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.provisioning.panel.set-dismissed", + "owner": "team-lifecycle", + "disposition": "decomposed", + "securityClass": "team-resource", + "target": "WP-7-TEAM-LIFECYCLE", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamProvisioningPanel.tsx", + "sourceHash": "sha256:9c049a3cc78ef2e2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.sessions.section.button-on-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamSessionsSection.tsx", + "sourceHash": "sha256:9d8dd10677000fbf", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.sessions.section.handle-session-click", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamSessionsSection.tsx", + "sourceHash": "sha256:1906099cc42207e7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamSessionsSection.tsx", + "sourceHash": "sha256:9bdff1fbf589081b", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.sessions.section.on-select-session", + "owner": "team-runtime-control", + "disposition": "decomposed", + "securityClass": "runtime-operator", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": [ + "Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamSessionsSection.tsx", + "sourceHash": "sha256:4697e3c1af281087", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.team.sessions.section.toggle-filter", + "owner": "team-runtime-control", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-5-RUNTIME-CONTROL", + "evidence": ["The contained click changes the session filter."], + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamSessionsSection.tsx", + "sourceHash": "sha256:2371d0ec1c024ef2", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.tool.approval.diff.preview.toggle-expanded", + "owner": "team-approvals", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-9-APPROVALS", + "evidence": [ + "Reviewed mounted approval diff disclosure control; it changes only renderer presentation state." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ToolApprovalDiffPreview.tsx", + "sourceHash": "sha256:2b22549ddb269e8f", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.tool.approval.settings.toggle-expanded", + "owner": "team-approvals", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-9-APPROVALS", + "evidence": [ + "Reviewed mounted approval settings disclosure control; it changes only renderer presentation state." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:2646e344df8b7d3a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:6c558a10417d1bb1", + "siteCount": 1 + } + ] + }, + { + "id": "team.legacy-control.tool.approval.sheet.select-question-option", + "owner": "team-approvals", + "disposition": "direct", + "securityClass": "renderer-local", + "target": "WP-9-APPROVALS", + "evidence": [ + "Reviewed mounted approval-question selection control; the selected value is submitted only through the approval response action." + ], + "sourceRefs": [ + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:20e8f7899e0f12da", + "siteCount": 1 + } + ] + } + ], + "apiActionBindings": [ + { + "actionId": "team.lifecycle.list", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "list", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamDirectoryTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.get-data", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "getData", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamViewDataTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.delete-team", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "deleteTeam", + "rendererCallers": ["src/renderer/composition/team/createTeamLifecycleMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.restore-team", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "restoreTeam", + "rendererCallers": ["src/renderer/composition/team/createTeamLifecycleMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.permanently-delete-team", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "permanentlyDeleteTeam", + "rendererCallers": ["src/renderer/composition/team/createTeamLifecycleMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.prepare-provisioning", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "prepareProvisioning", + "rendererCallers": [ + "src/renderer/composition/team/createRuntimeProviderProvisioningReadinessTransport.ts", + "src/renderer/composition/team/createTeamProvisioningPreparationTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.create-team", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "createTeam", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningLaunchTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.get-provisioning-status", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "getProvisioningStatus", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningControlTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.get-launch-failure-diagnostics", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "getLaunchFailureDiagnostics", + "rendererCallers": [ + "src/renderer/composition/team/createTeamProvisioningDiagnosticsTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.cancel-provisioning", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "cancelProvisioning", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningControlTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.create-config", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "createConfig", + "rendererCallers": ["src/renderer/composition/team/createTeamConfigurationTransport.ts"] + }, + { + "actionId": "team.lifecycle.launch-team", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "launchTeam", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningLaunchTransport.ts" + ] + }, + { + "actionId": "team.lifecycle.update-config", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "updateConfig", + "rendererCallers": ["src/renderer/composition/team/createTeamConfigurationTransport.ts"] + }, + { + "actionId": "team.lifecycle.add-member", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "addMember", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.replace-members", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "replaceMembers", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.remove-member", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "removeMember", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.restore-member", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "restoreMember", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.update-member-role", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "updateMemberRole", + "rendererCallers": ["src/renderer/composition/team/createTeamRosterMutationTransport.ts"] + }, + { + "actionId": "team.lifecycle.restart-member", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "restartMember", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"] + }, + { + "actionId": "team.lifecycle.skip-member-for-launch", + "owner": "team-lifecycle", + "source": "TeamsAPI", + "sourceMember": "skipMemberForLaunch", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"] + }, + { + "actionId": "workspace.registry.get-worktree-git-status", + "owner": "workspace-registry", + "source": "TeamsAPI", + "sourceMember": "getWorktreeGitStatus", + "rendererCallers": [ + "src/renderer/composition/team/createTeamWorktreeGitReadinessTransport.ts" + ] + }, + { + "actionId": "workspace.registry.initialize-git-repository", + "owner": "workspace-registry", + "source": "TeamsAPI", + "sourceMember": "initializeGitRepository", + "rendererCallers": [ + "src/renderer/composition/team/createTeamWorktreeGitReadinessTransport.ts" + ] + }, + { + "actionId": "workspace.registry.create-initial-git-commit", + "owner": "workspace-registry", + "source": "TeamsAPI", + "sourceMember": "createInitialGitCommit", + "rendererCallers": [ + "src/renderer/composition/team/createTeamWorktreeGitReadinessTransport.ts" + ] + }, + { + "actionId": "workspace.registry.get-project-branch", + "owner": "workspace-registry", + "source": "TeamsAPI", + "sourceMember": "getProjectBranch", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamDirectoryTransport.ts" + ] + }, + { + "actionId": "workspace.registry.set-project-branch-tracking", + "owner": "workspace-registry", + "source": "TeamsAPI", + "sourceMember": "setProjectBranchTracking", + "rendererCallers": ["src/renderer/composition/team/createTeamBranchTrackingTransport.ts"] + }, + { + "actionId": "team.task.get-task-change-presence", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "getTaskChangePresence", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ] + }, + { + "actionId": "team.task.create-task", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "createTask", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.get-task", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "getTask", + "rendererCallers": ["src/renderer/composition/team/createTeamTaskDetailTransport.ts"] + }, + { + "actionId": "team.task.request-review", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "requestReview", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.update-kanban", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "updateKanban", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.update-kanban-column-order", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "updateKanbanColumnOrder", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.update-task-status", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "updateTaskStatus", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.update-task-owner", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "updateTaskOwner", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.update-task-fields", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "updateTaskFields", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.start-task", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "startTask", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.start-task-by-user", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "startTaskByUser", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.get-all-tasks", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "getAllTasks", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamDirectoryTransport.ts" + ] + }, + { + "actionId": "team.task.add-task-comment", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "addTaskComment", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ] + }, + { + "actionId": "team.task.set-task-clarification", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "setTaskClarification", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.soft-delete-task", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "softDeleteTask", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.restore-task", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "restoreTask", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.get-deleted-tasks", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "getDeletedTasks", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.add-task-relationship", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "addTaskRelationship", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.task.remove-task-relationship", + "owner": "team-task-board", + "source": "TeamsAPI", + "sourceMember": "removeTaskRelationship", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskBoardTransport.ts" + ] + }, + { + "actionId": "team.message.get-saved-request", + "owner": "team-messaging", + "source": "TeamsAPI", + "sourceMember": "getSavedRequest", + "rendererCallers": ["src/renderer/composition/team/createTeamConfigurationTransport.ts"] + }, + { + "actionId": "team.message.send-message", + "owner": "team-messaging", + "source": "TeamsAPI", + "sourceMember": "sendMessage", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"] + }, + { + "actionId": "team.message.get-open-code-runtime-delivery-status", + "owner": "team-messaging", + "source": "TeamsAPI", + "sourceMember": "getOpenCodeRuntimeDeliveryStatus", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"] + }, + { + "actionId": "team.message.get-messages-page", + "owner": "team-messaging", + "source": "TeamsAPI", + "sourceMember": "getMessagesPage", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamMessageFeedTransport.ts" + ] + }, + { + "actionId": "team.message.process-send", + "owner": "team-messaging", + "source": "TeamsAPI", + "sourceMember": "processSend", + "rendererCallers": [ + "src/renderer/composition/team/createTeamGraphTaskNotificationTransport.ts", + "src/renderer/composition/team/createTeamTaskDetailTransport.ts" + ] + }, + { + "actionId": "team.runtime.set-task-log-stream-tracking", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "setTaskLogStreamTracking", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.runtime.get-claude-logs", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getClaudeLogs", + "rendererCallers": ["src/renderer/composition/team/createTeamOperationalReadTransport.ts"] + }, + { + "actionId": "team.runtime.get-member-activity-meta", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getMemberActivityMeta", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamMessageFeedTransport.ts" + ] + }, + { + "actionId": "team.runtime.alive-list", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "aliveList", + "rendererCallers": ["src/renderer/composition/team/createTeamAliveListReadPort.ts"] + }, + { + "actionId": "team.runtime.get-member-logs", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getMemberLogs", + "rendererCallers": [ + "src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts" + ] + }, + { + "actionId": "team.runtime.get-logs-for-task", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getLogsForTask", + "rendererCallers": [ + "src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts" + ] + }, + { + "actionId": "team.runtime.get-task-activity", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getTaskActivity", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.runtime.get-task-activity-detail", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getTaskActivityDetail", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.runtime.get-task-log-stream-summary", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getTaskLogStreamSummary", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.runtime.get-task-log-stream", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getTaskLogStream", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.runtime.get-task-exact-log-summaries", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getTaskExactLogSummaries", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.runtime.get-task-exact-log-detail", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getTaskExactLogDetail", + "rendererCallers": [ + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.runtime.get-member-stats", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "getMemberStats", + "rendererCallers": ["src/renderer/composition/team/createTeamOperationalReadTransport.ts"] + }, + { + "actionId": "team.runtime.kill-process", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "killProcess", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"] + }, + { + "actionId": "team.runtime.retry-failed-open-code-secondary-lanes", + "owner": "team-runtime-control", + "source": "TeamsAPI", + "sourceMember": "retryFailedOpenCodeSecondaryLanes", + "rendererCallers": ["src/renderer/composition/team/createTeamRuntimeOperationsTransport.ts"] + }, + { + "actionId": "agent.attachment.get-attachments", + "owner": "agent-attachments", + "source": "TeamsAPI", + "sourceMember": "getAttachments", + "rendererCallers": [ + "src/renderer/composition/team/createTeamMessageAttachmentReadTransport.ts" + ] + }, + { + "actionId": "agent.attachment.save-task-attachment", + "owner": "agent-attachments", + "source": "TeamsAPI", + "sourceMember": "saveTaskAttachment", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ] + }, + { + "actionId": "agent.attachment.get-task-attachment", + "owner": "agent-attachments", + "source": "TeamsAPI", + "sourceMember": "getTaskAttachment", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ] + }, + { + "actionId": "agent.attachment.delete-task-attachment", + "owner": "agent-attachments", + "source": "TeamsAPI", + "sourceMember": "deleteTaskAttachment", + "rendererCallers": [ + "src/features/team-task-board/renderer/adapters/createTeamTaskArtifactsTransport.ts" + ] + }, + { + "actionId": "team.approval.validate-cli-args", + "owner": "team-approvals", + "source": "TeamsAPI", + "sourceMember": "validateCliArgs", + "rendererCallers": [ + "src/renderer/composition/team/createTeamProvisioningDiagnosticsTransport.ts" + ] + }, + { + "actionId": "team.console.on-team-change", + "owner": "team-console", + "source": "TeamsAPI", + "sourceMember": "onTeamChange", + "rendererCallers": [ + "src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts", + "src/renderer/composition/team/createTaskLogObservabilityRendererTransport.ts" + ] + }, + { + "actionId": "team.console.on-provisioning-progress", + "owner": "team-console", + "source": "TeamsAPI", + "sourceMember": "onProvisioningProgress", + "rendererCallers": [ + "src/features/team-provisioning/renderer/adapters/createTeamProvisioningControlTransport.ts" + ] + }, + { + "actionId": "team.review.get-agent-changes", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "getAgentChanges", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.get-task-changes", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "getTaskChanges", + "rendererCallers": [ + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "src/renderer/store/slices/changeReviewSlice.ts" + ] + }, + { + "actionId": "team.review.get-team-task-change-summaries", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "getTeamTaskChangeSummaries", + "rendererCallers": ["src/renderer/components/team/useTeamChangesSummaries.ts"] + }, + { + "actionId": "team.review.invalidate-task-change-summaries", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "invalidateTaskChangeSummaries", + "rendererCallers": [ + "src/features/team-view-read-model/renderer/adapters/createTeamViewDataTransport.ts" + ] + }, + { + "actionId": "team.review.get-change-stats", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "getChangeStats", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.get-file-content", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "getFileContent", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.apply-decisions", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "applyDecisions", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.execute-mutation", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "executeMutation", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewFileDecisionPorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewFileDraftPorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts" + ] + }, + { + "actionId": "team.review.retry-mutation-recovery", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "retryMutationRecovery", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDialogLifecyclePorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts" + ] + }, + { + "actionId": "team.review.restore-history", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "restoreHistory", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts" + ] + }, + { + "actionId": "team.review.check-conflict", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "checkConflict", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts", + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts", + "src/features/change-review/renderer/adapters/createChangeReviewFileDecisionPorts.ts", + "src/features/change-review/renderer/adapters/createChangeReviewFileDraftPorts.ts", + "src/renderer/components/team/review/ChangeReviewDialog.tsx" + ] + }, + { + "actionId": "team.review.save-edited-file", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "saveEditedFile", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.watch-files", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "watchFiles", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts" + ] + }, + { + "actionId": "team.review.unwatch-files", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "unwatchFiles", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts" + ] + }, + { + "actionId": "team.review.on-external-file-change", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "onExternalFileChange", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts" + ] + }, + { + "actionId": "team.review.load-decisions", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "loadDecisions", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.save-decisions", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "saveDecisions", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.clear-decisions", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "clearDecisions", + "rendererCallers": ["src/renderer/store/slices/changeReviewSlice.ts"] + }, + { + "actionId": "team.review.load-decision-conflict-candidates", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "loadDecisionConflictCandidates", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts" + ] + }, + { + "actionId": "team.review.resolve-decision-conflict-candidate", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "resolveDecisionConflictCandidate", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts" + ] + }, + { + "actionId": "team.review.load-draft-history", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "loadDraftHistory", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ] + }, + { + "actionId": "team.review.save-draft-history-entry", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "saveDraftHistoryEntry", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ] + }, + { + "actionId": "team.review.clear-draft-history", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "clearDraftHistory", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ] + }, + { + "actionId": "team.review.load-draft-history-conflict-candidates", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "loadDraftHistoryConflictCandidates", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts" + ] + }, + { + "actionId": "team.review.resolve-draft-history-conflict-candidate", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "resolveDraftHistoryConflictCandidate", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ] + }, + { + "actionId": "team.review.replace-draft-history-conflict-candidate", + "owner": "team-review", + "source": "ReviewAPI", + "sourceMember": "replaceDraftHistoryConflictCandidate", + "rendererCallers": [ + "src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts" + ] + }, + { + "actionId": "team.message.cross-team.send", + "owner": "team-messaging", + "source": "CrossTeamAPI", + "sourceMember": "send", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"] + }, + { + "actionId": "team.message.cross-team.list-targets", + "owner": "team-messaging", + "source": "CrossTeamAPI", + "sourceMember": "listTargets", + "rendererCallers": ["src/renderer/composition/team/createTeamMessageDeliveryTransport.ts"] + } + ], + "legacyChildApiActionBindings": [ + { + "childActionId": "team.legacy-control.dialogs.add.member.dialog.handle-submit", + "apiActionId": "team.lifecycle.add-member", + "owner": "team-lifecycle" + }, + { + "childActionId": "team.legacy-control.members.member.card.handle-restart-member", + "apiActionId": "team.lifecycle.restart-member", + "owner": "team-lifecycle" + }, + { + "childActionId": "team.legacy-control.members.member.card.handle-restore-member", + "apiActionId": "team.lifecycle.restore-member", + "owner": "team-lifecycle" + } + ], + "deliberateAbsences": [ + { + "id": "P0.W1.ABSENCE.dialog-state", + "reason": "Dialog open/cancel state is local and creates no hosted command.", + "sourceRefs": [ + { + "file": "src/renderer/components/team/TeamListView.tsx", + "sourceHash": "sha256:6f151970571293da", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:257597c3ce8bca4e", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:a30b08f387e731a0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:00e75ccb184739af", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:85535c3fe2325671", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamDetailView.tsx", + "sourceHash": "sha256:613400ca3d7039e8", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "sourceHash": "sha256:089f3839d882cd3f", + "siteCount": 1 + } + ] + }, + { + "id": "P0.W1.ABSENCE.event-containment", + "reason": "Event containment is not a semantic action.", + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:49a1bb745fad7f34", + "siteCount": 2 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:54f6c7a4459a5ffa", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:60e136772eb5c4bc", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:8b8e8db68dec5b0a", + "siteCount": 1 + }, + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:66f02e5325663a12", + "siteCount": 2 + } + ] + }, + { + "id": "P0.W1.ABSENCE.disabled-wrapper", + "reason": "A disabled presentation wrapper without a handler is not an action.", + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "sourceHash": "sha256:726e1410bc9bb643", + "siteCount": 1 + } + ] + }, + { + "id": "P0.W1.ABSENCE.child-dialog-state", + "reason": "Dialog open-state synchronization is renderer-local and creates no hosted command.", + "sourceRefs": [ + { + "file": "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx", + "sourceHash": "sha256:dd466d63fab182f1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/MessageExpandDialog.tsx", + "sourceHash": "sha256:e22189d5df0cc84c", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "sourceHash": "sha256:92974d65ae355ab0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsPanel.tsx", + "sourceHash": "sha256:a776559b101e6d86", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:956fdf27287a3c3d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:d2178fcbdf8fe4a7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsSection.tsx", + "sourceHash": "sha256:e22189d5df0cc84c", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/AddMemberDialog.tsx", + "sourceHash": "sha256:6f5e2ab654f9c4d3", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "sourceHash": "sha256:6f5e2ab654f9c4d3", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "sourceHash": "sha256:f4e0527dfd75aa54", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "sourceHash": "sha256:104a3d16fc455e5a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/LaunchTeamDialogLoadingFallback.tsx", + "sourceHash": "sha256:6f151970571293da", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "sourceHash": "sha256:431f7cd777704945", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx", + "sourceHash": "sha256:0dd47c279314a03f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ReviewDialog.tsx", + "sourceHash": "sha256:f1dec35445a53a2d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:6f5e2ab654f9c4d3", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:2dc86aef41fa4c56", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:576d217346772bcf", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:755e0d9e783b45ec", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:95074338371b433d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "sourceHash": "sha256:f3703741e7f16ed1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:d364fcb593062c0d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:e93751269dd31464", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:a28fce28804339ab", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorShortcutsHelp.tsx", + "sourceHash": "sha256:f35d406393a8c276", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:4852d0ea4e865f0b", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:50671fcc26b476b2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "sourceHash": "sha256:73d2e0cb2461b907", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "sourceHash": "sha256:310d6f37fcbf2e33", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanSortPopover.tsx", + "sourceHash": "sha256:310d6f37fcbf2e33", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:04a72f139df99543", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:7c0c8c8af26a9ba4", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/TrashDialog.tsx", + "sourceHash": "sha256:95074338371b433d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:9e6ac9ce228d1530", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberDetailDialog.tsx", + "sourceHash": "sha256:f4e0527dfd75aa54", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberHoverCard.tsx", + "sourceHash": "sha256:42e8c8e2309e2b26", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberMessagesTab.tsx", + "sourceHash": "sha256:694d4be1e2950b63", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:7f428418a53df234", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:bfb370153b73c4ce", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "sourceHash": "sha256:92974d65ae355ab0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:9582aa455f7d5fd0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:e216377fd932fdb2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/ChangeReviewDialogView.tsx", + "sourceHash": "sha256:05d3c7a7e9f2fd8d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/ChangeReviewDialogView.tsx", + "sourceHash": "sha256:2548d11dc28b46ea", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:17e8f9ac1d274a88", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:310d6f37fcbf2e33", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "sourceHash": "sha256:d23fe47e3f8ec44d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx", + "sourceHash": "sha256:28ef9554a8de86f0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx", + "sourceHash": "sha256:74ac28f4b8c942c3", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/sidebar/TeamSidebarRail.tsx", + "sourceHash": "sha256:b31be8cf2f853d22", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TaskTooltip.tsx", + "sourceHash": "sha256:ce6b28bb456bf26a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/TeamChangesSection.tsx", + "sourceHash": "sha256:354dd30014620e9b", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/UnreadCommentsBadge.tsx", + "sourceHash": "sha256:ce6b28bb456bf26a", + "siteCount": 1 + } + ] + }, + { + "id": "P0.W1.ABSENCE.child-event-containment", + "reason": "Event containment or default suppression is not a hosted semantic action.", + "sourceRefs": [ + { + "file": "src/renderer/components/team/activity/ActivityItem.tsx", + "sourceHash": "sha256:bd2c930acae6fa82", + "siteCount": 3 + }, + { + "file": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sourceHash": "sha256:bd2c930acae6fa82", + "siteCount": 3 + }, + { + "file": "src/renderer/components/team/attachments/AttachmentDisplay.tsx", + "sourceHash": "sha256:a3a0edf693152a8e", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "sourceHash": "sha256:3cfd8c00595dd6d6", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:165e086f0e3330f1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorSelectionMenu.tsx", + "sourceHash": "sha256:47dcef1a90df906f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sourceHash": "sha256:77a2033921a6ec5e", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx", + "sourceHash": "sha256:402ed943870d54ea", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx", + "sourceHash": "sha256:a3a0edf693152a8e", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:0bae4f78a9f70b54", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:323fe6817e91a5ed", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:8fef14b8b3cb85fe", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:edf3da0467f3e3be", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberLogsTab.tsx", + "sourceHash": "sha256:87178eec6faacfd5", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberQuickActions.tsx", + "sourceHash": "sha256:a3a0edf693152a8e", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:165e086f0e3330f1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:4a29a6745b4ec867", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:c760d80cc9f1875b", + "siteCount": 1 + } + ] + }, + { + "id": "P0.W1.ABSENCE.child-local-presentation", + "reason": "Hover, focus, scroll, drag-affordance, and tooltip presentation state creates no hosted semantic action.", + "sourceRefs": [ + { + "file": "src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx", + "sourceHash": "sha256:bb70f538871bb947", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "sourceHash": "sha256:6387e3393dd3ae35", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ClaudeLogsPanel.tsx", + "sourceHash": "sha256:1280af97cd52326a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/CliLogsRichView.tsx", + "sourceHash": "sha256:56c86c3686f3cb6d", + "siteCount": 2 + }, + { + "file": "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "sourceHash": "sha256:e3fa633e40d5b512", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:874c54e7c9a5b6dd", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:8ea8d7742f3d3125", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "sourceHash": "sha256:95a4f8ff49aba399", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sourceHash": "sha256:540d6fbf5956bd35", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "sourceHash": "sha256:d9fd063bad9cdf03", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:b459c5798b12af22", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "sourceHash": "sha256:f11af62a1beaa1c5", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:0f82202e91b11be7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:4af5671b033319ba", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorFileTree.tsx", + "sourceHash": "sha256:7318668c84f70c64", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorImagePreview.tsx", + "sourceHash": "sha256:4c80853062110e3b", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/EditorTabBar.tsx", + "sourceHash": "sha256:4af5671b033319ba", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/FileIcon.tsx", + "sourceHash": "sha256:dfa7d7b286637829", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/MarkdownPreviewPane.tsx", + "sourceHash": "sha256:2c7c862ac45552e2", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/MarkdownSplitView.tsx", + "sourceHash": "sha256:221f26b6c85e4bcf", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/editor/NewFileDialog.tsx", + "sourceHash": "sha256:7318668c84f70c64", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "sourceHash": "sha256:9630af694ccd1dfc", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:91365fb5a464bdfb", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "sourceHash": "sha256:95bc5862872de27f", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:6b592916076b2d06", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:9a7336d73c7728be", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberCard.tsx", + "sourceHash": "sha256:bf6e84c42f770025", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberList.tsx", + "sourceHash": "sha256:b738dcd6113ca196", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberList.tsx", + "sourceHash": "sha256:bd79976348fd0dd0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberList.tsx", + "sourceHash": "sha256:d2b85c3a43fadc2d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberList.tsx", + "sourceHash": "sha256:e8211337b0af9fff", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/members/MemberQuickActions.tsx", + "sourceHash": "sha256:8ef7483029ff523d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:3dc7b49ba3c8368d", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:4e0ae98aa980e659", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:5064b8a25a40cfd1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:797c82045a15b0de", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessageComposer.tsx", + "sourceHash": "sha256:972453b7e7123179", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:93d1b881ec3b8fd7", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/messages/MessagesPanel.tsx", + "sourceHash": "sha256:af6e8057703bf33a", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "sourceHash": "sha256:b80a1ae5cb6e36de", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "sourceHash": "sha256:c6dd1b048c150ff1", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalDiffPreview.tsx", + "sourceHash": "sha256:b459c5798b12af22", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalDiffPreview.tsx", + "sourceHash": "sha256:f11af62a1beaa1c5", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:6bf5c0024c0bbbe0", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:91c42630baef09ef", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:9559a433ceebf5ec", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:b459c5798b12af22", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:e56a6451194401d8", + "siteCount": 1 + }, + { + "file": "src/renderer/components/team/ToolApprovalSheet.tsx", + "sourceHash": "sha256:fbd8fb7c3324fe9e", + "siteCount": 1 + } + ] + } + ], + "dynamicDispatch": { + "unannotatedCount": 0, + "annotation": "@hosted-web-dynamic-action " + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json b/docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json new file mode 100644 index 0000000000..65f7bc2af6 --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/renderer-child-control-catalog.json @@ -0,0 +1,1424 @@ +{ + "schemaId": "p0-w1-renderer-child-control-catalog", + "schemaVersion": 2, + "evidenceId": "P0.W1.RENDERER_CHILD_CONTROLS", + "packetRevision": "phase-00-r2", + "pinnedBaseSha": "cbe501ad0f1fa0e51a038e832ad35fce4120321b", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "identityRule": "Action IDs are explicit reviewed component/handler semantics. File paths, source hashes, site counts and line positions never enter identity.", + "roots": [ + "src/renderer/components/team/TeamListView.tsx", + "src/renderer/components/team/TeamDetailView.tsx", + "src/renderer/components/team/ToolApprovalSheet.tsx", + "src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx" + ], + "sourceFiles": [ + "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx", + "src/features/change-review/renderer/ui/ChangeReviewSidebar.tsx", + "src/features/change-review/renderer/ui/FileEditTimeline.tsx", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx", + "src/features/change-review/renderer/ui/TaskChangesEmptyState.tsx", + "src/features/runtime-provider-management/renderer/ui/LegacyConfiguredModelsPanel.tsx", + "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderModelTestResult.tsx", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderSupplementalStatus.tsx", + "src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx", + "src/renderer/components/team/ClaudeLogsPanel.tsx", + "src/renderer/components/team/ClaudeLogsSection.tsx", + "src/renderer/components/team/CliLogsRichView.tsx", + "src/renderer/components/team/CollapsibleTeamSection.tsx", + "src/renderer/components/team/LeadSessionDetailGate.tsx", + "src/renderer/components/team/LiveRuntimeStatusBridge.tsx", + "src/renderer/components/team/LiveRuntimeStatusSection.tsx", + "src/renderer/components/team/MemberBadge.tsx", + "src/renderer/components/team/ProcessesSection.tsx", + "src/renderer/components/team/ProvisioningProgressBlock.tsx", + "src/renderer/components/team/RoleSelect.tsx", + "src/renderer/components/team/StepProgressBar.tsx", + "src/renderer/components/team/TaskTooltip.tsx", + "src/renderer/components/team/TeamChangesSection.tsx", + "src/renderer/components/team/TeamDetailView.tsx", + "src/renderer/components/team/TeamEmptyState.tsx", + "src/renderer/components/team/TeamListFilterPopover.tsx", + "src/renderer/components/team/TeamListView.tsx", + "src/renderer/components/team/TeamLoadingSkeleton.tsx", + "src/renderer/components/team/TeamProvisioningBanner.tsx", + "src/renderer/components/team/TeamProvisioningPanel.tsx", + "src/renderer/components/team/TeamSessionsSection.tsx", + "src/renderer/components/team/TeamTaskStatusSummary.tsx", + "src/renderer/components/team/ToolApprovalDiffPreview.tsx", + "src/renderer/components/team/ToolApprovalSheet.tsx", + "src/renderer/components/team/UnreadCommentsBadge.tsx", + "src/renderer/components/team/activity/ActiveTasksBlock.tsx", + "src/renderer/components/team/activity/ActivityItem.tsx", + "src/renderer/components/team/activity/ActivityTimeline.tsx", + "src/renderer/components/team/activity/AnimatedHeightReveal.tsx", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx", + "src/renderer/components/team/activity/MessageExpandDialog.tsx", + "src/renderer/components/team/activity/PendingRepliesBlock.tsx", + "src/renderer/components/team/activity/ReplyQuoteBlock.tsx", + "src/renderer/components/team/activity/ThoughtBodyContent.tsx", + "src/renderer/components/team/activity/TimelineHeaderAccent.tsx", + "src/renderer/components/team/attachments/AttachmentDisplay.tsx", + "src/renderer/components/team/attachments/AttachmentPreviewItem.tsx", + "src/renderer/components/team/attachments/AttachmentPreviewList.tsx", + "src/renderer/components/team/attachments/AttachmentThumbnail.tsx", + "src/renderer/components/team/attachments/DropZoneOverlay.tsx", + "src/renderer/components/team/attachments/ImageLightbox.tsx", + "src/renderer/components/team/attachments/SourceMessageAttachments.tsx", + "src/renderer/components/team/composer/ComposerSurface.tsx", + "src/renderer/components/team/dialogs/AddMemberDialog.tsx", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx", + "src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx", + "src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx", + "src/renderer/components/team/dialogs/CodexFastModeSelector.tsx", + "src/renderer/components/team/dialogs/CodexModelCatalogFallbackNotice.tsx", + "src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx", + "src/renderer/components/team/dialogs/CreateTeamDialog.tsx", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx", + "src/renderer/components/team/dialogs/EffortLevelSelector.tsx", + "src/renderer/components/team/dialogs/ExperimentalLocalModelOverride.tsx", + "src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx", + "src/renderer/components/team/dialogs/LaunchTeamDialogLoadingFallback.tsx", + "src/renderer/components/team/dialogs/LimitContextCheckbox.tsx", + "src/renderer/components/team/dialogs/MembersJsonEditor.tsx", + "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx", + "src/renderer/components/team/dialogs/OpenCodeLocalModelStatus.tsx", + "src/renderer/components/team/dialogs/OpenCodeLocalModelsFeedback.tsx", + "src/renderer/components/team/dialogs/OptionalSettingsSection.tsx", + "src/renderer/components/team/dialogs/ProjectPathSelector.tsx", + "src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx", + "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx", + "src/renderer/components/team/dialogs/ReviewDialog.tsx", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx", + "src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx", + "src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx", + "src/renderer/components/team/dialogs/TaskAttachments.tsx", + "src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx", + "src/renderer/components/team/dialogs/TeamModelBrandIcon.tsx", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx", + "src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx", + "src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx", + "src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx", + "src/renderer/components/team/editor/CodeMirrorEditor.tsx", + "src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx", + "src/renderer/components/team/editor/EditorBinaryState.tsx", + "src/renderer/components/team/editor/EditorContextMenu.tsx", + "src/renderer/components/team/editor/EditorEmptyState.tsx", + "src/renderer/components/team/editor/EditorErrorBoundary.tsx", + "src/renderer/components/team/editor/EditorErrorState.tsx", + "src/renderer/components/team/editor/EditorFileTree.tsx", + "src/renderer/components/team/editor/EditorImagePreview.tsx", + "src/renderer/components/team/editor/EditorSearchPanel.tsx", + "src/renderer/components/team/editor/EditorSelectionMenu.tsx", + "src/renderer/components/team/editor/EditorShortcutsHelp.tsx", + "src/renderer/components/team/editor/EditorStatusBar.tsx", + "src/renderer/components/team/editor/EditorTabBar.tsx", + "src/renderer/components/team/editor/EditorTabContextMenu.tsx", + "src/renderer/components/team/editor/EditorToolbar.tsx", + "src/renderer/components/team/editor/FileIcon.tsx", + "src/renderer/components/team/editor/GitStatusBadge.tsx", + "src/renderer/components/team/editor/GoToLineDialog.tsx", + "src/renderer/components/team/editor/MarkdownPreviewPane.tsx", + "src/renderer/components/team/editor/MarkdownSplitView.tsx", + "src/renderer/components/team/editor/NewFileDialog.tsx", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx", + "src/renderer/components/team/editor/QuickOpenDialog.tsx", + "src/renderer/components/team/editor/SearchInFilesPanel.tsx", + "src/renderer/components/team/kanban/KanbanBoard.tsx", + "src/renderer/components/team/kanban/KanbanColumn.tsx", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx", + "src/renderer/components/team/kanban/KanbanGridLayout.tsx", + "src/renderer/components/team/kanban/KanbanSearchInput.tsx", + "src/renderer/components/team/kanban/KanbanSortPopover.tsx", + "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx", + "src/renderer/components/team/kanban/KanbanTaskCardSkeleton.tsx", + "src/renderer/components/team/kanban/TrashDialog.tsx", + "src/renderer/components/team/members/CurrentTaskIndicator.tsx", + "src/renderer/components/team/members/LeadModelRow.tsx", + "src/renderer/components/team/members/MemberCard.tsx", + "src/renderer/components/team/members/MemberDetailDialog.tsx", + "src/renderer/components/team/members/MemberDetailHeader.tsx", + "src/renderer/components/team/members/MemberDetailStats.tsx", + "src/renderer/components/team/members/MemberDraftRow.tsx", + "src/renderer/components/team/members/MemberExecutionLog.tsx", + "src/renderer/components/team/members/MemberHoverCard.tsx", + "src/renderer/components/team/members/MemberLaunchDiagnosticsButton.tsx", + "src/renderer/components/team/members/MemberList.tsx", + "src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx", + "src/renderer/components/team/members/MemberLogsTab.tsx", + "src/renderer/components/team/members/MemberMessagesTab.tsx", + "src/renderer/components/team/members/MemberPresenceDot.tsx", + "src/renderer/components/team/members/MemberQuickActions.tsx", + "src/renderer/components/team/members/MemberStatsTab.tsx", + "src/renderer/components/team/members/MemberTasksTab.tsx", + "src/renderer/components/team/members/MembersEditorSection.tsx", + "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx", + "src/renderer/components/team/members/TeamRosterEditorSection.tsx", + "src/renderer/components/team/messages/ActionModeSelector.tsx", + "src/renderer/components/team/messages/MessageComposer.tsx", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx", + "src/renderer/components/team/messages/MessagesPanel.tsx", + "src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx", + "src/renderer/components/team/messages/StatusBlock.tsx", + "src/renderer/components/team/review/ChangeReviewDialog.tsx", + "src/renderer/components/team/review/ChangeReviewDialogView.tsx", + "src/renderer/components/team/review/ChangeStatsBadge.tsx", + "src/renderer/components/team/review/ChangesLoadingAnimation.tsx", + "src/renderer/components/team/review/CodeMirrorDiffView.tsx", + "src/renderer/components/team/review/ConfidenceBadge.tsx", + "src/renderer/components/team/review/ContinuousScrollView.tsx", + "src/renderer/components/team/review/DiffErrorBoundary.tsx", + "src/renderer/components/team/review/FileSectionDiff.tsx", + "src/renderer/components/team/review/FileSectionHeader.tsx", + "src/renderer/components/team/review/FileSectionPlaceholder.tsx", + "src/renderer/components/team/review/FullDiffLoadingBanner.tsx", + "src/renderer/components/team/review/KeyboardShortcutsHelp.tsx", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx", + "src/renderer/components/team/review/ReviewDiffContent.tsx", + "src/renderer/components/team/review/ReviewToolbar.tsx", + "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx", + "src/renderer/components/team/review/ScopeWarningBanner.tsx", + "src/renderer/components/team/review/ViewedProgressBar.tsx", + "src/renderer/components/team/schedule/CronScheduleInput.tsx", + "src/renderer/components/team/schedule/ScheduleEmptyState.tsx", + "src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx", + "src/renderer/components/team/schedule/ScheduleRunRow.tsx", + "src/renderer/components/team/schedule/ScheduleSection.tsx", + "src/renderer/components/team/schedule/ScheduleStatusBadge.tsx", + "src/renderer/components/team/sidebar/TeamSidebarHost.tsx", + "src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx", + "src/renderer/components/team/sidebar/TeamSidebarRail.tsx", + "src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx", + "src/renderer/components/team/taskLogs/TaskActivityLinkedToolCard.tsx", + "src/renderer/components/team/taskLogs/TaskActivitySection.tsx", + "src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx", + "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx" + ], + "actions": { + "team.approval.respond-to-tool-approval": "team-approvals|decomposed|approval-policy|WP-9-APPROVALS|Reviewed mounted approval response control; the direct TeamsAPI action and owner are pinned by P0.W1.API_PARITY_LEDGER.", + "team.approval.update-tool-approval-settings": "team-approvals|decomposed|approval-policy|WP-9-APPROVALS|Reviewed mounted approval policy control; the direct TeamsAPI action and owner are pinned by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.active.tasks.block.on-member-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.active.tasks.block.on-task-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.active.tasks.block.set-collapsed": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.activity.item.handle-header-toggle": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.activity.item.on-member-name-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.activity.item.on-team-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.activity.timeline.handle-show-all": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.activity.timeline.handle-show-more": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.lead.thoughts.group.handle-body-toggle": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.pending.replies.block.on-member-click": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.reply.quote.block.set-expanded": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.activity.thought.body.content.handle-reply": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.attachments.attachment.display.set-lightbox-index": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.attachments.attachment.preview.item.on-preview": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.attachments.attachment.preview.item.on-remove": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.attachments.attachment.preview.list.on-dismiss-error": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.attachments.attachment.thumbnail.button-on-click": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.filter.popover.button-implicit-action": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.filter.popover.handle-reset": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.filter.popover.handle-save": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.filter.popover.toggle-kind": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.filter.popover.toggle-stream": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.panel.apply-pending": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.panel.load-older-logs": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.panel.set-search-query": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.section.on-source-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.section.selected-member": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.claude.logs.section.set-selected-source-key": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.cli.logs.rich.view.on-toggle": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.collapsible.team.section.set-open": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.add.member.dialog.handle-submit": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed add-member submission; the scanner maps this legacy child ID to team.lifecycle.add-member and enforces the API owner.", + "team.legacy-control.dialogs.add.member.dialog.on-close": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed add-member dialog control; ownership follows the team lifecycle operation composed by this child.", + "team.legacy-control.dialogs.add.member.dialog.set-members": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed add-member draft control; ownership follows the team lifecycle operation composed by this child.", + "team.legacy-control.dialogs.advanced.cli.section.handle-custom-args-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.advanced.cli.section.handle-validate": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.advanced.cli.section.on-worktree-enabled-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.advanced.cli.section.on-worktree-name-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.advanced.cli.section.set-is-open": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.anthropic.extra.usage.warning.a-navigate": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.anthropic.fast.mode.selector.button-on-click": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.codex.fast.mode.selector.button-on-click": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.codex.model.catalog.fallback.notice.on-update": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.codex.reconnect.prompt.on-device-code-reconnect": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.codex.reconnect.prompt.open-external": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.handle-submit": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.on-close": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.set-blocked-by-search": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.set-owner": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.set-related-search": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.set-show-optional-fields": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.set-start-immediately": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.set-subject": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.set-value": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.toggle-blocked-by": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.create.task.dialog.toggle-related": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.edit.team.dialog.clear-transient-errors": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.edit.team.dialog.handle-save": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.edit.team.dialog.on-close": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.effort.level.selector.button-on-click": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.experimental.local.model.override.checkbox-on-checked-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Records the explicit experimental local-model override selected for this team launch.", + "team.legacy-control.dialogs.global.task.detail.open-team": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed globally mounted task-detail navigation control; no remote mutation is implied.", + "team.legacy-control.dialogs.launch.team.dialog.a-navigate": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.close-dialog": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.handle-submit": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-allow-experimental-local-models": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Updates the reviewed launch draft's explicit experimental local-model override.", + "team.legacy-control.dialogs.launch.team.dialog.set-conflict-dismissed": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-max-budget-usd": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-max-turns": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-sched-expanded": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-sched-label": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-selected-effort": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-selected-fast-mode": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-selected-model": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-selected-team-name": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-skip-permissions": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.launch.team.dialog.set-value": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.limit.context.checkbox.checkbox-on-checked-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.members.json.editor.on-close": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.approve": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Forwards an explicitly approved private-network local-model target to the provider-management renderer boundary for testing.", + "team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.cancel": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Closes the private-network approval prompt and clears its renderer-local approval state.", + "team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.set-approved": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Records explicit renderer-local consent for the reviewed private-network target.", + "team.legacy-control.dialogs.open.code.local.models.feedback.retry": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Retries the local-model lookup through the provider-management renderer boundary.", + "team.legacy-control.dialogs.optional.settings.section.handle-toggle-open": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.project.path.selector.on-custom-cwd-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.project.path.selector.on-cwd-mode-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.project.path.selector.on-selected-project-path-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.project.path.selector.select-folders": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.provisioning.provider.status.list.copy-support-diagnostics": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.provisioning.provider.status.list.on-open-provider-settings": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.review.dialog.handle-submit": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.review.dialog.set-value": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.button-on-click": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.handle-drop-wrapper": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.handle-file-input-change": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.handle-submit": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.set-action-mode": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.set-member": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.set-quote": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.set-quote-expanded": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.send.message.dialog.set-value": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.skip.permissions.checkbox.checkbox-on-checked-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.attachments.button-on-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.attachments.handle-drop": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.attachments.handle-file-select": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.attachments.on-preview": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.add-files": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.button-on-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.handle-submit": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.on-clear-reply": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.open-external": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.set-lightbox-index": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.set-quote-expanded": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comment.input.set-value": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comments.section.handle-submit": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comments.section.on-preview": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comments.section.on-reply": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comments.section.set-reply-to": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comments.section.set-value": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.comments.section.set-visible-count": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.handle-dependency-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.on-delete-task": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.on-owner-change": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.on-preview": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.on-view-changes": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.save-description": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.set-description-draft": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.set-editing-description": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.set-subject-draft": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.start-edit-description": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.task.detail.dialog.start-edit-subject": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.button-on-click": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.clear-open-code-filters": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Clears every reviewed OpenCode model filter without changing provider or model ownership.", + "team.legacy-control.dialogs.team.model.selector.input-on-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.refresh-open-code-local-providers": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Refreshes the local OpenCode provider catalog through the provider-management renderer boundary.", + "team.legacy-control.dialogs.team.model.selector.select-local-models": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Selects the reviewed local-models surface while retaining the model selector's provider boundary.", + "team.legacy-control.dialogs.team.model.selector.selected-provider-id": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.selector": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.set-free-only": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.set-inspected-provider-id": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.set-model-query": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.set-new-only": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Toggles the reviewed recently-released model filter.", + "team.legacy-control.dialogs.team.model.selector.set-open-code-source-query": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.set-recommended-only": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.set-selected-open-code-source-ids": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.start": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.team.model.selector.toggle-open-code-route-tag": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Toggles a reviewed OpenCode route-status filter independently of source filtering.", + "team.legacy-control.dialogs.team.model.selector.toggle-open-code-source-filter": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.teammate.runtime.compatibility.notice.on-open-dashboard": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.worktree.git.readiness.banner.button-on-click": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.dialogs.worktree.git.readiness.banner.create-initial-commit": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.binary.placeholder.handle-open-external": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.context.menu.context-menu-item-on-select": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.context.menu.on-create-task": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.context.menu.on-delete": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.context.menu.on-new-file": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.context.menu.on-new-folder": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.context.menu.on-rename": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.context.menu.on-send-message": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.error.boundary.handle-retry": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.error.state.on-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.error.state.on-retry": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.handle-cancel-delete": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.handle-click": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.handle-confirm-delete": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.handle-drag-end": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.handle-key-down": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.handle-new-item-submit": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.handle-node-click": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.on-rename-submit": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.file.tree.set-value": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.image.preview.handle-open-external": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.image.preview.set-lightbox-open": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.button-on-click": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.handle-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.handle-find-next": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.handle-find-prev": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.handle-replace-all": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.handle-replace-key-down": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.handle-replace-next": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.handle-search-key-down": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.set-case-sensitive": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.set-replace-text": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.set-search-text": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.set-show-replace": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.set-use-regexp": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.search.panel.set-whole-word": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.selection.menu.button-on-click": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.selection.menu.on-create-task": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.selection.menu.on-send-message": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.status.bar.toggle-watcher": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.bar.handle-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.bar.handle-drag-end": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.bar.on-activate": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.context.menu.on-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.context.menu.on-close-all": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.context.menu.on-close-others": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.context.menu.on-close-to-left": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.tab.context.menu.on-close-to-right": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.toolbar.button-on-click": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.toolbar.handle-redo": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.toolbar.handle-save": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.toolbar.handle-undo": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.toolbar.on-toggle-full-preview": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.toolbar.on-toggle-split": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.editor.toolbar.toggle-line-wrap": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.go.to.line.dialog.handle-go": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.go.to.line.dialog.handle-key-down": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.go.to.line.dialog.on-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.go.to.line.dialog.set-value": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.new.file.dialog.handle-change": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.new.file.dialog.handle-key-down": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.close-editor-tab": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-cancel-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-cancel-close-tab": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-cancel-conflict": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-close-request": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-discard-and-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-discard-and-close-tab": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-discard-draft": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-dismiss-draft-banner": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-force-overwrite": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-keep-mine": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-manual-refresh": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-reload-external-change": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-save-and-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.handle-save-and-close-tab": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.save-file": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.set-shortcuts-help-visible": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.project.editor.overlay.toggle-sidebar": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.quick.open.dialog.handle-select": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.quick.open.dialog.on-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.search.in.files.panel.handle-case-sensitive-toggle": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.search.in.files.panel.handle-query-change": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.search.in.files.panel.on-close": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.search.in.files.panel.on-select-match": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.editor.search.in.files.panel.on-toggle": "team-console|desktop-only|desktop-shell|ABSENT-BEFORE-HOSTED-MOUNT|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.board.add-handler": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.board.button-on-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.board.handle-drag-end": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.board.handle-props": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.board.on-open-trash": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.filter.popover.button-implicit-action": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.filter.popover.handle-clear-all": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.filter.popover.handle-column-toggle": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.filter.popover.handle-owner-toggle": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.filter.popover.handle-session-select": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.search.input.button-on-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.search.input.handle-key-down": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.search.input.input-on-change": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.search.input.select-task": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.sort.popover.button-implicit-action": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.sort.popover.on-sort-change": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.task.card.button-on-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.task.card.on-task-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.kanban.task.card.set-open": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.trash.dialog.on-close": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.kanban.trash.dialog.on-restore": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.lead.model.row.on-effort-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.lead.model.row.on-limit-context-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.lead.model.row.on-model-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.lead.model.row.on-sync-models-with-teammates-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.lead.model.row.set-model-expanded": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.card.div-on-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.card.handle-restart-member": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed member restart control; the scanner maps this legacy child ID to team.lifecycle.restart-member and enforces the API owner.", + "team.legacy-control.members.member.card.handle-restore-member": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed member restore control; the scanner maps this legacy child ID to team.lifecycle.restore-member and enforces the API owner.", + "team.legacy-control.members.member.card.handle-skip-failed-launch": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.card.on-assign-task": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.card.on-send-message": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.dialog.on-assign-task": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.dialog.on-remove-member": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.dialog.on-send-message": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.dialog.set-active-tab": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.dialog.set-restart-error": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.dialog.tabs-trigger-implicit-action": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.header.set-editing": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.stats.button-on-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.detail.stats.on-tab-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.button-on-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.handle-mcp-mode-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.handle-workflow-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-effort-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-limit-context-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-model-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-name-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-remove": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-restore": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-role-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.on-worktree-isolation-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.select-item-implicit-action": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.select-trigger-implicit-action": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.set-mcp-expanded": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.set-model-expanded": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.toggle-workflow-expanded": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.update-mcp-scope": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.draft.row.update-mcp-server-names": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.execution.log.on-toggle-expanded": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.execution.log.set-agent-info-open": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.launch.diagnostics.button.copy-diagnostics": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.list.handle-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.logs.tab.on-toggle": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.messages.tab.load-older-messages": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.messages.tab.set-activity-filter": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.messages.tab.set-expanded-item": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.stats.tab.open-kiro-usage": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Opens the fixed Kiro account usage URL without exposing runtime credentials.", + "team.legacy-control.members.member.stats.tab.on-file-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.stats.tab.on-show-all": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.stats.tab.set-expanded": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.member.tasks.tab.on-task-click": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.members.editor.section.add-member": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed add-member entry control; ownership follows the team lifecycle operation opened by this child.", + "team.legacy-control.members.members.editor.section.handle-json-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.members.editor.section.toggle-json-editor": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.members.editor.section.update-agent-teams-mcp-lock": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.members.editor.section.update-teammate-worktree-default": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.subagent.recent.messages.preview.on-load-more": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.subagent.recent.messages.preview.set-expanded-all": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.members.team.roster.editor.section.on-members-change": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.action.mode.selector.button-on-click": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.button-implicit-action": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.button-on-click": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.handle-drop-wrapper": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.handle-file-input-change": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.handle-revision-cancel": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.handle-send": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.open-external": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.set-action-mode": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.set-recipient": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.set-recipient-search": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.set-selected-team": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.message.composer.set-text": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.filter.popover.button-implicit-action": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.filter.popover.handle-reset": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.filter.popover.handle-save": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.filter.popover.set-draft": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.filter.popover.toggle-from": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.filter.popover.toggle-to": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.dropdown-menu-item-on-select": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.handle-mark-all-read": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.on-load-older-messages": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.open": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.set-messages-collapsed": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.set-messages-search-bar-visible": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.set-messages-search-query": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.messages.panel.toggle-bottom-sheet-expansion": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.open.code.delivery.warning.handle-copy": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.open.code.delivery.warning.set-expanded-key": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.messages.status.block.set-collapsed": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.processes.section.kill-process": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.processes.section.open-external": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.provisioning.progress.block.copy-diagnostics": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.provisioning.progress.block.on-cancel": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.provisioning.progress.block.on-dismiss": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.provisioning.progress.block.set-diagnostics-open": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.provisioning.progress.block.set-live-output-open": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.provisioning.progress.block.set-logs-open": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.change.review.conflicts.open-discard-confirmation": "team-review|decomposed|review-change-set|WP-9-REVIEW|Opens the explicit confirmation gate for deleting a conflicting recovery branch without mutating either branch.", + "team.legacy-control.review.change.review.conflicts.recover-candidate": "team-review|decomposed|review-change-set|WP-9-REVIEW|Switches to the selected compatible recovery branch while preserving the current branch as a recoverable copy.", + "team.legacy-control.review.change.review.conflicts.retry-load": "team-review|decomposed|review-change-set|WP-9-REVIEW|Retries verification of saved recovery branches while review mutations remain locked.", + "team.legacy-control.review.change.review.conflicts.run-discard": "team-review|decomposed|review-change-set|WP-9-REVIEW|Runs only the confirmed deletion of the selected conflicting recovery branch without changing project files.", + "team.legacy-control.review.change.review.dialog.button-on-click": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.change.review.dialog.set-timeline-open": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.diff.error.boundary.set-state": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.edit.timeline.on-event-click": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.handle-header-click": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.handle-header-key-down": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.on-accept-file": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.on-discard": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.on-keep-draft": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.on-reject-file": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.on-reload-from-disk": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.on-restore-missing-file": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.file.section.header.on-save": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.keyboard.shortcuts.help.button-on-click": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.action.history.popover.navigate-to-action": "team-review|decomposed|review-change-set|WP-9-REVIEW|Navigates to the file-backed review action selected from retained history.", + "team.legacy-control.review.review.action.history.popover.open-history": "team-review|decomposed|review-change-set|WP-9-REVIEW|Opens the retained review action history surface.", + "team.legacy-control.review.review.action.history.popover.request-restore": "team-review|decomposed|review-change-set|WP-9-REVIEW|Prepares a checkpoint restore preview before any review or disk transition is applied.", + "team.legacy-control.review.review.action.history.popover.retry-persistence": "team-review|decomposed|review-change-set|WP-9-REVIEW|Retries persistence of the latest review action through the owning review workflow.", + "team.legacy-control.review.review.action.history.popover.run-restore": "team-review|decomposed|review-change-set|WP-9-REVIEW|Runs only the confirmed, revalidated review-history restore request.", + "team.legacy-control.review.review.action.history.popover.show-older": "team-review|decomposed|review-change-set|WP-9-REVIEW|Expands the retained history window without mutating review decisions.", + "team.legacy-control.review.review.file.tree.on-select-file": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.file.tree.on-toggle-folder": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.file.tree.set-filter-new": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.file.tree.set-filter-rejected": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.file.tree.set-filter-unresolved": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.file.tree.set-query": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.toolbar.on-accept-all": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.toolbar.on-apply": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.toolbar.on-auto-viewed-change": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.toolbar.on-redo": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reapplies the next reviewed action from the bounded redo history.", + "team.legacy-control.review.review.toolbar.on-reject-all": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.review.toolbar.on-undo": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.saved.review.state.recovery.gate.open-discard-confirmation": "team-review|decomposed|review-change-set|WP-9-REVIEW|Opens the explicit confirmation gate without deleting saved review recovery data.", + "team.legacy-control.review.saved.review.state.recovery.gate.retry-load": "team-review|decomposed|review-change-set|WP-9-REVIEW|Retries loading unreadable review recovery state while leaving the stored copy untouched.", + "team.legacy-control.review.saved.review.state.recovery.gate.run-discard": "team-review|decomposed|review-change-set|WP-9-REVIEW|Runs the confirmed deletion of unreadable review recovery data without changing project files.", + "team.legacy-control.review.scope.warning.banner.on-dismiss": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.review.scope.warning.banner.set-expanded": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.role.select.handle-custom-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.role.select.handle-value-change": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.cron.schedule.input.on-cron-expression-change": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.cron.schedule.input.on-timezone-change": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.cron.schedule.input.on-warm-up-minutes-change": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.cron.schedule.input.select-item-implicit-action": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.cron.schedule.input.select-trigger-implicit-action": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.run.log.dialog.on-close": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.run.row.div-on-click": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.button-implicit-action": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.handle-create": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.handle-expand": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.on-delete": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.on-edit": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.on-pause": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.on-resume": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.on-trigger-now": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.schedule.schedule.section.set-selected-run": "team-schedule|decomposed|team-resource|WP-8-SCHEDULES|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.task.logs.task.activity.section.on-toggle": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.task.logs.task.logs.panel.set-active-tab": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.task.logs.task.logs.panel.tabs-trigger-implicit-action": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.changes.section.on-open-task": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.changes.section.on-view-changes": "team-review|decomposed|review-change-set|WP-9-REVIEW|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.empty.state.on-create-team": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.empty.state.on-import-team": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The empty-state import control opens the reviewed local team-import workflow.", + "team.legacy-control.team.list.filter.popover.button-implicit-action": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.list.filter.popover.handle-clear-all": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.list.filter.popover.handle-project-toggle": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.list.filter.popover.handle-status-toggle": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.provisioning.panel.retry-failed-open-code-secondary-lanes": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.provisioning.panel.set-dismissed": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.sessions.section.button-on-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.sessions.section.handle-session-click": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.team.sessions.section.on-select-session": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Reviewed renderer control; exact source expression is pinned by the mapping hash and direct IPC semantics remain bound by P0.W1.API_PARITY_LEDGER.", + "team.legacy-control.tool.approval.diff.preview.toggle-expanded": "team-approvals|direct|renderer-local|WP-9-APPROVALS|Reviewed mounted approval diff disclosure control; it changes only renderer presentation state.", + "team.legacy-control.tool.approval.settings.toggle-expanded": "team-approvals|direct|renderer-local|WP-9-APPROVALS|Reviewed mounted approval settings disclosure control; it changes only renderer presentation state.", + "team.legacy-control.tool.approval.sheet.select-question-option": "team-approvals|direct|renderer-local|WP-9-APPROVALS|Reviewed mounted approval-question selection control; the selected value is submitted only through the approval response action.", + "team.legacy-control.activity.activity.item.open-task-link": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Captured task-link activation is a semantic navigation action even though the handler also contains the parent click event.", + "team.legacy-control.activity.thought.body.content.open-task-link": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Captured task-link activation is a semantic navigation action.", + "team.legacy-control.dialogs.advanced.cli.section.commit-worktree-name": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Blur commits the selected worktree name to renderer-local history.", + "team.legacy-control.dialogs.advanced.cli.section.select-worktree-history": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Mouse selection updates the worktree name and closes its history popover; default suppression is only containment.", + "team.legacy-control.dialogs.send.message.dialog.handle-paste-wrapper": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Clipboard files enter the bounded message attachment flow.", + "team.legacy-control.dialogs.task.comments.section.open-task-link": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Captured task-link activation is a semantic navigation action even though the handler also contains the parent click event.", + "team.legacy-control.dialogs.task.detail.dialog.open-task-link": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Captured description task-link activation is a semantic navigation action even though the handler also contains the parent click event.", + "team.legacy-control.dialogs.task.detail.dialog.save-subject": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|Enter and blur both commit the edited task subject through updateTaskFields; propagation containment on Enter is not the semantic effect.", + "team.legacy-control.dialogs.tool.approval.settings.update-timeout-seconds": "team-approvals|decomposed|approval-policy|WP-9-APPROVALS|Blur validates and persists the tool approval timeout setting.", + "team.legacy-control.editor.editor.context.menu.capture-target": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The context-menu event selects the file target used by the visible editor menu.", + "team.legacy-control.editor.editor.file.tree.handle-drag-start": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Drag start establishes the editor file-tree item for the subsequent move action.", + "team.legacy-control.editor.editor.tab.bar.handle-drag-start": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Drag start establishes the editor tab for the subsequent reorder action.", + "team.legacy-control.editor.markdown.split.view.resize": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Pointer-driven divider resizing changes the mounted editor layout.", + "team.legacy-control.messages.message.composer.handle-paste-wrapper": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Clipboard files enter the bounded composer attachment flow.", + "team.legacy-control.review.code.mirror.diff.view.accept-active-chunk": "team-review|decomposed|review-change-set|WP-9-REVIEW|Mouse activation accepts the active review chunk; default suppression only preserves editor focus.", + "team.legacy-control.review.code.mirror.diff.view.move-between-chunks": "team-review|direct|renderer-local|WP-9-REVIEW|Mouse activation navigates between review chunks; default suppression only preserves editor focus.", + "team.legacy-control.review.code.mirror.diff.view.reject-active-chunk": "team-review|decomposed|review-change-set|WP-9-REVIEW|Mouse activation rejects the active review chunk; default suppression only preserves editor focus.", + "team.legacy-control.sidebar.team.sidebar.rail.resize-logs": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Mouse-down begins resizing the mounted logs panel.", + "team.legacy-control.sidebar.team.sidebar.rail.resize-sidebar": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Mouse-down begins resizing the mounted team sidebar.", + "team.legacy-control.claude.logs.section.open-dialog": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained click opens the selected logs dialog.", + "team.legacy-control.member.badge.select-member": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained badge click selects a member.", + "team.legacy-control.team.changes.section.refresh": "team-review|decomposed|review-change-set|WP-9-REVIEW|The contained click refreshes the visible change set.", + "team.legacy-control.team.sessions.section.toggle-filter": "team-runtime-control|direct|renderer-local|WP-5-RUNTIME-CONTROL|The contained click changes the session filter.", + "team.legacy-control.activity.activity.item.expand-item": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained click expands an activity item.", + "team.legacy-control.activity.activity.item.revise-message": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|The contained click enters message revision.", + "team.legacy-control.activity.activity.item.reply-message": "team-messaging|decomposed|team-resource|WP-8-MESSAGING|The contained click enters message reply.", + "team.legacy-control.activity.activity.item.create-task": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|The contained click creates a task from activity context.", + "team.legacy-control.activity.activity.item.restart-team": "team-lifecycle|decomposed|team-resource|WP-7-TEAM-LIFECYCLE|The contained click restarts the failed team flow.", + "team.legacy-control.activity.lead.thoughts.group.expand-item": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained click expands a lead-thought activity item.", + "team.legacy-control.attachments.attachment.display.reveal-file": "agent-attachments|direct|renderer-local|WP-9-ATTACHMENTS|The contained click reveals the attachment in the editor.", + "team.legacy-control.dialogs.task.attachments.remove": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|The contained click removes a task attachment.", + "team.legacy-control.dialogs.task.comment.input.remove-attachment": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|The contained click removes a pending comment attachment.", + "team.legacy-control.dialogs.task.detail.dialog.clear-clarification": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|The contained click clears the task clarification state.", + "team.legacy-control.dialogs.task.detail.dialog.open-file-in-editor": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained click opens the changed file in the editor.", + "team.legacy-control.dialogs.task.detail.dialog.refresh-changes": "team-review|decomposed|review-change-set|WP-9-REVIEW|The contained click refreshes task changes.", + "team.legacy-control.kanban.kanban.task.card.open-task": "team-task-board|direct|team-read|WP-8-TASK-BOARD|The contained click opens the selected task.", + "team.legacy-control.kanban.kanban.task.card.view-changes": "team-review|decomposed|review-change-set|WP-9-REVIEW|The contained click opens task changes.", + "team.legacy-control.kanban.kanban.task.card.delete-task": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|The contained click deletes the task.", + "team.legacy-control.kanban.kanban.task.card.start-task": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|The contained click starts the task.", + "team.legacy-control.kanban.kanban.task.card.complete-task": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|The contained click completes the task.", + "team.legacy-control.kanban.kanban.task.card.approve-task": "team-review|decomposed|review-change-set|WP-9-REVIEW|The contained click approves the task review.", + "team.legacy-control.kanban.kanban.task.card.request-review": "team-review|decomposed|review-change-set|WP-9-REVIEW|The contained click requests task review.", + "team.legacy-control.kanban.kanban.task.card.request-changes": "team-review|decomposed|review-change-set|WP-9-REVIEW|The contained click requests review changes.", + "team.legacy-control.kanban.kanban.task.card.move-back-to-done": "team-task-board|decomposed|team-resource|WP-8-TASK-BOARD|The contained click returns the task to done.", + "team.legacy-control.members.current.task.indicator.open-task": "team-task-board|direct|team-read|WP-8-TASK-BOARD|Click and keyboard activation open the current task; containment prevents opening the member card.", + "team.legacy-control.members.member.hover.card.open-profile": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained click opens the member profile.", + "team.legacy-control.messages.messages.panel.move-to-floating-composer": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained click moves messages to the floating composer.", + "team.legacy-control.messages.messages.panel.move-to-inline": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|Closing the mounted message sheet moves the message surface to its inline position.", + "team.legacy-control.messages.messages.panel.move-to-sidebar": "team-console|direct|renderer-local|WP-7-TEAM-CONSOLE|The contained click moves messages to the sidebar.", + "provider.management.model.clear-project-default": "runtime-provider-management|direct|provider-setting|WP-4-PROVIDER-AVAILABILITY|Clears the project-scoped default while retaining the inherited all-projects model.", + "team.legacy-control.kanban.kanban.task.attachment.mosaic.open-lightbox": "agent-attachments|decomposed|bounded-content|WP-9-ATTACHMENTS|Opens the reviewed task attachment in the bounded image lightbox.", + "team.legacy-control.members.member.quick.actions.invoke": "team-runtime-control|decomposed|runtime-operator|WP-5-RUNTIME-CONTROL|Invokes the reviewed member quick-action callback while containing the parent member-row interaction." + }, + "absences": { + "P0.W1.ABSENCE.child-dialog-state": "Dialog open-state synchronization is renderer-local and creates no hosted command.", + "P0.W1.ABSENCE.child-event-containment": "Event containment or default suppression is not a hosted semantic action.", + "P0.W1.ABSENCE.child-local-presentation": "Hover, focus, scroll, drag-affordance, and tooltip presentation state creates no hosted semantic action." + }, + "mappings": { + "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx#sha256:362691d41ec59619": "1|team.legacy-control.review.change.review.conflicts.recover-candidate", + "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx#sha256:bf0baea67e85504a": "1|team.legacy-control.review.change.review.conflicts.run-discard", + "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx#sha256:c482b3d393efcafe": "1|team.legacy-control.review.change.review.conflicts.open-discard-confirmation", + "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx#sha256:dd466d63fab182f1": "1|P0.W1.ABSENCE.child-dialog-state", + "src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx#sha256:f0fc4d947e39cc24": "1|team.legacy-control.review.change.review.conflicts.retry-load", + "src/features/change-review/renderer/ui/ChangeReviewSidebar.tsx#sha256:e4bafd44818fcf8a": "1|team.legacy-control.review.change.review.dialog.set-timeline-open", + "src/features/change-review/renderer/ui/FileEditTimeline.tsx#sha256:1e060a0df3b36305": "1|team.legacy-control.review.file.edit.timeline.on-event-click", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx#sha256:090e131a1e7420a5": "1|team.legacy-control.review.review.file.tree.set-filter-new", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx#sha256:1dbfec2f05aca127": "1|team.legacy-control.review.review.file.tree.set-filter-unresolved", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx#sha256:26dffc67e10890fd": "1|team.legacy-control.review.review.file.tree.on-toggle-folder", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx#sha256:30adde618d9d3f90": "1|team.legacy-control.review.review.file.tree.set-query", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx#sha256:bb19d502ae3f85ab": "1|team.legacy-control.review.review.file.tree.on-select-file", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx#sha256:be159ef6bc193e23": "1|team.legacy-control.review.review.file.tree.set-filter-rejected", + "src/features/change-review/renderer/ui/ReviewFileTree.tsx#sha256:c8fb366849e90b1d": "1|team.legacy-control.review.review.file.tree.set-query", + "src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx#sha256:bb70f538871bb947": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:1a03106d2f7f1b9f": "1|team.legacy-control.claude.logs.filter.popover.toggle-kind", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:2060ed5e48f06cfa": "1|team.legacy-control.claude.logs.filter.popover.button-implicit-action", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:2437052668d35413": "1|team.legacy-control.claude.logs.filter.popover.toggle-stream", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:65ac8be6bc9b1e0f": "1|team.legacy-control.claude.logs.filter.popover.toggle-kind", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:860fed20d5d1788f": "1|team.legacy-control.claude.logs.filter.popover.handle-reset", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:89f4f78e8ef143ef": "1|team.legacy-control.claude.logs.filter.popover.handle-save", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:92974d65ae355ab0": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:c5d93359810b859e": "1|team.legacy-control.claude.logs.filter.popover.toggle-kind", + "src/renderer/components/team/ClaudeLogsFilterPopover.tsx#sha256:f98f9e02b9cf40c4": "1|team.legacy-control.claude.logs.filter.popover.toggle-stream", + "src/renderer/components/team/ClaudeLogsPanel.tsx#sha256:1280af97cd52326a": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ClaudeLogsPanel.tsx#sha256:4e22903fa28a7450": "1|team.legacy-control.claude.logs.panel.set-search-query", + "src/renderer/components/team/ClaudeLogsPanel.tsx#sha256:a776559b101e6d86": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/ClaudeLogsPanel.tsx#sha256:c8b33959d668022e": "1|team.legacy-control.claude.logs.panel.load-older-logs", + "src/renderer/components/team/ClaudeLogsPanel.tsx#sha256:ce7f5b87c7280207": "1|team.legacy-control.claude.logs.panel.apply-pending", + "src/renderer/components/team/ClaudeLogsPanel.tsx#sha256:f14d8f464ef8f114": "1|team.legacy-control.claude.logs.panel.set-search-query", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:38f45e36f541f950": "1|team.legacy-control.claude.logs.section.set-selected-source-key", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:5f5910daf98c9ae0": "1|team.legacy-control.messages.messages.panel.move-to-inline", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:67599cbbaba93945": "1|team.legacy-control.claude.logs.section.on-source-change", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:956fdf27287a3c3d": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:c5f8ddc5de0fb528": "1|team.legacy-control.claude.logs.section.selected-member", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:d2178fcbdf8fe4a7": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:e22189d5df0cc84c": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/ClaudeLogsSection.tsx#sha256:f01a74e48ab0db88": "1|team.legacy-control.claude.logs.section.open-dialog", + "src/renderer/components/team/CliLogsRichView.tsx#sha256:2646e344df8b7d3a": "2|team.legacy-control.cli.logs.rich.view.on-toggle", + "src/renderer/components/team/CliLogsRichView.tsx#sha256:56c86c3686f3cb6d": "2|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/CliLogsRichView.tsx#sha256:92d41522fa881272": "1|team.legacy-control.cli.logs.rich.view.on-toggle", + "src/renderer/components/team/CliLogsRichView.tsx#sha256:c7498daea8a3e729": "1|team.legacy-control.cli.logs.rich.view.on-toggle", + "src/renderer/components/team/CliLogsRichView.tsx#sha256:d8284ab9c9fb0eb5": "1|team.legacy-control.cli.logs.rich.view.on-toggle", + "src/renderer/components/team/CollapsibleTeamSection.tsx#sha256:ed30a2c92627299f": "1|team.legacy-control.collapsible.team.section.set-open", + "src/renderer/components/team/MemberBadge.tsx#sha256:b9d68bc2ebfb27be": "1|team.legacy-control.member.badge.select-member", + "src/renderer/components/team/ProcessesSection.tsx#sha256:57f6a5d8adf996c3": "1|team.legacy-control.processes.section.kill-process", + "src/renderer/components/team/ProcessesSection.tsx#sha256:cf382cc6bff2d6bb": "2|team.legacy-control.processes.section.open-external", + "src/renderer/components/team/ProvisioningProgressBlock.tsx#sha256:18c30c4853e71f2d": "1|team.legacy-control.provisioning.progress.block.set-logs-open", + "src/renderer/components/team/ProvisioningProgressBlock.tsx#sha256:2329304f8b156d8c": "1|team.legacy-control.provisioning.progress.block.copy-diagnostics", + "src/renderer/components/team/ProvisioningProgressBlock.tsx#sha256:359e0bd630aa01c4": "1|team.legacy-control.provisioning.progress.block.set-live-output-open", + "src/renderer/components/team/ProvisioningProgressBlock.tsx#sha256:5ae058cd41c9af09": "1|team.legacy-control.provisioning.progress.block.on-cancel", + "src/renderer/components/team/ProvisioningProgressBlock.tsx#sha256:6339dc9f894411f7": "1|team.legacy-control.provisioning.progress.block.set-diagnostics-open", + "src/renderer/components/team/ProvisioningProgressBlock.tsx#sha256:8a9fbf13ec55a744": "2|team.legacy-control.provisioning.progress.block.on-dismiss", + "src/renderer/components/team/RoleSelect.tsx#sha256:2513be99882acade": "1|team.legacy-control.role.select.handle-value-change", + "src/renderer/components/team/RoleSelect.tsx#sha256:94555a767bd3d768": "1|team.legacy-control.role.select.handle-custom-change", + "src/renderer/components/team/TaskTooltip.tsx#sha256:ce6b28bb456bf26a": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/TeamChangesSection.tsx#sha256:34b70081a41b3c78": "1|team.legacy-control.team.changes.section.refresh", + "src/renderer/components/team/TeamChangesSection.tsx#sha256:354dd30014620e9b": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/TeamChangesSection.tsx#sha256:461d1c5d470d48d3": "1|team.legacy-control.team.changes.section.on-open-task", + "src/renderer/components/team/TeamChangesSection.tsx#sha256:525bc3f0efb44e02": "1|team.legacy-control.team.changes.section.on-view-changes", + "src/renderer/components/team/TeamChangesSection.tsx#sha256:75a0eb88c9577b60": "1|team.legacy-control.team.changes.section.on-view-changes", + "src/renderer/components/team/TeamChangesSection.tsx#sha256:a1d006c56aa7ff68": "1|team.legacy-control.team.changes.section.on-view-changes", + "src/renderer/components/team/TeamChangesSection.tsx#sha256:c37418c8c5a72dcf": "1|team.legacy-control.team.changes.section.on-view-changes", + "src/renderer/components/team/TeamEmptyState.tsx#sha256:4eda11d99450e79c": "1|team.legacy-control.team.empty.state.on-import-team", + "src/renderer/components/team/TeamEmptyState.tsx#sha256:685d8d76af0d2b0b": "1|team.legacy-control.team.empty.state.on-create-team", + "src/renderer/components/team/TeamListFilterPopover.tsx#sha256:05fe8602224eab1e": "1|team.legacy-control.team.list.filter.popover.button-implicit-action", + "src/renderer/components/team/TeamListFilterPopover.tsx#sha256:94368e8568421121": "1|team.legacy-control.team.list.filter.popover.handle-status-toggle", + "src/renderer/components/team/TeamListFilterPopover.tsx#sha256:9a50d22e5652ab4f": "1|team.legacy-control.team.list.filter.popover.handle-clear-all", + "src/renderer/components/team/TeamListFilterPopover.tsx#sha256:a56e210a56971e71": "1|team.legacy-control.team.list.filter.popover.handle-project-toggle", + "src/renderer/components/team/TeamListFilterPopover.tsx#sha256:c10868f460ab8710": "1|team.legacy-control.team.list.filter.popover.handle-status-toggle", + "src/renderer/components/team/TeamProvisioningPanel.tsx#sha256:9c049a3cc78ef2e2": "1|team.legacy-control.team.provisioning.panel.set-dismissed", + "src/renderer/components/team/TeamProvisioningPanel.tsx#sha256:9f2d4fd8ee026f7f": "1|team.legacy-control.team.provisioning.panel.retry-failed-open-code-secondary-lanes", + "src/renderer/components/team/TeamSessionsSection.tsx#sha256:1906099cc42207e7": "1|team.legacy-control.team.sessions.section.handle-session-click", + "src/renderer/components/team/TeamSessionsSection.tsx#sha256:2371d0ec1c024ef2": "1|team.legacy-control.team.sessions.section.toggle-filter", + "src/renderer/components/team/TeamSessionsSection.tsx#sha256:4697e3c1af281087": "1|team.legacy-control.team.sessions.section.on-select-session", + "src/renderer/components/team/TeamSessionsSection.tsx#sha256:9bdff1fbf589081b": "1|team.legacy-control.team.sessions.section.handle-session-click", + "src/renderer/components/team/TeamSessionsSection.tsx#sha256:9d8dd10677000fbf": "1|team.legacy-control.team.sessions.section.button-on-click", + "src/renderer/components/team/ToolApprovalDiffPreview.tsx#sha256:2b22549ddb269e8f": "1|team.legacy-control.tool.approval.diff.preview.toggle-expanded", + "src/renderer/components/team/ToolApprovalDiffPreview.tsx#sha256:b459c5798b12af22": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ToolApprovalDiffPreview.tsx#sha256:f11af62a1beaa1c5": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:20e8f7899e0f12da": "1|team.legacy-control.tool.approval.sheet.select-question-option", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:6bf5c0024c0bbbe0": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:6c558a10417d1bb1": "1|team.legacy-control.tool.approval.settings.toggle-expanded", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:91c42630baef09ef": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:9559a433ceebf5ec": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:9d3a55a830af01c7": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:b459c5798b12af22": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:bc7f650e270a2b3a": "1|team.approval.respond-to-tool-approval", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:e56a6451194401d8": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:f619abd9d2f06a2b": "1|team.approval.respond-to-tool-approval", + "src/renderer/components/team/ToolApprovalSheet.tsx#sha256:fbd8fb7c3324fe9e": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/UnreadCommentsBadge.tsx#sha256:ce6b28bb456bf26a": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/activity/ActiveTasksBlock.tsx#sha256:4421fe061f0fa5e3": "1|team.legacy-control.activity.active.tasks.block.set-collapsed", + "src/renderer/components/team/activity/ActiveTasksBlock.tsx#sha256:65239ab1e502cfef": "1|team.legacy-control.activity.active.tasks.block.on-task-click", + "src/renderer/components/team/activity/ActiveTasksBlock.tsx#sha256:776c8ad348a81495": "1|team.legacy-control.activity.active.tasks.block.on-member-click", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:0abbe7e35bc3e74d": "1|team.legacy-control.activity.activity.item.open-task-link", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:21d37e2405406467": "1|team.legacy-control.activity.activity.item.handle-header-toggle", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:2a2f9994cdddf0d2": "1|team.legacy-control.activity.activity.item.create-task", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:3597114d854da9e9": "1|team.legacy-control.activity.activity.item.open-task-link", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:3c184571207a875b": "1|team.legacy-control.activity.activity.item.restart-team", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:3ee5a263eec43eec": "1|team.legacy-control.activity.activity.item.revise-message", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:3f0d324d42e9187f": "1|team.legacy-control.activity.activity.item.handle-header-toggle", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:5c3bf89cda708003": "1|team.legacy-control.activity.activity.item.reply-message", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:64877a4886f6d509": "9|team.legacy-control.activity.activity.item.on-member-name-click", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:96cd94c3da2fbb43": "4|team.legacy-control.activity.activity.item.on-team-click", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:9b1cd0dbc06e2b98": "3|team.legacy-control.activity.activity.item.expand-item", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:ab652cf88de6aa42": "1|team.legacy-control.activity.activity.item.open-task-link", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:bd2c930acae6fa82": "3|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:db8f2250885aaef2": "1|team.legacy-control.activity.activity.item.on-team-click", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:e05f22c26512f818": "1|team.legacy-control.activity.activity.item.open-task-link", + "src/renderer/components/team/activity/ActivityItem.tsx#sha256:fd94940e350e840a": "1|team.legacy-control.activity.activity.item.open-task-link", + "src/renderer/components/team/activity/ActivityTimeline.tsx#sha256:152181cf86666501": "1|team.legacy-control.activity.activity.timeline.handle-show-all", + "src/renderer/components/team/activity/ActivityTimeline.tsx#sha256:1c5ae926aba4b86e": "1|team.legacy-control.activity.activity.timeline.handle-show-more", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx#sha256:6387e3393dd3ae35": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx#sha256:760064b4e0e491ee": "1|team.legacy-control.activity.lead.thoughts.group.handle-body-toggle", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx#sha256:79d283e138e8aeb1": "1|team.legacy-control.activity.lead.thoughts.group.handle-body-toggle", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx#sha256:88576fbb278b499d": "1|team.legacy-control.activity.lead.thoughts.group.handle-body-toggle", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx#sha256:9b1cd0dbc06e2b98": "3|team.legacy-control.activity.lead.thoughts.group.expand-item", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx#sha256:bd2c930acae6fa82": "3|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx#sha256:c3d65956de4b8afa": "1|team.legacy-control.activity.lead.thoughts.group.handle-body-toggle", + "src/renderer/components/team/activity/MessageExpandDialog.tsx#sha256:e22189d5df0cc84c": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/activity/PendingRepliesBlock.tsx#sha256:559a0e684ecb6d7a": "1|team.legacy-control.activity.pending.replies.block.on-member-click", + "src/renderer/components/team/activity/ReplyQuoteBlock.tsx#sha256:6154ba301d82e191": "1|team.legacy-control.activity.reply.quote.block.set-expanded", + "src/renderer/components/team/activity/ThoughtBodyContent.tsx#sha256:3df432be4adb4daa": "1|team.legacy-control.activity.thought.body.content.handle-reply", + "src/renderer/components/team/activity/ThoughtBodyContent.tsx#sha256:436f1ae0b82487d7": "1|team.legacy-control.activity.thought.body.content.open-task-link", + "src/renderer/components/team/attachments/AttachmentDisplay.tsx#sha256:4549841228813945": "1|team.legacy-control.attachments.attachment.display.reveal-file", + "src/renderer/components/team/attachments/AttachmentDisplay.tsx#sha256:a3a0edf693152a8e": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/attachments/AttachmentDisplay.tsx#sha256:d86a3ee9a8d0f707": "1|team.legacy-control.attachments.attachment.display.set-lightbox-index", + "src/renderer/components/team/attachments/AttachmentPreviewItem.tsx#sha256:8f6db31e809ba621": "1|team.legacy-control.attachments.attachment.preview.item.on-preview", + "src/renderer/components/team/attachments/AttachmentPreviewItem.tsx#sha256:ad91c210ee511e02": "1|team.legacy-control.attachments.attachment.preview.item.on-remove", + "src/renderer/components/team/attachments/AttachmentPreviewList.tsx#sha256:c42f7c0bd5709b16": "1|team.legacy-control.attachments.attachment.preview.list.on-dismiss-error", + "src/renderer/components/team/attachments/AttachmentThumbnail.tsx#sha256:9d8dd10677000fbf": "1|team.legacy-control.attachments.attachment.thumbnail.button-on-click", + "src/renderer/components/team/dialogs/AddMemberDialog.tsx#sha256:18314de50ac15794": "1|team.legacy-control.dialogs.add.member.dialog.set-members", + "src/renderer/components/team/dialogs/AddMemberDialog.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.dialogs.add.member.dialog.on-close", + "src/renderer/components/team/dialogs/AddMemberDialog.tsx#sha256:4fb257c290cbb747": "1|team.legacy-control.dialogs.add.member.dialog.handle-submit", + "src/renderer/components/team/dialogs/AddMemberDialog.tsx#sha256:6f5e2ab654f9c4d3": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:0d3bc93ca9be5a98": "1|team.legacy-control.dialogs.advanced.cli.section.handle-custom-args-change", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:8d34f712f4b4025a": "1|team.legacy-control.dialogs.advanced.cli.section.on-worktree-name-change", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:a1a930afc781506a": "1|team.legacy-control.dialogs.advanced.cli.section.on-worktree-enabled-change", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:c1c7f5fbfb81fd53": "1|team.legacy-control.dialogs.advanced.cli.section.commit-worktree-name", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:ca1f60e0b25ae80d": "1|team.legacy-control.dialogs.advanced.cli.section.set-is-open", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:d153c061599f89bd": "1|team.legacy-control.dialogs.advanced.cli.section.handle-validate", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:e3fa633e40d5b512": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/AdvancedCliSection.tsx#sha256:e6a6ac571f32e01f": "1|team.legacy-control.dialogs.advanced.cli.section.select-worktree-history", + "src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx#sha256:d54edf670b7f3f5d": "1|team.legacy-control.dialogs.anthropic.extra.usage.warning.a-navigate", + "src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx#sha256:dd1d63ca0667a1ad": "1|team.legacy-control.dialogs.anthropic.fast.mode.selector.button-on-click", + "src/renderer/components/team/dialogs/CodexFastModeSelector.tsx#sha256:dd1d63ca0667a1ad": "1|team.legacy-control.dialogs.codex.fast.mode.selector.button-on-click", + "src/renderer/components/team/dialogs/CodexModelCatalogFallbackNotice.tsx#sha256:72a8e38b31f78583": "1|team.legacy-control.dialogs.codex.model.catalog.fallback.notice.on-update", + "src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx#sha256:868be2c5570a8df0": "1|team.legacy-control.dialogs.codex.reconnect.prompt.open-external", + "src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx#sha256:ec5ccd285d814dfb": "1|team.legacy-control.dialogs.codex.reconnect.prompt.on-device-code-reconnect", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:0a40439e96fda7b1": "1|team.legacy-control.dialogs.create.task.dialog.toggle-related", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:22d0f0395703c5e0": "1|team.legacy-control.dialogs.create.task.dialog.handle-submit", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.dialogs.create.task.dialog.on-close", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:4fb257c290cbb747": "1|team.legacy-control.dialogs.create.task.dialog.handle-submit", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:5e5913be8e02e68e": "1|team.legacy-control.dialogs.create.task.dialog.set-owner", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:640f0fd45311b25a": "1|team.legacy-control.dialogs.create.task.dialog.set-blocked-by-search", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:6f5e2ab654f9c4d3": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:74ba7e5481ee2aaf": "1|team.legacy-control.dialogs.create.task.dialog.set-related-search", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:762fde2a6e3d4e73": "1|team.legacy-control.dialogs.create.task.dialog.set-show-optional-fields", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:8d0c842df9b86dbd": "1|team.legacy-control.dialogs.create.task.dialog.toggle-blocked-by", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:a0d21a43a409f198": "1|team.legacy-control.dialogs.create.task.dialog.set-value", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:b595fc47dbad28f4": "1|team.legacy-control.dialogs.create.task.dialog.set-value", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:b84dc8559ca2f4cc": "1|team.legacy-control.dialogs.create.task.dialog.set-subject", + "src/renderer/components/team/dialogs/CreateTaskDialog.tsx#sha256:ddfcf6a1ee7d0dee": "1|team.legacy-control.dialogs.create.task.dialog.set-start-immediately", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:147f6cc5b33ebefa": "1|team.legacy-control.dialogs.edit.team.dialog.handle-save", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.dialogs.edit.team.dialog.on-close", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:3c674be7bfd5ed09": "1|team.legacy-control.dialogs.edit.team.dialog.clear-transient-errors", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:60b36aa10e62d143": "1|team.legacy-control.dialogs.edit.team.dialog.clear-transient-errors", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:89f4f78e8ef143ef": "1|team.legacy-control.dialogs.edit.team.dialog.handle-save", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:cdfbccdf13077442": "1|team.legacy-control.dialogs.edit.team.dialog.clear-transient-errors", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:f4e0527dfd75aa54": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/EditTeamDialog.tsx#sha256:ff5c8716807a3bda": "1|team.legacy-control.dialogs.edit.team.dialog.clear-transient-errors", + "src/renderer/components/team/dialogs/EffortLevelSelector.tsx#sha256:864641f3781829a5": "1|team.legacy-control.dialogs.effort.level.selector.button-on-click", + "src/renderer/components/team/dialogs/ExperimentalLocalModelOverride.tsx#sha256:a39765a59375118b": "1|team.legacy-control.dialogs.experimental.local.model.override.checkbox-on-checked-change", + "src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx#sha256:159ec492291198df": "1|team.legacy-control.dialogs.global.task.detail.open-team", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:104a3d16fc455e5a": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:460a6a2c6167d111": "2|team.legacy-control.dialogs.launch.team.dialog.set-selected-fast-mode", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:475c27e450b1966c": "1|team.legacy-control.dialogs.launch.team.dialog.set-selected-model", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:63d5e65eadb10c41": "1|team.legacy-control.dialogs.launch.team.dialog.set-selected-team-name", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:69d44025e3159010": "1|team.legacy-control.dialogs.launch.team.dialog.set-selected-effort", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:6b0010b808dbf984": "1|team.legacy-control.dialogs.launch.team.dialog.set-sched-expanded", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:6eb6d60bd8a2b4f9": "2|team.legacy-control.dialogs.launch.team.dialog.set-selected-fast-mode", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:6f36c61f588155f7": "1|team.legacy-control.dialogs.launch.team.dialog.set-max-turns", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:7a8bb7d6691ae182": "1|team.legacy-control.dialogs.launch.team.dialog.handle-submit", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:8ae85785caeac217": "1|team.legacy-control.dialogs.launch.team.dialog.set-conflict-dismissed", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:9c184401f5913a9b": "1|team.legacy-control.dialogs.launch.team.dialog.set-allow-experimental-local-models", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:b595fc47dbad28f4": "2|team.legacy-control.dialogs.launch.team.dialog.set-value", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:c906db2f343959c1": "1|team.legacy-control.dialogs.launch.team.dialog.set-sched-label", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:cdb8b3b29e5bd5dc": "2|team.legacy-control.dialogs.launch.team.dialog.set-skip-permissions", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:d9c62f7bc0b7316b": "1|team.legacy-control.dialogs.launch.team.dialog.set-max-budget-usd", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:dd72b478da5dc495": "1|team.legacy-control.dialogs.launch.team.dialog.a-navigate", + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx#sha256:ec5979de08611b7f": "1|team.legacy-control.dialogs.launch.team.dialog.close-dialog", + "src/renderer/components/team/dialogs/LaunchTeamDialogLoadingFallback.tsx#sha256:6f151970571293da": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/LimitContextCheckbox.tsx#sha256:d559eeaff913da1d": "1|team.legacy-control.dialogs.limit.context.checkbox.checkbox-on-checked-change", + "src/renderer/components/team/dialogs/MembersJsonEditor.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.dialogs.members.json.editor.on-close", + "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx#sha256:51efb043d08e8d3c": "1|team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.set-approved", + "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx#sha256:56a483faf0535891": "1|team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.cancel", + "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx#sha256:9a1c55d9a2c0a180": "1|team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.approve", + "src/renderer/components/team/dialogs/OpenCodeLocalModelPrivateNetworkApprovalDialog.tsx#sha256:ec1bcf4ca573ce82": "1|team.legacy-control.dialogs.open.code.local.model.private.network.approval.dialog.cancel", + "src/renderer/components/team/dialogs/OpenCodeLocalModelsFeedback.tsx#sha256:3c7874e3acc27459": "1|team.legacy-control.dialogs.open.code.local.models.feedback.retry", + "src/renderer/components/team/dialogs/OptionalSettingsSection.tsx#sha256:1dadf3ee2133253f": "1|team.legacy-control.dialogs.optional.settings.section.handle-toggle-open", + "src/renderer/components/team/dialogs/ProjectPathSelector.tsx#sha256:2733ef581ae6be7c": "1|team.legacy-control.dialogs.project.path.selector.select-folders", + "src/renderer/components/team/dialogs/ProjectPathSelector.tsx#sha256:431f7cd777704945": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/ProjectPathSelector.tsx#sha256:57837e7119ca8908": "1|team.legacy-control.dialogs.project.path.selector.on-custom-cwd-change", + "src/renderer/components/team/dialogs/ProjectPathSelector.tsx#sha256:972d9a703667be0a": "1|team.legacy-control.dialogs.project.path.selector.on-selected-project-path-change", + "src/renderer/components/team/dialogs/ProjectPathSelector.tsx#sha256:9a314fed16f46386": "1|team.legacy-control.dialogs.project.path.selector.on-cwd-mode-change", + "src/renderer/components/team/dialogs/ProjectPathSelector.tsx#sha256:f396779d53c48a31": "1|team.legacy-control.dialogs.project.path.selector.on-cwd-mode-change", + "src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx#sha256:0dd47c279314a03f": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx#sha256:1c33ab1082d34d7a": "1|team.legacy-control.dialogs.provisioning.provider.status.list.copy-support-diagnostics", + "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx#sha256:ad15c8016c689996": "1|team.legacy-control.dialogs.provisioning.provider.status.list.on-open-provider-settings", + "src/renderer/components/team/dialogs/ReviewDialog.tsx#sha256:0f993adb0364f137": "1|team.legacy-control.dialogs.review.dialog.set-value", + "src/renderer/components/team/dialogs/ReviewDialog.tsx#sha256:b0a5b6551de6254e": "1|team.legacy-control.dialogs.review.dialog.handle-submit", + "src/renderer/components/team/dialogs/ReviewDialog.tsx#sha256:f1dec35445a53a2d": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:044656db8bb51d6c": "1|team.legacy-control.dialogs.send.message.dialog.set-quote-expanded", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:0aa522480c988f84": "1|team.legacy-control.dialogs.send.message.dialog.handle-drop-wrapper", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:1083d5415080aece": "1|team.legacy-control.dialogs.send.message.dialog.set-action-mode", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:23575b99a3ec0245": "1|team.legacy-control.dialogs.send.message.dialog.button-on-click", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:2769f62c04b77f24": "1|team.legacy-control.dialogs.send.message.dialog.set-value", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:338157eede87f7f7": "1|team.legacy-control.dialogs.send.message.dialog.set-member", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:5c60d794af536bea": "1|team.legacy-control.dialogs.send.message.dialog.handle-paste-wrapper", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:6f5e2ab654f9c4d3": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:874c54e7c9a5b6dd": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:8ea8d7742f3d3125": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:95a4f8ff49aba399": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:b0a5b6551de6254e": "1|team.legacy-control.dialogs.send.message.dialog.handle-submit", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:d4dcdb7cda23aebe": "1|team.legacy-control.dialogs.send.message.dialog.set-quote", + "src/renderer/components/team/dialogs/SendMessageDialog.tsx#sha256:ecf725a5bf914c68": "1|team.legacy-control.dialogs.send.message.dialog.handle-file-input-change", + "src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx#sha256:d559eeaff913da1d": "1|team.legacy-control.dialogs.skip.permissions.checkbox.checkbox-on-checked-change", + "src/renderer/components/team/dialogs/TaskAttachments.tsx#sha256:1c1709b687a3831f": "1|team.legacy-control.dialogs.task.attachments.handle-file-select", + "src/renderer/components/team/dialogs/TaskAttachments.tsx#sha256:337463bd9f0728f8": "1|team.legacy-control.dialogs.task.attachments.remove", + "src/renderer/components/team/dialogs/TaskAttachments.tsx#sha256:5394e9ac9cf0f993": "1|team.legacy-control.dialogs.task.attachments.button-on-click", + "src/renderer/components/team/dialogs/TaskAttachments.tsx#sha256:540d6fbf5956bd35": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/TaskAttachments.tsx#sha256:baa1d5f971ad5ab1": "1|team.legacy-control.dialogs.task.attachments.on-preview", + "src/renderer/components/team/dialogs/TaskAttachments.tsx#sha256:cc484c1a462a5936": "1|team.legacy-control.dialogs.task.attachments.handle-drop", + "src/renderer/components/team/dialogs/TaskAttachments.tsx#sha256:d9fd063bad9cdf03": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:044656db8bb51d6c": "1|team.legacy-control.dialogs.task.comment.input.set-quote-expanded", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:1930cef4ef48b7aa": "1|team.legacy-control.dialogs.task.comment.input.open-external", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:23575b99a3ec0245": "1|team.legacy-control.dialogs.task.comment.input.button-on-click", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:3b1bf79189c59b8e": "1|team.legacy-control.dialogs.task.comment.input.remove-attachment", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:49c2025f629fc6a5": "1|team.legacy-control.dialogs.task.comment.input.add-files", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:78dafed7fc648f8e": "1|team.legacy-control.dialogs.task.comment.input.set-value", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:c4711728543e3896": "1|team.legacy-control.dialogs.task.comment.input.handle-submit", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:ce17f7b90a4ed042": "1|team.legacy-control.dialogs.task.comment.input.add-files", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:d459482f74869f10": "1|team.legacy-control.dialogs.task.comment.input.on-clear-reply", + "src/renderer/components/team/dialogs/TaskCommentInput.tsx#sha256:ed413735bae6df87": "1|team.legacy-control.dialogs.task.comment.input.set-lightbox-index", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx#sha256:0f993adb0364f137": "1|team.legacy-control.dialogs.task.comments.section.set-value", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx#sha256:5063f7f70094b11d": "1|team.legacy-control.dialogs.task.comments.section.on-preview", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx#sha256:5e15e3064027e405": "1|team.legacy-control.dialogs.task.comments.section.open-task-link", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx#sha256:6ed15ef5d220029b": "1|team.legacy-control.dialogs.task.comments.section.set-visible-count", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx#sha256:751319ed97a12502": "1|team.legacy-control.dialogs.task.comments.section.set-reply-to", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx#sha256:c4711728543e3896": "1|team.legacy-control.dialogs.task.comments.section.handle-submit", + "src/renderer/components/team/dialogs/TaskCommentsSection.tsx#sha256:f66bdfce139d045e": "1|team.legacy-control.dialogs.task.comments.section.on-reply", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:021705b23ece0331": "4|team.legacy-control.dialogs.task.detail.dialog.handle-dependency-click", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:05cead1401b5923a": "1|team.legacy-control.dialogs.task.detail.dialog.set-description-draft", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:0f19fa7312b191e4": "1|team.legacy-control.dialogs.task.detail.dialog.on-view-changes", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:2dc86aef41fa4c56": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:3396c30df3ddab56": "1|team.legacy-control.dialogs.task.detail.dialog.open-file-in-editor", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:34ac5ab7760542b1": "2|team.legacy-control.dialogs.task.detail.dialog.start-edit-description", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:3829d019ae6f70fa": "1|team.legacy-control.dialogs.task.detail.dialog.save-description", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:3c4ae868ca1c6f66": "1|team.legacy-control.dialogs.task.detail.dialog.on-owner-change", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:4acf2eae8f586d13": "1|team.legacy-control.dialogs.task.detail.dialog.refresh-changes", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:4cecf1aa0432a3f8": "1|team.legacy-control.dialogs.task.detail.dialog.on-view-changes", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:576d217346772bcf": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:67400a38bd5f5f82": "1|team.legacy-control.dialogs.task.detail.dialog.set-subject-draft", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:755e0d9e783b45ec": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:93962f93c60e163c": "1|team.legacy-control.dialogs.task.detail.dialog.set-editing-description", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:948ebb56a04f9668": "1|team.legacy-control.dialogs.task.detail.dialog.clear-clarification", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:95074338371b433d": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:9efc0f174eb61fbf": "1|team.legacy-control.dialogs.task.detail.dialog.start-edit-subject", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:b834d684e97eb77c": "1|team.legacy-control.dialogs.task.detail.dialog.save-subject", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:cc8553b286400412": "1|team.legacy-control.dialogs.task.detail.dialog.on-delete-task", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:ce4b111e83de5309": "1|team.legacy-control.dialogs.task.detail.dialog.on-preview", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:e6859407fcfa8f46": "1|team.legacy-control.dialogs.task.detail.dialog.on-view-changes", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:e8f41755b35aa23e": "1|team.legacy-control.dialogs.task.detail.dialog.open-task-link", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:f3703741e7f16ed1": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx#sha256:ffbee3e9b15abf4b": "1|team.legacy-control.dialogs.task.detail.dialog.save-subject", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:0fc2b993439e07e7": "1|team.legacy-control.dialogs.team.model.selector.toggle-open-code-source-filter", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:10fb7a27563b057f": "1|team.legacy-control.dialogs.team.model.selector.set-model-query", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:17a904c69575ab0b": "1|team.legacy-control.dialogs.team.model.selector.toggle-open-code-route-tag", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:2bb2bdfee1c697b5": "1|team.legacy-control.dialogs.team.model.selector.start", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:3252000e85dcf19c": "1|team.legacy-control.dialogs.team.model.selector.input-on-change", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:3cfd8c00595dd6d6": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:40fd58216316d4b1": "1|team.legacy-control.dialogs.team.model.selector.set-recommended-only", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:50a3c0db14930221": "1|team.legacy-control.dialogs.team.model.selector.start", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:572e2b448b909358": "1|team.legacy-control.dialogs.team.model.selector.toggle-open-code-source-filter", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:6dc1bd60808700a5": "1|team.legacy-control.dialogs.team.model.selector.set-free-only", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:7c1f38bc7952f185": "1|team.legacy-control.dialogs.team.model.selector.set-model-query", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:810a54f86c6a89c3": "1|team.legacy-control.dialogs.team.model.selector.button-on-click", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:89f66ae52d695ad1": "1|team.legacy-control.dialogs.team.model.selector.select-local-models", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:9d43df1c40e5bd13": "1|team.legacy-control.dialogs.team.model.selector.set-new-only", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:aacf73143639013c": "1|team.legacy-control.dialogs.team.model.selector.selected-provider-id", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:c48b6df013f974bb": "1|team.legacy-control.dialogs.team.model.selector.selector", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:d364fcb593062c0d": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:d9547002c6b51b05": "1|team.legacy-control.dialogs.team.model.selector.clear-open-code-filters", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:df0ce05ac83533a4": "1|team.legacy-control.dialogs.team.model.selector.set-open-code-source-query", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:e89d93d04c9671db": "1|team.legacy-control.dialogs.team.model.selector.set-selected-open-code-source-ids", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:e93751269dd31464": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:fa4de5c4343b2a0e": "1|team.legacy-control.dialogs.team.model.selector.refresh-open-code-local-providers", + "src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx#sha256:66e95fdb697761b9": "1|team.legacy-control.dialogs.teammate.runtime.compatibility.notice.on-open-dashboard", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:14b1fad488bf44d0": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:2646e344df8b7d3a": "1|team.legacy-control.tool.approval.settings.toggle-expanded", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:38304436e7d56f95": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:5d82cf9b106c6afe": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:7fe8a34c246e2291": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:85f56bd2ce48476a": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:979be4ab3421ec63": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:99c4c86dfa822b94": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:a7fe6352ba186223": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:b459c5798b12af22": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:c8d1ffc5278c260d": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:cf3ee774403f7d17": "1|team.approval.update-tool-approval-settings", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:f11af62a1beaa1c5": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx#sha256:fa074c37a7cd5cec": "1|team.legacy-control.dialogs.tool.approval.settings.update-timeout-seconds", + "src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx#sha256:3e0fc8827976633a": "1|team.legacy-control.dialogs.worktree.git.readiness.banner.create-initial-commit", + "src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx#sha256:8335aa5d0ff87b9e": "1|team.legacy-control.dialogs.worktree.git.readiness.banner.button-on-click", + "src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx#sha256:a834d7f422c4709a": "1|team.legacy-control.editor.editor.binary.placeholder.handle-open-external", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:4e329b524c8af1f8": "1|team.legacy-control.editor.editor.context.menu.on-rename", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:50ff16eddeee89d8": "1|team.legacy-control.editor.editor.context.menu.context-menu-item-on-select", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:620a7348fb2eed2a": "1|team.legacy-control.editor.editor.context.menu.context-menu-item-on-select", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:84e4d2a23b7bb9c7": "1|team.legacy-control.editor.editor.context.menu.on-delete", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:a8028e4c9f679ba2": "1|team.legacy-control.editor.editor.context.menu.context-menu-item-on-select", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:a88e14ec390c4ec4": "1|team.legacy-control.editor.editor.context.menu.on-new-file", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:b9fa2dee3e6146d1": "1|team.legacy-control.editor.editor.context.menu.on-send-message", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:c2164068ef21b97d": "1|team.legacy-control.editor.editor.context.menu.on-new-folder", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:e6fc5c43f303fbd6": "1|team.legacy-control.editor.editor.context.menu.on-create-task", + "src/renderer/components/team/editor/EditorContextMenu.tsx#sha256:f681bb4cde566ba3": "1|team.legacy-control.editor.editor.context.menu.capture-target", + "src/renderer/components/team/editor/EditorErrorBoundary.tsx#sha256:1c1a80b7bc1db652": "1|team.legacy-control.editor.editor.error.boundary.handle-retry", + "src/renderer/components/team/editor/EditorErrorState.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.editor.editor.error.state.on-close", + "src/renderer/components/team/editor/EditorErrorState.tsx#sha256:3c7874e3acc27459": "1|team.legacy-control.editor.editor.error.state.on-retry", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:0f82202e91b11be7": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:0ffa016838d1496f": "1|team.legacy-control.editor.editor.file.tree.handle-node-click", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:151747a5e64177ec": "1|team.legacy-control.editor.editor.file.tree.handle-new-item-submit", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:165e086f0e3330f1": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:3426659605f1217f": "1|team.legacy-control.editor.editor.file.tree.on-rename-submit", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:4af5671b033319ba": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:5d4b4cefb14d7039": "1|team.legacy-control.editor.editor.file.tree.handle-confirm-delete", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:6205a0482f291e19": "1|team.legacy-control.editor.editor.file.tree.handle-click", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:6e39a42add585b77": "1|team.legacy-control.editor.editor.file.tree.set-value", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:70a068044f5b9d78": "1|team.legacy-control.editor.editor.file.tree.handle-key-down", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:70b73e5fc93691a2": "1|team.legacy-control.editor.editor.file.tree.handle-drag-end", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:7318668c84f70c64": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:73a07f14b212f146": "1|team.legacy-control.editor.editor.file.tree.handle-drag-start", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:97e749305880f84b": "1|team.legacy-control.editor.editor.file.tree.handle-cancel-delete", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:a28fce28804339ab": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/editor/EditorFileTree.tsx#sha256:f689c4c30d5eb6d3": "1|team.legacy-control.editor.editor.file.tree.handle-new-item-submit", + "src/renderer/components/team/editor/EditorImagePreview.tsx#sha256:4c80853062110e3b": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/EditorImagePreview.tsx#sha256:a834d7f422c4709a": "1|team.legacy-control.editor.editor.image.preview.handle-open-external", + "src/renderer/components/team/editor/EditorImagePreview.tsx#sha256:a897c4d1494f0ccc": "1|team.legacy-control.editor.editor.image.preview.set-lightbox-open", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:21fcc1874d599637": "1|team.legacy-control.editor.editor.search.panel.handle-find-next", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:3d3975f2adb533ce": "1|team.legacy-control.editor.editor.search.panel.handle-replace-key-down", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:6b67d8251b7eecec": "1|team.legacy-control.editor.editor.search.panel.handle-search-key-down", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:715ab701362654cc": "1|team.legacy-control.editor.editor.search.panel.set-case-sensitive", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:8142b4a576157a2e": "1|team.legacy-control.editor.editor.search.panel.set-search-text", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:9b3e4f35054cfb3e": "1|team.legacy-control.editor.editor.search.panel.set-whole-word", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:9d8dd10677000fbf": "1|team.legacy-control.editor.editor.search.panel.button-on-click", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:a168081b6de3a100": "1|team.legacy-control.editor.editor.search.panel.set-use-regexp", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:a4c29c0480bee6f2": "1|team.legacy-control.editor.editor.search.panel.handle-replace-all", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:bb41769acc35383d": "1|team.legacy-control.editor.editor.search.panel.handle-find-prev", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:bdae76e1dc7a313e": "1|team.legacy-control.editor.editor.search.panel.handle-close", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:cc24b856a5d25b9c": "1|team.legacy-control.editor.editor.search.panel.set-show-replace", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:e5fc3eaf5485bb35": "1|team.legacy-control.editor.editor.search.panel.handle-replace-next", + "src/renderer/components/team/editor/EditorSearchPanel.tsx#sha256:e7230a3dcf929fa4": "1|team.legacy-control.editor.editor.search.panel.set-replace-text", + "src/renderer/components/team/editor/EditorSelectionMenu.tsx#sha256:150fa828171f33aa": "1|team.legacy-control.editor.editor.selection.menu.button-on-click", + "src/renderer/components/team/editor/EditorSelectionMenu.tsx#sha256:4750d3247bd0ceaa": "1|team.legacy-control.editor.editor.selection.menu.on-create-task", + "src/renderer/components/team/editor/EditorSelectionMenu.tsx#sha256:47dcef1a90df906f": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/editor/EditorSelectionMenu.tsx#sha256:c1d11540b3a81f2a": "1|team.legacy-control.editor.editor.selection.menu.on-send-message", + "src/renderer/components/team/editor/EditorShortcutsHelp.tsx#sha256:f35d406393a8c276": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/editor/EditorStatusBar.tsx#sha256:e1be7bb0cf5e76eb": "1|team.legacy-control.editor.editor.status.bar.toggle-watcher", + "src/renderer/components/team/editor/EditorTabBar.tsx#sha256:3bea81922bfdd4ec": "1|team.legacy-control.editor.editor.tab.bar.handle-close", + "src/renderer/components/team/editor/EditorTabBar.tsx#sha256:4af5671b033319ba": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/EditorTabBar.tsx#sha256:6e2e253c731c77d9": "1|team.legacy-control.editor.editor.tab.bar.handle-close", + "src/renderer/components/team/editor/EditorTabBar.tsx#sha256:70b73e5fc93691a2": "1|team.legacy-control.editor.editor.tab.bar.handle-drag-end", + "src/renderer/components/team/editor/EditorTabBar.tsx#sha256:72cd053d873aedfd": "1|team.legacy-control.editor.editor.tab.bar.on-activate", + "src/renderer/components/team/editor/EditorTabBar.tsx#sha256:73a07f14b212f146": "1|team.legacy-control.editor.editor.tab.bar.handle-drag-start", + "src/renderer/components/team/editor/EditorTabBar.tsx#sha256:77a2033921a6ec5e": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/editor/EditorTabContextMenu.tsx#sha256:824f098c5f112f97": "1|team.legacy-control.editor.editor.tab.context.menu.on-close-to-left", + "src/renderer/components/team/editor/EditorTabContextMenu.tsx#sha256:97820edd3e82af53": "1|team.legacy-control.editor.editor.tab.context.menu.on-close-others", + "src/renderer/components/team/editor/EditorTabContextMenu.tsx#sha256:a72ace722d9c2bfa": "1|team.legacy-control.editor.editor.tab.context.menu.on-close-to-right", + "src/renderer/components/team/editor/EditorTabContextMenu.tsx#sha256:a7542898e4f99a43": "1|team.legacy-control.editor.editor.tab.context.menu.on-close", + "src/renderer/components/team/editor/EditorTabContextMenu.tsx#sha256:de977b2d4e46245d": "1|team.legacy-control.editor.editor.tab.context.menu.on-close-all", + "src/renderer/components/team/editor/EditorToolbar.tsx#sha256:150fa828171f33aa": "1|team.legacy-control.editor.editor.toolbar.button-on-click", + "src/renderer/components/team/editor/EditorToolbar.tsx#sha256:60dd93072558fe4d": "1|team.legacy-control.editor.editor.toolbar.toggle-line-wrap", + "src/renderer/components/team/editor/EditorToolbar.tsx#sha256:787e87f75857f9c6": "1|team.legacy-control.editor.editor.toolbar.on-toggle-full-preview", + "src/renderer/components/team/editor/EditorToolbar.tsx#sha256:8d18187a49d8ef63": "1|team.legacy-control.editor.editor.toolbar.handle-undo", + "src/renderer/components/team/editor/EditorToolbar.tsx#sha256:98165effca6b82e6": "1|team.legacy-control.editor.editor.toolbar.handle-redo", + "src/renderer/components/team/editor/EditorToolbar.tsx#sha256:c112d0b1acbac05c": "1|team.legacy-control.editor.editor.toolbar.on-toggle-split", + "src/renderer/components/team/editor/EditorToolbar.tsx#sha256:e5a231e61a272d8a": "1|team.legacy-control.editor.editor.toolbar.handle-save", + "src/renderer/components/team/editor/FileIcon.tsx#sha256:dfa7d7b286637829": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/GoToLineDialog.tsx#sha256:6e492356fd0ee852": "1|team.legacy-control.editor.go.to.line.dialog.handle-go", + "src/renderer/components/team/editor/GoToLineDialog.tsx#sha256:bb2b3396f7083929": "1|team.legacy-control.editor.go.to.line.dialog.set-value", + "src/renderer/components/team/editor/GoToLineDialog.tsx#sha256:c361e9d9fef1bf78": "1|team.legacy-control.editor.go.to.line.dialog.on-close", + "src/renderer/components/team/editor/GoToLineDialog.tsx#sha256:d98d81e48a8a91fb": "1|team.legacy-control.editor.go.to.line.dialog.handle-key-down", + "src/renderer/components/team/editor/GoToLineDialog.tsx#sha256:e71c1e77d477b5eb": "1|team.legacy-control.editor.go.to.line.dialog.on-close", + "src/renderer/components/team/editor/MarkdownPreviewPane.tsx#sha256:2c7c862ac45552e2": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/MarkdownSplitView.tsx#sha256:221f26b6c85e4bcf": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/MarkdownSplitView.tsx#sha256:f0dc8db4d8cbe64b": "1|team.legacy-control.editor.markdown.split.view.resize", + "src/renderer/components/team/editor/NewFileDialog.tsx#sha256:2258dda53fa6812c": "1|team.legacy-control.editor.new.file.dialog.handle-key-down", + "src/renderer/components/team/editor/NewFileDialog.tsx#sha256:2e02a1335684a63c": "1|team.legacy-control.editor.new.file.dialog.handle-change", + "src/renderer/components/team/editor/NewFileDialog.tsx#sha256:7318668c84f70c64": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:1235bde8107f252f": "1|team.legacy-control.editor.project.editor.overlay.handle-discard-and-close", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:24054be910ff4d63": "1|team.legacy-control.editor.project.editor.overlay.handle-save-and-close", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:27d7e9814e1ff86a": "1|team.legacy-control.editor.project.editor.overlay.handle-cancel-conflict", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:287e3b76153b3658": "1|team.legacy-control.editor.project.editor.overlay.handle-force-overwrite", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:4852d0ea4e865f0b": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:50671fcc26b476b2": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:705e41726415c3e4": "1|team.legacy-control.editor.project.editor.overlay.handle-save-and-close-tab", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:71c886f00da9175f": "1|team.legacy-control.editor.project.editor.overlay.handle-manual-refresh", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:73d2e0cb2461b907": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:73e831c94e2e1c0d": "1|team.legacy-control.editor.project.editor.overlay.handle-cancel-close-tab", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:97078b48034972dc": "1|team.legacy-control.editor.project.editor.overlay.handle-reload-external-change", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:9aa5289f0d604bed": "1|team.legacy-control.editor.project.editor.overlay.handle-keep-mine", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:b016dc6e58840710": "1|team.legacy-control.editor.project.editor.overlay.save-file", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:b9663750abcbb441": "1|team.legacy-control.editor.project.editor.overlay.handle-dismiss-draft-banner", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:c1605279efb6db43": "1|team.legacy-control.editor.project.editor.overlay.close-editor-tab", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:c4bfe84a733abe3b": "1|team.legacy-control.editor.project.editor.overlay.handle-discard-draft", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:cffd52e07b4fcccc": "1|team.legacy-control.editor.project.editor.overlay.set-shortcuts-help-visible", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:d5c5b6b0421253e3": "1|team.legacy-control.editor.project.editor.overlay.handle-cancel-close", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:ebba9c9acb4d6dc6": "1|team.legacy-control.editor.project.editor.overlay.handle-close-request", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:f9b330ea2ff28fb4": "2|team.legacy-control.editor.project.editor.overlay.toggle-sidebar", + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx#sha256:fe5c782fdbbf6b1f": "1|team.legacy-control.editor.project.editor.overlay.handle-discard-and-close-tab", + "src/renderer/components/team/editor/QuickOpenDialog.tsx#sha256:417c894eb9d816e7": "1|team.legacy-control.editor.quick.open.dialog.handle-select", + "src/renderer/components/team/editor/QuickOpenDialog.tsx#sha256:c361e9d9fef1bf78": "1|team.legacy-control.editor.quick.open.dialog.on-close", + "src/renderer/components/team/editor/QuickOpenDialog.tsx#sha256:e71c1e77d477b5eb": "1|team.legacy-control.editor.quick.open.dialog.on-close", + "src/renderer/components/team/editor/SearchInFilesPanel.tsx#sha256:2646e344df8b7d3a": "1|team.legacy-control.editor.search.in.files.panel.on-toggle", + "src/renderer/components/team/editor/SearchInFilesPanel.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.editor.search.in.files.panel.on-close", + "src/renderer/components/team/editor/SearchInFilesPanel.tsx#sha256:61980f57ee660bc6": "1|team.legacy-control.editor.search.in.files.panel.handle-case-sensitive-toggle", + "src/renderer/components/team/editor/SearchInFilesPanel.tsx#sha256:714ae75d2984398e": "1|team.legacy-control.editor.search.in.files.panel.on-toggle", + "src/renderer/components/team/editor/SearchInFilesPanel.tsx#sha256:8f91af26ba6781a2": "1|team.legacy-control.editor.search.in.files.panel.handle-query-change", + "src/renderer/components/team/editor/SearchInFilesPanel.tsx#sha256:d4947db6cc28af4b": "1|team.legacy-control.editor.search.in.files.panel.on-select-match", + "src/renderer/components/team/kanban/KanbanBoard.tsx#sha256:70b73e5fc93691a2": "1|team.legacy-control.kanban.kanban.board.handle-drag-end", + "src/renderer/components/team/kanban/KanbanBoard.tsx#sha256:9aae3a3e7b342bd2": "1|team.legacy-control.kanban.kanban.board.button-on-click", + "src/renderer/components/team/kanban/KanbanBoard.tsx#sha256:a163939d4a5176f9": "1|team.legacy-control.kanban.kanban.board.button-on-click", + "src/renderer/components/team/kanban/KanbanBoard.tsx#sha256:a2d89139281b9aa7": "1|team.legacy-control.kanban.kanban.board.handle-props", + "src/renderer/components/team/kanban/KanbanBoard.tsx#sha256:da08b879ddc9545c": "1|team.legacy-control.kanban.kanban.board.button-on-click", + "src/renderer/components/team/kanban/KanbanBoard.tsx#sha256:dacd98373c3111cc": "1|team.legacy-control.kanban.kanban.board.add-handler", + "src/renderer/components/team/kanban/KanbanBoard.tsx#sha256:eb738d2bbc359be5": "1|team.legacy-control.kanban.kanban.board.on-open-trash", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:037750eaf41a30f4": "1|team.legacy-control.kanban.kanban.filter.popover.button-implicit-action", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:310d6f37fcbf2e33": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:6eb102b1932fed12": "1|team.legacy-control.kanban.kanban.filter.popover.handle-owner-toggle", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:9a50d22e5652ab4f": "1|team.legacy-control.kanban.kanban.filter.popover.handle-clear-all", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:a565f8e4fc4606a7": "1|team.legacy-control.kanban.kanban.filter.popover.handle-session-select", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:ddf7fff0fe4a6b23": "1|team.legacy-control.kanban.kanban.filter.popover.handle-session-select", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:ef686c0ff19f2c10": "1|team.legacy-control.kanban.kanban.filter.popover.handle-column-toggle", + "src/renderer/components/team/kanban/KanbanFilterPopover.tsx#sha256:f00ae16ba221cb25": "1|team.legacy-control.kanban.kanban.filter.popover.handle-owner-toggle", + "src/renderer/components/team/kanban/KanbanSearchInput.tsx#sha256:2258dda53fa6812c": "1|team.legacy-control.kanban.kanban.search.input.handle-key-down", + "src/renderer/components/team/kanban/KanbanSearchInput.tsx#sha256:37c877908aed431d": "1|team.legacy-control.kanban.kanban.search.input.button-on-click", + "src/renderer/components/team/kanban/KanbanSearchInput.tsx#sha256:542445918a83612c": "1|team.legacy-control.kanban.kanban.search.input.input-on-change", + "src/renderer/components/team/kanban/KanbanSearchInput.tsx#sha256:6b4931aa98305003": "1|team.legacy-control.kanban.kanban.search.input.button-on-click", + "src/renderer/components/team/kanban/KanbanSearchInput.tsx#sha256:9630af694ccd1dfc": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/kanban/KanbanSearchInput.tsx#sha256:9e0fcad7e020c7c8": "1|team.legacy-control.kanban.kanban.search.input.select-task", + "src/renderer/components/team/kanban/KanbanSortPopover.tsx#sha256:0ae61a8c32e918cb": "1|team.legacy-control.kanban.kanban.sort.popover.on-sort-change", + "src/renderer/components/team/kanban/KanbanSortPopover.tsx#sha256:18f7ca3782abe410": "1|team.legacy-control.kanban.kanban.sort.popover.on-sort-change", + "src/renderer/components/team/kanban/KanbanSortPopover.tsx#sha256:1ff14bc8954ca063": "1|team.legacy-control.kanban.kanban.sort.popover.button-implicit-action", + "src/renderer/components/team/kanban/KanbanSortPopover.tsx#sha256:310d6f37fcbf2e33": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:04a72f139df99543": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:09fe3d215bf39d4d": "2|team.legacy-control.kanban.kanban.task.card.open-task", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:0bae4f78a9f70b54": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:150fa828171f33aa": "1|team.legacy-control.kanban.kanban.task.card.button-on-click", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:1758cd0fcf33fefe": "1|team.legacy-control.kanban.kanban.task.card.request-review", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:323fe6817e91a5ed": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:381d345194f1b837": "1|team.legacy-control.kanban.kanban.task.card.request-changes", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:449c22d1f2d8cdfd": "1|team.legacy-control.kanban.kanban.task.card.move-back-to-done", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:51dcd3b14062cfd7": "1|team.legacy-control.kanban.kanban.task.card.set-open", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:74c135b70a736526": "2|team.legacy-control.kanban.kanban.task.card.approve-task", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:7c0c8c8af26a9ba4": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:7d9fe604f5cad108": "2|team.legacy-control.kanban.kanban.task.card.complete-task", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:82cb75d1d6a964b7": "1|team.legacy-control.kanban.kanban.task.card.on-task-click", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:8fef14b8b3cb85fe": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:91365fb5a464bdfb": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:95bc5862872de27f": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:b1e9a1605b3b9e37": "1|team.legacy-control.kanban.kanban.task.card.start-task", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:b9baa31930ee1747": "1|team.legacy-control.kanban.kanban.task.card.delete-task", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:bcfa4c958fadb421": "1|team.legacy-control.kanban.kanban.task.card.on-task-click", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:edf3da0467f3e3be": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:f171c6da12f3b1c0": "1|team.legacy-control.kanban.kanban.task.card.set-open", + "src/renderer/components/team/kanban/KanbanTaskCard.tsx#sha256:f9bd2634aa08123e": "1|team.legacy-control.kanban.kanban.task.card.view-changes", + "src/renderer/components/team/kanban/TrashDialog.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.kanban.trash.dialog.on-close", + "src/renderer/components/team/kanban/TrashDialog.tsx#sha256:95074338371b433d": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/kanban/TrashDialog.tsx#sha256:bce23b6a20b21a35": "1|team.legacy-control.kanban.trash.dialog.on-restore", + "src/renderer/components/team/members/CurrentTaskIndicator.tsx#sha256:0f418a37e20a9ecc": "1|team.legacy-control.members.current.task.indicator.open-task", + "src/renderer/components/team/members/CurrentTaskIndicator.tsx#sha256:85ca85b4fcc679d7": "1|team.legacy-control.members.current.task.indicator.open-task", + "src/renderer/components/team/members/LeadModelRow.tsx#sha256:36dae0755851e898": "1|team.legacy-control.members.lead.model.row.on-model-change", + "src/renderer/components/team/members/LeadModelRow.tsx#sha256:51a531385d10fa64": "1|team.legacy-control.members.lead.model.row.set-model-expanded", + "src/renderer/components/team/members/LeadModelRow.tsx#sha256:85a6275b58d8b03c": "1|team.legacy-control.members.lead.model.row.on-effort-change", + "src/renderer/components/team/members/LeadModelRow.tsx#sha256:a5c8d5429dd56f8c": "1|team.legacy-control.members.lead.model.row.on-sync-models-with-teammates-change", + "src/renderer/components/team/members/LeadModelRow.tsx#sha256:f81035079dd04423": "1|team.legacy-control.members.lead.model.row.on-limit-context-change", + "src/renderer/components/team/members/MemberCard.tsx#sha256:398cdac18d991269": "5|team.legacy-control.members.member.card.handle-restart-member", + "src/renderer/components/team/members/MemberCard.tsx#sha256:6b592916076b2d06": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberCard.tsx#sha256:9a7336d73c7728be": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberCard.tsx#sha256:9e6ac9ce228d1530": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/members/MemberCard.tsx#sha256:a4cbc87c805bfc2f": "1|team.legacy-control.members.member.card.handle-skip-failed-launch", + "src/renderer/components/team/members/MemberCard.tsx#sha256:be6067847d21e9e4": "1|team.legacy-control.members.member.card.div-on-click", + "src/renderer/components/team/members/MemberCard.tsx#sha256:bf6e84c42f770025": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberCard.tsx#sha256:d812381411a0d780": "1|team.legacy-control.members.member.card.handle-restore-member", + "src/renderer/components/team/members/MemberCard.tsx#sha256:e20ed8d3ae6565d5": "1|team.legacy-control.members.member.card.div-on-click", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:03a2b14dd9b57efb": "1|team.legacy-control.members.member.detail.dialog.tabs-trigger-implicit-action", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:36ce0e6dd1a64091": "1|team.legacy-control.members.member.detail.dialog.on-assign-task", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:3bf46ee1ab140cfb": "1|team.legacy-control.members.member.detail.dialog.set-restart-error", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:5fbf5c1083e7c1db": "1|team.legacy-control.members.member.detail.dialog.set-active-tab", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:8ef2a6693be1702c": "1|team.legacy-control.members.member.detail.dialog.tabs-trigger-implicit-action", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:edc25a3cdb7a3aeb": "1|team.legacy-control.members.member.detail.dialog.on-send-message", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:f4e0527dfd75aa54": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:fa3ce9971f2cc036": "1|team.legacy-control.members.member.detail.dialog.tabs-trigger-implicit-action", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:fafda63dc7df6242": "1|team.legacy-control.members.member.detail.dialog.on-remove-member", + "src/renderer/components/team/members/MemberDetailDialog.tsx#sha256:ffb516b4f710f1ec": "1|team.legacy-control.members.member.detail.dialog.tabs-trigger-implicit-action", + "src/renderer/components/team/members/MemberDetailStats.tsx#sha256:0fd437022800aece": "1|team.legacy-control.members.member.detail.stats.on-tab-change", + "src/renderer/components/team/members/MemberDetailStats.tsx#sha256:95c7da16da6519f6": "1|team.legacy-control.members.member.detail.stats.on-tab-change", + "src/renderer/components/team/members/MemberDetailStats.tsx#sha256:9d8dd10677000fbf": "1|team.legacy-control.members.member.detail.stats.button-on-click", + "src/renderer/components/team/members/MemberDetailStats.tsx#sha256:bb6c5452f1f6a5d0": "2|team.legacy-control.members.member.detail.stats.on-tab-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:0ca42b585cb8c596": "1|team.legacy-control.members.member.draft.row.update-mcp-server-names", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:1358b8f49e2055a4": "1|team.legacy-control.members.member.draft.row.select-item-implicit-action", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:1dbb3c9aceefe984": "1|team.legacy-control.members.member.draft.row.select-item-implicit-action", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:2957ba1dbd202651": "1|team.legacy-control.members.member.draft.row.on-restore", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:335b01914dc27564": "1|team.legacy-control.members.member.draft.row.update-mcp-scope", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:405e61ef2011df5a": "1|team.legacy-control.members.member.draft.row.handle-mcp-mode-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:48c5933667a9260a": "1|team.legacy-control.members.member.draft.row.on-name-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:4ae4e5b96633aad3": "1|team.legacy-control.members.member.draft.row.on-effort-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:50c1c59d2be63237": "1|team.legacy-control.members.member.draft.row.set-mcp-expanded", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:51a531385d10fa64": "1|team.legacy-control.members.member.draft.row.set-model-expanded", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:83624c5bdfe3250d": "1|team.legacy-control.members.member.draft.row.on-remove", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:87ce702a82465df4": "1|team.legacy-control.members.member.draft.row.select-item-implicit-action", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:98c755a43f46007f": "1|team.legacy-control.members.member.draft.row.select-trigger-implicit-action", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:9fe23e46dc233b3f": "1|team.legacy-control.members.member.draft.row.select-item-implicit-action", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:b018bee76d11f9a8": "1|team.legacy-control.members.member.draft.row.on-worktree-isolation-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:bedfd56f09ded7b5": "1|team.legacy-control.members.member.draft.row.handle-workflow-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:c8a21020554a2ac0": "1|team.legacy-control.members.member.draft.row.on-model-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:cb3756948f68ad2a": "1|team.legacy-control.members.member.draft.row.on-role-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:d3c336e38e12ff7f": "1|team.legacy-control.members.member.draft.row.button-on-click", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:f81035079dd04423": "1|team.legacy-control.members.member.draft.row.on-limit-context-change", + "src/renderer/components/team/members/MemberDraftRow.tsx#sha256:fd2bf08005b68d00": "1|team.legacy-control.members.member.draft.row.toggle-workflow-expanded", + "src/renderer/components/team/members/MemberExecutionLog.tsx#sha256:ad74f7f671a163e9": "1|team.legacy-control.members.member.execution.log.set-agent-info-open", + "src/renderer/components/team/members/MemberExecutionLog.tsx#sha256:fcbe1525bd6993af": "1|team.legacy-control.members.member.execution.log.on-toggle-expanded", + "src/renderer/components/team/members/MemberHoverCard.tsx#sha256:42e8c8e2309e2b26": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/members/MemberHoverCard.tsx#sha256:73444aad60936a64": "1|team.legacy-control.members.member.hover.card.open-profile", + "src/renderer/components/team/members/MemberLaunchDiagnosticsButton.tsx#sha256:dfc9faec5a25d4c8": "1|team.legacy-control.members.member.launch.diagnostics.button.copy-diagnostics", + "src/renderer/components/team/members/MemberList.tsx#sha256:1c81f9dccb20814c": "1|team.legacy-control.members.member.list.handle-click", + "src/renderer/components/team/members/MemberList.tsx#sha256:b738dcd6113ca196": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberList.tsx#sha256:bd79976348fd0dd0": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberList.tsx#sha256:d2b85c3a43fadc2d": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberList.tsx#sha256:e8211337b0af9fff": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberLogsTab.tsx#sha256:2646e344df8b7d3a": "1|team.legacy-control.members.member.logs.tab.on-toggle", + "src/renderer/components/team/members/MemberLogsTab.tsx#sha256:87178eec6faacfd5": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/members/MemberLogsTab.tsx#sha256:99d9eed00c4b8c0f": "1|team.legacy-control.members.member.logs.tab.on-toggle", + "src/renderer/components/team/members/MemberMessagesTab.tsx#sha256:400d43a4e8a45239": "1|team.legacy-control.members.member.messages.tab.set-expanded-item", + "src/renderer/components/team/members/MemberMessagesTab.tsx#sha256:48b58103af4e195f": "1|team.legacy-control.members.member.messages.tab.set-expanded-item", + "src/renderer/components/team/members/MemberMessagesTab.tsx#sha256:694d4be1e2950b63": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/members/MemberMessagesTab.tsx#sha256:8f2bd003298bd023": "1|team.legacy-control.members.member.messages.tab.set-activity-filter", + "src/renderer/components/team/members/MemberMessagesTab.tsx#sha256:fa11e9014eafa4cf": "1|team.legacy-control.members.member.messages.tab.load-older-messages", + "src/renderer/components/team/members/MemberStatsTab.tsx#sha256:8c9371333b6ec662": "1|team.legacy-control.members.member.stats.tab.open-kiro-usage", + "src/renderer/components/team/members/MemberStatsTab.tsx#sha256:31a5e7dbbee7b2c3": "1|team.legacy-control.members.member.stats.tab.on-file-click", + "src/renderer/components/team/members/MemberStatsTab.tsx#sha256:51ec50ac130b54b2": "1|team.legacy-control.members.member.stats.tab.set-expanded", + "src/renderer/components/team/members/MemberStatsTab.tsx#sha256:865603edb68b6353": "1|team.legacy-control.members.member.stats.tab.on-show-all", + "src/renderer/components/team/members/MemberTasksTab.tsx#sha256:c256c129aaaa73da": "1|team.legacy-control.members.member.tasks.tab.on-task-click", + "src/renderer/components/team/members/MembersEditorSection.tsx#sha256:6bb7038047952f63": "1|team.legacy-control.members.members.editor.section.toggle-json-editor", + "src/renderer/components/team/members/MembersEditorSection.tsx#sha256:8ca9646e8a8e4bce": "1|team.legacy-control.members.members.editor.section.update-teammate-worktree-default", + "src/renderer/components/team/members/MembersEditorSection.tsx#sha256:adb298abc9104b41": "1|team.legacy-control.members.members.editor.section.update-agent-teams-mcp-lock", + "src/renderer/components/team/members/MembersEditorSection.tsx#sha256:c3013f5d5ebe67d6": "1|team.legacy-control.members.members.editor.section.add-member", + "src/renderer/components/team/members/MembersEditorSection.tsx#sha256:cd907d90933afe16": "1|team.legacy-control.members.members.editor.section.handle-json-change", + "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx#sha256:54a9503f22264083": "1|team.legacy-control.members.subagent.recent.messages.preview.set-expanded-all", + "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx#sha256:d772a27952143aff": "1|team.legacy-control.members.subagent.recent.messages.preview.on-load-more", + "src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx#sha256:fea85ba602133582": "1|team.legacy-control.members.subagent.recent.messages.preview.set-expanded-all", + "src/renderer/components/team/members/TeamRosterEditorSection.tsx#sha256:88303c06462179a9": "1|team.legacy-control.members.team.roster.editor.section.on-members-change", + "src/renderer/components/team/messages/ActionModeSelector.tsx#sha256:05df411a51c7253b": "1|team.legacy-control.messages.action.mode.selector.button-on-click", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:03f4e77a3c55e14e": "1|team.legacy-control.messages.message.composer.set-selected-team", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:1083d5415080aece": "1|team.legacy-control.messages.message.composer.set-action-mode", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:14690bc95522a1a0": "1|team.legacy-control.messages.message.composer.set-recipient", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:1930cef4ef48b7aa": "1|team.legacy-control.messages.message.composer.open-external", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:23575b99a3ec0245": "1|team.legacy-control.messages.message.composer.button-on-click", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:1ff82e5efc2af2fa": "1|team.legacy-control.messages.message.composer.button-implicit-action", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:3b6f0923ffdae25d": "1|team.legacy-control.messages.message.composer.button-implicit-action", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:3dc7b49ba3c8368d": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:4e0ae98aa980e659": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:5064b8a25a40cfd1": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:6a4111d7d2940c97": "1|team.legacy-control.messages.message.composer.handle-paste-wrapper", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:797c82045a15b0de": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:7f428418a53df234": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:8cf19f761556fd08": "1|team.legacy-control.messages.message.composer.set-selected-team", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:972453b7e7123179": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:af2027a7b3199457": "1|team.legacy-control.messages.message.composer.set-recipient-search", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:af143ae5254dade2": "1|team.legacy-control.messages.message.composer.handle-drop-wrapper", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:b7ff1bf34c32df73": "1|team.legacy-control.messages.message.composer.handle-revision-cancel", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:bfb370153b73c4ce": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:eb9a0907ae47120a": "1|team.legacy-control.messages.message.composer.set-text", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:ecf725a5bf914c68": "1|team.legacy-control.messages.message.composer.handle-file-input-change", + "src/renderer/components/team/messages/MessageComposer.tsx#sha256:f65cafeb004180cc": "1|team.legacy-control.messages.message.composer.handle-send", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx#sha256:76fe2fcf5e57caff": "1|team.legacy-control.messages.messages.filter.popover.toggle-from", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx#sha256:802f5b26b7601d04": "1|team.legacy-control.messages.messages.filter.popover.toggle-to", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx#sha256:860fed20d5d1788f": "1|team.legacy-control.messages.messages.filter.popover.handle-reset", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx#sha256:89f4f78e8ef143ef": "1|team.legacy-control.messages.messages.filter.popover.handle-save", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx#sha256:92974d65ae355ab0": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx#sha256:a9b5d3f2e0548916": "1|team.legacy-control.messages.messages.filter.popover.button-implicit-action", + "src/renderer/components/team/messages/MessagesFilterPopover.tsx#sha256:e66ddec95e72fee3": "1|team.legacy-control.messages.messages.filter.popover.set-draft", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:0b09dcc73d8b7739": "2|team.legacy-control.messages.messages.panel.dropdown-menu-item-on-select", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:165e086f0e3330f1": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:1d94ff167be9bd42": "1|team.legacy-control.messages.messages.panel.set-messages-search-query", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:23a4eea97b4a2f9f": "1|team.legacy-control.messages.messages.panel.toggle-bottom-sheet-expansion", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:289b970d7f1e0295": "1|team.legacy-control.messages.messages.panel.handle-mark-all-read", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:38505ab577d21079": "1|team.legacy-control.messages.messages.panel.open", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:4721402883141c58": "1|team.legacy-control.messages.messages.panel.set-messages-search-bar-visible", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:4a29a6745b4ec867": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:58504e372676f6e2": "1|team.legacy-control.messages.messages.panel.open", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:5ccfc493b026f1f7": "1|team.legacy-control.messages.messages.panel.set-messages-search-query", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:7842557d5bb4a34c": "1|team.legacy-control.messages.messages.panel.set-messages-collapsed", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:8a54843df3f8b1c2": "1|team.legacy-control.messages.messages.panel.move-to-sidebar", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:8e6a1a8844194273": "1|team.legacy-control.messages.messages.panel.set-messages-search-bar-visible", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:93d1b881ec3b8fd7": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:9565b8e4aadca5cc": "1|team.legacy-control.messages.messages.panel.move-to-floating-composer", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:9582aa455f7d5fd0": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:aeb8574611b5a88c": "1|team.legacy-control.messages.messages.panel.open", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:af6e8057703bf33a": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:b0c71590e72d19c1": "1|team.legacy-control.messages.messages.panel.move-to-inline", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:b7133cf15aba7f05": "1|team.legacy-control.messages.messages.panel.handle-mark-all-read", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:c19a25c09f1236ae": "1|team.legacy-control.messages.messages.panel.handle-mark-all-read", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:c760d80cc9f1875b": "1|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:c894f06cdd1bdfb4": "1|team.legacy-control.messages.messages.panel.set-messages-collapsed", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:ce549a4b7bc1899a": "2|team.legacy-control.messages.messages.panel.dropdown-menu-item-on-select", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:cf4e7b94acde4094": "1|team.legacy-control.messages.messages.panel.toggle-bottom-sheet-expansion", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:d33115a046c3962e": "3|team.legacy-control.messages.messages.panel.dropdown-menu-item-on-select", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:da2daa4f6443829e": "2|team.legacy-control.messages.messages.panel.dropdown-menu-item-on-select", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:e216377fd932fdb2": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:eecf7f8123e239b0": "1|team.legacy-control.messages.messages.panel.on-load-older-messages", + "src/renderer/components/team/messages/MessagesPanel.tsx#sha256:fa1e8f6a9b40c16b": "1|team.legacy-control.messages.messages.panel.set-messages-collapsed", + "src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx#sha256:bfcd3b1308f435e5": "1|team.legacy-control.messages.open.code.delivery.warning.set-expanded-key", + "src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx#sha256:e9803b74743f3e17": "1|team.legacy-control.messages.open.code.delivery.warning.handle-copy", + "src/renderer/components/team/messages/StatusBlock.tsx#sha256:8371900221f4de19": "1|team.legacy-control.messages.status.block.set-collapsed", + "src/renderer/components/team/review/ChangeReviewDialogView.tsx#sha256:05d3c7a7e9f2fd8d": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/review/ChangeReviewDialogView.tsx#sha256:2548d11dc28b46ea": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/review/ChangeReviewDialogView.tsx#sha256:6d9df3d79cb041c0": "1|team.legacy-control.review.change.review.dialog.button-on-click", + "src/renderer/components/team/review/CodeMirrorDiffView.tsx#sha256:4119204bd4815774": "1|team.legacy-control.review.code.mirror.diff.view.move-between-chunks", + "src/renderer/components/team/review/CodeMirrorDiffView.tsx#sha256:668b838dfd60b27c": "1|team.legacy-control.review.code.mirror.diff.view.reject-active-chunk", + "src/renderer/components/team/review/CodeMirrorDiffView.tsx#sha256:8d1e667f3f7399a2": "1|team.legacy-control.review.code.mirror.diff.view.accept-active-chunk", + "src/renderer/components/team/review/CodeMirrorDiffView.tsx#sha256:b4834fa4d85f7d1b": "1|team.legacy-control.review.code.mirror.diff.view.move-between-chunks", + "src/renderer/components/team/review/CodeMirrorDiffView.tsx#sha256:b80a1ae5cb6e36de": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/review/CodeMirrorDiffView.tsx#sha256:c6dd1b048c150ff1": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/review/DiffErrorBoundary.tsx#sha256:da065a6c807cfa60": "1|team.legacy-control.review.diff.error.boundary.set-state", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:1580b468000c02a8": "1|team.legacy-control.review.file.section.header.on-save", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:1f5af573b4283064": "1|team.legacy-control.review.file.section.header.on-discard", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:662276bb914ba803": "1|team.legacy-control.review.file.section.header.handle-header-key-down", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:9e05499709a8493d": "1|team.legacy-control.review.file.section.header.handle-header-click", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:9ec5878ca549c8c9": "1|team.legacy-control.review.file.section.header.on-reload-from-disk", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:b009f291508abc15": "1|team.legacy-control.review.file.section.header.on-restore-missing-file", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:b014b06093f49267": "1|team.legacy-control.review.file.section.header.on-keep-draft", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:d47840d46a76f203": "1|team.legacy-control.review.file.section.header.on-accept-file", + "src/renderer/components/team/review/FileSectionHeader.tsx#sha256:d78b87a9cd22ca9e": "1|team.legacy-control.review.file.section.header.on-reject-file", + "src/renderer/components/team/review/KeyboardShortcutsHelp.tsx#sha256:191efb3695441fbf": "1|team.legacy-control.review.keyboard.shortcuts.help.button-on-click", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:17e8f9ac1d274a88": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:310d6f37fcbf2e33": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:37870a899a708ea1": "1|team.legacy-control.review.review.action.history.popover.request-restore", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:8c820300e854ec66": "1|team.legacy-control.review.review.action.history.popover.retry-persistence", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:900f949becc50693": "1|team.legacy-control.review.review.action.history.popover.request-restore", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:af982a72ee143ef2": "1|team.legacy-control.review.review.action.history.popover.show-older", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:bd6fc14c2953a704": "1|team.legacy-control.review.review.action.history.popover.run-restore", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:c512b00e999e336e": "1|team.legacy-control.review.review.action.history.popover.navigate-to-action", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:d23fe47e3f8ec44d": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/review/ReviewActionHistoryPopover.tsx#sha256:f5daf8bf356c6a2b": "1|team.legacy-control.review.review.action.history.popover.open-history", + "src/renderer/components/team/review/ReviewToolbar.tsx#sha256:57e516abbfff82c3": "1|team.legacy-control.review.review.toolbar.on-auto-viewed-change", + "src/renderer/components/team/review/ReviewToolbar.tsx#sha256:6d6a27e74c73c9d6": "1|team.legacy-control.review.review.toolbar.on-reject-all", + "src/renderer/components/team/review/ReviewToolbar.tsx#sha256:78813f4b1bcf062e": "1|team.legacy-control.review.review.toolbar.on-undo", + "src/renderer/components/team/review/ReviewToolbar.tsx#sha256:c48bb67e828f6d21": "1|team.legacy-control.review.review.toolbar.on-redo", + "src/renderer/components/team/review/ReviewToolbar.tsx#sha256:f66b2cf52cdfc405": "1|team.legacy-control.review.review.toolbar.on-apply", + "src/renderer/components/team/review/ReviewToolbar.tsx#sha256:f729ef93a109fa39": "1|team.legacy-control.review.review.toolbar.on-accept-all", + "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx#sha256:28ef9554a8de86f0": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx#sha256:3c7874e3acc27459": "1|team.legacy-control.review.saved.review.state.recovery.gate.retry-load", + "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx#sha256:5a825749b237ee5d": "1|team.legacy-control.review.saved.review.state.recovery.gate.open-discard-confirmation", + "src/renderer/components/team/review/SavedReviewStateRecoveryGate.tsx#sha256:71f805080d3f440f": "1|team.legacy-control.review.saved.review.state.recovery.gate.run-discard", + "src/renderer/components/team/review/ScopeWarningBanner.tsx#sha256:0d11da2c9aa8ee58": "1|team.legacy-control.review.scope.warning.banner.on-dismiss", + "src/renderer/components/team/review/ScopeWarningBanner.tsx#sha256:51ec50ac130b54b2": "1|team.legacy-control.review.scope.warning.banner.set-expanded", + "src/renderer/components/team/schedule/CronScheduleInput.tsx#sha256:17d8b5f046508240": "1|team.legacy-control.schedule.cron.schedule.input.on-timezone-change", + "src/renderer/components/team/schedule/CronScheduleInput.tsx#sha256:1a7ffd59f07eefa3": "2|team.legacy-control.schedule.cron.schedule.input.select-trigger-implicit-action", + "src/renderer/components/team/schedule/CronScheduleInput.tsx#sha256:3d52cc1ed779b3f9": "1|team.legacy-control.schedule.cron.schedule.input.select-item-implicit-action", + "src/renderer/components/team/schedule/CronScheduleInput.tsx#sha256:4ceb2e3faf7c3e83": "1|team.legacy-control.schedule.cron.schedule.input.on-cron-expression-change", + "src/renderer/components/team/schedule/CronScheduleInput.tsx#sha256:67b1cd2ea566b4c9": "1|team.legacy-control.schedule.cron.schedule.input.on-warm-up-minutes-change", + "src/renderer/components/team/schedule/CronScheduleInput.tsx#sha256:84e9852c762fda6e": "1|team.legacy-control.schedule.cron.schedule.input.on-cron-expression-change", + "src/renderer/components/team/schedule/CronScheduleInput.tsx#sha256:ac2bf2d47ed95393": "1|team.legacy-control.schedule.cron.schedule.input.select-item-implicit-action", + "src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx#sha256:2ac17d45d6b87776": "1|team.legacy-control.schedule.schedule.run.log.dialog.on-close", + "src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx#sha256:74ac28f4b8c942c3": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/schedule/ScheduleRunRow.tsx#sha256:4608ad394dda92b8": "1|team.legacy-control.schedule.schedule.run.row.div-on-click", + "src/renderer/components/team/schedule/ScheduleRunRow.tsx#sha256:74d0b7ee50c0aacc": "1|team.legacy-control.schedule.schedule.run.row.div-on-click", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:087933c406a1ecac": "1|team.legacy-control.schedule.schedule.section.on-pause", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:0a3965c0c9464c85": "1|team.legacy-control.schedule.schedule.section.handle-create", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:0c5fe0978fcb11bc": "1|team.legacy-control.schedule.schedule.section.on-trigger-now", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:20dace1dfa824ff1": "1|team.legacy-control.schedule.schedule.section.on-resume", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:4783618710bf26f6": "1|team.legacy-control.schedule.schedule.section.on-edit", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:6429bf3743988762": "1|team.legacy-control.schedule.schedule.section.on-pause", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:81e89f82cc61da9d": "1|team.legacy-control.schedule.schedule.section.button-implicit-action", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:9fdc23f915926061": "1|team.legacy-control.schedule.schedule.section.on-delete", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:a7c5c148395f97eb": "1|team.legacy-control.schedule.schedule.section.set-selected-run", + "src/renderer/components/team/schedule/ScheduleSection.tsx#sha256:adcbc16e6f23ed52": "1|team.legacy-control.schedule.schedule.section.handle-expand", + "src/renderer/components/team/sidebar/TeamSidebarRail.tsx#sha256:16913658139e8b59": "1|team.legacy-control.sidebar.team.sidebar.rail.resize-sidebar", + "src/renderer/components/team/sidebar/TeamSidebarRail.tsx#sha256:473f548b65cd06b2": "1|team.legacy-control.sidebar.team.sidebar.rail.resize-logs", + "src/renderer/components/team/sidebar/TeamSidebarRail.tsx#sha256:b31be8cf2f853d22": "1|P0.W1.ABSENCE.child-dialog-state", + "src/renderer/components/team/taskLogs/TaskActivitySection.tsx#sha256:2646e344df8b7d3a": "1|team.legacy-control.task.logs.task.activity.section.on-toggle", + "src/renderer/components/team/taskLogs/TaskActivitySection.tsx#sha256:89b9575674feeb83": "1|team.legacy-control.task.logs.task.activity.section.on-toggle", + "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx#sha256:2cefd5b62ce5e9a2": "1|team.legacy-control.task.logs.task.logs.panel.tabs-trigger-implicit-action", + "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx#sha256:38cf62e7cce76a2e": "1|team.legacy-control.task.logs.task.logs.panel.set-active-tab", + "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx#sha256:b3dd66172834da8f": "1|team.legacy-control.task.logs.task.logs.panel.tabs-trigger-implicit-action", + "src/renderer/components/team/taskLogs/TaskLogsPanel.tsx#sha256:df3e2d3a29f24aee": "1|team.legacy-control.task.logs.task.logs.panel.tabs-trigger-implicit-action", + "src/features/runtime-provider-management/renderer/ui/LegacyConfiguredModelsPanel.tsx#sha256:3ca82c0a17e2c6b8": "1|provider.management.model.test", + "src/features/runtime-provider-management/renderer/ui/LegacyConfiguredModelsPanel.tsx#sha256:cb4da3f063e872fb": "1|provider.management.model.set-default", + "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx#sha256:12f518d37b2905ba": "1|provider.management.model.clear-project-default", + "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx#sha256:5ae058cd41c9af09": "1|provider.management.local-view", + "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx#sha256:a389fafd087613a4": "1|provider.management.model.select", + "src/features/runtime-provider-management/renderer/ui/OpenCodeDefaultModelInheritanceCard.tsx#sha256:b10ea2885a389036": "1|provider.management.model.select", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx#sha256:c8eec5619ffb86e6": "1|provider.management.model.set-default", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx#sha256:0a0021d660c78354": "1|provider.management.local-view", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx#sha256:620ec57979b46e1d": "1|provider.management.local-view", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx#sha256:6e5654d9504c1a58": "1|provider.management.local-view", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderProjectContextSelect.tsx#sha256:cf5c09d33d6c7f49": "1|provider.management.local-view", + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderSupplementalStatus.tsx#sha256:947f48b23ad4a7dd": "1|provider.management.local-view", + "src/renderer/components/team/dialogs/TeamModelSelector.tsx#sha256:c0ebb76b460439cb": "1|team.legacy-control.dialogs.team.model.selector.set-inspected-provider-id", + "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx#sha256:402ed943870d54ea": "2|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx#sha256:76a6214da3b9b5dd": "1|team.legacy-control.kanban.kanban.task.attachment.mosaic.open-lightbox", + "src/renderer/components/team/kanban/KanbanTaskAttachmentMosaic.tsx#sha256:a3a0edf693152a8e": "2|P0.W1.ABSENCE.child-event-containment", + "src/renderer/components/team/members/MemberDetailHeader.tsx#sha256:54cdcd170ef061a8": "1|team.legacy-control.members.member.detail.header.set-editing", + "src/renderer/components/team/members/MemberQuickActions.tsx#sha256:1dfcdfb03494f078": "1|team.legacy-control.members.member.detail.header.set-editing", + "src/renderer/components/team/members/MemberQuickActions.tsx#sha256:39b93e3350569795": "1|team.legacy-control.members.member.card.on-assign-task", + "src/renderer/components/team/members/MemberQuickActions.tsx#sha256:4ab707f37dcd3028": "1|team.legacy-control.members.member.card.on-send-message", + "src/renderer/components/team/members/MemberQuickActions.tsx#sha256:81f3be0b253f385b": "1|team.legacy-control.members.member.quick.actions.invoke", + "src/renderer/components/team/members/MemberQuickActions.tsx#sha256:8ef7483029ff523d": "1|P0.W1.ABSENCE.child-local-presentation", + "src/renderer/components/team/members/MemberQuickActions.tsx#sha256:a3a0edf693152a8e": "1|P0.W1.ABSENCE.child-event-containment" + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/schemas/api-parity-ledger.schema.json b/docs/research/hosted-web/phase-0/parity-renderer/schemas/api-parity-ledger.schema.json new file mode 100644 index 0000000000..becdb4fd1d --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/schemas/api-parity-ledger.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "schemaId", + "schemaVersion", + "evidenceId", + "packetRevision", + "pinnedBaseSha", + "phaseStartSha", + "counts", + "members" + ], + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "required": [ + "stableId", + "source", + "sourceMember", + "legacySignature", + "sourceSignatureHash", + "rendererCallers", + "owningFeature", + "disposition", + "securityClass", + "requiredSemanticEvidence", + "actionId", + "targetWorkPackage" + ] + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/schemas/estimate-input.schema.json b/docs/research/hosted-web/phase-0/parity-renderer/schemas/estimate-input.schema.json new file mode 100644 index 0000000000..e2263961ab --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/schemas/estimate-input.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "schemaId", + "schemaVersion", + "evidenceId", + "packetRevision", + "pinnedBaseSha", + "phaseStartSha", + "unit", + "buckets", + "varianceAssessment" + ], + "properties": { + "buckets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "bucketId", + "packages", + "productionLines", + "testLines", + "deletedLines", + "netLines", + "excludedGeneratedVendorLines", + "overlap", + "confidence", + "assumptions", + "evidenceRefs" + ] + } + }, + "varianceAssessment": { + "type": "object", + "required": [ + "parentRangeStillSupported", + "uniqueBucketOverTwentyPercent", + "scopeReviewRequired", + "changes" + ] + } + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/schemas/legacy-bypass-inventory.schema.json b/docs/research/hosted-web/phase-0/parity-renderer/schemas/legacy-bypass-inventory.schema.json new file mode 100644 index 0000000000..48d983ce4d --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/schemas/legacy-bypass-inventory.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "schemaId", + "schemaVersion", + "evidenceId", + "packetRevision", + "pinnedBaseSha", + "phaseStartSha", + "summary", + "rawArtifact" + ], + "properties": { + "rawArtifact": { + "type": "object", + "required": [ + "format", + "recordCount", + "sha256", + "externalPath", + "pathScope", + "reproductionCommand" + ], + "properties": { + "externalPath": { + "const": "legacy-bypass-raw.json" + }, + "pathScope": { + "const": "artifact-pack-relative" + }, + "reproductionCommand": { + "const": "W1_RAW_EVIDENCE_ROOT= node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts" + } + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-action-inventory.schema.json b/docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-action-inventory.schema.json new file mode 100644 index 0000000000..638555f14d --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-action-inventory.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "schemaId", + "schemaVersion", + "evidenceId", + "packetRevision", + "pinnedBaseSha", + "phaseStartSha", + "roots", + "mountProofs", + "sourceFiles", + "excludedSourceFiles", + "actions", + "apiActionBindings", + "legacyChildApiActionBindings", + "deliberateAbsences" + ], + "properties": { + "roots": { + "type": "array", + "items": { + "type": "string" + } + }, + "mountProofs": { + "type": "array", + "items": { + "type": "object", + "required": ["root", "mountChain"] + } + }, + "sourceFiles": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "sha256", "interactionSiteCount"] + } + }, + "excludedSourceFiles": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "reason", "interactionSiteCount"] + } + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "owner", + "disposition", + "securityClass", + "target", + "evidence", + "sourceRefs" + ] + } + }, + "apiActionBindings": { + "type": "array", + "items": { + "type": "object", + "required": ["actionId", "owner", "source", "sourceMember", "rendererCallers"] + } + }, + "legacyChildApiActionBindings": { + "type": "array", + "items": { + "type": "object", + "required": ["childActionId", "apiActionId", "owner"] + } + }, + "deliberateAbsences": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "reason", "sourceRefs"] + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-child-control-catalog.schema.json b/docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-child-control-catalog.schema.json new file mode 100644 index 0000000000..f68b2b6df2 --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-child-control-catalog.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "schemaId", + "schemaVersion", + "evidenceId", + "packetRevision", + "pinnedBaseSha", + "phaseStartSha", + "roots", + "sourceFiles", + "actions", + "absences", + "mappings" + ], + "properties": { + "roots": { + "type": "array", + "items": { + "type": "string" + } + }, + "sourceFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "actions": { + "type": "object" + }, + "absences": { + "type": "object" + }, + "mappings": { + "type": "object" + } + } +} diff --git a/docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md b/docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md new file mode 100644 index 0000000000..680ce54f20 --- /dev/null +++ b/docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md @@ -0,0 +1,137 @@ +# Phase 0 W1 selection and reconciliation invariants + +Evidence ID: `P0.W1.SELECTION_INVARIANTS` + +Pinned AST: `a32f509e6d9bd31ba2135940e336729bf90c3d93` + +Result: `characterized` + +This record freezes required behavior; it does not claim that the current renderer already implements +the hosted revision/cursor model. Current desktop characterization comes from +`src/renderer/store/slices/teamSlice.ts`, `test/renderer/store/teamSlice.test.ts` and +`test/renderer/store/teamSliceContextRace.test.ts`. Phase 1 may extract reducers and narrow facets, but +must not replace those suites with snapshots or weaken their race coverage. + +## Selection and identity + +1. Selection commits only for the captured context identity, context epoch, team name, team-local + epoch and selected-team load nonce. A response from a previous context, a reset using the same + context ID, a previous team selection, delete, stop or launch is stale and has no visible effect. +2. Selecting an uncached team clears stale detail immediately. Selecting a cached team may reuse only + the cache entry for that exact current scope; the selected pointer and cache entry must not diverge. +3. Hosted identity expands the current guards to the canonical tuple: deployment ID, boot ID, context + epoch, opaque team ID, team generation, binding generation, workspace mount generation, roster + generation, file-writer epoch, snapshot revision, instance-event cursor and current run reference. + Team name is a display/legacy alias, not cache authority. +4. A boot ID change invalidates capabilities and session-derived allowance, forces a fresh snapshot, + and cannot be treated as an ordinary reconnect. Stable deployment/team IDs may retain only safe UI + preferences. +5. Only one reconciler selects `currentRunRef`. Provisioning, member-spawn, runtime, stop and cancel + projections consume that reference; none chooses an arbitrary newest run. + +Current proof: context-race tests cover old context, same-ID epoch reset and team reset; teamSlice tests +cover immediate stale selection clearing, cached selection, launch/delete invalidation and current-run +replacement. Hosted generation fields beyond the current context/team epochs remain Phase 1 work. + +## Thin and full snapshots + +1. Thin and full requests use distinct single-flight keys. Repeated callers may request at most one + required fresh follow-up; a forced full refresh never aliases an in-flight thin request. +2. A thin selection snapshot may paint first, but cannot erase member, roster or runtime information + from a newer full snapshot. A queued full refresh drains after the thin request settles or through + the post-paint fallback. +3. Empty or lead-only rosters are not proof of deletion. Existing members may be preserved only when a + same-scope summary/config/full launch record confirms the complete roster. An explicit removed + marker is authoritative and must commit. +4. Any snapshot response carries a revision vector and the ADR-33 same-transaction/lower replay + cursor. The cursor is a lower replay barrier, never described as “latest” or “current.” +5. A snapshot commits only when its captured identity tuple is still current and its revision is not + older than the visible projection. Retryable failure keeps the last valid same-generation snapshot; + only authoritative deletion or generation change clears it. +6. Semantically unchanged snapshots preserve object identity. Runtime timestamp observations may + advance freshness memory but may not replace visible data unless renderer-facing state changed. + +Current proof: focused tests cover queued full-after-thin, late thin responses, empty/lead-only roster +handling, explicit removal, post-paint failures and semantic runtime stabilization. Current snapshots do +not carry the complete hosted revision vector or ADR-33 cursor; that is an explicit unverified gap. + +## Tombstones and terminal state + +1. Delete, stop, launch replacement and a missing/cleared provisioning run create generation-scoped + tombstones before clearing projections. Late SSE, poll, watcher, snapshot or member-runtime results + for a tombstoned run are ignored. +2. An optimistic pending run is atomically replaced by the returned canonical run. A second unrelated + run cannot displace the selected canonical run merely because its timestamp is newer. +3. Provisioning terminal states never regress. The only deliberate lifecycle exception is an explicitly + modeled `ready -> disconnected` transition. +4. Tombstones are bounded by time and count, but cannot expire while a conflicting run is still + observable. Cleanup removes a tombstone only with proof that the old run/generation is no longer an + event, poll, process or snapshot source. +5. Cleanup of timers, subscriptions, pending approvals, runtime tool layers, messages and cached + projections is exact to the team generation being removed. Cleanup from an old generation must not + erase replacement state with the same display team name. + +Current proof: teamSlice tests cover delete tombstoning, launch epoch invalidation, pending-to-canonical +replacement and stale progress rejection. The current `ignoredProvisioningRunIds` and +`ignoredRuntimeRunIds` maps do not encode bounded expiry/count or the complete hosted generation tuple; +bounded, observability-aware tombstone retention remains unverified Phase 1 work. + +## Message pagination + +1. Head refresh and older-page loading are serialized for one team. Concurrent head refreshes are + single-flight and schedule at most one fresh follow-up. +2. An older page is requested with the exact current cursor and feed revision. If a head refresh or + generation change invalidates either while the request is waiting, the page is discarded and a fresh + head is loaded; the stale cursor is never continued. +3. Head merge preserves an already loaded older tail. Message identity deduplication is deterministic, + and a historical feed change is distinguished from a visible-head change. +4. Pagination state is scoped to the full identity tuple. Context switch, launch, delete or team + generation change clears only the obsolete scope and prevents queued work from restarting there. +5. Hosted queries are bounded and return explicit `nextCursor`, `hasMore`, feed/snapshot revision and + redaction semantics. An empty browser stub response is never accepted as a complete page. + +Current proof: teamSlice tests cover head single-flight/follow-up, head-behind-older serialization, +tail preservation, feed revision mismatch, launch invalidation and stale-cursor rejection. Server +revision-vector integration is not present on the pinned branch. + +## Snapshot, event and poll races + +The hosted reconciler must execute this order: + +1. Capture the complete scope/generation tuple before a request. +2. Fetch a snapshot with revision vector and lower replay cursor. +3. Commit only if scope is still current and the snapshot is not older than visible state. +4. Subscribe from that cursor before declaring the view live. +5. Deduplicate by event ID; reject old aggregate, run, roster, mount or writer generations. +6. Apply a pure reducer when the event is complete; otherwise schedule one coalesced bounded refresh. +7. On gap, `resync_required` or schema mismatch, pause incremental application, fetch a new snapshot + and resume from its cursor. +8. Use polling only as bounded health/recovery fallback. Poll responses obey identical scope and + revision checks and cannot resurrect terminal/tombstoned state. +9. Retry with `Retry-After`, exponential backoff plus jitter, browser offline/visibility awareness and + a per-tab request budget. Auth errors stop ordinary retry, coalesce one device renewal across tabs, + then re-bootstrap or enter explicit pairing recovery. Mutation requests are never loop-retried. + +Current desktop wiring is not proof of this algorithm: `initializeNotificationListeners` registers +team/tool/tracking listeners globally, provisioning subscription checks method presence, and component +log surfaces register additional listeners. `P0.W1.LEGACY_BYPASSES` enumerates those sites. ADR-20 +requires feature-owned mount/unmount registration and runtime capability-permutation tests before the +hosted team entrypoint mounts. + +## Fail-closed assertions for Phase 1 gates + +- No facet presence, optional chain or method-existence check is action-support evidence. +- No supported action may throw “not available in browser mode,” silently no-op, or fabricate empty, + offline or successful data. +- Every rendered control has one action mapping; unavailable desktop/deferred controls and their + effects/listeners are absent before mount. +- Every event and poll fixture includes a stale identity/generation negative control. +- Every deletion/stop/launch fixture injects a late snapshot, event and poll result and proves no + resurrection. +- Every pagination fixture advances the head/feed revision while an older page is pending and proves + stale cursor rejection. +- Every snapshot fixture tests thin-after-full ordering and semantic object-identity preservation. + +Proof levels are intentionally split: current Electron race behavior is `characterized`; the hosted +revision/cursor/tombstone algorithm is a mandatory invariant specification and remains `unverified` +until Phase 1 implementation and conformance tests exist. diff --git a/docs/research/hosted-web/phase-0/provider-runtime/README.md b/docs/research/hosted-web/phase-0/provider-runtime/README.md new file mode 100644 index 0000000000..f7647219e4 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/README.md @@ -0,0 +1,71 @@ +# Phase 0 W2 provider/runtime evidence + +This targeted-a1 canonical-base remediation preserves the independently reviewed corrections for +reciprocal-review findings `R12-X-001`, `R12-W2-001` through `R12-W2-005`, and remediation +findings `R12R-W2-001` through `R12R-W2-004`. Claims remain source-observed at +`c72fd201867b9bcd1ef77d5e0f95ba379adb4fca`; no provider, real project, credential value or private +provider payload was used. + +## Corrected evidence + +- Four provider identities remain separated from two execution backend families. Anthropic, Codex + and Gemini share the provisioning CLI primary; OpenCode uses its adapter/bridge lanes. +- All five current OpenCode operations record direction, caller, authority, idempotency, body IDs, + persisted evidence, route and canonical disposition. The proposed route/auth mapping has an empty + browser/runtime authority intersection: provider settings/auth use `provider.management.*`, team + launch/stop/delete use `team.lifecycle.*`, and machine ingress cannot make either class of operator + decision. +- Environment discovery performs a production-source census across `src/main`, Codex account, + member-work-sync, and the workspace-trust provider-child sanitizer instead of trusting the + original three-root boundary. The workspace-trust census independently recognizes exact-key sets + and prefix policies, including `CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER`, + `AGENT_TEAMS_RUNTIME_TURN_SETTLED_*`, `AGENT_TEAMS_MCP_*`, and `CLAUDE_TEAM_BOOTSTRAP_*`. + Dedicated negatives remove the sanitizer surface and each exact/prefix policy. Every exact key or + explicit wildcard has its own source class, owner, platform scope, execution-unit IDs, + provider/backend/version bindings, credential-exposure-set membership, secret class, exact probe, + child visibility, redaction rule, and source-observed/target-unverified status. Windows-only and + POSIX-primary inputs resolve through separate profiles instead of inheriting a broad portable-row + classification. Host-only OpenCode policy inputs are distinct from derived or emitted child keys: + for example, `CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE` is forbidden in the child while the optional + `OPENCODE_DISABLE_AUTOUPDATE` child key is derived from it. The ten conditional provider-routing + keys are excluded from the authored expectation table: focused tests execute the real + provisioning, configured-backend and provider-routing branches across seven provider/backend + scenarios, then compare their required/optional/forbidden and emit/preserve/remove observations + with seven distinct ledger profiles. The remaining keys use the independent source-authority + fixture. Dimension mutations and any omitted key fail. Exposure sets link back to exact per-key + IDs rather than broad execution-unit prose. +- Six artifact-specific draft-2020-12 schemas require nested acceptance fields and reject unknown + top-level and nested properties. Provider-assigned profiles require nonempty `providerBindings`; + the sole providerless target prohibition is explicit and separately constrained. Deliberate + negatives reject an empty assigned binding and an implicit providerless case. +- The provider matrix binds all 13 required cases to independently addressable positive and + failing-negative tests. Those 26 tests import and execute canonical planner, provider-preflight, + OpenCode adapter, capability-response parser and stale-lane recovery seams; no W2-local truth-table + executor remains. The malformed-capability case sends invalid OpenAPI JSON through the real + detector and compares it with a complete valid OpenAPI control. The + repository scanner launches the focused proof suite, verifies every exact test ID passed, and pins + each case to its canonical source export, so arbitrary prose or an arbitrary existing file cannot + count as proof. Provider/mode dispositions are derived from execution-topology provider/mode rows and the + current ingress inventory: four OpenCode modes have the five source-observed OpenCode operations, + while Anthropic, Codex, and Gemini primary modes explicitly have no current runtime-ingress path. + No target-unverified Cartesian product is described as supported. +- W2 contributes a 4.3k-6.75k net range only to canonical `EST-LIFECYCLE-RUNTIME`. W4 exclusively owns + lease, workspace guard, process anchor and native/final-image probes; the controller must replace, + not add to, the existing canonical range. + +## Target-unverified boundaries + +The proposed ADR-14 route/auth split is proved disjoint as a contract, but `/api/hosted/v1`, +`/api/runtime/v1`, the ADR-30 relay and lane credential do not exist at the phase-start SHA. Final-image +provider conformance, credential canaries, externally owned process adoption, and final-image +secondary-lane recovery remain Phase 1 or final-shape prerequisites; the deterministic Phase 0 +contract fixtures do not claim those final-image probes ran. + +## Reproduction + +```text +node --experimental-strip-types scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts +pnpm exec vitest run test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts +pnpm lint:fast:files -- scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts +pnpm exec prettier --check .codex-handoff/phase-00-w2.json docs/research/hosted-web/phase-0/provider-runtime scripts/hosted-web/phase-0/provider-runtime test/architecture/hosted-web/phase-0/provider-runtime +``` diff --git a/docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json b/docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json new file mode 100644 index 0000000000..99fd7d953a --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json @@ -0,0 +1,326 @@ +{ + "$schema": "./schemas/credential-exposure-matrix.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W2.CREDENTIAL_EXPOSURE_MATRIX", + "phaseStartSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "authoredBy": "phase-00-remediation-w2-final-r1-source-review", + "validatedBy": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "claimStatus": "source_observed_not_target_verified", + "records": [ + { + "id": "unit-primary-provisioning", + "executionUnit": "shared provisioning CLI primary", + "providerSet": ["anthropic", "codex", "gemini"], + "currentExposureClasses": [ + "ambient_process_environment", + "selected_lead_auth", + "cross_provider_member_auth", + "provider_account_storage" + ], + "currentIsolation": "shared_execution_unit", + "finding": "Cross-provider setup deliberately unions safe Codex metadata and required Anthropic auth into one child; whole process/shell env inheritance can widen it further.", + "targetRule": "persist the minimum SecretRef-class union in CompositeRuntimePlan; no per-member isolation claim inside this process", + "executionUnitId": "unit-primary-provisioning", + "source": "src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "claimStatus": "source_observed_not_target_verified", + "exposureSetIds": [ + "ces-ambient-unbounded", + "ces-provider-secret-material", + "ces-host-only-denied", + "ces-runtime-metadata" + ] + }, + { + "id": "unit-opencode-bridge", + "executionUnit": "OpenCode bridge/side process", + "providerSet": ["opencode"], + "currentExposureClasses": [ + "ambient_process_environment", + "runtime_binary_metadata", + "mcp_launch_metadata", + "app_identity_metadata" + ], + "currentIsolation": "dedicated_process_but_exposure_not_disjoint", + "finding": "Bridge env starts from process.env; a separate process is therefore not proof of credential isolation.", + "targetRule": "dedicated_execution_unit only after an allowlisted environment proves a disjoint CredentialExposureSet", + "executionUnitId": "unit-opencode-bridge", + "source": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "claimStatus": "source_observed_not_target_verified", + "exposureSetIds": ["ces-ambient-unbounded", "ces-host-only-denied", "ces-runtime-metadata"] + }, + { + "id": "unit-runtime-relay-target", + "executionUnit": "controller-owned lane relay (target only)", + "providerSet": ["anthropic", "codex", "gemini", "opencode"], + "currentExposureClasses": [], + "currentIsolation": "not_implemented", + "finding": "No canonical relay or /api/runtime/v1 credential exists at phaseStartSha.", + "targetRule": "plaintext opaque lane credential enters relay over one-use inherited pipe; provider gets only narrow local capability", + "executionUnitId": "unit-runtime-relay-target", + "source": "docs/hosted-web-e2e-completion-plan.md", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "claimStatus": "source_observed_not_target_verified", + "exposureSetIds": ["ces-target-ingress-relay-secret"] + }, + { + "id": "unit-browser", + "executionUnit": "operator browser session", + "providerSet": [], + "currentExposureClasses": ["browser_session"], + "currentIsolation": "target_trust_surface_only", + "finding": "Current local HTTP namespace does not yet prove the hosted split.", + "targetRule": "browser session cannot authenticate runtime ingress; runtime credential cannot authorize operator verbs", + "executionUnitId": "unit-browser", + "source": "docs/hosted-web-e2e-completion-plan.md", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "claimStatus": "source_observed_not_target_verified", + "exposureSetIds": [] + } + ], + "canonicalOwnership": { + "providerSettingsAndAuth": "provider.management.* operator actions on /api/hosted/v1 only", + "teamLaunchStopDelete": "team.lifecycle.* operator actions on /api/hosted/v1 only", + "runtimeIngress": "machine-only runtime.* observations/requests on /api/runtime/v1; never operator decisions", + "permissionDirection": "runtime submits a permission request; operator policy/API owns the decision; an outbound adapter delivers it" + }, + "exposureSets": [ + { + "id": "ces-ambient-unbounded", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "secretClass": "ambient_unknown_may_contain_secret", + "currentStatus": "open-ended process and shell inheritance; not a bounded credential set", + "targetStatus": "forbidden until every admitted key is explicitly classified", + "memberKeyEvidenceIds": ["env-key:interactiveShellEnv.*", "env-key:process.env.*"], + "provenanceArtifact": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts#proves-every-per-key-provenance-and-exposure-binding" + }, + { + "id": "ces-provider-secret-material", + "executionUnitIds": ["unit-primary-provisioning"], + "secretClass": "provider_credential_or_reference", + "currentStatus": "selected provider material may enter the shared primary unit; ambient inheritance may widen exposure", + "targetStatus": "minimum SecretRef-class union for the resolved provider plan", + "memberKeyEvidenceIds": [ + "env-key:ANTHROPIC_API_KEY", + "env-key:ANTHROPIC_AUTH_TOKEN", + "env-key:ANTHROPIC_AWS_API_KEY", + "env-key:CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + "env-key:CLAUDE_CODE_BOOTSTRAP_PROOF_TOKEN", + "env-key:CLAUDE_CODE_OAUTH_TOKEN", + "env-key:CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "env-key:CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH", + "env-key:CLAUDE_TEAM_DISABLE_ANTHROPIC_API_KEY_HELPER", + "env-key:CODEX_API_KEY", + "env-key:GEMINI_API_KEY", + "env-key:GOOGLE_APPLICATION_CREDENTIALS", + "env-key:OPENAI_API_KEY" + ], + "provenanceArtifact": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts#proves-every-per-key-provenance-and-exposure-binding" + }, + { + "id": "ces-target-ingress-relay-secret", + "executionUnitIds": ["unit-runtime-relay-target"], + "secretClass": "runtime_ingress_bearer", + "currentStatus": "not implemented at canonical base", + "targetStatus": "relay-only one-use inherited pipe; never provider child, argv, settings, MCP config, or browser", + "memberKeyEvidenceIds": ["env-key:runtime-ingress bearer in any environment key"], + "provenanceArtifact": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts#proves-every-per-key-provenance-and-exposure-binding" + }, + { + "id": "ces-host-only-denied", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "secretClass": "host_only_or_injection_sensitive", + "currentStatus": "some values can currently cross through ambient inheritance; explicit deletion is separately recorded per key", + "targetStatus": "absent from provider execution units", + "memberKeyEvidenceIds": [ + "env-key:AGENT_TEAMS_DISABLE_GPU", + "env-key:AGENT_TEAMS_ELECTRON_CLAUDE_ROOT", + "env-key:AGENT_TEAMS_ELECTRON_USER_DATA_DIR", + "env-key:AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_ARGS", + "env-key:AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_COMMAND", + "env-key:AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_JSON", + "env-key:AGENT_TEAMS_TOKEN_USAGE_COMMAND_REFRESH_MS", + "env-key:AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_ARGS", + "env-key:AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_COMMAND", + "env-key:AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_JSON", + "env-key:BUILD_ID", + "env-key:CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH", + "env-key:CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "env-key:CLAUDE_CLI_PATH", + "env-key:CLAUDE_CONTEXT_DISABLE_CACHE", + "env-key:CLAUDE_DEVTOOLS_STRICT_SESSION_FILTER", + "env-key:CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "env-key:CLAUDE_HOOK_JUDGE_MODE", + "env-key:CLAUDE_ROOT", + "env-key:CLAUDE_SKIP_ANALYTICS", + "env-key:CLAUDE_TEAM_BOARD_TASK_ACTIVITY_READ_ENABLED", + "env-key:CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED", + "env-key:CLAUDE_TEAM_CLI_FLAVOR", + "env-key:CLAUDE_TEAM_CODEX_NATIVE_TRACE_FALLBACK_ENABLED", + "env-key:CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER", + "env-key:AGENT_TEAMS_RUNTIME_TURN_SETTLED_*", + "env-key:AGENT_TEAMS_MCP_*", + "env-key:CLAUDE_TEAM_BOOTSTRAP_*", + "env-key:CLAUDE_TEAM_DISABLE_MCP_ORPHAN_CLEANUP", + "env-key:CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE", + "env-key:CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER", + "env-key:CLAUDE_TEAM_EVENT_LOOP_LAG_MONITOR_ENABLED", + "env-key:CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE", + "env-key:CLAUDE_TEAM_OPENCODE_MCP_HTTP", + "env-key:CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT", + "env-key:CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG", + "env-key:CLAUDE_TEAM_OPENCODE_TASK_CHANGE_DIAG", + "env-key:CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED", + "env-key:CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS", + "env-key:CLAUDE_TEAM_PARALLEL_PROVIDER_STATUS", + "env-key:CLAUDE_TEAM_RUNTIME_PIDUSAGE_ENABLED", + "env-key:CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS", + "env-key:CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED", + "env-key:CLAUDE_TEAM_TASK_STALL_ALERT_COOLDOWN_MS", + "env-key:CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED", + "env-key:CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS", + "env-key:CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS", + "env-key:CLAUDE_TEAM_TEAMMATE_MODE", + "env-key:CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX", + "env-key:CORS_ORIGIN", + "env-key:ELECTRON_RENDERER_URL", + "env-key:ELECTRON_RUN_AS_NODE", + "env-key:ELECTRON_VITE_DEV_SERVER_URL", + "env-key:GIT_SHA", + "env-key:HOST", + "env-key:NODE_BINARY", + "env-key:NODE_OPTIONS", + "env-key:PORT", + "env-key:RENDERER_PATH", + "env-key:SENTRY_DSN", + "env-key:UV_THREADPOOL_SIZE", + "env-key:VITEST", + "env-key:VITE_BUILD_ID", + "env-key:VITE_DEV_SERVER_URL", + "env-key:VITE_GIT_SHA" + ], + "provenanceArtifact": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts#proves-every-per-key-provenance-and-exposure-binding" + }, + { + "id": "ces-runtime-metadata", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "secretClass": "non_secret_configuration", + "currentStatus": "required or selected metadata may be visible according to the per-key provider binding", + "targetStatus": "allowlisted and snapshotted per ProcessExecutionUnit", + "memberKeyEvidenceIds": [ + "env-key:AGENT_STUDIO_NPM_CMD", + "env-key:AGENT_STUDIO_NPX_CMD", + "env-key:AGENT_STUDIO_RUNNER_CACHE_ROOT", + "env-key:AGENT_TEAMS_IDENTITY_STORE_PATH", + "env-key:AGENT_TEAMS_MCP_CLAUDE_DIR", + "env-key:AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH", + "env-key:AGENT_TEAMS_MCP_HTTP_ENDPOINT", + "env-key:AGENT_TEAMS_MCP_HTTP_HOST", + "env-key:AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE", + "env-key:AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH", + "env-key:AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID", + "env-key:AGENT_TEAMS_MCP_HTTP_PORT", + "env-key:AGENT_TEAMS_MCP_TRANSPORT", + "env-key:AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT", + "env-key:ANDROID_SDK_HOME", + "env-key:ANDROID_USER_HOME", + "env-key:ANTHROPIC_AWS_WORKSPACE_ID", + "env-key:ANTHROPIC_BASE_URL", + "env-key:APPDATA", + "env-key:AWS_PROFILE", + "env-key:AWS_REGION", + "env-key:CLAUDECODE", + "env-key:CLAUDE_CODE_CODEX_BACKEND", + "env-key:CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD", + "env-key:CLAUDE_CODE_ENTRY_PROVIDER", + "env-key:CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + "env-key:CLAUDE_CODE_GEMINI_BACKEND", + "env-key:CLAUDE_CODE_NATIVE_APP_MANAGED_BOOTSTRAP_CONTEXT_PATH", + "env-key:CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", + "env-key:CLAUDE_CODE_TEAMMATE_RUNTIME", + "env-key:CLAUDE_CODE_TEAMMATE_RUNTIME_EVENTS_PATH", + "env-key:CLAUDE_CODE_USE_BEDROCK", + "env-key:CLAUDE_CODE_USE_FOUNDRY", + "env-key:CLAUDE_CODE_USE_GEMINI", + "env-key:CLAUDE_CODE_USE_OPENAI", + "env-key:CLAUDE_CODE_USE_VERTEX", + "env-key:CLAUDE_CONFIG_DIR", + "env-key:CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "env-key:CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON", + "env-key:CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND", + "env-key:CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY", + "env-key:CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON", + "env-key:CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL", + "env-key:CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH", + "env-key:CLAUDE_MULTIMODEL_DATA_HOME", + "env-key:CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH", + "env-key:CLAUDE_TEAM_ANTHROPIC_AUTH_MODE", + "env-key:CLAUDE_TEAM_APP_INSTANCE_ID", + "env-key:CLAUDE_TEAM_CONTROL_URL", + "env-key:CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES", + "env-key:CLAUDE_TEAM_RUNTIME_SETTINGS_PATH", + "env-key:CODEX_CLI_PATH", + "env-key:CODEX_HOME", + "env-key:COLUMNS", + "env-key:COMSPEC", + "env-key:CURL_CA_BUNDLE", + "env-key:ComSpec", + "env-key:EDITOR", + "env-key:GCLOUD_PROJECT", + "env-key:GEMINI_API_VERSION", + "env-key:GEMINI_BASE_URL", + "env-key:GOOGLE_CLOUD_PROJECT", + "env-key:GOOGLE_CLOUD_PROJECT_ID", + "env-key:GRADLE_OPTS", + "env-key:GRADLE_USER_HOME", + "env-key:HOME", + "env-key:HOMEDRIVE", + "env-key:HOMEPATH", + "env-key:HTTPS_PROXY", + "env-key:HTTP_PROXY", + "env-key:JAVA_TOOL_OPTIONS", + "env-key:LANG", + "env-key:LINES", + "env-key:LOCALAPPDATA", + "env-key:LOGNAME", + "env-key:NODE_ENV", + "env-key:NODE_EXTRA_CA_CERTS", + "env-key:NO_PROXY", + "env-key:NPM_CONFIG_CACHE", + "env-key:OPENCODE_BIN_PATH", + "env-key:OPENCODE_CONFIG_CONTENT", + "env-key:OPENCODE_DISABLE_AUTOUPDATE", + "env-key:PATH", + "env-key:PATHEXT", + "env-key:ProgramFiles", + "env-key:REQUESTS_CA_BUNDLE", + "env-key:SHELL", + "env-key:SSH_AUTH_SOCK", + "env-key:SSL_CERT_FILE", + "env-key:STUDIO_AGENT_CACHE_ROOT", + "env-key:SystemRoot", + "env-key:TEMP", + "env-key:TERM", + "env-key:TMP", + "env-key:TMPDIR", + "env-key:USER", + "env-key:USERNAME", + "env-key:USERPROFILE", + "env-key:VISUAL", + "env-key:WINDIR", + "env-key:XDG_CONFIG_HOME", + "env-key:XDG_STATE_HOME", + "env-key:npm_config_cache", + "env-key:npm_config_script_shell" + ], + "provenanceArtifact": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json", + "probeTest": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts#proves-every-per-key-provenance-and-exposure-binding" + } + ], + "targetVerification": "target-unverified: exposure-set disjointness and final-image child visibility require target probes" +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json b/docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json new file mode 100644 index 0000000000..985a02532a --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json @@ -0,0 +1,2442 @@ +{ + "$schema": "./schemas/environment-provenance.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W2.ENVIRONMENT_PROVENANCE", + "phaseStartSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "authoredBy": "phase-00-w2-targeted-a1-source-review", + "validatedBy": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "claimStatus": "source_observed_not_target_verified", + "records": [ + { + "id": "ambient-inheritance", + "keys": ["process.env.*", "interactiveShellEnv.*"], + "provenance": "app process and best-effort login shell merged wholesale before policy overlays", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "forbidden", + "currentBehavior": "inherited; this makes the current exposure set open-ended", + "targetRule": "replace with an explicit per-execution-unit allowlist; an unclassified key fails plan creation", + "source": "src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts", + "sourceToken": "...processEnv", + "discoveryDisposition": "open_ended_wildcard" + }, + { + "id": "portable-process-basics", + "keys": [ + "PATH", + "HOME", + "USERPROFILE", + "USER", + "USERNAME", + "LOGNAME", + "TERM", + "COLUMNS", + "LINES", + "SHELL", + "COMSPEC", + "ComSpec", + "XDG_CONFIG_HOME", + "XDG_STATE_HOME", + "NODE_ENV", + "LANG" + ], + "provenance": "shell/app process with normalized home, identity, terminal and development-mode defaults", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "required", + "targetRule": "resolve and snapshot non-secret values in ProcessExecutionUnit environment policy", + "source": "src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts", + "sourceToken": "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + "discoveryDisposition": "source_discovered" + }, + { + "id": "provider-custom-config", + "keys": ["CLAUDE_CONFIG_DIR"], + "provenance": "custom Claude configuration selected before provider-aware child construction", + "providers": ["anthropic", "codex", "gemini"], + "classification": "optional", + "targetRule": "preserve only when the configured Claude base differs from the auto-detected default", + "source": "src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts", + "sourceToken": "resolvedClaudeBasePath !== autoDetectedClaudeBasePath", + "discoveryDisposition": "source_discovered" + }, + { + "id": "provider-routing-pins", + "keys": ["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "CLAUDE_CODE_ENTRY_PROVIDER"], + "provenance": "host-emitted positive provider and backend selection pins", + "providers": ["anthropic", "codex", "gemini"], + "classification": "required", + "targetRule": "emit both pins for every selected primary provider after removing inherited routing keys", + "source": "src/main/services/runtime/providerRuntimeEnv.ts", + "sourceToken": "env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1'", + "discoveryDisposition": "source_discovered" + }, + { + "id": "provider-routing-legacy-removals", + "keys": ["CLAUDE_CODE_USE_OPENAI", "CLAUDE_CODE_USE_GEMINI"], + "provenance": "inherited legacy provider-routing flags removed before every primary provider spawn", + "providers": ["anthropic", "codex", "gemini"], + "classification": "forbidden", + "targetRule": "remove before spawn and never re-emit; CLAUDE_CODE_ENTRY_PROVIDER is the positive provider pin", + "source": "src/main/services/runtime/providerRuntimeEnv.ts", + "sourceToken": "for (const key of PROVIDER_ROUTING_ENV_KEYS)", + "discoveryDisposition": "source_discovered" + }, + { + "id": "anthropic-bedrock-routing", + "keys": ["CLAUDE_CODE_USE_BEDROCK"], + "provenance": "inherited Anthropic backend selection normalized to one positive Bedrock flag", + "providers": ["anthropic", "codex", "gemini"], + "classification": "optional", + "targetRule": "re-emit only for an Anthropic Bedrock backend; remove for all other Anthropic backends and providers", + "source": "src/main/services/runtime/providerRuntimeEnv.ts", + "sourceToken": "backend === 'bedrock'", + "discoveryDisposition": "source_discovered" + }, + { + "id": "anthropic-vertex-routing", + "keys": ["CLAUDE_CODE_USE_VERTEX"], + "provenance": "inherited Anthropic backend selection normalized to one positive Vertex flag", + "providers": ["anthropic", "codex", "gemini"], + "classification": "optional", + "targetRule": "re-emit only for an Anthropic Vertex backend; remove for all other Anthropic backends and providers", + "source": "src/main/services/runtime/providerRuntimeEnv.ts", + "sourceToken": "backend === 'vertex'", + "discoveryDisposition": "source_discovered" + }, + { + "id": "anthropic-foundry-routing", + "keys": ["CLAUDE_CODE_USE_FOUNDRY"], + "provenance": "inherited Anthropic backend selection normalized to one positive Foundry flag", + "providers": ["anthropic", "codex", "gemini"], + "classification": "optional", + "targetRule": "re-emit only for an Anthropic Foundry backend; remove for all other Anthropic backends and providers", + "source": "src/main/services/runtime/providerRuntimeEnv.ts", + "sourceToken": "backend === 'foundry'", + "discoveryDisposition": "source_discovered" + }, + { + "id": "configured-provider-backends", + "keys": ["CLAUDE_CODE_CODEX_BACKEND", "CLAUDE_CODE_GEMINI_BACKEND"], + "provenance": "configured backend identifiers emitted before provider-specific overrides", + "providers": ["anthropic", "codex", "gemini"], + "classification": "required", + "targetRule": "emit the configured Codex and Gemini backend identifiers, then override the selected provider when explicitly requested", + "source": "src/main/services/runtime/buildRuntimeBaseEnv.ts", + "sourceToken": "applyConfiguredRuntimeBackendsEnv", + "discoveryDisposition": "source_discovered" + }, + { + "id": "team-bootstrap-control", + "keys": [ + "CLAUDECODE", + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + "CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES", + "CLAUDE_TEAM_RUNTIME_SETTINGS_PATH", + "CLAUDE_TEAM_CONTROL_URL", + "CLAUDE_CODE_NATIVE_APP_MANAGED_BOOTSTRAP_CONTEXT_PATH", + "CLAUDE_CODE_TEAMMATE_RUNTIME", + "CLAUDE_CODE_TEAMMATE_RUNTIME_EVENTS_PATH", + "CLAUDE_CODE_BOOTSTRAP_PROOF_TOKEN" + ], + "provenance": "app-owned deterministic provisioning setup", + "providers": ["anthropic", "codex", "gemini"], + "classification": "required", + "targetRule": "runtime settings path must be owned/read-only; control URL is location only and conveys no authority", + "source": "src/main/services/team/provisioning/TeamProvisioningLaunchDeterministicSpawnFlow.ts", + "sourceToken": "CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "discoveryDisposition": "source_discovered" + }, + { + "id": "anthropic-auth", + "keys": [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AWS_WORKSPACE_ID", + "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "ANTHROPIC_AWS_API_KEY" + ], + "provenance": "explicit environment, approved stored connection, Claude credential storage, or compatible endpoint settings", + "providers": ["anthropic"], + "classification": "optional", + "targetRule": "minimum one selected auth mode; competing values cleared; represent exposure by SecretRef class, never value", + "source": "src/main/services/runtime/anthropicTeamApiKeyHelper.ts", + "sourceToken": "ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS", + "discoveryDisposition": "source_discovered" + }, + { + "id": "anthropic-helper-metadata", + "keys": [ + "CLAUDE_TEAM_ANTHROPIC_AUTH_MODE", + "CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH", + "CLAUDE_TEAM_DISABLE_ANTHROPIC_API_KEY_HELPER" + ], + "provenance": "app-owned per-team/run helper material", + "providers": ["anthropic"], + "classification": "optional", + "targetRule": "path metadata only; helper/key files remain 0700/0600 and must not cross execution units", + "source": "src/main/services/runtime/anthropicTeamApiKeyHelper.ts", + "sourceToken": "CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV", + "discoveryDisposition": "source_discovered" + }, + { + "id": "codex-auth-and-runtime", + "keys": [ + "OPENAI_API_KEY", + "CODEX_API_KEY", + "CODEX_CLI_PATH", + "CODEX_HOME", + "CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD" + ], + "provenance": "Codex account snapshot, approved stored key, explicit env, app-managed binary, and connection configuration", + "providers": ["codex"], + "classification": "optional", + "targetRule": "key or account artifacts only when selected; binary/home metadata may be required by resolved backend", + "source": "src/main/services/runtime/ProviderConnectionService.ts", + "sourceToken": "CODEX_NATIVE_API_KEY_ENV_VAR", + "discoveryDisposition": "source_discovered" + }, + { + "id": "gemini-auth-and-project", + "keys": [ + "GEMINI_API_KEY", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_PROJECT_ID", + "GCLOUD_PROJECT", + "GEMINI_API_VERSION", + "GEMINI_BASE_URL", + "GOOGLE_APPLICATION_CREDENTIALS" + ], + "provenance": "approved stored key/explicit env or Google ADC plus project metadata", + "providers": ["gemini"], + "classification": "optional", + "targetRule": "API key or compatible ADC/project pair according to immutable backend selection", + "source": "src/main/services/runtime/geminiRuntimeAuth.ts", + "sourceToken": "GOOGLE_CLOUD_PROJECT_ID", + "discoveryDisposition": "source_discovered" + }, + { + "id": "opencode-emitted-required", + "keys": [ + "CLAUDE_TEAM_APP_INSTANCE_ID", + "AGENT_TEAMS_MCP_CLAUDE_DIR", + "OPENCODE_CONFIG_CONTENT" + ], + "provenance": "app-derived OpenCode child metadata and managed configuration", + "classification": "required", + "targetRule": "derive in the host and emit only to the managed OpenCode execution unit", + "source": "src/main/index.ts", + "sourceToken": "CLAUDE_TEAM_APP_INSTANCE_ID", + "providers": ["opencode"], + "discoveryDisposition": "source_discovered" + }, + { + "id": "opencode-selected-child-input", + "keys": ["CLAUDE_MULTIMODEL_DATA_HOME"], + "provenance": "optional managed OpenCode data-home marker selected by the runtime host", + "classification": "optional", + "targetRule": "admit only as a selected OpenCode child input; do not treat presence as host authority", + "source": "src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts", + "sourceToken": "CLAUDE_MULTIMODEL_DATA_HOME=", + "providers": ["opencode"], + "discoveryDisposition": "source_discovered" + }, + { + "id": "opencode-emitted-optional", + "keys": ["OPENCODE_DISABLE_AUTOUPDATE"], + "provenance": "host policy derives an optional OpenCode-native child key", + "classification": "optional", + "targetRule": "emit OPENCODE_DISABLE_AUTOUPDATE unless explicit host policy permits updates; never emit the allow-policy key", + "source": "src/main/services/runtime/openCodeAutoUpdatePolicy.ts", + "sourceToken": "OPENCODE_DISABLE_AUTOUPDATE_ENV", + "providers": ["opencode"], + "discoveryDisposition": "source_discovered" + }, + { + "id": "opencode-host-policy", + "keys": [ + "CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE", + "CLAUDE_TEAM_OPENCODE_MCP_HTTP", + "CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH", + "CLAUDE_CLI_PATH", + "CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT", + "CLAUDE_TEAM_TEAMMATE_MODE" + ], + "provenance": "desktop host policy and binary-selection inputs consumed before child environment construction", + "classification": "forbidden", + "targetRule": "consume on the host only; do not expose these policy/override keys to the managed OpenCode child", + "source": "src/main/services/runtime/openCodeAutoUpdatePolicy.ts", + "sourceToken": "CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE_ENV", + "providers": ["opencode"], + "discoveryDisposition": "source_discovered" + }, + { + "id": "opencode-binary-dual", + "keys": ["CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH", "OPENCODE_BIN_PATH"], + "provenance": "host-selected binary paths copied to the managed OpenCode bridge for compatibility", + "classification": "optional", + "targetRule": "validate on the host, then emit only the selected paths to the OpenCode bridge child", + "source": "src/main/services/runtime/openCodeRuntimeBinaryEnv.ts", + "sourceToken": "OPENCODE_RUNTIME_BINARY_PATH_ENV", + "providers": ["opencode"], + "discoveryDisposition": "source_discovered" + }, + { + "id": "mcp-launch", + "keys": [ + "CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND", + "CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY", + "CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON", + "CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON", + "CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL", + "CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH", + "AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH", + "AGENT_TEAMS_MCP_HTTP_ENDPOINT", + "AGENT_TEAMS_MCP_HTTP_HOST", + "AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE", + "AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH", + "AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID", + "AGENT_TEAMS_MCP_HTTP_PORT", + "AGENT_TEAMS_MCP_TRANSPORT" + ], + "provenance": "app-resolved MCP launch spec or app-owned local HTTP MCP server", + "providers": ["opencode"], + "classification": "optional", + "targetRule": "allowlisted launch metadata only; MCP env JSON must not contain provider or runtime-ingress credentials", + "source": "src/main/index.ts", + "sourceToken": "CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON", + "discoveryDisposition": "source_discovered" + }, + { + "id": "runtime-spool-and-identity", + "keys": ["AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT", "AGENT_TEAMS_IDENTITY_STORE_PATH"], + "provenance": "app-owned member-work-sync spool and installation identity store", + "providers": ["codex", "opencode"], + "classification": "optional", + "targetRule": "registered roots only; identity is correlation metadata, never authorization", + "source": "src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts", + "sourceToken": "AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT", + "discoveryDisposition": "source_discovered" + }, + { + "id": "windows-writable-cache", + "keys": [ + "AGENT_STUDIO_RUNNER_CACHE_ROOT", + "STUDIO_AGENT_CACHE_ROOT", + "LOCALAPPDATA", + "TEMP", + "TMP", + "TMPDIR", + "npm_config_cache", + "NPM_CONFIG_CACHE", + "GRADLE_USER_HOME", + "ANDROID_USER_HOME", + "ANDROID_SDK_HOME", + "npm_config_script_shell", + "AGENT_STUDIO_NPM_CMD", + "AGENT_STUDIO_NPX_CMD", + "GRADLE_OPTS", + "JAVA_TOOL_OPTIONS" + ], + "provenance": "app-derived and write-probed runner cache paths on Windows", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "optional", + "targetRule": "absent on Linux; if Windows is later supported, paths must be execution-unit owned", + "source": "src/main/services/runtime/agentChildProcessPreflight.ts", + "sourceToken": "AGENT_STUDIO_NPX_CMD", + "discoveryDisposition": "source_discovered" + }, + { + "id": "desktop-bootstrap-guard-inputs", + "keys": [ + "CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP" + ], + "provenance": "desktop app process kill switches read before deterministic bootstrap", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "forbidden", + "currentBehavior": "read from the app environment but not intentionally forwarded as child policy", + "targetRule": "host configuration may gate readiness; these desktop compatibility flags are absent from provider children", + "source": "src/main/services/team/provisioning/TeamProvisioningEnvGuards.ts", + "sourceToken": "CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "discoveryDisposition": "source_discovered" + }, + { + "id": "electron-mode", + "keys": ["ELECTRON_RUN_AS_NODE"], + "provenance": "ambient Electron host environment", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "forbidden", + "currentBehavior": "explicitly deleted before provider/bridge execution", + "targetRule": "must remain absent", + "source": "src/main/services/runtime/providerAwareCliEnv.ts", + "sourceToken": "removeGlobalElectronRunAsNodeEnv", + "discoveryDisposition": "source_discovered" + }, + { + "id": "canonical-ingress-secret", + "keys": ["runtime-ingress bearer in any environment key"], + "provenance": "target lane relay one-use inherited pipe only", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "forbidden", + "targetRule": "provider/agent environment, argv, settings, MCP config and run files never receive the bearer", + "source": "docs/hosted-web-e2e-completion-plan.md", + "sourceToken": "provider/agent process receives", + "discoveryDisposition": "target_only_prohibition" + }, + { + "id": "ambient-backend-test-characterization", + "keys": ["AWS_PROFILE", "AWS_REGION"], + "provenance": "generic ambient environment preservation characterized by the provider runtime environment fixture", + "providers": ["anthropic"], + "classification": "optional", + "targetRule": "admit only for the selected Bedrock backend and resolve through the execution-unit policy", + "source": "src/main/services/runtime/providerRuntimeEnv.ts", + "sourceToken": "NodeJS.ProcessEnv", + "discoveryDisposition": "fixture_bound", + "keyBindings": [ + { + "key": "AWS_PROFILE", + "path": "src/main/services/runtime/providerRuntimeEnv.test.ts" + }, + { + "key": "AWS_REGION", + "path": "src/main/services/runtime/providerRuntimeEnv.test.ts" + } + ] + }, + { + "id": "portable-platform-and-network-inputs", + "keys": [ + "APPDATA", + "CURL_CA_BUNDLE", + "EDITOR", + "HOMEDRIVE", + "HOMEPATH", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "PATHEXT", + "ProgramFiles", + "REQUESTS_CA_BUNDLE", + "SSH_AUTH_SOCK", + "SSL_CERT_FILE", + "SystemRoot", + "VISUAL", + "WINDIR" + ], + "provenance": "host platform, proxy, CA and interactive-shell environment reads in production main-process runtime paths", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "optional", + "targetRule": "admit only values explicitly required by the resolved backend; paths and proxy/CA inputs are snapshotted and policy-reviewed", + "source": "src/main/index.ts", + "sourceToken": "process.env", + "discoveryDisposition": "source_discovered" + }, + { + "id": "host-only-and-injection-inputs", + "keys": [ + "AGENT_TEAMS_DISABLE_GPU", + "AGENT_TEAMS_ELECTRON_CLAUDE_ROOT", + "AGENT_TEAMS_ELECTRON_USER_DATA_DIR", + "AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_ARGS", + "AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_COMMAND", + "AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_JSON", + "AGENT_TEAMS_TOKEN_USAGE_COMMAND_REFRESH_MS", + "AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_ARGS", + "AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_COMMAND", + "AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_JSON", + "BUILD_ID", + "CLAUDE_CONTEXT_DISABLE_CACHE", + "CLAUDE_DEVTOOLS_STRICT_SESSION_FILTER", + "CLAUDE_HOOK_JUDGE_MODE", + "CLAUDE_ROOT", + "CLAUDE_SKIP_ANALYTICS", + "CLAUDE_TEAM_BOARD_TASK_ACTIVITY_READ_ENABLED", + "CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED", + "CLAUDE_TEAM_CLI_FLAVOR", + "CLAUDE_TEAM_CODEX_NATIVE_TRACE_FALLBACK_ENABLED", + "CLAUDE_TEAM_DISABLE_MCP_ORPHAN_CLEANUP", + "CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE", + "CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER", + "CLAUDE_TEAM_EVENT_LOOP_LAG_MONITOR_ENABLED", + "CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG", + "CLAUDE_TEAM_OPENCODE_TASK_CHANGE_DIAG", + "CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED", + "CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS", + "CLAUDE_TEAM_PARALLEL_PROVIDER_STATUS", + "CLAUDE_TEAM_RUNTIME_PIDUSAGE_ENABLED", + "CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS", + "CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED", + "CLAUDE_TEAM_TASK_STALL_ALERT_COOLDOWN_MS", + "CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED", + "CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS", + "CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS", + "CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX", + "CORS_ORIGIN", + "ELECTRON_RENDERER_URL", + "ELECTRON_VITE_DEV_SERVER_URL", + "GIT_SHA", + "HOST", + "NODE_BINARY", + "NODE_OPTIONS", + "PORT", + "RENDERER_PATH", + "SENTRY_DSN", + "UV_THREADPOOL_SIZE", + "VITEST", + "VITE_BUILD_ID", + "VITE_DEV_SERVER_URL", + "VITE_GIT_SHA" + ], + "provenance": "desktop/controller configuration, diagnostics, feature gates and injection-sensitive runtime controls read by production main-process code", + "providers": ["anthropic", "codex", "gemini", "opencode"], + "classification": "forbidden", + "currentBehavior": "ambient inheritance can currently expose these values to descendants", + "targetRule": "host-only inputs and loader/runtime injection controls are absent from every provider execution unit", + "source": "src/main/index.ts", + "sourceToken": "process.env", + "discoveryDisposition": "source_discovered" + }, + { + "id": "workspace-trust-exact-prohibition", + "keys": ["CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER"], + "provenance": "workspace-trust Claude PTY preflight sanitizer removes the inherited helper-mode control before provider-child execution", + "providers": ["anthropic"], + "classification": "forbidden", + "currentBehavior": "explicitly removed from the workspace-trust preflight child environment", + "targetRule": "the helper-mode host control remains absent from every workspace-trust Claude provider child", + "source": "src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts", + "sourceToken": "CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER", + "discoveryDisposition": "source_discovered" + }, + { + "id": "workspace-trust-prefix-prohibitions", + "keys": [ + "AGENT_TEAMS_RUNTIME_TURN_SETTLED_*", + "AGENT_TEAMS_MCP_*", + "CLAUDE_TEAM_BOOTSTRAP_*" + ], + "provenance": "workspace-trust Claude PTY preflight sanitizer removes every inherited key in the runtime-settled, MCP, and team-bootstrap namespaces", + "providers": ["anthropic"], + "classification": "forbidden", + "currentBehavior": "all matching keys are explicitly removed from the workspace-trust preflight child environment", + "targetRule": "the three host-control namespaces remain absent from every workspace-trust Claude provider child", + "source": "src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts", + "sourceToken": "STRIP_ENV_PREFIXES", + "discoveryDisposition": "source_discovered" + } + ], + "sourceDiscovery": { + "strategy": "repository_production_source_census", + "roots": [ + "src/main", + "src/features/codex-account", + "src/features/member-work-sync", + "src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts" + ], + "excludedSegments": ["/__tests__/", "/renderer/"], + "extensions": [".ts", ".tsx"], + "extractionVersion": 3 + }, + "keyEvidence": { + "fields": ["key", "groupId", "policyProfileId", "probePathId"], + "probePaths": [ + { + "id": "sp-01", + "path": "src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts" + }, + { + "id": "sp-02", + "path": "src/main/services/infrastructure/CliInstallerService.ts" + }, + { + "id": "sp-03", + "path": "src/main/services/runtime/agentChildProcessPreflight.ts" + }, + { + "id": "sp-04", + "path": "src/main/services/infrastructure/SshConnectionManager.ts" + }, + { + "id": "sp-05", + "path": "src/main/ipc/config.ts" + }, + { + "id": "sp-06", + "path": "src/main/services/team/ClaudeDoctorProbe.ts" + }, + { + "id": "sp-07", + "path": "src/main/services/infrastructure/PtyTerminalService.ts" + }, + { + "id": "sp-08", + "path": "src/main/index.ts" + }, + { + "id": "sp-09", + "path": "src/main/services/team/provisioning/TeamProvisioningAgentLanguage.ts" + }, + { + "id": "sp-10", + "path": "src/main/services/runtime/geminiRuntimeAuth.ts" + }, + { + "id": "sp-11", + "path": "src/main/services/runtime/providerRuntimeEnv.ts" + }, + { + "id": "sp-12", + "path": "src/main/services/runtime/buildRuntimeBaseEnv.ts" + }, + { + "id": "sp-13", + "path": "src/main/services/schedule/ScheduledTaskExecutor.ts" + }, + { + "id": "sp-14", + "path": "src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts" + }, + { + "id": "sp-15", + "path": "src/main/services/team/provisioning/TeamProvisioningCreateDeterministicSpawnFlow.ts" + }, + { + "id": "sp-16", + "path": "src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts" + }, + { + "id": "sp-17", + "path": "src/main/services/team/TeamMcpConfigBuilder.ts" + }, + { + "id": "sp-18", + "path": "src/main/services/team/provisioning/TeamProvisioningMemberLifecycle.ts" + }, + { + "id": "sp-19", + "path": "src/main/services/runtime/ProviderConnectionService.ts" + }, + { + "id": "sp-20", + "path": "src/main/services/runtime/ClaudeMultimodelBridgeService.ts" + }, + { + "id": "sp-21", + "path": "src/main/services/runtime/anthropicTeamApiKeyHelper.ts" + }, + { + "id": "sp-22", + "path": "src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts" + }, + { + "id": "sp-23", + "path": "src/main/services/runtime/openCodeRuntimeBinaryEnv.ts" + }, + { + "id": "sp-24", + "path": "src/main/services/runtime/openCodeAutoUpdatePolicy.ts" + }, + { + "id": "sp-25", + "path": "src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts" + }, + { + "id": "sp-26", + "path": "src/main/services/team/ClaudeBinaryResolver.ts" + }, + { + "id": "sp-27", + "path": "src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts" + }, + { + "id": "sp-28", + "path": "src/main/services/team/AgentTeamsMcpHttpServer.ts" + }, + { + "id": "sp-29", + "path": "src/main/services/team/runtimeTeammateMode.ts" + }, + { + "id": "sp-30", + "path": "src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts" + }, + { + "id": "sp-31", + "path": "src/main/services/identity/AgentTeamsIdentityStore.ts" + }, + { + "id": "sp-32", + "path": "src/main/services/team/provisioning/TeamProvisioningEnvGuards.ts" + }, + { + "id": "sp-33", + "path": "docs/hosted-web-e2e-completion-plan.md" + }, + { + "id": "sp-34", + "path": "src/main/services/runtime/providerRuntimeEnv.test.ts" + }, + { + "id": "sp-35", + "path": "src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts" + }, + { + "id": "sp-36", + "path": "src/main/utils/pathDecoder.ts" + }, + { + "id": "sp-37", + "path": "src/main/utils/electronDevPathOverrides.ts" + }, + { + "id": "sp-38", + "path": "src/main/services/infrastructure/ServiceContext.ts" + }, + { + "id": "sp-39", + "path": "src/main/services/discovery/WorktreeGrouper.ts" + }, + { + "id": "sp-40", + "path": "src/main/utils/childProcess.ts" + }, + { + "id": "sp-41", + "path": "src/main/standalone.ts" + }, + { + "id": "sp-42", + "path": "src/main/services/team/taskLogs/activity/featureGates.ts" + }, + { + "id": "sp-43", + "path": "src/main/services/team/taskLogs/exact/featureGates.ts" + }, + { + "id": "sp-44", + "path": "src/main/services/team/cliFlavor.ts" + }, + { + "id": "sp-45", + "path": "src/main/services/team/ChangeExtractorService.ts" + }, + { + "id": "sp-46", + "path": "src/main/services/team/TaskChangeWorkerClient.ts" + }, + { + "id": "sp-47", + "path": "src/main/services/infrastructure/EventLoopLagMonitor.ts" + }, + { + "id": "sp-48", + "path": "src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdogScheduler.ts" + }, + { + "id": "sp-49", + "path": "src/main/utils/openCodeTaskChangeDiagLog.ts" + }, + { + "id": "sp-50", + "path": "src/main/services/team/stallMonitor/featureGates.ts" + }, + { + "id": "sp-51", + "path": "src/main/ipc/cliInstaller.ts" + }, + { + "id": "sp-52", + "path": "src/main/services/team/TeamRuntimeTelemetry.ts" + }, + { + "id": "sp-53", + "path": "src/main/services/team/TeamTranscriptProjectResolver.ts" + }, + { + "id": "sp-54", + "path": "src/main/services/infrastructure/HttpServer.ts" + }, + { + "id": "sp-55", + "path": "src/main/sentry.ts" + }, + { + "id": "sp-56", + "path": "src/main/services/team/TeamFsWorkerClient.ts" + }, + { + "id": "sp-57", + "path": "src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts" + }, + { + "id": "sp-58", + "path": "src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts" + } + ], + "rows": [ + ["process.env.*", "ambient-inheritance", "kp-01", "sp-01"], + ["interactiveShellEnv.*", "ambient-inheritance", "kp-01", "sp-01"], + ["PATH", "portable-process-basics", "kp-02", "sp-02"], + ["HOME", "portable-process-basics", "kp-02", "sp-03"], + ["USERPROFILE", "portable-process-basics", "kp-03", "sp-04"], + ["USER", "portable-process-basics", "kp-04", "sp-01"], + ["USERNAME", "portable-process-basics", "kp-03", "sp-05"], + ["LOGNAME", "portable-process-basics", "kp-04", "sp-01"], + ["TERM", "portable-process-basics", "kp-02", "sp-01"], + ["COLUMNS", "portable-process-basics", "kp-02", "sp-06"], + ["LINES", "portable-process-basics", "kp-02", "sp-06"], + ["SHELL", "portable-process-basics", "kp-04", "sp-07"], + ["COMSPEC", "portable-process-basics", "kp-03", "sp-07"], + ["ComSpec", "portable-process-basics", "kp-03", "sp-03"], + ["XDG_CONFIG_HOME", "portable-process-basics", "kp-02", "sp-01"], + ["XDG_STATE_HOME", "portable-process-basics", "kp-02", "sp-01"], + ["NODE_ENV", "portable-process-basics", "kp-02", "sp-08"], + ["LANG", "portable-process-basics", "kp-02", "sp-09"], + ["CLAUDE_CONFIG_DIR", "provider-custom-config", "kp-30", "sp-57"], + ["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "provider-routing-pins", "kp-31", "sp-11"], + ["CLAUDE_CODE_ENTRY_PROVIDER", "provider-routing-pins", "kp-31", "sp-11"], + ["CLAUDE_CODE_USE_OPENAI", "provider-routing-legacy-removals", "kp-32", "sp-11"], + ["CLAUDE_CODE_USE_BEDROCK", "anthropic-bedrock-routing", "kp-33", "sp-11"], + ["CLAUDE_CODE_USE_VERTEX", "anthropic-vertex-routing", "kp-34", "sp-11"], + ["CLAUDE_CODE_USE_FOUNDRY", "anthropic-foundry-routing", "kp-35", "sp-11"], + ["CLAUDE_CODE_USE_GEMINI", "provider-routing-legacy-removals", "kp-32", "sp-11"], + ["CLAUDE_CODE_CODEX_BACKEND", "configured-provider-backends", "kp-36", "sp-12"], + ["CLAUDE_CODE_GEMINI_BACKEND", "configured-provider-backends", "kp-36", "sp-12"], + ["CLAUDECODE", "team-bootstrap-control", "kp-06", "sp-13"], + ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "team-bootstrap-control", "kp-06", "sp-14"], + ["CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP", "team-bootstrap-control", "kp-06", "sp-15"], + ["CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES", "team-bootstrap-control", "kp-06", "sp-16"], + ["CLAUDE_TEAM_RUNTIME_SETTINGS_PATH", "team-bootstrap-control", "kp-06", "sp-14"], + ["CLAUDE_TEAM_CONTROL_URL", "team-bootstrap-control", "kp-06", "sp-17"], + [ + "CLAUDE_CODE_NATIVE_APP_MANAGED_BOOTSTRAP_CONTEXT_PATH", + "team-bootstrap-control", + "kp-06", + "sp-18" + ], + ["CLAUDE_CODE_TEAMMATE_RUNTIME", "team-bootstrap-control", "kp-06", "sp-18"], + ["CLAUDE_CODE_TEAMMATE_RUNTIME_EVENTS_PATH", "team-bootstrap-control", "kp-06", "sp-18"], + ["CLAUDE_CODE_BOOTSTRAP_PROOF_TOKEN", "team-bootstrap-control", "kp-07", "sp-18"], + ["ANTHROPIC_API_KEY", "anthropic-auth", "kp-08", "sp-19"], + ["ANTHROPIC_AUTH_TOKEN", "anthropic-auth", "kp-08", "sp-20"], + ["ANTHROPIC_BASE_URL", "anthropic-auth", "kp-09", "sp-19"], + ["ANTHROPIC_AWS_WORKSPACE_ID", "anthropic-auth", "kp-09", "sp-11"], + ["CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", "anthropic-auth", "kp-08", "sp-21"], + ["CLAUDE_CODE_OAUTH_TOKEN", "anthropic-auth", "kp-08", "sp-21"], + ["CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", "anthropic-auth", "kp-08", "sp-21"], + ["ANTHROPIC_AWS_API_KEY", "anthropic-auth", "kp-08", "sp-14"], + ["CLAUDE_TEAM_ANTHROPIC_AUTH_MODE", "anthropic-helper-metadata", "kp-10", "sp-21"], + [ + "CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH", + "anthropic-helper-metadata", + "kp-11", + "sp-21" + ], + [ + "CLAUDE_TEAM_DISABLE_ANTHROPIC_API_KEY_HELPER", + "anthropic-helper-metadata", + "kp-11", + "sp-21" + ], + ["OPENAI_API_KEY", "codex-auth-and-runtime", "kp-12", "sp-19"], + ["CODEX_API_KEY", "codex-auth-and-runtime", "kp-12", "sp-19"], + ["CODEX_CLI_PATH", "codex-auth-and-runtime", "kp-13", "sp-22"], + ["CODEX_HOME", "codex-auth-and-runtime", "kp-13", "sp-19"], + ["CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD", "codex-auth-and-runtime", "kp-13", "sp-19"], + ["GEMINI_API_KEY", "gemini-auth-and-project", "kp-14", "sp-19"], + ["GOOGLE_CLOUD_PROJECT", "gemini-auth-and-project", "kp-15", "sp-10"], + ["GOOGLE_CLOUD_PROJECT_ID", "gemini-auth-and-project", "kp-15", "sp-10"], + ["GCLOUD_PROJECT", "gemini-auth-and-project", "kp-15", "sp-10"], + ["GEMINI_API_VERSION", "gemini-auth-and-project", "kp-15", "sp-14"], + ["GEMINI_BASE_URL", "gemini-auth-and-project", "kp-15", "sp-14"], + ["GOOGLE_APPLICATION_CREDENTIALS", "gemini-auth-and-project", "kp-14", "sp-14"], + ["CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH", "opencode-binary-dual", "kp-29", "sp-23"], + ["OPENCODE_BIN_PATH", "opencode-binary-dual", "kp-29", "sp-23"], + ["OPENCODE_DISABLE_AUTOUPDATE", "opencode-emitted-optional", "kp-27", "sp-12"], + ["CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE", "opencode-host-policy", "kp-28", "sp-24"], + ["CLAUDE_TEAM_OPENCODE_MCP_HTTP", "opencode-host-policy", "kp-28", "sp-25"], + ["CLAUDE_TEAM_APP_INSTANCE_ID", "opencode-emitted-required", "kp-16", "sp-08"], + ["AGENT_TEAMS_MCP_CLAUDE_DIR", "opencode-emitted-required", "kp-16", "sp-08"], + ["CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH", "opencode-host-policy", "kp-28", "sp-26"], + ["CLAUDE_CLI_PATH", "opencode-host-policy", "kp-28", "sp-26"], + ["CLAUDE_MULTIMODEL_DATA_HOME", "opencode-selected-child-input", "kp-17", "sp-27"], + ["CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT", "opencode-host-policy", "kp-28", "sp-28"], + ["CLAUDE_TEAM_TEAMMATE_MODE", "opencode-host-policy", "kp-28", "sp-29"], + ["OPENCODE_CONFIG_CONTENT", "opencode-emitted-required", "kp-16", "sp-27"], + ["CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND", "mcp-launch", "kp-17", "sp-08"], + ["CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY", "mcp-launch", "kp-17", "sp-08"], + ["CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON", "mcp-launch", "kp-17", "sp-08"], + ["CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON", "mcp-launch", "kp-17", "sp-08"], + ["CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL", "mcp-launch", "kp-17", "sp-08"], + ["CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH", "mcp-launch", "kp-17", "sp-08"], + ["AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH", "mcp-launch", "kp-17", "sp-28"], + ["AGENT_TEAMS_MCP_HTTP_ENDPOINT", "mcp-launch", "kp-17", "sp-28"], + ["AGENT_TEAMS_MCP_HTTP_HOST", "mcp-launch", "kp-17", "sp-28"], + ["AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE", "mcp-launch", "kp-17", "sp-28"], + ["AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH", "mcp-launch", "kp-17", "sp-28"], + ["AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID", "mcp-launch", "kp-17", "sp-28"], + ["AGENT_TEAMS_MCP_HTTP_PORT", "mcp-launch", "kp-17", "sp-28"], + ["AGENT_TEAMS_MCP_TRANSPORT", "mcp-launch", "kp-17", "sp-28"], + [ + "AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT", + "runtime-spool-and-identity", + "kp-18", + "sp-30" + ], + ["AGENT_TEAMS_IDENTITY_STORE_PATH", "runtime-spool-and-identity", "kp-18", "sp-31"], + ["AGENT_STUDIO_RUNNER_CACHE_ROOT", "windows-writable-cache", "kp-19", "sp-03"], + ["STUDIO_AGENT_CACHE_ROOT", "windows-writable-cache", "kp-19", "sp-03"], + ["LOCALAPPDATA", "windows-writable-cache", "kp-19", "sp-03"], + ["TEMP", "windows-writable-cache", "kp-19", "sp-03"], + ["TMP", "windows-writable-cache", "kp-19", "sp-03"], + ["TMPDIR", "windows-writable-cache", "kp-19", "sp-03"], + ["npm_config_cache", "windows-writable-cache", "kp-19", "sp-03"], + ["NPM_CONFIG_CACHE", "windows-writable-cache", "kp-19", "sp-03"], + ["GRADLE_USER_HOME", "windows-writable-cache", "kp-19", "sp-03"], + ["ANDROID_USER_HOME", "windows-writable-cache", "kp-19", "sp-03"], + ["ANDROID_SDK_HOME", "windows-writable-cache", "kp-19", "sp-03"], + ["npm_config_script_shell", "windows-writable-cache", "kp-19", "sp-03"], + ["AGENT_STUDIO_NPM_CMD", "windows-writable-cache", "kp-19", "sp-03"], + ["AGENT_STUDIO_NPX_CMD", "windows-writable-cache", "kp-19", "sp-03"], + ["GRADLE_OPTS", "windows-writable-cache", "kp-19", "sp-03"], + ["JAVA_TOOL_OPTIONS", "windows-writable-cache", "kp-19", "sp-03"], + [ + "CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "desktop-bootstrap-guard-inputs", + "kp-20", + "sp-32" + ], + [ + "CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP", + "desktop-bootstrap-guard-inputs", + "kp-20", + "sp-32" + ], + ["ELECTRON_RUN_AS_NODE", "electron-mode", "kp-21", "sp-08"], + [ + "runtime-ingress bearer in any environment key", + "canonical-ingress-secret", + "kp-22", + "sp-33" + ], + ["AWS_PROFILE", "ambient-backend-test-characterization", "kp-23", "sp-34"], + ["AWS_REGION", "ambient-backend-test-characterization", "kp-23", "sp-34"], + ["APPDATA", "portable-platform-and-network-inputs", "kp-19", "sp-35"], + ["CURL_CA_BUNDLE", "portable-platform-and-network-inputs", "kp-24", "sp-14"], + ["EDITOR", "portable-platform-and-network-inputs", "kp-24", "sp-05"], + ["HOMEDRIVE", "portable-platform-and-network-inputs", "kp-19", "sp-36"], + ["HOMEPATH", "portable-platform-and-network-inputs", "kp-19", "sp-36"], + ["HTTPS_PROXY", "portable-platform-and-network-inputs", "kp-24", "sp-14"], + ["HTTP_PROXY", "portable-platform-and-network-inputs", "kp-24", "sp-14"], + ["NODE_EXTRA_CA_CERTS", "portable-platform-and-network-inputs", "kp-24", "sp-14"], + ["NO_PROXY", "portable-platform-and-network-inputs", "kp-24", "sp-14"], + ["PATHEXT", "portable-platform-and-network-inputs", "kp-19", "sp-22"], + ["ProgramFiles", "portable-platform-and-network-inputs", "kp-19", "sp-26"], + ["REQUESTS_CA_BUNDLE", "portable-platform-and-network-inputs", "kp-24", "sp-14"], + ["SSH_AUTH_SOCK", "portable-platform-and-network-inputs", "kp-25", "sp-04"], + ["SSL_CERT_FILE", "portable-platform-and-network-inputs", "kp-24", "sp-14"], + ["SystemRoot", "portable-platform-and-network-inputs", "kp-19", "sp-05"], + ["VISUAL", "portable-platform-and-network-inputs", "kp-24", "sp-05"], + ["WINDIR", "portable-platform-and-network-inputs", "kp-19", "sp-05"], + ["AGENT_TEAMS_DISABLE_GPU", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["AGENT_TEAMS_ELECTRON_CLAUDE_ROOT", "host-only-and-injection-inputs", "kp-26", "sp-37"], + ["AGENT_TEAMS_ELECTRON_USER_DATA_DIR", "host-only-and-injection-inputs", "kp-26", "sp-37"], + ["AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_ARGS", "host-only-and-injection-inputs", "kp-26", "sp-08"], + [ + "AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_COMMAND", + "host-only-and-injection-inputs", + "kp-26", + "sp-08" + ], + ["AGENT_TEAMS_TOKEN_USAGE_CCUSAGE_JSON", "host-only-and-injection-inputs", "kp-26", "sp-08"], + [ + "AGENT_TEAMS_TOKEN_USAGE_COMMAND_REFRESH_MS", + "host-only-and-injection-inputs", + "kp-26", + "sp-08" + ], + ["AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_ARGS", "host-only-and-injection-inputs", "kp-26", "sp-08"], + [ + "AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_COMMAND", + "host-only-and-injection-inputs", + "kp-26", + "sp-08" + ], + ["AGENT_TEAMS_TOKEN_USAGE_TOKSCALE_JSON", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["BUILD_ID", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["CLAUDE_CONTEXT_DISABLE_CACHE", "host-only-and-injection-inputs", "kp-26", "sp-38"], + ["CLAUDE_DEVTOOLS_STRICT_SESSION_FILTER", "host-only-and-injection-inputs", "kp-26", "sp-39"], + ["CLAUDE_HOOK_JUDGE_MODE", "host-only-and-injection-inputs", "kp-26", "sp-40"], + ["CLAUDE_ROOT", "host-only-and-injection-inputs", "kp-26", "sp-41"], + ["CLAUDE_SKIP_ANALYTICS", "host-only-and-injection-inputs", "kp-26", "sp-02"], + [ + "CLAUDE_TEAM_BOARD_TASK_ACTIVITY_READ_ENABLED", + "host-only-and-injection-inputs", + "kp-26", + "sp-42" + ], + [ + "CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED", + "host-only-and-injection-inputs", + "kp-26", + "sp-43" + ], + ["CLAUDE_TEAM_CLI_FLAVOR", "host-only-and-injection-inputs", "kp-26", "sp-44"], + [ + "CLAUDE_TEAM_CODEX_NATIVE_TRACE_FALLBACK_ENABLED", + "host-only-and-injection-inputs", + "kp-26", + "sp-43" + ], + [ + "CLAUDE_TEAM_DISABLE_MCP_ORPHAN_CLEANUP", + "host-only-and-injection-inputs", + "kp-26", + "sp-28" + ], + [ + "CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE", + "host-only-and-injection-inputs", + "kp-26", + "sp-45" + ], + ["CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER", "host-only-and-injection-inputs", "kp-26", "sp-46"], + [ + "CLAUDE_TEAM_EVENT_LOOP_LAG_MONITOR_ENABLED", + "host-only-and-injection-inputs", + "kp-26", + "sp-47" + ], + [ + "CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG", + "host-only-and-injection-inputs", + "kp-26", + "sp-48" + ], + ["CLAUDE_TEAM_OPENCODE_TASK_CHANGE_DIAG", "host-only-and-injection-inputs", "kp-26", "sp-49"], + [ + "CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED", + "host-only-and-injection-inputs", + "kp-26", + "sp-50" + ], + [ + "CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS", + "host-only-and-injection-inputs", + "kp-26", + "sp-50" + ], + ["CLAUDE_TEAM_PARALLEL_PROVIDER_STATUS", "host-only-and-injection-inputs", "kp-26", "sp-51"], + ["CLAUDE_TEAM_RUNTIME_PIDUSAGE_ENABLED", "host-only-and-injection-inputs", "kp-26", "sp-52"], + [ + "CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS", + "host-only-and-injection-inputs", + "kp-26", + "sp-50" + ], + ["CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED", "host-only-and-injection-inputs", "kp-26", "sp-50"], + [ + "CLAUDE_TEAM_TASK_STALL_ALERT_COOLDOWN_MS", + "host-only-and-injection-inputs", + "kp-26", + "sp-50" + ], + [ + "CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED", + "host-only-and-injection-inputs", + "kp-26", + "sp-50" + ], + [ + "CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS", + "host-only-and-injection-inputs", + "kp-26", + "sp-50" + ], + [ + "CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS", + "host-only-and-injection-inputs", + "kp-26", + "sp-50" + ], + ["CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX", "host-only-and-injection-inputs", "kp-26", "sp-53"], + ["CORS_ORIGIN", "host-only-and-injection-inputs", "kp-26", "sp-54"], + ["ELECTRON_RENDERER_URL", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["ELECTRON_VITE_DEV_SERVER_URL", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["GIT_SHA", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["HOST", "host-only-and-injection-inputs", "kp-26", "sp-41"], + ["NODE_BINARY", "host-only-and-injection-inputs", "kp-26", "sp-17"], + ["NODE_OPTIONS", "host-only-and-injection-inputs", "kp-26", "sp-17"], + ["PORT", "host-only-and-injection-inputs", "kp-26", "sp-41"], + ["RENDERER_PATH", "host-only-and-injection-inputs", "kp-26", "sp-54"], + ["SENTRY_DSN", "host-only-and-injection-inputs", "kp-26", "sp-55"], + ["UV_THREADPOOL_SIZE", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["VITEST", "host-only-and-injection-inputs", "kp-26", "sp-56"], + ["VITE_BUILD_ID", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["VITE_DEV_SERVER_URL", "host-only-and-injection-inputs", "kp-26", "sp-08"], + ["VITE_GIT_SHA", "host-only-and-injection-inputs", "kp-26", "sp-08"], + [ + "CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER", + "workspace-trust-exact-prohibition", + "kp-37", + "sp-58" + ], + [ + "AGENT_TEAMS_RUNTIME_TURN_SETTLED_*", + "workspace-trust-prefix-prohibitions", + "kp-38", + "sp-58" + ], + ["AGENT_TEAMS_MCP_*", "workspace-trust-prefix-prohibitions", "kp-38", "sp-58"], + ["CLAUDE_TEAM_BOOTSTRAP_*", "workspace-trust-prefix-prohibitions", "kp-38", "sp-58"] + ] + }, + "perKeyModel": { + "strategy": "normalized_one_row_per_exact_key_or_explicit_wildcard", + "executionUnitModelVersion": "adr-18-adr-30-r3-source-executed-routing-semantics", + "targetVerification": "target-unverified: final-image child visibility, disjoint credential sets, and runtime relay injection remain unproved", + "resolution": "Each non-routing key resolves through an independent source-authority fixture. Provider-routing keys resolve through executable production environment branches across seven provider/backend scenarios, including emission, conditional preservation, and removal behavior." + }, + "keyPolicyProfiles": [ + { + "id": "kp-01", + "sourceClass": "ambient_open_ended_inheritance", + "owner": "host_environment_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + } + ], + "credentialExposureSetIds": ["ces-ambient-unbounded"], + "secretClass": "ambient_unknown_may_contain_secret", + "childVisibility": "currently_possible_target_forbidden", + "redactionRule": "never_record inherited values; classify names before target admission", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "ambient_inheritance" + }, + { + "id": "kp-02", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-03", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "windows_only", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-04", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "posix_primary", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-06", + "sourceClass": "production_source_census", + "owner": "team_provisioning_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-07", + "sourceClass": "production_source_census", + "owner": "team_provisioning_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + } + ], + "credentialExposureSetIds": ["ces-provider-secret-material"], + "secretClass": "provider_credential_or_reference", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "never_record_value; record key name, class and SecretRef only", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-08", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-provider-secret-material"], + "secretClass": "provider_credential_or_reference", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "never_record_value; record key name, class and SecretRef only", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-09", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-10", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-11", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-provider-secret-material"], + "secretClass": "provider_credential_or_reference", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "never_record_value; record key name, class and SecretRef only", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-12", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-provider-secret-material"], + "secretClass": "provider_credential_or_reference", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "never_record_value; record key name, class and SecretRef only", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-13", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-14", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-provider-secret-material"], + "secretClass": "provider_credential_or_reference", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "never_record_value; record key name, class and SecretRef only", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-15", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-16", + "sourceClass": "production_source_census", + "owner": "opencode_runtime_adapter", + "executionUnitIds": ["unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "emitted_child_key" + }, + { + "id": "kp-17", + "sourceClass": "production_source_census", + "owner": "opencode_runtime_adapter", + "executionUnitIds": ["unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-18", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-19", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "windows_only", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-20", + "sourceClass": "production_source_census", + "owner": "team_provisioning_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + } + ], + "credentialExposureSetIds": ["ces-host-only-denied"], + "secretClass": "non_secret_configuration", + "childVisibility": "currently_possible_target_forbidden", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "host_policy_input" + }, + { + "id": "kp-21", + "sourceClass": "production_source_census", + "owner": "desktop_host_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + } + ], + "credentialExposureSetIds": ["ces-host-only-denied"], + "secretClass": "non_secret_configuration", + "childVisibility": "absent_current_and_target", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "host_policy_input" + }, + { + "id": "kp-22", + "sourceClass": "target_contract_prohibition", + "owner": "controller_lane_relay", + "executionUnitIds": ["unit-runtime-relay-target"], + "providerBindings": [], + "credentialExposureSetIds": ["ces-target-ingress-relay-secret"], + "secretClass": "runtime_ingress_bearer", + "childVisibility": "target_relay_only_provider_forbidden", + "redactionRule": "never_record_value; record key name, class and SecretRef only", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "target_platform_independent", + "semanticRole": "target_relay_secret", + "providerlessProhibition": { + "scope": "all_provider_execution_units", + "targetDisposition": "forbidden", + "reason": "Runtime-ingress relay bearers are controller-to-runtime relay authority and are prohibited from every provider child environment." + } + }, + { + "id": "kp-23", + "sourceClass": "checked_fixture_binding", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-24", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-25", + "sourceClass": "production_source_census", + "owner": "process_execution_unit_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "posix_primary", + "semanticRole": "selected_child_input" + }, + { + "id": "kp-26", + "sourceClass": "production_source_census", + "owner": "desktop_host_policy", + "executionUnitIds": ["unit-primary-provisioning", "unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + }, + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + } + ], + "credentialExposureSetIds": ["ces-host-only-denied"], + "secretClass": "non_secret_configuration", + "childVisibility": "currently_possible_target_forbidden", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "host_policy_input" + }, + { + "id": "kp-27", + "sourceClass": "production_source_census", + "owner": "opencode_runtime_adapter", + "executionUnitIds": ["unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "emitted_child_key" + }, + { + "id": "kp-28", + "sourceClass": "production_source_census", + "owner": "desktop_host_policy", + "executionUnitIds": ["unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + } + ], + "credentialExposureSetIds": ["ces-host-only-denied"], + "secretClass": "non_secret_configuration", + "childVisibility": "currently_possible_target_forbidden", + "redactionRule": "record key name and policy outcome only; never copy host policy input into child environment", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "host_policy_input" + }, + { + "id": "kp-29", + "sourceClass": "production_source_census", + "owner": "opencode_runtime_adapter", + "executionUnitIds": ["unit-opencode-bridge"], + "providerBindings": [ + { + "providerId": "opencode", + "backendFamily": "opencode_runtime_adapter", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional" + } + ], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "childVisibility": "provider_child_visible_when_selected", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "host_policy_input_and_emitted_child_key" + }, + { + "executionUnitIds": ["unit-primary-provisioning"], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "id": "kp-30", + "sourceClass": "production_source_census", + "owner": "team_provisioning_policy", + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_default", + "emissionDisposition": "preserved_when_custom_configuration" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_bedrock", + "emissionDisposition": "preserved_when_custom_configuration" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_vertex", + "emissionDisposition": "preserved_when_custom_configuration" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_foundry", + "emissionDisposition": "preserved_when_custom_configuration" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_claude_platform_aws", + "emissionDisposition": "preserved_when_custom_configuration" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "codex_configured", + "emissionDisposition": "preserved_when_custom_configuration" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "gemini_configured", + "emissionDisposition": "preserved_when_custom_configuration" + } + ], + "childVisibility": "provider_child_visible_when_selected", + "semanticRole": "selected_child_input" + }, + { + "executionUnitIds": ["unit-primary-provisioning"], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "id": "kp-31", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_default", + "emissionDisposition": "emitted_always" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_bedrock", + "emissionDisposition": "emitted_always" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_vertex", + "emissionDisposition": "emitted_always" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_foundry", + "emissionDisposition": "emitted_always" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_claude_platform_aws", + "emissionDisposition": "emitted_always" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "codex_configured", + "emissionDisposition": "emitted_always" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "gemini_configured", + "emissionDisposition": "emitted_always" + } + ], + "childVisibility": "provider_child_visible_when_selected", + "semanticRole": "emitted_child_key" + }, + { + "executionUnitIds": ["unit-primary-provisioning"], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "id": "kp-32", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_default", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_bedrock", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_vertex", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_foundry", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_claude_platform_aws", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "codex_configured", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "gemini_configured", + "emissionDisposition": "removed_before_spawn" + } + ], + "childVisibility": "absent_current_and_target", + "semanticRole": "removed_child_key" + }, + { + "executionUnitIds": ["unit-primary-provisioning"], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "id": "kp-33", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_default", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_bedrock", + "emissionDisposition": "emitted_when_backend_selected" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_vertex", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_foundry", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_claude_platform_aws", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "codex_configured", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "gemini_configured", + "emissionDisposition": "removed_before_spawn" + } + ], + "childVisibility": "provider_child_visible_when_selected", + "semanticRole": "host_policy_input_and_emitted_child_key" + }, + { + "executionUnitIds": ["unit-primary-provisioning"], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "id": "kp-34", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_default", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_bedrock", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_vertex", + "emissionDisposition": "emitted_when_backend_selected" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_foundry", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_claude_platform_aws", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "codex_configured", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "gemini_configured", + "emissionDisposition": "removed_before_spawn" + } + ], + "childVisibility": "provider_child_visible_when_selected", + "semanticRole": "host_policy_input_and_emitted_child_key" + }, + { + "executionUnitIds": ["unit-primary-provisioning"], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "id": "kp-35", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_default", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_bedrock", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_vertex", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "optional", + "runtimeBackend": "anthropic_foundry", + "emissionDisposition": "emitted_when_backend_selected" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "anthropic_claude_platform_aws", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "codex_configured", + "emissionDisposition": "removed_before_spawn" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden", + "runtimeBackend": "gemini_configured", + "emissionDisposition": "removed_before_spawn" + } + ], + "childVisibility": "provider_child_visible_when_selected", + "semanticRole": "host_policy_input_and_emitted_child_key" + }, + { + "executionUnitIds": ["unit-primary-provisioning"], + "credentialExposureSetIds": ["ces-runtime-metadata"], + "secretClass": "non_secret_configuration", + "redactionRule": "record key name and policy class only; redact values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "id": "kp-36", + "sourceClass": "production_source_census", + "owner": "provider_runtime_policy", + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_default", + "emissionDisposition": "emitted_configured_backend" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_bedrock", + "emissionDisposition": "emitted_configured_backend" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_vertex", + "emissionDisposition": "emitted_configured_backend" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_foundry", + "emissionDisposition": "emitted_configured_backend" + }, + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "anthropic_claude_platform_aws", + "emissionDisposition": "emitted_configured_backend" + }, + { + "providerId": "codex", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "codex_configured", + "emissionDisposition": "emitted_configured_backend" + }, + { + "providerId": "gemini", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "required", + "runtimeBackend": "gemini_configured", + "emissionDisposition": "emitted_configured_backend" + } + ], + "childVisibility": "provider_child_visible_when_selected", + "semanticRole": "emitted_child_key" + }, + { + "id": "kp-37", + "sourceClass": "production_source_census", + "owner": "team_provisioning_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + } + ], + "credentialExposureSetIds": ["ces-host-only-denied"], + "secretClass": "non_secret_configuration", + "childVisibility": "absent_current_and_target", + "redactionRule": "record the exact prohibited key name only; never record inherited values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "removed_child_key" + }, + { + "id": "kp-38", + "sourceClass": "production_source_census", + "owner": "team_provisioning_policy", + "executionUnitIds": ["unit-primary-provisioning"], + "providerBindings": [ + { + "providerId": "anthropic", + "backendFamily": "provisioning_cli_primary", + "versionScope": "canonical-base-c72fd2018-source-observed", + "targetDisposition": "forbidden" + } + ], + "credentialExposureSetIds": ["ces-host-only-denied"], + "secretClass": "ambient_unknown_may_contain_secret", + "childVisibility": "absent_current_and_target", + "redactionRule": "record prohibited namespace prefixes only; never enumerate or record matching inherited values", + "claimStatus": "source_observed_not_target_verified", + "platformScope": "cross_platform_or_provider_conditional", + "semanticRole": "removed_child_key" + } + ] +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json b/docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json new file mode 100644 index 0000000000..71ff4673b6 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json @@ -0,0 +1,80 @@ +{ + "$schema": "./schemas/estimate-input.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W2.ESTIMATE", + "phaseStartSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "canonicalBucketId": "EST-LIFECYCLE-RUNTIME", + "canonicalBucketBaseline": { + "lowNetLines": 5000, + "highNetLines": 8000 + }, + "ownerContribution": "W2", + "ranges": { + "productionLines": { + "low": 2500, + "high": 3600 + }, + "testLines": { + "low": 2300, + "high": 3400 + }, + "deletedLines": { + "low": 250, + "high": 500 + }, + "netChangedLines": { + "low": 4300, + "high": 6750 + } + }, + "subcomponents": [ + { + "id": "provider-plan-capabilities", + "packages": ["team-runtime-lanes", "team-runtime-control", "provider capability composition"], + "confidence": "medium" + }, + { + "id": "runtime-ingress-relay-protocol", + "packages": [ + "private runtime routes", + "lane relay", + "command claim/replay", + "OpenCode compatibility adapter" + ], + "confidence": "low" + }, + { + "id": "environment-credential-policy", + "packages": [ + "environment allowlist", + "SecretRef exposure metadata", + "provider conformance fixtures" + ], + "confidence": "low" + } + ], + "w4Reconciliation": { + "sharedCanonicalBucket": "EST-LIFECYCLE-RUNTIME", + "w2Includes": [ + "provider topology", + "runtime ingress protocol", + "lane relay", + "provider environment and credential policy", + "provider conformance fixtures" + ], + "w2ExcludesAsW4Owned": [ + "instance lease", + "workspace guard", + "process anchor", + "native helper build and final-image probes" + ], + "overlapRule": "Provider fixture assertions are W2; reusable OS process/lease/guard primitives and their final-image probes are W4. The controller must replace, not add to, the existing canonical bucket range." + }, + "assumptions": [ + "preserve two backend families", + "use the ADR-30 controller-owned relay", + "remove ambient child-environment inheritance" + ], + "uncertainty": "The W2 contribution can vary by more than 20 percent until relay transport and final-image conformance are probed; controller scope review remains required before freeze.", + "authoredBy": "phase-00-remediation-w2-final-r1-source-review" +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json b/docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json new file mode 100644 index 0000000000..a3a2c57c54 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json @@ -0,0 +1,67 @@ +{ + "$schema": "./schemas/execution-topology.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W2.EXECUTION_TOPOLOGY", + "phaseStartSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "authoredBy": "phase-00-remediation-w2-final-r1-source-review", + "validatedBy": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "claimStatus": "source_observed_not_target_verified", + "records": [ + { + "id": "provider-anthropic", + "providerIdentity": "anthropic", + "backendFamily": "provisioning_cli_primary", + "compatibility": "Claude-compatible native path; OAuth, direct API key, compatible endpoint token, or app-managed API-key helper may supply auth.", + "source": "src/main/services/team/runtime/TeamRuntimeAdapter.ts" + }, + { + "id": "provider-codex", + "providerIdentity": "codex", + "backendFamily": "provisioning_cli_primary", + "compatibility": "Claude-compatible orchestrator entry with codex-native backend selection; ChatGPT account state or API-key/custom-provider launch configuration is resolved before spawn.", + "source": "src/shared/utils/providerBackend.ts" + }, + { + "id": "provider-gemini", + "providerIdentity": "gemini", + "backendFamily": "provisioning_cli_primary", + "compatibility": "Claude-compatible orchestrator entry; api and cli-sdk selection differ in accepted auth evidence and remain assumptions until hosted conformance runs.", + "source": "src/main/services/runtime/geminiRuntimeAuth.ts" + }, + { + "id": "provider-opencode", + "providerIdentity": "opencode", + "backendFamily": "opencode_runtime_adapter", + "compatibility": "Separate bridge commands, per-lane manifests, delivery journals and process evidence; it is not Claude-compatible provider JSON.", + "source": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts" + }, + { + "id": "topology-primary-only", + "mode": "primary_only", + "processUnits": "one provisioning CLI primary process containing all non-OpenCode members", + "ordering": "primary process owns bootstrap and teammate ordering", + "source": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts" + }, + { + "id": "topology-mixed", + "mode": "mixed_opencode_side_lanes", + "processUnits": "one shared provisioning CLI primary unit plus one OpenCode adapter process lane per OpenCode member", + "ordering": "primary reaches its characterized gate before queued side lanes are admitted", + "source": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts" + }, + { + "id": "topology-pure-opencode", + "mode": "pure_opencode|pure_opencode_solo|pure_opencode_member_lanes", + "processUnits": "OpenCode adapter primary, with deterministic per-member side lanes for distinct worktree roots", + "ordering": "adapter-specific ordering; no provisioning CLI primary for pure OpenCode", + "source": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts" + }, + { + "id": "topology-rejection", + "mode": "unsupported_opencode_led_mixed_team", + "processUnits": "none", + "ordering": "planner rejects before execution", + "source": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts" + } + ] +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json b/docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json new file mode 100644 index 0000000000..6cee0099d8 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json @@ -0,0 +1,329 @@ +{ + "$schema": "./schemas/fake-runtime-fixture-matrix.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W2.RUNTIME_SCANNER", + "phaseStartSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "authoredBy": "phase-00-remediation-w2-final-r1-source-review", + "validatedBy": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "claimStatus": "source_observed_not_target_verified", + "records": [ + { + "id": "homogeneous-anthropic", + "case": "homogeneous_anthropic", + "currentFixturePath": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_anthropic.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_anthropic.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "final-image launch/auth/liveness/stop/recovery", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "planner", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.homogeneous_anthropic.positive", + "failingNegativeTestId": "w2.fake-runtime.homogeneous_anthropic.failing-negative", + "positiveObservable": "planner returns primary_only with two Anthropic members", + "failingNegativeObservable": "planner returns mixed_opencode_side_lanes after an OpenCode member is introduced", + "authority": { + "path": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "token": "export function planTeamRuntimeLanes" + } + } + }, + { + "id": "homogeneous-codex", + "case": "homogeneous_codex", + "currentFixturePath": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_codex.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_codex.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "final-image provider conformance", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "planner", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.homogeneous_codex.positive", + "failingNegativeTestId": "w2.fake-runtime.homogeneous_codex.failing-negative", + "positiveObservable": "planner returns primary_only with two Codex members", + "failingNegativeObservable": "planner returns mixed_opencode_side_lanes after an OpenCode member is introduced", + "authority": { + "path": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "token": "export function planTeamRuntimeLanes" + } + } + }, + { + "id": "homogeneous-gemini", + "case": "homogeneous_gemini", + "currentFixturePath": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_gemini.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_gemini.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "both backend variants in final image", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "planner", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.homogeneous_gemini.positive", + "failingNegativeTestId": "w2.fake-runtime.homogeneous_gemini.failing-negative", + "positiveObservable": "planner returns primary_only with two Gemini members", + "failingNegativeObservable": "planner returns mixed_opencode_side_lanes after an OpenCode member is introduced", + "authority": { + "path": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "token": "export function planTeamRuntimeLanes" + } + } + }, + { + "id": "homogeneous-opencode", + "case": "homogeneous_opencode", + "currentFixturePath": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_opencode.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.homogeneous_opencode.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "final-image adapter lifecycle", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "planner", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.homogeneous_opencode.positive", + "failingNegativeTestId": "w2.fake-runtime.homogeneous_opencode.failing-negative", + "positiveObservable": "planner returns pure_opencode with two OpenCode members", + "failingNegativeObservable": "planner rejects an OpenCode-led mixed team with unsupported_opencode_led_mixed_team", + "authority": { + "path": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "token": "export function planTeamRuntimeLanes" + } + } + }, + { + "id": "mixed-provider", + "case": "mixed_provider_team", + "currentFixturePath": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.mixed_provider_team.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.mixed_provider_team.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "credential exposure canaries and process execution", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "planner", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.mixed_provider_team.positive", + "failingNegativeTestId": "w2.fake-runtime.mixed_provider_team.failing-negative", + "positiveObservable": "planner returns mixed_opencode_side_lanes for Codex, Gemini and OpenCode members", + "failingNegativeObservable": "planner rejects the OpenCode-led mixed control with unsupported_opencode_led_mixed_team", + "authority": { + "path": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "token": "export function planTeamRuntimeLanes" + } + } + }, + { + "id": "missing-runtime", + "case": "missing_runtime", + "currentFixturePath": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.missing_runtime.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.missing_runtime.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "hosted readiness remains false before spawn", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "adapter", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.missing_runtime.positive", + "failingNegativeTestId": "w2.fake-runtime.missing_runtime.failing-negative", + "positiveObservable": "adapter prepare returns ok=false and reason=not_installed", + "failingNegativeObservable": "adapter prepare returns ok=true for ready runtime evidence", + "authority": { + "path": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts", + "token": "export class OpenCodeTeamRuntimeAdapter" + } + } + }, + { + "id": "missing-auth", + "case": "missing_auth", + "currentFixturePath": "src/main/services/team/provisioning/TeamProvisioningProviderPreflight.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.missing_auth.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.missing_auth.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "all four hosted provider identities", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "preflight", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.missing_auth.positive", + "failingNegativeTestId": "w2.fake-runtime.missing_auth.failing-negative", + "positiveObservable": "provider preflight returns authenticated=false from provider-specific auth status", + "failingNegativeObservable": "provider preflight returns authenticated=true from provider-specific auth status", + "authority": { + "path": "src/main/services/team/provisioning/TeamProvisioningProviderPreflight.ts", + "token": "export function extractAuthStatusReadiness" + } + } + }, + { + "id": "unsupported-backend", + "case": "unsupported_backend", + "currentFixturePath": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.unsupported_backend.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.unsupported_backend.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "hosted capability readiness rejection", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "planner", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.unsupported_backend.positive", + "failingNegativeTestId": "w2.fake-runtime.unsupported_backend.failing-negative", + "positiveObservable": "planner rejects OpenCode-led mixed providers with unsupported_opencode_led_mixed_team", + "failingNegativeObservable": "planner accepts Anthropic-led OpenCode mixing as mixed_opencode_side_lanes", + "authority": { + "path": "src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts", + "token": "export function planTeamRuntimeLanes" + } + } + }, + { + "id": "malformed-capability", + "case": "malformed_capability_response", + "currentFixturePath": "src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.malformed_capability_response.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.malformed_capability_response.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "final-image capability advertisement remains target-unverified", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "capability_response_parser", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.malformed_capability_response.positive", + "failingNegativeTestId": "w2.fake-runtime.malformed_capability_response.failing-negative", + "positiveObservable": "capability detector parses an actually malformed OpenAPI response, records parse diagnostics, and reports requiredForTeamLaunch.ready=false", + "failingNegativeObservable": "capability detector parses a complete valid OpenAPI control and reports requiredForTeamLaunch.ready=true", + "authority": { + "path": "src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts", + "token": "export async function detectOpenCodeApiCapabilities" + } + } + }, + { + "id": "process-timeout", + "case": "process_timeout", + "currentFixturePath": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.process_timeout.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.process_timeout.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "typed final-image drain evidence", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "adapter", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.process_timeout.positive", + "failingNegativeTestId": "w2.fake-runtime.process_timeout.failing-negative", + "positiveObservable": "adapter launch maps timeout readiness to partial_failure and failed_to_start", + "failingNegativeObservable": "adapter launch maps ready evidence to clean_success and confirmed_alive", + "authority": { + "path": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts", + "token": "export class OpenCodeTeamRuntimeAdapter" + } + } + }, + { + "id": "partial-launch", + "case": "partial_launch", + "currentFixturePath": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.partial_launch.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.partial_launch.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "durable aggregate recovery", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "adapter", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.partial_launch.positive", + "failingNegativeTestId": "w2.fake-runtime.partial_launch.failing-negative", + "positiveObservable": "adapter launch preserves the omitted member as runtime_pending_bootstrap and returns partial_pending", + "failingNegativeObservable": "adapter launch returns clean_success when both members are confirmed_alive", + "authority": { + "path": "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts", + "token": "export class OpenCodeTeamRuntimeAdapter" + } + } + }, + { + "id": "restart-adoption", + "case": "restart_adoption", + "currentFixturePath": "src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.restart_adoption.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.restart_adoption.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "external-lifetime process identity and credential rotation remain final-shape prerequisites", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "recovery", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.restart_adoption.positive", + "failingNegativeTestId": "w2.fake-runtime.restart_adoption.failing-negative", + "positiveObservable": "recovery returns the already-current persisted mixed-lane snapshot by identity", + "failingNegativeObservable": "recovery returns null when persisted identity cannot be adopted and team metadata is absent", + "authority": { + "path": "src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts", + "token": "export async function recoverStaleMixedSecondaryLaunchSnapshotWithPorts" + } + } + }, + { + "id": "opencode-secondary-recovery", + "case": "opencode_secondary_lane_recovery", + "currentFixturePath": "src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts", + "positiveProof": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.opencode_secondary_lane_recovery.positive", + "negativeControl": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts#w2.fake-runtime.opencode_secondary_lane_recovery.failing-negative", + "proofLevel": "fixture_characterized", + "remainingTargetHostGap": "final-image side-lane process and delivery-journal recovery remains target-unverified", + "positiveFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "negativeFixturePath": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "executableProof": { + "runner": "vitest_canonical_runtime_seams_v1", + "seam": "recovery", + "testFile": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts", + "positiveTestId": "w2.fake-runtime.opencode_secondary_lane_recovery.positive", + "failingNegativeTestId": "w2.fake-runtime.opencode_secondary_lane_recovery.failing-negative", + "positiveObservable": "recovery projects active OpenCode lane evidence into a persisted aggregate snapshot", + "failingNegativeObservable": "recovery returns null when active lane runtime evidence cannot be recovered", + "authority": { + "path": "src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts", + "token": "export async function recoverStaleMixedSecondaryLaunchSnapshotWithPorts" + } + } + } + ], + "proofContract": { + "runner": "vitest_canonical_runtime_seams_v1", + "execution": "The scanner launches the focused Vitest file with an exact w2.fake-runtime filter and verifies all 26 case-specific assertions passed.", + "independence": "Each positive and failing-negative test imports and executes a canonical planner, preflight, adapter, or recovery export. The matrix contains no executable truth-table inputs.", + "targetVerification": "target-unverified: deterministic source seam execution does not claim final-image provider, relay, credential, or live-process probes" + } +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json b/docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json new file mode 100644 index 0000000000..3711af76fc --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json @@ -0,0 +1,140 @@ +{ + "$schema": "./schemas/runtime-ingress-inventory.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W2.RUNTIME_INGRESS_INVENTORY", + "phaseStartSha": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca", + "authoredBy": "phase-00-remediation-w2-final-r1-source-review", + "validatedBy": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts", + "claimStatus": "current_source_observed; target_mapping_unimplemented", + "records": [ + { + "id": "runtime-bootstrap-checkin", + "operation": "bootstrap", + "commandKind": "runtime.bootstrap-checkin", + "currentRoute": "/api/teams/:teamName/opencode/runtime/bootstrap-checkin", + "direction": "runtime_to_app", + "caller": "OpenCode agent/MCP compatibility callback", + "currentAuthority": "route teamName plus body runId/memberName/runtimeSessionId; lane is server-resolved, but no route-specific machine credential is checked", + "idempotency": "derived commandId; durable session evidence treats same session as duplicate and rejects a conflicting session", + "bodyIds": ["teamName", "runId", "memberName", "runtimeSessionId"], + "persistedEvidence": [ + "lane manifest", + "opencode-sessions.json", + "launch-state.json", + "runtime run tombstone check" + ], + "targetDisposition": "move_to_lane_scoped_machine_ingress", + "source": "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeCheckin.ts" + }, + { + "id": "runtime-deliver-message", + "operation": "delivery", + "commandKind": "runtime.deliver-message", + "currentRoute": "/api/teams/:teamName/opencode/runtime/deliver-message", + "direction": "runtime_to_app", + "caller": "OpenCode runtime callback", + "currentAuthority": "body chooses source and user/member/cross-team target; current-run tombstone is checked, but canonical sender/target authorization is not credential-derived", + "idempotency": "persisted key plus payload hash; duplicate resumes/verifies destination and conflicting reuse is rejected", + "bodyIds": [ + "teamName", + "runId", + "fromMemberName", + "runtimeSessionId", + "idempotencyKey", + "to" + ], + "persistedEvidence": [ + "lane opencode-delivery-journal.json", + "sent messages/inbox/cross-team outbox destination" + ], + "targetDisposition": "derive_sender_lane_and_reauthorize_target", + "source": "src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts" + }, + { + "id": "runtime-task-event", + "operation": "task", + "commandKind": "runtime.task-event", + "currentRoute": "/api/teams/:teamName/opencode/runtime/task-event", + "direction": "runtime_to_app", + "caller": "OpenCode runtime callback", + "currentAuthority": "body selects member/task/event; lane and current run are checked but task mutation authorization is not credential-derived", + "idempotency": "key is required and included in commandId; underlying attribution upsert is durable, but normalized-intent conflict evidence is not present at this boundary", + "bodyIds": [ + "teamName", + "runId", + "memberName", + "taskId", + "runtimeSessionId", + "idempotencyKey" + ], + "persistedEvidence": ["opencode-task-log-attribution.json", "task-log-change notification"], + "targetDisposition": "lane_scope_and_add_normalized_intent_fingerprint", + "source": "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeCheckin.ts" + }, + { + "id": "runtime-heartbeat", + "operation": "heartbeat", + "commandKind": "runtime.heartbeat", + "currentRoute": "/api/teams/:teamName/opencode/runtime/heartbeat", + "direction": "runtime_to_app", + "caller": "OpenCode runtime callback", + "currentAuthority": "body run/member/session plus server-resolved lane and current-run tombstone check; no lane credential", + "idempotency": "commandId includes observedAt; liveness snapshot write is repeatable, but no persisted monotonic sequence is enforced", + "bodyIds": ["teamName", "runId", "memberName", "runtimeSessionId", "observedAt"], + "persistedEvidence": [ + "launch-state.json lastHeartbeatAt/runtime evidence", + "member-spawn change" + ], + "targetDisposition": "lane_scope_and_require_bounded_sequence_or_freshness", + "source": "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeCheckin.ts" + }, + { + "id": "runtime-permission-answer", + "operation": "permission", + "commandKind": "runtime.permission-answer", + "currentRoute": null, + "direction": "operator_to_runtime_non_network", + "caller": "trusted non-HTTP application compatibility boundary", + "currentAuthority": "no HTTP route is registered; trusted application callers reach runtime control through the process-local compatibility boundary", + "idempotency": "derived from requestId+decision; bridge command ledger may recover the provider effect, but ingress claim is not durably persisted first", + "bodyIds": [ + "teamName", + "runId", + "laneId", + "memberName", + "requestId", + "decision", + "cwd", + "expectedMembers" + ], + "persistedEvidence": [ + "runtime approval/bridge command evidence when available", + "RuntimePermissionAnswered event only if an event sink is configured" + ], + "targetDisposition": "replace_with_runtime_permission_request_and_operator_decision_outbound", + "source": "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimePermissionAnswerBoundary.ts" + } + ], + "trustSurfaceProof": { + "currentNegativeControl": "Four runtime callbacks remain under /api/teams and register beside browser/operator routes; runtime.permission-answer is a non-network domain command with no currentRoute. Source contains no /api/runtime/v1 route or lane credential check.", + "targetBrowserAuthority": "/api/hosted/v1/* uses operator session plus CSRF/Origin and cannot present runtime credentials.", + "targetRuntimeAuthority": "/api/runtime/v1/runs/:runId/* uses only hash-verified lane/run-scoped relay credentials, ignores browser session/CSRF semantics, and cannot invoke operator verbs.", + "targetProviderExposure": "Provider process receives a narrow local relay, never the canonical bearer in argv, env, settings, MCP config or run files.", + "authorityIntersection": [], + "operatorOnlyActions": [ + "provider.management.*", + "team.lifecycle.launch", + "team.lifecycle.stop", + "team.lifecycle.delete", + "permission.decision" + ], + "runtimeOnlyActions": [ + "runtime.bootstrap-checkin", + "runtime.deliver-message", + "runtime.task-event", + "runtime.heartbeat", + "runtime.permission-request" + ], + "verificationStatus": "proposed_mapping_proved_disjoint_by_route_and_credential_rules; implementation remains target-unverified" + } +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json b/docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json new file mode 100644 index 0000000000..3345e91736 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "phase-00-w2-credential-exposure-r1", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "evidenceId", + "phaseStartSha", + "authoredBy", + "validatedBy", + "claimStatus", + "records", + "canonicalOwnership", + "exposureSets", + "targetVerification" + ], + "properties": { + "$schema": { + "const": "./schemas/credential-exposure-matrix.schema.json" + }, + "schemaVersion": { + "const": 1 + }, + "evidenceId": { + "const": "P0.W2.CREDENTIAL_EXPOSURE_MATRIX" + }, + "phaseStartSha": { + "const": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + "authoredBy": { + "const": "phase-00-remediation-w2-final-r1-source-review" + }, + "validatedBy": { + "const": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts" + }, + "claimStatus": { + "const": "source_observed_not_target_verified" + }, + "records": { + "type": "array", + "minItems": 4, + "items": { + "type": "object", + "required": [ + "id", + "executionUnit", + "providerSet", + "currentExposureClasses", + "currentIsolation", + "finding", + "targetRule", + "executionUnitId", + "source", + "probeTest", + "claimStatus", + "exposureSetIds" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "executionUnit": { + "type": "string", + "minLength": 1 + }, + "providerSet": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["anthropic", "codex", "gemini", "opencode"] + } + }, + "currentExposureClasses": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "currentIsolation": { + "type": "string", + "minLength": 1 + }, + "finding": { + "type": "string", + "minLength": 1 + }, + "targetRule": { + "type": "string", + "minLength": 1 + }, + "executionUnitId": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + }, + "probeTest": { + "type": "string", + "minLength": 1 + }, + "claimStatus": { + "const": "source_observed_not_target_verified" + }, + "exposureSetIds": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "ces-ambient-unbounded", + "ces-provider-secret-material", + "ces-target-ingress-relay-secret", + "ces-host-only-denied", + "ces-runtime-metadata" + ] + } + } + }, + "additionalProperties": false + } + }, + "canonicalOwnership": { + "type": "object", + "required": [ + "providerSettingsAndAuth", + "teamLaunchStopDelete", + "runtimeIngress", + "permissionDirection" + ], + "properties": { + "providerSettingsAndAuth": { + "type": "string", + "minLength": 1 + }, + "teamLaunchStopDelete": { + "type": "string", + "minLength": 1 + }, + "runtimeIngress": { + "type": "string", + "minLength": 1 + }, + "permissionDirection": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "exposureSets": { + "type": "array", + "minItems": 5, + "items": { + "type": "object", + "required": [ + "id", + "executionUnitIds", + "secretClass", + "currentStatus", + "targetStatus", + "memberKeyEvidenceIds", + "provenanceArtifact", + "probeTest" + ], + "properties": { + "id": { + "enum": [ + "ces-ambient-unbounded", + "ces-provider-secret-material", + "ces-target-ingress-relay-secret", + "ces-host-only-denied", + "ces-runtime-metadata" + ] + }, + "executionUnitIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "unit-primary-provisioning", + "unit-opencode-bridge", + "unit-runtime-relay-target" + ] + } + }, + "secretClass": { + "enum": [ + "ambient_unknown_may_contain_secret", + "runtime_ingress_bearer", + "provider_credential_or_reference", + "host_only_or_injection_sensitive", + "non_secret_configuration" + ] + }, + "currentStatus": { + "type": "string", + "minLength": 1 + }, + "targetStatus": { + "type": "string", + "minLength": 1 + }, + "memberKeyEvidenceIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "provenanceArtifact": { + "const": "docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json" + }, + "probeTest": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "targetVerification": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json b/docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json new file mode 100644 index 0000000000..4f553b3f56 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json @@ -0,0 +1,458 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "phase-00-w2-environment-provenance-r1", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "evidenceId", + "phaseStartSha", + "authoredBy", + "validatedBy", + "claimStatus", + "records", + "sourceDiscovery", + "perKeyModel", + "keyPolicyProfiles", + "keyEvidence" + ], + "properties": { + "$schema": { + "const": "./schemas/environment-provenance.schema.json" + }, + "schemaVersion": { + "const": 1 + }, + "evidenceId": { + "const": "P0.W2.ENVIRONMENT_PROVENANCE" + }, + "phaseStartSha": { + "const": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + "authoredBy": { + "const": "phase-00-w2-targeted-a1-source-review" + }, + "validatedBy": { + "const": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts" + }, + "claimStatus": { + "const": "source_observed_not_target_verified" + }, + "records": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "keys", + "provenance", + "providers", + "classification", + "targetRule", + "source", + "sourceToken", + "discoveryDisposition" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "keys": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "provenance": { + "type": "string", + "minLength": 1 + }, + "providers": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["anthropic", "codex", "gemini", "opencode"] + } + }, + "classification": { + "enum": ["required", "optional", "forbidden"] + }, + "targetRule": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + }, + "sourceToken": { + "type": "string", + "minLength": 1 + }, + "discoveryDisposition": { + "enum": [ + "source_discovered", + "open_ended_wildcard", + "target_only_prohibition", + "fixture_bound" + ] + }, + "keyBindings": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "required": ["key", "path"], + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "currentBehavior": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "sourceDiscovery": { + "type": "object", + "required": ["strategy", "roots", "excludedSegments", "extensions", "extractionVersion"], + "properties": { + "strategy": { + "const": "repository_production_source_census" + }, + "roots": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "excludedSegments": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "extensions": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "enum": [".ts", ".tsx"] + } + }, + "extractionVersion": { + "const": 3 + } + }, + "additionalProperties": false + }, + "keyEvidence": { + "type": "object", + "required": ["fields", "probePaths", "rows"], + "properties": { + "fields": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "enum": ["key", "groupId", "policyProfileId", "probePathId"] + } + }, + "probePaths": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "path"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "rows": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "prefixItems": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "string", + "minLength": 1 + }, + { + "type": "string", + "minLength": 1 + }, + { + "type": "string", + "minLength": 1 + } + ], + "items": false + } + } + }, + "additionalProperties": false + }, + "perKeyModel": { + "type": "object", + "required": ["strategy", "executionUnitModelVersion", "targetVerification", "resolution"], + "properties": { + "strategy": { + "const": "normalized_one_row_per_exact_key_or_explicit_wildcard" + }, + "executionUnitModelVersion": { + "const": "adr-18-adr-30-r3-source-executed-routing-semantics" + }, + "targetVerification": { + "type": "string", + "minLength": 1 + }, + "resolution": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "keyPolicyProfiles": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "sourceClass", + "owner", + "executionUnitIds", + "providerBindings", + "credentialExposureSetIds", + "secretClass", + "childVisibility", + "redactionRule", + "claimStatus", + "platformScope", + "semanticRole" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "sourceClass": { + "enum": [ + "production_source_census", + "checked_fixture_binding", + "ambient_open_ended_inheritance", + "target_contract_prohibition" + ] + }, + "owner": { + "enum": [ + "host_environment_policy", + "controller_lane_relay", + "provider_runtime_policy", + "team_provisioning_policy", + "opencode_runtime_adapter", + "desktop_host_policy", + "process_execution_unit_policy" + ] + }, + "executionUnitIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "unit-primary-provisioning", + "unit-opencode-bridge", + "unit-runtime-relay-target" + ] + } + }, + "providerBindings": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "required": ["providerId", "backendFamily", "versionScope", "targetDisposition"], + "properties": { + "providerId": { + "enum": ["anthropic", "codex", "gemini", "opencode"] + }, + "backendFamily": { + "enum": ["provisioning_cli_primary", "opencode_runtime_adapter"] + }, + "versionScope": { + "const": "canonical-base-c72fd2018-source-observed" + }, + "targetDisposition": { + "enum": ["required", "optional", "forbidden"] + }, + "runtimeBackend": { + "enum": [ + "anthropic_default", + "anthropic_bedrock", + "anthropic_vertex", + "anthropic_foundry", + "anthropic_claude_platform_aws", + "codex_configured", + "gemini_configured" + ] + }, + "emissionDisposition": { + "enum": [ + "emitted_always", + "emitted_when_backend_selected", + "emitted_configured_backend", + "preserved_when_custom_configuration", + "removed_before_spawn" + ] + } + }, + "additionalProperties": false + } + }, + "providerlessProhibition": { + "type": "object", + "required": ["scope", "targetDisposition", "reason"], + "properties": { + "scope": { + "const": "all_provider_execution_units" + }, + "targetDisposition": { + "const": "forbidden" + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "credentialExposureSetIds": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "ces-ambient-unbounded", + "ces-provider-secret-material", + "ces-target-ingress-relay-secret", + "ces-host-only-denied", + "ces-runtime-metadata" + ] + } + }, + "secretClass": { + "enum": [ + "ambient_unknown_may_contain_secret", + "runtime_ingress_bearer", + "provider_credential_or_reference", + "non_secret_configuration" + ] + }, + "childVisibility": { + "enum": [ + "target_relay_only_provider_forbidden", + "absent_current_and_target", + "currently_possible_target_forbidden", + "provider_child_visible_when_selected" + ] + }, + "redactionRule": { + "type": "string", + "minLength": 1 + }, + "claimStatus": { + "const": "source_observed_not_target_verified" + }, + "platformScope": { + "enum": [ + "windows_only", + "posix_primary", + "cross_platform_or_provider_conditional", + "target_platform_independent" + ] + }, + "semanticRole": { + "enum": [ + "ambient_inheritance", + "selected_child_input", + "emitted_child_key", + "removed_child_key", + "host_policy_input", + "host_policy_input_and_emitted_child_key", + "target_relay_secret" + ] + } + }, + "oneOf": [ + { + "properties": { + "providerBindings": { + "minItems": 1 + } + }, + "not": { + "required": ["providerlessProhibition"] + } + }, + { + "required": ["providerlessProhibition"], + "properties": { + "sourceClass": { + "const": "target_contract_prohibition" + }, + "providerBindings": { + "maxItems": 0 + } + } + } + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json b/docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json new file mode 100644 index 0000000000..b08cd72bc4 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json @@ -0,0 +1,198 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "phase-00-w2-estimate-input-r1", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "evidenceId", + "phaseStartSha", + "canonicalBucketId", + "canonicalBucketBaseline", + "ownerContribution", + "ranges", + "subcomponents", + "w4Reconciliation", + "assumptions", + "uncertainty", + "authoredBy" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "evidenceId": { + "const": "P0.W2.ESTIMATE" + }, + "phaseStartSha": { + "const": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + "canonicalBucketId": { + "const": "EST-LIFECYCLE-RUNTIME" + }, + "ownerContribution": { + "const": "W2" + }, + "canonicalBucketBaseline": { + "type": "object", + "required": ["lowNetLines", "highNetLines"], + "properties": { + "lowNetLines": { + "type": "integer", + "minimum": 0 + }, + "highNetLines": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "ranges": { + "type": "object", + "required": ["productionLines", "testLines", "deletedLines", "netChangedLines"], + "properties": { + "productionLines": { + "type": "object", + "required": ["low", "high"], + "properties": { + "low": { + "type": "integer", + "minimum": 0 + }, + "high": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "testLines": { + "type": "object", + "required": ["low", "high"], + "properties": { + "low": { + "type": "integer", + "minimum": 0 + }, + "high": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "deletedLines": { + "type": "object", + "required": ["low", "high"], + "properties": { + "low": { + "type": "integer", + "minimum": 0 + }, + "high": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "netChangedLines": { + "type": "object", + "required": ["low", "high"], + "properties": { + "low": { + "type": "integer", + "minimum": 0 + }, + "high": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "subcomponents": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "packages", "confidence"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "packages": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "confidence": { + "enum": ["low", "medium", "high"] + } + }, + "additionalProperties": false + } + }, + "w4Reconciliation": { + "type": "object", + "required": ["sharedCanonicalBucket", "w2Includes", "w2ExcludesAsW4Owned", "overlapRule"], + "properties": { + "sharedCanonicalBucket": { + "const": "EST-LIFECYCLE-RUNTIME" + }, + "w2Includes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "w2ExcludesAsW4Owned": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "overlapRule": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "assumptions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "uncertainty": { + "type": "string", + "minLength": 1 + }, + "authoredBy": { + "const": "phase-00-remediation-w2-final-r1-source-review" + }, + "$schema": { + "const": "./schemas/estimate-input.schema.json" + } + }, + "additionalProperties": false +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json b/docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json new file mode 100644 index 0000000000..4d5deef26b --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "phase-00-w2-execution-topology-r1", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "evidenceId", + "phaseStartSha", + "authoredBy", + "validatedBy", + "claimStatus", + "records" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "evidenceId": { + "const": "P0.W2.EXECUTION_TOPOLOGY" + }, + "phaseStartSha": { + "const": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + "authoredBy": { + "const": "phase-00-remediation-w2-final-r1-source-review" + }, + "validatedBy": { + "const": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts" + }, + "claimStatus": { + "enum": ["source_observed_not_target_verified"] + }, + "records": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "required": ["id", "source"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + }, + "providerIdentity": { + "enum": ["anthropic", "codex", "gemini", "opencode"] + }, + "backendFamily": { + "enum": ["provisioning_cli_primary", "opencode_runtime_adapter"] + }, + "compatibility": { + "type": "string", + "minLength": 1 + }, + "mode": { + "enum": [ + "primary_only", + "mixed_opencode_side_lanes", + "pure_opencode|pure_opencode_solo|pure_opencode_member_lanes", + "unsupported_opencode_led_mixed_team" + ] + }, + "processUnits": { + "type": "string", + "minLength": 1 + }, + "ordering": { + "type": "string", + "minLength": 1 + } + }, + "anyOf": [ + { + "required": ["providerIdentity", "backendFamily", "compatibility"] + }, + { + "required": ["mode", "processUnits", "ordering"] + } + ], + "additionalProperties": false + } + }, + "$schema": { + "const": "./schemas/execution-topology.schema.json" + } + }, + "additionalProperties": false +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json b/docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json new file mode 100644 index 0000000000..1bc360f6a8 --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "phase-00-w2-fake-runtime-matrix-r1", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "evidenceId", + "phaseStartSha", + "authoredBy", + "validatedBy", + "claimStatus", + "records", + "proofContract" + ], + "properties": { + "$schema": { + "const": "./schemas/fake-runtime-fixture-matrix.schema.json" + }, + "schemaVersion": { + "const": 1 + }, + "evidenceId": { + "const": "P0.W2.RUNTIME_SCANNER" + }, + "phaseStartSha": { + "const": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + "authoredBy": { + "const": "phase-00-remediation-w2-final-r1-source-review" + }, + "validatedBy": { + "const": "scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts" + }, + "claimStatus": { + "const": "source_observed_not_target_verified" + }, + "records": { + "type": "array", + "minItems": 13, + "maxItems": 13, + "items": { + "type": "object", + "required": [ + "id", + "case", + "currentFixturePath", + "positiveFixturePath", + "negativeFixturePath", + "positiveProof", + "negativeControl", + "proofLevel", + "remainingTargetHostGap", + "executableProof" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "enum": [ + "homogeneous_anthropic", + "homogeneous_codex", + "homogeneous_gemini", + "homogeneous_opencode", + "mixed_provider_team", + "missing_runtime", + "missing_auth", + "unsupported_backend", + "malformed_capability_response", + "process_timeout", + "partial_launch", + "restart_adoption", + "opencode_secondary_lane_recovery" + ] + }, + "currentFixturePath": { + "type": "string", + "minLength": 1 + }, + "positiveFixturePath": { + "type": "string", + "minLength": 1 + }, + "negativeFixturePath": { + "type": "string", + "minLength": 1 + }, + "positiveProof": { + "type": "string", + "minLength": 1 + }, + "negativeControl": { + "type": "string", + "minLength": 1 + }, + "proofLevel": { + "enum": ["fixture_characterized", "source_observed", "explicit_gap"] + }, + "remainingTargetHostGap": { + "type": "string", + "minLength": 1 + }, + "executableProof": { + "type": "object", + "required": [ + "runner", + "seam", + "testFile", + "positiveTestId", + "failingNegativeTestId", + "positiveObservable", + "failingNegativeObservable", + "authority" + ], + "properties": { + "runner": { + "const": "vitest_canonical_runtime_seams_v1" + }, + "seam": { + "enum": [ + "planner", + "preflight", + "adapter", + "capability_response_parser", + "recovery" + ] + }, + "testFile": { + "const": "test/architecture/hosted-web/phase-0/provider-runtime/fake-runtime-seams.test.ts" + }, + "positiveTestId": { + "type": "string", + "pattern": "^w2\\.fake-runtime\\.[a-z0-9_]+\\.positive$" + }, + "failingNegativeTestId": { + "type": "string", + "pattern": "^w2\\.fake-runtime\\.[a-z0-9_]+\\.failing-negative$" + }, + "positiveObservable": { + "type": "string", + "minLength": 1 + }, + "failingNegativeObservable": { + "type": "string", + "minLength": 1 + }, + "authority": { + "type": "object", + "required": ["path", "token"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "token": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "proofContract": { + "type": "object", + "required": ["runner", "execution", "independence", "targetVerification"], + "properties": { + "runner": { + "const": "vitest_canonical_runtime_seams_v1" + }, + "execution": { + "type": "string", + "minLength": 1 + }, + "independence": { + "type": "string", + "minLength": 1 + }, + "targetVerification": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json b/docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json new file mode 100644 index 0000000000..2c15acd56b --- /dev/null +++ b/docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json @@ -0,0 +1,183 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "phase-00-w2-runtime-ingress-r1", + "type": "object", + "required": [ + "$schema", + "schemaVersion", + "evidenceId", + "phaseStartSha", + "authoredBy", + "validatedBy", + "claimStatus", + "records", + "trustSurfaceProof" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "evidenceId": { + "const": "P0.W2.RUNTIME_INGRESS_INVENTORY" + }, + "phaseStartSha": { + "const": "c72fd201867b9bcd1ef77d5e0f95ba379adb4fca" + }, + "authoredBy": { + "const": "phase-00-remediation-w2-final-r1-source-review" + }, + "validatedBy": { + "type": "string", + "minLength": 1 + }, + "claimStatus": { + "type": "string", + "minLength": 1 + }, + "records": { + "type": "array", + "minItems": 5, + "items": { + "type": "object", + "required": [ + "id", + "operation", + "commandKind", + "currentRoute", + "direction", + "caller", + "currentAuthority", + "idempotency", + "bodyIds", + "persistedEvidence", + "targetDisposition", + "source" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "operation": { + "enum": ["bootstrap", "delivery", "task", "heartbeat", "permission"] + }, + "commandKind": { + "type": "string", + "minLength": 1 + }, + "currentRoute": { + "type": ["string", "null"], + "minLength": 1 + }, + "direction": { + "type": "string", + "minLength": 1 + }, + "caller": { + "type": "string", + "minLength": 1 + }, + "currentAuthority": { + "type": "string", + "minLength": 1 + }, + "idempotency": { + "type": "string", + "minLength": 1 + }, + "bodyIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "persistedEvidence": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "targetDisposition": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "trustSurfaceProof": { + "type": "object", + "required": [ + "currentNegativeControl", + "targetBrowserAuthority", + "targetRuntimeAuthority", + "targetProviderExposure", + "authorityIntersection", + "operatorOnlyActions", + "runtimeOnlyActions", + "verificationStatus" + ], + "properties": { + "authorityIntersection": { + "type": "array", + "maxItems": 0, + "items": { + "type": "string" + } + }, + "operatorOnlyActions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "runtimeOnlyActions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "currentNegativeControl": { + "type": "string", + "minLength": 1 + }, + "targetBrowserAuthority": { + "type": "string", + "minLength": 1 + }, + "targetRuntimeAuthority": { + "type": "string", + "minLength": 1 + }, + "targetProviderExposure": { + "type": "string", + "minLength": 1 + }, + "verificationStatus": { + "const": "proposed_mapping_proved_disjoint_by_route_and_credential_rules; implementation remains target-unverified" + } + }, + "additionalProperties": false + }, + "$schema": { + "const": "./schemas/runtime-ingress-inventory.schema.json" + } + }, + "additionalProperties": false +} diff --git a/docs/research/hosted-web/phase-0/reciprocal-review-w4-w6.md b/docs/research/hosted-web/phase-0/reciprocal-review-w4-w6.md new file mode 100644 index 0000000000..77e00f1f1b --- /dev/null +++ b/docs/research/hosted-web/phase-0/reciprocal-review-w4-w6.md @@ -0,0 +1,169 @@ +# Phase 0 reciprocal review: W4 / W6 + +- Review ID: `P0.R46.RECIPROCAL_REVIEW` +- Packet revision: `phase-00-r2` +- Phase start SHA: `a32f509e6d9bd31ba2135940e336729bf90c3d93` +- Producer worktrees: `phase-00-w4-lease-guard-process-v1`, + `phase-00-w6-auth-proxy-artifacts-v1` +- Review status: `rejected_pending_correction` + +## Dispositions + +| Producer | Disposition | Reason | +| ---------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W4 | **Reject for adoption** | The lane correctly stays `characterized`, remains in scope, and has useful current-host probes, but its descendant signaling contradicts ADR-31's no-numeric-PID fallback and its cleanup/FD-closure evidence overclaims what the harness proves. | +| W6 | **Reject for adoption** | The artifact gaps are usefully characterized, but the auth model restores mutation admission after logout/family revocation on restart, and reset is not bound to W4 typed drain evidence. | +| W4/W6 pair | **Reject paired integration** | The proposed artifact scan does not inventory the three required W4 binaries/manifests or prove their final init/seccomp/image placement, and auth reset can proceed without a W4 `drained` outcome. | + +The unavailable final-shape topology is not itself a producer defect: both handoffs correctly use +`characterized`, not `verified`. It remains a mandatory post-correction target-host gate. + +## Actionable findings + +### R46-01 — critical — W6 restart resurrects revoked authority + +- Evidence: `scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs:71-75` +- Contradicted claims: `docs/research/hosted-web/phase-0/auth-artifacts/report.md:9-13` and + `docs/research/hosted-web/phase-0/auth-artifacts/evidence.json:22-31` +- Affected evidence/decisions: `P0.W6.AUTH_TRANSITIONS`, ADR-7. +- Finding: `restart` sets `mutationAdmission` from keyring readiness and the mere presence of + `device.familyRef`. It does not require an active, unrevoked session or an unrevoked device. A focused + reviewer reproducer observed `authority_reloaded` and `mutationAdmission=true` after session logout, + `forget_device`, and replay-family revocation. +- Required correction: separate auth-storage readiness from request mutation admission. Recompute + admission only from a valid unrevoked session; keep expired/logged-out sessions closed until device + renewal and keep revoked/reset families closed across restart. Add restart schedules after expiry, + logout, forget-device, replay-family revoke, and every reset stage. + +### R46-02 — high — W6 reset is not fenced by W4 drain truth + +- Evidence: `scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs:128-145` and + `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:88-110` +- Cross-lane evidence: `scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c:323-329` +- Affected evidence/decisions: `P0.W6.AUTH_TRANSITIONS`, `P0.W4.PROCESS_ANCHOR_SPIKE`, ADR-7, ADR-31. +- Finding: reset accepts a caller-set integer `runtimeCount=0`; it carries no anchor nonce/generation, + reconciliation identity, or typed `drained` proof and cannot distinguish W4's + `unclassified_residual`. This violates the ADR-7 requirement that reset issue no plaintext challenge + until all live/unclassified runtimes are resolved. +- Required correction: model a generation-bound W4 reconciliation result and permit reset advancement + only on a current typed `drained` outcome. `unclassified_residual`, missing evidence, stale nonce, or + anchor failure must keep admission/challenge issuance closed and require whole-container replacement. + +### R46-03 — high — W4 signals descendants through reusable numeric PIDs + +- Evidence: `scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c:129-224` +- Contradicted claim: `docs/research/hosted-web/phase-0/host-primitives/process-anchor-spike.md:8-9` +- Affected evidence/decisions: `P0.W4.PROCESS_ANCHOR_SPIKE`, ADR-31. +- Finding: only the main child is signaled through a pidfd. Descendants are discovered from `/proc`, + then later passed to raw `kill(pid, signal)`. Exit/PID reuse between inspection and signal can target + an unrelated process; the unrelated `sleep` canary does not exercise reuse. ADR-31 explicitly rejects + PID/start-token recheck followed by `kill`/`killpg` as non-atomic. +- Required correction: target every signal through a kernel-stable ownership primitive. Prefer the + live anchor's allocated process group for in-group children and pidfds for any individually targeted + descendant; treat an escaped/ambiguous tree as unclassified and use the container boundary. Add the + required rapid PID/PGID reuse negative schedule before claiming zero unrelated signals. + +### R46-04 — high — W4 cleanup evidence is emitted before cleanup and hard-coded + +- Evidence: `scripts/hosted-web/phase-0/host-primitives/run-native-probes.py:571-593` +- Overstated record: `docs/research/hosted-web/phase-0/host-primitives/current-host-probe-results.json:2-5` +- Affected evidence: all executable W4 spike records. +- Finding: the runner serializes `markerRemoved: true` while the marker still exists, hard-codes + `ownedResidualProcesses: 0`, and only afterward calls `shutil.rmtree(..., ignore_errors=True)`. A + failed removal or untracked residual cannot fail the run, so this is not marker-owned cleanup proof. +- Required correction: track every spawned PID/PGID, perform and verify cleanup before emitting success, + fail on removal errors, verify the marker path is absent and no owned process remains, and generate + the checked-in result from that verified output rather than manually projecting success fields. + +### R46-05 — medium — W4 close-all proof has a fixed descriptor ceiling + +- Evidence: `scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c:150-153` + and `scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c:79-81,255-258` +- Affected evidence/decisions: `P0.W4.WORKSPACE_GUARD_SPIKE`, `P0.W4.PROCESS_ANCHOR_SPIKE`, ADR-28, + ADR-31. +- Finding: the fixtures close only FDs 3 through 1023 while the evidence says all non-stdio or + undeclared descriptors are closed. A lease/control descriptor duplicated above 1023 would survive. +- Required correction: use an admitted `close_range`/`/proc/self/fd` implementation that preserves an + explicit FD map, and add a negative fixture with lease/control canaries above 1023. + +### R46-06 — high — W6's artifact gate cannot prove the W4 final image contract + +- Evidence: `scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs:239-323` +- Cross-lane requirements: `docs/research/hosted-web/phase-0/host-primitives/native-artifact-proposal.md:5-19` + and `docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md:29-40` +- Affected evidence/decisions: `P0.W6.ARTIFACT_INVENTORY`, `P0.W6.ABI_STUB_REPORT`, all W4 evidence, + ADR-16, ADR-17, ADR-28, ADR-31. +- Finding: the scan inventories the server CJS graph, internal-storage worker, generic stubs, and + terminal markers, but has no required rows for the instance-lock launcher, workspace guard, process + anchor, their protocol/build hashes, init ordering, stripped/no-compiler image placement, or their + final UID/seccomp probe results. W4 consequently has no concrete W6 artifact topology to verify. +- Required correction: extend the proposed hosted artifact manifest and negative scanner with all three + W4 artifacts/manifests, launcher-before-Node/init ordering, final-image syscall/readiness probes, and + absence of compiler/source artifacts. W4 must rerun marker-owned probes against that exact image. + +### R46-07 — medium — W6 ABI/native smoke claims are not reproducible from owned code + +- Evidence: `docs/research/hosted-web/phase-0/auth-artifacts/evidence.json:177-240` +- Missing coverage: `scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs:239-323` and + `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:178-205` +- Affected evidence: `P0.W6.ABI_STUB_REPORT`. +- Finding: the evidence records Node/Electron ABIs and two SQLite smoke-query results, but no owned + scanner/test emits or checks those facts and the handoff contains no exact smoke command. The current + verifier validates schema and artifact-scan freshness only. +- Required correction: add deterministic ABI/package-resolution and native-load probes with exact + commands and assertions. Keep Electron/final-image loads explicitly unverified until run in those + runtimes. + +### R46-08 — low — producer `git diff --check` records did not cover untracked output + +- Evidence: every W4 and W6 owned file is untracked, while both handoffs record only + `git diff --check`. +- Affected evidence: both self-review handoffs. +- Finding: plain `git diff --check` returned success without inspecting an untracked file. The reviewer + separately ran `git diff --no-index --check /dev/null ` for every owned file and found zero + whitespace errors, so this is a provenance defect rather than a current whitespace defect. +- Required correction: record a diff-check that covers every untracked path, or run the standard check + after the integration owner has materialized the candidate diff. + +## Scope, secret, and evidence audit + +- W4: 15 changed paths, 1,916 lines. All paths are within the W4 lane surfaces or its local handoff. + The handoff correctly requests split adoption because this exceeds the 1,500-line ceiling. +- W6: 9 changed paths, 1,431 lines. All paths are within the W6 lane surfaces or its local handoff. +- Neither producer changed production source, package manifests, lockfiles, Docker entrypoints, another + lane, or Phase 1 code. +- A reviewer scan across every owned file found no private-key marker, bearer/token prefix, private + home path, `.claude` path, or real-project path. The fixtures use only fresh `/tmp`/test-owned data. +- Both producer handoffs use the correct base SHA, packet revision, evidence IDs, and + `status=characterized`. Their final-topology claims remain explicitly unverified. + +## Reviewer checks + +No broad CI was run and neither producer worktree was modified. + +| Check | Result | +| ---------------------------------------------------- | ------------------------------------------------------- | +| W4 native probe runner | pass; current host remained `finalShapeContainer=false` | +| W4 two owned Vitest files | pass, 3 tests | +| W4 evidence scanner and JSON parse | pass | +| W4 targeted ESLint, no cache | pass | +| W6 owned Vitest file | pass, 17 tests | +| W6 evidence/schema/freshness verifier and JSON parse | pass | +| W6 targeted ESLint, no cache | pass | +| All untracked files, independent no-index diff-check | pass, zero whitespace errors | +| Reviewer auth revocation/restart reproducer | fail as expected; reproduced R46-01 in all three cases | +| Reviewer secret/path scan over all owned files | pass, no findings | + +The producer Vitest commands were executed with this writable review worktree's equivalent root config +because Vite could not create `.vite-temp` in the read-only producer worktrees. Test discovery and test +working directories remained the producer worktrees. Direct producer `lint:fast:files` likewise could +not write its cache, so the same target files were linted with the same fast config and `--no-cache`. + +## Correction order + +1. Fix R46-01 and R46-02 before relying on W6 auth evidence. +2. Fix R46-03 and R46-04 before relying on W4 process/cleanup safety claims. +3. Reconcile the concrete W4 artifact/topology manifest in W6 (R46-06). +4. Fix R46-05, R46-07, and handoff diff-check provenance. +5. Rerun targeted checks, repeat reciprocal review, then run the corrected probes in the admitted + final-shape topology. Do not start Phase 1 from the current dispositions. diff --git a/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2-remediation-v3.md b/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2-remediation-v3.md new file mode 100644 index 0000000000..5e11b4c75d --- /dev/null +++ b/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2-remediation-v3.md @@ -0,0 +1,193 @@ +# Phase 0 remediation review: W1 and W2 + +## Decision + +- Review ID: `P0.R12R.W1_W2.V3` +- Review timestamp: `2026-07-11T19:25:00Z` +- Producer base: `0e8431b1935c71a2e77bea1384b134ee25c8aa12` +- W1: **remediate; do not adopt as a lane** +- W2: **remediate; do not adopt as a lane** +- Selective approval: nine files are approved below. No other producer file is approved. +- Integration, broad/final gates, Phase 1 work, terminal implementation, and producer writes were not + performed. + +The registry has no terminal result for either remediation producer. Both progress files still say +`running`, but PIDs `1314177` and `1321607` were absent at review time and their heartbeats stopped at +`2026-07-11T18:58:08Z` and `2026-07-11T18:56:38Z`. This review therefore freezes and assesses the dirty +worktree outputs directly; it does not infer completion from the stale registry state. + +## Exact approved files + +These files are semantically reviewed and formatting-clean. The controller may selectively adopt only +these paths, subject to its normal integration policy: + +1. W1 `docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md` +2. W2 `docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json` +3. W2 `docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json` +4. W2 `docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json` +5. W2 `docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json` +6. W2 `docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json` +7. W2 `docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json` +8. W2 `docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json` +9. W2 `docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json` + +The W2 estimate is approved as an input, not as a frozen controller estimate. It correctly contributes +`4.3k-6.75k` net lines to `EST-LIFECYCLE-RUNTIME`, excludes W4 primitives, and retains the required +greater-than-20-percent scope-review warning. + +## Every dirty producer filename + +There are 31 dirty files: 13 in W1 and 18 in W2. All are untracked; neither producer has a tracked +diff. + +### W1 + +| Path | Decision | Reason | +| ------------------------------------------------------------------------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `.codex-handoff/phase-00-w1.json` | remediate | Overclaims complete visible-control coverage and 11 W1 files are not formatting-clean. | +| `docs/research/hosted-web/phase-0/parity-renderer/README.md` | remediate | Describes the four-file scan as exhaustive even though immediate child controls are omitted. | +| `docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json` | hold | The 109-member count and ownership repair are useful, but this minified generated output is coupled to the rejected scanner package. | +| `docs/research/hosted-web/phase-0/parity-renderer/estimate-input.json` | hold | Arithmetic and variance escalation are corrected, but the file references the rejected action/scanner proof and fails formatting. | +| `docs/research/hosted-web/phase-0/parity-renderer/legacy-bypass-inventory.json` | hold | Reproducible compact/hash treatment is corrected, but the generated file fails formatting and remains coupled to the scanner package. | +| `docs/research/hosted-web/phase-0/parity-renderer/renderer-action-inventory.json` | reject | Covers only four source files and 106 sites, not the visible team composition/child-control closure. | +| `docs/research/hosted-web/phase-0/parity-renderer/schemas/api-parity-ledger.schema.json` | hold | No independent defect found; hold with its unapproved generated artifact and formatting remediation. | +| `docs/research/hosted-web/phase-0/parity-renderer/schemas/estimate-input.schema.json` | hold | No independent defect found; hold with its unapproved generated artifact and formatting remediation. | +| `docs/research/hosted-web/phase-0/parity-renderer/schemas/legacy-bypass-inventory.schema.json` | hold | No independent defect found; hold with its unapproved generated artifact and formatting remediation. | +| `docs/research/hosted-web/phase-0/parity-renderer/schemas/renderer-action-inventory.schema.json` | reject | Schema acceptance cannot make the incomplete source-file closure exhaustive. | +| `docs/research/hosted-web/phase-0/parity-renderer/selection-reconciliation-invariants.md` | adopt | Explicit selection/snapshot/tombstone/pagination/event-poll invariants and proof gaps; formatting passes. | +| `scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts` | reject | Hard-codes four control files; therefore its passing repository gate does not prove every visible team control. | +| `test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts` | reject | Seven tests pass, but the semantic missing/duplicate tests use a synthetic three-site fixture and never challenge omitted child controls. | + +### W2 + +| Path | Decision | Reason | +| --------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.codex-handoff/phase-00-w2.json` | reject | Stale: declares 14 paths while 18 exist, names a nonexistent envelope schema, omits five actual schemas, and reports the old three-test result. | +| `docs/research/hosted-web/phase-0/provider-runtime/README.md` | remediate | Claims all environment keys are removal-sensitive and the provider matrix is corrected; both claims are disproved below. | +| `docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json` | adopt | Source-observed execution-unit exposure and browser/runtime/provider ownership are explicit and cross-lane consistent. | +| `docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json` | reject | Eight explicit keys can be removed without the completeness validator detecting the omission. | +| `docs/research/hosted-web/phase-0/provider-runtime/estimate-input.json` | adopt | Canonical bucket, arithmetic, W4 exclusion, uncertainty, and replacement-not-addition rule are explicit. | +| `docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json` | adopt | Separates four provider identities from two backend families and keeps claims source-observed. | +| `docs/research/hosted-web/phase-0/provider-runtime/fake-runtime-fixture-matrix.json` | reject | Three required cases have `positiveProof: missing` and `negativeControl: missing`; this is an honest gap ledger, not the required complete matrix. | +| `docs/research/hosted-web/phase-0/provider-runtime/runtime-ingress-inventory.json` | adopt | Five operations have the required nested fields; the proposed operator/runtime authority sets are disjoint and target-unverified status is explicit. | +| `docs/research/hosted-web/phase-0/provider-runtime/schemas/credential-exposure-matrix.schema.json` | adopt | Requires the exposure and canonical-ownership fields used by the approved artifact. | +| `docs/research/hosted-web/phase-0/provider-runtime/schemas/environment-provenance.schema.json` | reject | Shape validation does not repair source-discovery completeness. | +| `docs/research/hosted-web/phase-0/provider-runtime/schemas/estimate-input.schema.json` | adopt | Requires the canonical W2 contribution and W4 reconciliation structure. | +| `docs/research/hosted-web/phase-0/provider-runtime/schemas/execution-topology.schema.json` | adopt | Enforces the provider/topology alternative and pinned source-observed envelope. | +| `docs/research/hosted-web/phase-0/provider-runtime/schemas/fake-runtime-fixture-matrix.schema.json` | reject | Explicitly permits `gap_only` rows whose positive and negative proofs are both missing. | +| `docs/research/hosted-web/phase-0/provider-runtime/schemas/runtime-ingress-inventory.schema.json` | adopt | Requires all operation field families and the disjoint-authority proof fields. | +| `scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts` | reject | Completeness removal check misses eight explicit environment keys and accepts three missing-proof matrix rows; it also fails formatting. | +| `test/architecture/hosted-web/phase-0/provider-runtime/fixtures/surfaces-negative.json` | hold | Useful route negative fixture, but not sufficient for the rejected environment/matrix acceptance claims. | +| `test/architecture/hosted-web/phase-0/provider-runtime/fixtures/surfaces-positive.json` | hold | Useful route surface fixture, but not a positive provider-runtime matrix. | +| `test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts` | reject | Eight tests pass but omit removal tests for every classified key and treat missing provider proofs as acceptable. | + +## Findings requiring correction + +### `R12R-W1-001` — high — visible-control closure remains incomplete + +`scan-api-and-actions.ts:11-17` limits semantic scanning to `TeamListView`, `TeamDetailView`, +`CreateTeamDialog`, and `RuntimeProviderManagementPanelView`. The packet's required read/acceptance +surface includes the team views and their child controls. `TeamListView` directly imports +`TeamListFilterPopover`; independently running the producer's `scanControls` on that child found five +interaction sites, none present in `renderer-action-inventory.json`. The same composition roots import +many other interactive dialogs, kanban, member, message, schedule, and sidebar children. + +Required correction: derive or explicitly declare the reachable visible team-control closure, list +every included/excluded child surface, map every site to exactly one semantic action or deliberate +absence, and add a repository negative fixture that fails when an immediate child control is omitted. + +This leaves `R12-W1-001` and `P0.REQ.W1.003` unresolved. Stable semantic IDs, stop/provider ownership, +estimate arithmetic, and external raw-bypass treatment are otherwise materially corrected. + +### `R12R-W1-002` — medium — W1 output is not adoption-formatted + +`prettier --check` fails the W1 handoff, four generated evidence JSON files, four schemas, scanner, and +test (11 files). The two Markdown evidence files pass. + +Required correction: format only the W1-owned files, regenerate/check hashes as needed, and rerun the +focused scanner/test. Do not broaden formatting. + +### `R12R-W2-001` — high — environment omission detection is still incomplete + +A mutation check removed each classified key in turn and called +`validateEnvironmentCompleteness`. Removing any of these eight keys produced no error: + +- `NODE_ENV` +- `AWS_PROFILE` +- `AWS_REGION` +- `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` +- `CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES` +- `CODEX_API_KEY` +- `CLAUDE_TEAM_APP_INSTANCE_ID` +- `AGENT_TEAMS_MCP_CLAUDE_DIR` + +The scanner discovers many source literals, but the remaining row-level `sourceToken` check proves +only that one token per row exists. It does not make every listed key removal-sensitive. + +Required correction: make every explicit classified key source-discovered or bind it to a checked +source occurrence; add a looped negative test over all non-wildcard keys. This leaves `R12-W2-001` and +`P0.REQ.W2.003` unresolved. + +### `R12R-W2-002` — high — required positive/negative provider cases remain missing + +`malformed_capability_response`, `restart_adoption`, and `opencode_secondary_lane_recovery` are +`gap_only` rows with both proofs set to `missing`. The scanner intentionally accepts that state. It is +good gap reporting, but it is not the complete positive/negative fake-runtime provider matrix required +by `P0.REQ.W2.006`. + +Required correction: add focused deterministic positive and failing-negative fixtures for all three +cases, or keep W2 explicitly failed/blocked rather than claiming the requirement satisfied. + +### `R12R-W2-003` — high — structured handoff does not describe the dirty output + +The handoff declares 14 changed paths while Git reports 18. It lists the nonexistent +`schemas/evidence-envelope.schema.json`, omits five actual artifact schemas, reports three tests rather +than the current eight, and contains the original pre-remediation review state. + +Required correction: regenerate the handoff from the final dirty snapshot, enumerate all 18 paths, +record resolution status per original finding and requirement, and include the actual narrow results. + +### `R12R-W2-004` — low — scanner formatting + +Only `scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts` fails the W2 formatting +check. Format that file only and rerun lint/test/scanner. + +## Filename and semantic overlap audit + +- Exact relative-path collisions: none. +- Duplicate basenames: `README.md`, `estimate-input.json`, and `estimate-input.schema.json`. Each is + contained in its lane-owned directory; there is no write collision. +- Script/test ownership: W1 uses `parity-renderer/**`; W2 uses `provider-runtime/**`. No duplicate + script, fixture, or test path exists. +- `team.lifecycle.stop`: W1 assigns it to `team-lifecycle`; W2 includes it only in the operator-only + authority set. Consistent, no duplicated implementation ownership. +- Provider controls: W1 owns renderer action semantics under `provider.management.*`; W2 owns + execution topology, child environment, credential exposure, and runtime ingress. The split is + consistent, though W1's UI reachability proof is incomplete. +- Credentials: W1's renderer action treats credential entry as provider management; W2 prohibits + runtime ingress from operator decisions and keeps canonical runtime bearer material out of provider + exposure. Consistent. +- Estimates: W1 contributes `EST-CONTRACTS`, `EST-RENDERER-LIFECYCLE`, and + `EST-REMAINING-PARITY`; W2 contributes only `EST-LIFECYCLE-RUNTIME` and excludes W4 primitives. + There is no bucket collision or detected double count. + +## Narrow verification record + +| Check | Result | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Producer `git status --porcelain=v1 -uall` | W1 13 untracked files; W2 18 untracked files; no tracked diffs. | +| Registry progress, process, and result check | Both progress files stale at `running`; both PIDs absent; neither latest-result exists. | +| W1 scanner in an isolated `/tmp` copy | Pass: 109 API members, 106 catalogued control sites, raw bypass hash reproduced; generated JSON hashes match the producer snapshot. | +| W1 Vitest | Pass: 1 file, 7 tests. | +| W2 scanner | Pass under its current acceptance rules: 4 providers, 2 backend families, 5 operations, 13 rows. | +| W2 Vitest | Pass: 1 file, 8 tests. | +| Focused ESLint | Pass for both scanners and both test files with `--no-cache`. | +| JSON parse | All producer JSON outputs parsed. | +| `git diff --check` plus per-untracked-file no-index checks | No whitespace diagnostics. | +| Prettier | W1 fails 11 files; W2 fails the scanner only. | +| W1 omitted-child probe | `TeamListFilterPopover.tsx` has 5 scanner-visible sites but is absent from the four-file catalog. | +| W2 all-key removal probe | 8 classified keys can be removed without a completeness error. | +| Bounded secret/real-project-path scan | No private-key, bearer/token-shaped value, or prohibited real-project path match. | + +Broad CI, typecheck, build, standalone/runtime smoke, final-image/provider execution, and terminal tests +were deliberately not run. diff --git a/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md b/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md new file mode 100644 index 0000000000..341ed512db --- /dev/null +++ b/docs/research/hosted-web/phase-0/reciprocal-reviews/w1-w2.md @@ -0,0 +1,205 @@ +# Phase 0 reciprocal review: W1 / W2 + +- Review ID: `P0.R12.W1_W2` +- Packet revision: `phase-00-r2` +- Phase start SHA: `a32f509e6d9bd31ba2135940e336729bf90c3d93` +- Producer worktrees: `phase-00-w1-parity-renderer-v1`, `phase-00-w2-provider-runtime-v1` +- Result: `failed` +- W1 disposition: **reject** +- W2 disposition: **reject** + +The review did not modify either producer. Both producers are based on the recorded phase start, the +canonical base is an ancestor, and W1's `src/shared/types/api.ts` is byte-identical to the canonical +base. Both producer diffs stay inside their lane-owned paths plus their worktree-local handoffs. No +secret, raw auth payload, private provider payload, or real-project path was found by the review scan. + +## Artifact dispositions + +| Owner | Artifact or owned file group | Disposition | Reason | +| ----- | ---------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W1 | `P0.W1.API_PARITY_LEDGER` | reject | The AST count is reproducible, but method-name heuristics assign incorrect feature/action ownership and phases. | +| W1 | `P0.W1.RENDERER_ACTIONS` | reject | The generator maps JSX sites, including event-containment handlers and multiple parts of one widget, rather than exact visible semantic actions. | +| W1 | `P0.W1.LEGACY_BYPASSES` | reject | The source observations are useful, but the 7,095-line raw projection must be compacted/hashed with the other oversized generated ledgers before adoption. | +| W1 | `P0.W1.SELECTION_INVARIANTS` | approve | Selection, snapshot, tombstone, pagination, and event/poll race requirements are explicit and proof gaps are labeled. | +| W1 | `P0.W1.SCANNER`, schemas, fixtures, and test | reject | The passing structural fixture does not detect semantic duplicate actions, false actions, ownership errors, or unstable IDs. | +| W1 | `P0.W1.ESTIMATE` | reject | Net arithmetic contradicts the claimed baseline-range/variance result. | +| W1 | README, generator, and handoff | reject | They inherit the action, estimate, and adoption-size defects below. | +| W2 | `P0.W2.EXECUTION_TOPOLOGY` | approve | It explicitly separates four provider identities from two backend families and labels the result source-observed. | +| W2 | `P0.W2.RUNTIME_INGRESS_INVENTORY` | reject | The rows are useful, but required nested fields are not schema-enforced or covered by the negative fixture. | +| W2 | `P0.W2.ENVIRONMENT_PROVENANCE` | reject | Explicit environment inputs are omitted and the scanner cannot discover an omitted key. | +| W2 | `P0.W2.CREDENTIAL_EXPOSURE_MATRIX` | reject | The characterization is plausible, but its generic envelope validates only record IDs and `generatedBy` names a scanner that does not generate the file. | +| W2 | `P0.W2.RUNTIME_SCANNER`, schemas, fixtures, and test | reject | Validation is shallow and the only negative fixture removes/duplicates a route. | +| W2 | `P0.W2.ESTIMATE` | reject | It invents three non-canonical bucket IDs instead of contributing to `EST-LIFECYCLE-RUNTIME`. | +| W2 | fake-runtime matrix, README, and handoff | reject | The hosted test matrix is incomplete and the handoff's plain `git diff --check` did not inspect untracked lane files. | + +## Required findings + +### `R12-W1-001` — high — generated JSX rows are not an exact action inventory + +Evidence: + +- `scripts/hosted-web/phase-0/parity-renderer/generate-evidence.ts:296` defaults every unrecognized + path to `team-lifecycle`; lines 344-365 classify replacement/security from handler-name regexes; + lines 368-420 manufacture an action ID for each discovered JSX site. +- `renderer-action-inventory.json:1477` turns `event.stopPropagation()` into a destructive + `team-lifecycle` action. Lines 1512-1540 classify an API-key input in + `runtime-provider-management` as `team-lifecycle` / `teamRead/teamLifecycle`. +- `scan-api-and-actions.ts:431-449` proves only one inventory row per AST site. It does not prove one + stable semantic action per visible control or reject several AST sites that represent one widget. + +Required correction: replace heuristic row generation with a reviewed semantic action catalog keyed +by stable action ID and owning feature. Keep AST sites as references to that catalog. Add fixtures for +event-containment handlers (no action), one Select widget spanning trigger/items/change (one semantic +action), provider credential controls, and semantic missing/duplicate actions. + +Affected evidence/decisions: `P0.W1.RENDERER_ACTIONS`, `P0.W1.SCANNER`, ADR-19, ADR-20. + +### `R12-W1-002` — high — action IDs are not stable + +Evidence: `scan-api-and-actions.ts:217-226` includes the source line in `sourceControlKey`, and +`generate-evidence.ts:373-375` hashes that key into `stableId`. Inserting an unrelated line changes the +purported stable ID and disconnects review/estimate history. + +Required correction: assign explicit semantic IDs independent of source location; store line/hash only +as refreshable source evidence. Add a fixture that inserts blank/unrelated lines and proves IDs do not +change. + +Affected evidence/decisions: `P0.W1.RENDERER_ACTIONS`, `P0.W1.SCANNER`, ADR-19. + +### `R12-X-001` — high — W1 ownership conflicts with W2 direction and the canonical ownership matrix + +Evidence: + +- `api-parity-ledger.json:1957-1982` assigns `TeamsAPI.stop` to `member-lifecycle`, action + `member.lifecycle.stop`, Phase 9. +- The master ownership matrix at `docs/hosted-web-e2e-completion-plan.md:871` assigns prepare, launch, + stop, delete, restore, and recovery to `team-lifecycle`. +- W2 correctly treats launch/stop/delete as operator actions in `runtime-ingress-inventory.json:80-85` + and the ADR-14 contract at `docs/hosted-web-e2e-completion-plan.md:2354-2359`. +- W1's provider-management rows at `renderer-action-inventory.json:1512-1540` use the + `teamRead/teamLifecycle` facet, while W2 models provider availability/auth and credential exposure + as separate provider/runtime concerns. + +Required correction: reconcile W1 and W2 to canonical feature/action terminology before adoption. +At minimum map team stop to a `team.lifecycle.*` action, keep runtime ingress machine-only, and map +provider settings/auth controls to their provider-management capability rather than team lifecycle. + +Affected evidence/decisions: `P0.W1.API_PARITY_LEDGER`, `P0.W1.RENDERER_ACTIONS`, +`P0.W2.EXECUTION_TOPOLOGY`, `P0.W2.CREDENTIAL_EXPOSURE_MATRIX`, ADR-14, ADR-19, ADR-20. + +### `R12-W1-003` — high — estimate variance is arithmetically misreported + +Evidence: in `estimate-input.json:45-78`, renderer lifecycle nets to 2.1k-3.2k lines +(`production + test - deleted`), not the 3.0k-5.0k controller range. Lines 81-118 net remaining parity +to 2.8k-4.2k, not 4.0k-6.5k. Nevertheless lines 121-125 set +`uniqueBucketOverTwentyPercent: false` and claim both are within the baseline ranges. + +Required correction: recompute low/high net totals deterministically, set the variance result from the +canonical bucket comparison, and send both greater-than-20-percent changes to controller scope review. + +Affected evidence/decisions: `P0.W1.ESTIMATE`, Phase 0 estimate freeze and fail-closed condition. + +### `R12-W1-004` — high — W1 is not adoptable within the evidence budget + +Evidence: W1 has 21 untracked owned files totaling 48,661 lines. The three raw ledgers alone contain +46,262 lines. The packet requires a 400-800-line inventory/scanner target, explicit approval above +1,500 lines, and summary/hash treatment for large raw artifacts +(`docs/hosted-web-phase-0-execution-packet.md:575-580`). No size exception appears in the handoff. + +Required correction: retain the deterministic generator, compact review indexes/counts and hashes in +Git, store large raw output outside Git, or obtain an explicit controller-approved split before +adoption. + +Affected evidence/decisions: all W1 adoption artifacts; integration step 0D.1. + +### `R12-W2-001` — high — environment completeness is not proved + +Evidence: + +- `scan-runtime-surfaces.ts:162-174` iterates only keys already present in the evidence and checks one + source token per row; it never discovers source environment keys. An omitted key therefore passes. +- The source uses child/runtime environment inputs absent from the explicit ledger, including + `AGENT_STUDIO_RUNNER_CACHE_ROOT`, `STUDIO_AGENT_CACHE_ROOT`, `LOCALAPPDATA`, and `ComSpec` at + `src/main/services/runtime/agentChildProcessPreflight.ts:57-75`, plus `USERNAME` at + `TeamProvisioningEnvBuilder.ts:214-218`. A bounded review extraction found 10 explicit source keys + absent from the ledger's explicit list. + +Required correction: make the scanner discover reads/writes/deletes from every declared child-env +construction surface, compare the discovered set with the ledger, and add a negative fixture that +removes one explicit key/classification. + +Affected evidence/decisions: `P0.W2.ENVIRONMENT_PROVENANCE`, `P0.W2.RUNTIME_SCANNER`, ADR-18. + +### `R12-W2-002` — high — schemas and negative fixtures do not enforce acceptance fields + +Evidence: `schemas/evidence-envelope.schema.json:5-12` requires only envelope fields and `id` on each +record. `scan-runtime-surfaces.ts:106-130` validates only top-level rules and does not recurse into +record item schemas. The test at `scan-runtime-surfaces.test.ts:30-36` exercises only a missing and +duplicate route. Removing `direction`, `currentAuthority`, `idempotency`, `bodyIds`, or +`persistedEvidence` from an ingress row is not rejected by schema validation. + +Required correction: add artifact-specific schemas with required nested fields/enums and use a real +draft-2020-12 validator (or complete equivalent). Add deliberate negative fixtures for every required +operation-field family, environment classification, credential exposure row, and estimate bucket. + +Affected evidence/decisions: all W2 machine-readable evidence, `P0.W2.RUNTIME_SCANNER`, ADR-14, +ADR-18. + +### `R12-W2-003` — high — estimate IDs cannot be reconciled with the controller ledger + +Evidence: `estimate-input.json:8`, lines 20 and 32 use +`provider-runtime-plan-and-capabilities`, `runtime-ingress-relay-and-protocol`, and +`environment-and-credential-policy`. The controller's unique ledger assigns W2/W4 jointly to +`EST-LIFECYCLE-RUNTIME` (`docs/research/hosted-web/phase-0/estimate-ledger.md:18`). The W2 handoff +repeats the non-canonical IDs at lines 101-104. + +Required correction: contribute low/high production/test/deleted ranges to +`EST-LIFECYCLE-RUNTIME`, state the exact W4 overlap/exclusion, and preserve detail as packages or +subcomponents rather than new top-level bucket IDs. + +Affected evidence/decisions: `P0.W2.ESTIMATE`, Phase 0 estimate freeze. + +### `R12-W2-004` — medium — fake-runtime coverage matrix is incomplete + +Evidence: `fake-runtime-fixture-matrix.json:42-47` collapses all future hosted cases into one gap row +and omits explicit rows for unsupported backend and malformed capability response, both required by +`docs/hosted-web-e2e-completion-plan.md:4677-4691`. + +Required correction: add one deterministic row per provider/test-matrix case with current fixture +path, positive proof, negative control, proof level, and remaining target-host gap. + +Affected evidence/decisions: W2 fake-runtime fixture matrix, ADR-18, hosted provider test gate. + +### `R12-W2-005` — medium — producer diff-check evidence was vacuous + +Evidence: all 14 W2 files are untracked, while `.codex-handoff/phase-00-w2.json:76-79` records only +plain `git diff --check`. That command checks no untracked content. The reciprocal review separately +ran `git diff --no-index --check /dev/null ` for each file and found no whitespace diagnostics, +so this is an evidence-quality defect rather than a current whitespace defect. + +Required correction: stage in the controller-owned adoption attempt before `git diff --check`, or +record a per-untracked-file no-index check as W1 did. + +Affected evidence/decisions: W2 handoff/self-review gate. + +## Verification record + +- Scope: both producer statuses contain only lane-owned untracked paths and their local handoffs. +- Base: both producer `HEAD`s equal the phase start; canonical base ancestry passed; W1 API source is + byte-identical to the canonical base. +- W1 scanner: passed with 86/20/3 API members, 993 JSX sites, zero reported dynamic sites. +- W1 targeted test: 6/6 passed using an equivalent read-only-safe Vitest config with the repository's + 15-second timeout. The first 5-second minimal-config run timed out only on the 5.8-second repository + scan, then passed at the repository timeout. +- W2 scanner: passed its current checks with four providers, two backend families, five operations, + and six planner outcomes. +- W2 targeted test: 3/3 passed using the same read-only-safe Vitest config. +- Lint: both changed TypeScript sets passed ESLint with the repository fast config and `--no-cache`. +- Diff check: tracked diff checks were clean; every untracked producer file was independently checked + with no-index and had no whitespace diagnostics. +- Secrets: no candidate private key, bearer, API-key value, raw auth/provider payload, or real-project + path was found in either owned output set. +- Broad CI was not run, as required by the reciprocal-review scope. + +The owners must correct or contest these findings before 0D adopts W1/W2. No Phase 1 work is +authorized by this review. diff --git a/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5-current-canonical.md b/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5-current-canonical.md new file mode 100644 index 0000000000..25db39b18c --- /dev/null +++ b/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5-current-canonical.md @@ -0,0 +1,132 @@ +# Phase 0 W3/W5 current canonical reciprocal compatibility review + +- Review time: `2026-07-11T21:05:00Z` +- Canonical review HEAD / adopted W5 commit: `ffaecae3fc70a42df1ac49c65469f84515ea5ed8` +- Adopted W3 commit: `7f23e7b628b09e8fbed71c914af5e665f14dab25` +- W3 ancestry: `7f23e7b...` is an ancestor of `ffaecae3...` +- Review scope: the already adopted W3 and W5 outputs at canonical HEAD; no producer output was + regenerated or edited +- Disposition: **reciprocally compatible within the Phase 0 r3 evidence-only boundary** + +This is the current canonical review of the adopted pair. The older +[`w3-w5.md`](./w3-w5.md) remains the historical remediation review and is not the disposition for the +adopted W5 commit. + +## Phase 0 r3 narrowing + +The current lane artifacts retain their `phase-00-r2` provenance. This review applies the controller's +Phase 0 r3 narrowing without rewriting those adopted artifacts: + +- W3 proves only that `better-sqlite3#backup` is feasible with an active WAL on the exercised current + Linux Node ABI and that the production `TeamBackupService` has been fault-characterized as a + `legacy_unverified safety copy`. +- W3 does **not** prove or provide hosted backup, hosted restore, a deployment recovery point, coordinated + recovery, final packaged Electron/container ABI compatibility, production worker wiring, writer drain, + watcher-watermark closure, immutable recovery-point publication, or credential/keyring preservation. +- W5's recovery model does not promote W3's feasibility or characterization into a hosted capability. + All 101 W5 effects remain fail closed: 50 are `operator_required_until_transaction_exists` and 51 are + `operator_required`. +- W3 and W5 estimates remain overlapping decompositions of `EST-RECOVERY-STATE`; they must not be added. + +Accordingly, compatibility here means that W5 consumes W3's writer truth without contradiction or +capability inflation. It is not a Phase 1 admission, production recovery claim, or hosted backup signoff. + +## Reciprocal findings + +### W5 against W3 + +Pass. W5 explicitly binds +`docs/research/hosted-web/phase-0/state-writers/writer-coordination.json` as its integrated W3 authority. +Every one of the 101 generated effects references `P0.W3.WRITER_COORDINATION` (50 through the future +`sqlite.mutate` seam and 51 through the general writer authority), and no effect has +`automaticRecoveryAdmitted=true`. Current uncoordinated task, inbox, provider, process, Git, review and +runtime effects therefore remain operator-gated. + +The W5 focused suite also passes its actual fresh-process proof: seven tests, including 52 two-process +effect-recovery schedules, the independent 114-member source census and omission controls, schema checks, +snapshot schedules and immutable fingerprint oracles. + +### W3 against W5 + +Pass under r3 narrowing. No W3-owned output changed between the W3 adoption commit and canonical HEAD. +The current production backup service classification remains `legacy_unverified safety copy`; all 12 +fault-matrix rows have `recoveryPointSafe=false`. The WAL spike remains `executable-spike` evidence with +an explicit limitation that it is not final packaged Electron/container proof. + +W3's estimate says W5 owns the shared workflow once. W5 uses the same `EST-RECOVERY-STATE` bucket and +explicitly says not to sum W3's shared transaction/storage fixtures twice. There is no reciprocal estimate +contradiction. + +## Commands and results + +All commands were read-only with respect to repository content. Executable fixtures used only +marker-owned operating-system temporary directories. The review worktree intentionally had no local +`node_modules`; W3 commands used the dependency-materialized canonical integration checkout's dependency +tree while executing this worktree's sources. W5's ESM TypeScript census was run in that clean integration +checkout, whose HEAD was independently verified as the same canonical SHA. + +| Command | Exit | Result | +| --- | ---: | --- | +| `git rev-parse HEAD` | 0 | `ffaecae3fc70a42df1ac49c65469f84515ea5ed8` | +| `git merge-base --is-ancestor 7f23e7b628b09e8fbed71c914af5e665f14dab25 ffaecae3fc70a42df1ac49c65469f84515ea5ed8` | 0 | W3 is an ancestor of W5/current HEAD | +| `git diff --quiet 7f23e7b628b09e8fbed71c914af5e665f14dab25 ffaecae3fc70a42df1ac49c65469f84515ea5ed8 -- .codex-handoff/phase-00-w3.json docs/research/hosted-web/phase-0/state-writers scripts/hosted-web/phase-0/state-writers test/architecture/hosted-web/phase-0/state-writers` | 0 | no adopted W3 output changed after W3 adoption | +| `NODE_PATH="$DEPS" node --import "$DEPS/tsx/dist/loader.mjs" test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs` | 0 | 7 tests passed; TB-01 through TB-12 characterized | +| `NODE_PATH="$DEPS" node scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs` | 0 | WAL active; 2,000 independently reopened rows; `integrity_check=ok` | +| `NODE_PATH="$DEPS" node scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs` | 0 | `lostExternalUpdate=true` | +| `NODE_PATH="$DEPS" node scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs` | 0 | 6 evidence files, 17 families and 12 operations verified | +| `NODE_PATH="$DEPS" node --test test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs` | 0 | focused W3 architecture test passed | +| `(cd /var/data/agent-teams-hosted-web-refactor/worktrees/integration-hosted-web-feature-boundaries && git status --short && git rev-parse HEAD && node scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs --check)` | 0 | clean checkout at `ffaecae3...`; 9 W5 evidence files verified fresh | +| `(cd /var/data/agent-teams-hosted-web-refactor/worktrees/integration-hosted-web-feature-boundaries && node test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs)` | 0 | 7 tests passed, including 52 two-process recovery schedules and omission controls | +| read-only compatibility assertion below | 0 | 101/101 W5 effects bind W3 authority; 0 admit automatic recovery; legacy and estimate boundaries preserved | + +For the W3 commands, the exact dependency variable was: + +```bash +DEPS=/var/data/agent-teams-hosted-web-refactor/worktrees/integration-hosted-web-feature-boundaries/node_modules +``` + +The read-only compatibility assertion was: + +```bash +node - <<'NODE' +const fs = require('fs') +const read = (path) => JSON.parse(fs.readFileSync(path, 'utf8')) +const w3Handoff = read('.codex-handoff/phase-00-w3.json') +const w5Handoff = read('.codex-handoff/phase-00-w5.json') +const backup = read('docs/research/hosted-web/phase-0/state-writers/backup-behavior.json') +const wal = read('docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json') +const w3Estimate = read('docs/research/hosted-web/phase-0/state-writers/estimate-input.json') +const effects = read('docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json').effects +const w5Estimate = read('docs/research/hosted-web/phase-0/recovery-events/estimate-input.json') +const assert = (condition, message) => { if (!condition) throw new Error(message) } +assert(w3Handoff.status === 'ready_for_focused_re_review', 'unexpected W3 handoff state') +assert(w5Handoff.status === 'remediated_pending_reciprocal_review', 'unexpected W5 handoff state') +assert(w5Handoff.integratedW3Authority?.path === 'docs/research/hosted-web/phase-0/state-writers/writer-coordination.json', 'W5 does not bind adopted W3 writer authority') +assert(w5Handoff.integratedW3Authority?.preserved === true, 'W5 does not preserve W3 authority') +assert(effects.length === 101, 'unexpected W5 effect count') +assert(effects.every((effect) => effect.automaticRecoveryAdmitted === false), 'W5 promotes an effect beyond W3 proof') +assert(effects.every((effect) => effect.writerEvidenceRef.startsWith('P0.W3.WRITER_COORDINATION')), 'W5 effect lacks W3 writer evidence reference') +assert(backup.currentService?.classification === 'legacy_unverified safety copy', 'legacy backup overclaimed') +assert(backup.faultMatrix.every((row) => row.recoveryPointSafe === false), 'legacy backup fault row overclaims recovery safety') +assert(wal.proofLevel === 'executable-spike', 'WAL proof level changed') +assert(wal.limitations.some((item) => item.includes('not the final packaged Electron ABI or final container image')), 'WAL final-artifact limitation missing') +assert(w3Estimate.parentBucketFit.includes('W5 owns shared'), 'W3 estimate overlap rule missing') +assert(w5Estimate.bucketId === 'EST-RECOVERY-STATE', 'W5 estimate bucket changed') +assert(w5Estimate.overlap.some((item) => item.includes('do not sum')), 'W5 estimate deduplication rule missing') +console.log(JSON.stringify({ w5Effects: effects.length, automaticRecoveryAdmitted: 0, compatible: true })) +NODE +``` + +Initial direct invocations without the dependency path failed with `ERR_MODULE_NOT_FOUND` for `tsx`, +`better-sqlite3` and ESM `typescript`. Those were environment-only attempts before the successful commands +above; they did not contradict the evidence and did not modify the worktree. + +## Compatibility disposition + +**Accept the adopted W3/W5 pair as current Phase 0 reciprocal evidence.** W5 closes the historical +fresh-process, source-census, handoff and whitespace findings while preserving W3's fail-closed writer +authority. W3 remains limited to current-ABI WAL Online Backup feasibility plus legacy service +characterization. No hosted backup or recovery capability may be inferred from this acceptance. + +No W3/W5 lane evidence, handoff, controller ledger, Phase 0 index, decision/estimate ledger, Phase 1 +packet, production source, terminal implementation or other project was changed by this review. diff --git a/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md b/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md new file mode 100644 index 0000000000..bc197e42b7 --- /dev/null +++ b/docs/research/hosted-web/phase-0/reciprocal-reviews/w3-w5.md @@ -0,0 +1,272 @@ +# Phase 0 W3/W5 remediation reciprocal review + +- Packet revision: `phase-00-r2` +- Phase start: `a32f509e6d9bd31ba2135940e336729bf90c3d93` +- Remediation review base: `0e8431b1935c71a2e77bea1384b134ee25c8aa12` +- W3 producer: `phase-00-remediation-w3-v1` +- W5 producer: `phase-00-remediation-w5-v1` +- Review scope: preserved producer worktrees, registry/job evidence, every dirty output, and narrow checks only +- Overall decision: **adopt W3; remediate W5 before lane adoption** + +This review did not edit either producer. W3 has a completed registry result and 16 entirely untracked +outputs. W5 has no latest-result record; its progress record still says `running` at +`2026-07-11T18:57:06.034Z`, but PID `1326592` is dead. The 17 preserved dirty W5 outputs were reviewed +directly rather than discarded because the producer result is missing. + +## Exact decisions + +| Lane | Decision | Approved files | Reason | +| ---- | ------------- | -------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W3 | **adopt** | 16 of 16 | `RW35-001` is closed by a marker-owned production `TeamBackupService` fault suite; `RW35-006` is closed by a clean no-index check over every addition. | +| W5 | **remediate** | 4 of 17 | Writer/effect fail-closed reconciliation and fingerprint coverage improved, but the effect crash runner still recovers inline, the census has no independent completeness/omission gate, the handoff is stale, and two files fail the no-index whitespace check. | + +### W3 approved files + +Adopt exactly these 16 files together: + +1. `.codex-handoff/phase-00-w3.json` +2. `docs/research/hosted-web/phase-0/state-writers/backup-behavior.json` +3. `docs/research/hosted-web/phase-0/state-writers/estimate-input.json` +4. `docs/research/hosted-web/phase-0/state-writers/evidence.schema.json` +5. `docs/research/hosted-web/phase-0/state-writers/index.md` +6. `docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json` +7. `docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json` +8. `docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md` +9. `docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json` +10. `docs/research/hosted-web/phase-0/state-writers/writer-coordination.json` +11. `scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs` +12. `scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs` +13. `scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs` +14. `test/architecture/hosted-web/phase-0/state-writers/fixtures/invalid-state-family-catalog.json` +15. `test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs` +16. `test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs` + +The new fault suite constructs production `TeamBackupService` with marker-owned temporary provider and +app roots. Seven tests assert twelve cases: readiness/enumeration/identity, copy and enumeration errors, +stale and retention pruning, split manifest/registry publication, shutdown error swallowing, corrupt +registry rebuild, and missing/corrupt/partial/mtime restore. The evidence continues to classify the +service as `legacy_unverified`; it does not promote independent file copies to a recovery point. + +### W5 files approved as useful, self-contained evidence + +Only these four files are approved from the preserved W5 output: + +1. `docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json` +2. `docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json` +3. `docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json` +4. `docs/research/hosted-web/phase-0/recovery-events/estimate-input.json` + +The event inventory and estimate are semantically unchanged from the previously accepted records. The +snapshot artifact now covers 288 mutation/crash pairs across both accepted algorithms and re-enters a +fresh snapshot attempt after each modeled crash. The fingerprint artifact adds a non-empty ordered +array, omitted-versus-materialized defaults, a changed fingerprint version, retained v1 recomputation, +and same-intent/changed-intent comparison outcomes. + +Approval here means these evidence records are usable at their declared proof level. It does not approve +the W5 lane, its generator, or its handoff, and it does not authorize integration before the controller +decides whether to adopt partial evidence from a remediating lane. + +### W5 files held for remediation + +Do not adopt these 13 files yet: + +1. `.codex-handoff/phase-00-w5.json` +2. `docs/research/hosted-web/phase-0/recovery-events/README.md` +3. `docs/research/hosted-web/phase-0/recovery-events/command-catalog.json` +4. `docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json` +5. `docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json` +6. `docs/research/hosted-web/phase-0/recovery-events/index.json` +7. `docs/research/hosted-web/phase-0/recovery-events/index.schema.json` +8. `docs/research/hosted-web/phase-0/recovery-events/mutation-census.json` +9. `docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json` +10. `scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs` +11. `scripts/hosted-web/phase-0/recovery-events/model.mjs` +12. `test/architecture/hosted-web/phase-0/recovery-events/fixtures/invalid-command-catalog.json` +13. `test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs` + +## Findings + +### R35-R2-001 — effect recovery still continues in the crashed closure + +- Lane/evidence: W5, `P0.W5.EFFECT_RECOVERY_MATRIX` +- Severity: high +- Prior finding: `RW35-002`, partially corrected +- Evidence: + - `scripts/hosted-web/phase-0/recovery-events/model.mjs:362` records a boundary. + - `model.mjs:365-369` snapshots the object, appends crash/restart labels, calls `recover()` in the same + closure, and then returns to the pre-crash execution path. It does not serialize/reload the durable + record or instantiate a fresh recovery runner. + - `test/architecture/hosted-web/phase-0/recovery-events/recovery-events.test.mjs:106-139` checks one + restart counter, non-null snapshots, aggregate duplicate flags, and trace labels. It does not assert + exact expected state/effect/compensation/publication counts for every boundary after a fresh runner. +- Impact: compensation, command-commit and event-publication labels are no longer appended only after all + recovery, but the fixture still cannot prove that ephemeral pre-crash state is discarded. The generic + no-duplicate claim remains weaker than the required durable crash/restart characterization. +- Required remediation: persist a minimal durable record, terminate the attempt at the crash, create a + fresh recovery-machine instance, and assert exact post-restart states and effect/publication counts for + every recovery class and boundary. Keep non-reconcilable response loss at `operator_required`. + +The snapshot half of `RW35-002` is corrected: `runAcceptedSchedule()` throws out the partial snapshot and +executes a second attempt from the durable server journal. That is why the generated snapshot evidence is +approved separately while the shared model/generator remains held. + +### R35-R2-002 — census separation does not prove census completeness + +- Lane/evidence: W5, `P0.W5.COMMAND_CATALOG` and supporting mutation census +- Severity: high +- Prior finding: `RW35-005`, partially corrected +- Evidence: + - `scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs:93-157` defines + `MUTATION_CENSUS` manually inside the same generator that defines the command descriptors. + - `generate-evidence.mjs:213` derives `requiredMutationMethods` from that list; this correctly makes a + deleted command descriptor fail while the list is intact. + - `generate-evidence.mjs:514-530` generates `mutation-census.json` from the same list. Deleting or + forgetting a list row removes both the artifact row and the obligation. + - `recovery-events.test.mjs:160-181` iterates only the supplied census rows. It does not extract the + source interfaces or compare them with a complete mutation/query/ephemeral/deferred disposition set. + - `fixtures/invalid-command-catalog.json` contains its declared `dangerousRetry` mapping. It is an + unsafe-recovery fixture, not the required deliberately omitted-command fixture. +- Impact: the original catalog-to-itself circularity is reduced, but the remediation still cannot fail on + an omitted source mutation or prove the independently censused acceptance requirement. +- Required remediation: consume a reviewed external census or independently extract every relevant member + from pinned `TeamsAPI`, `CrossTeamAPI`, `ReviewAPI`, schedule/terminal deferrals, and runtime-control + ingress; record explicit mutation/query/ephemeral/deferred dispositions; add negative fixtures for an + omitted census row and an omitted descriptor. + +### R35-R2-003 — W5 result and handoff do not describe the preserved remediation + +- Lane/evidence: W5 handoff and registry/job state +- Severity: medium +- Evidence: + - No W5 `latest-result.json` exists. + - The progress record says `running`, but its PID is dead. + - `.codex-handoff/phase-00-w5.json:6-7` still identifies the old phase-start base and `characterized` + status rather than the remediation base/status. + - `.codex-handoff/phase-00-w5.json:40-55` lists 15 paths, while the worktree contains 17 additions; + both mutation-census files are omitted. + - The handoff contains none of the remediation finding resolutions and still records plain + `git diff --check` at line 71. +- Impact: the controller cannot use the handoff as an immutable description of the dirty output. +- Required remediation: regenerate the handoff from current status, include all 17 paths, base/phase-start + distinction, prior-finding resolution status, exact narrow checks, and remaining limitations; then + produce a successful result record. + +### R35-R2-004 — W5 no-index whitespace check fails + +- Lane: W5 +- Severity: low +- Evidence: `git diff --no-index --check /dev/null ` reports a new blank line at EOF in: + - `.codex-handoff/phase-00-w5.json:109` + - `test/architecture/hosted-web/phase-0/recovery-events/fixtures/invalid-command-catalog.json:20` +- Required remediation: remove the extra blank lines and record a no-index or staged check over every + addition. This leaves `RW35-006` open for W5; W3 has closed it. + +## Complete changed-file census and semantic delta + +### W3 — 16 untracked additions + +| Path | Semantic delta from original W3 | Decision | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------- | +| `.codex-handoff/phase-00-w3.json` | Rewritten for remediation, 16-path census, finding resolutions, limits and no-index result. | approve | +| `state-writers/backup-behavior.json` | Replaces source deduction with 12 production-service fault cases while retaining `legacy_unverified`. | approve | +| `state-writers/estimate-input.json` | JSON formatting only; semantic deep comparison equal. | approve | +| `state-writers/evidence.schema.json` | JSON formatting only; semantic deep comparison equal. | approve | +| `state-writers/index.md` | Adds fixture authority/result and fault conclusions. | approve | +| `state-writers/schema-unknown-fields.json` | JSON formatting only; semantic deep comparison equal. | approve | +| `state-writers/sqlite-online-backup-results.json` | JSON formatting only; semantic deep comparison equal. | approve | +| `state-writers/sqlite-online-backup-spike.md` | Byte-identical retained evidence. | approve | +| `state-writers/state-family-catalog.json` | JSON formatting only; semantic deep comparison equal. | approve | +| `state-writers/writer-coordination.json` | JSON formatting only; semantic deep comparison equal. | approve | +| `state-writers/external-writer-negative-fixture.mjs` | Formatting only. | approve | +| `state-writers/sqlite-online-backup-spike.mjs` | Formatting only. | approve | +| `state-writers/verify-evidence.mjs` | Adds fixture ownership/source/count/case/assertion/safety validation. | approve | +| `state-writers/fixtures/invalid-state-family-catalog.json` | JSON formatting only; semantic deep comparison equal. | approve | +| `state-writers/state-writers.test.mjs` | Extends the user-state guard to the production-service fixture; otherwise formatting. | approve | +| `state-writers/team-backup-service-faults.test.mjs` | New marker-owned production-service fault characterization. | approve | + +Paths in the table are abbreviated below their already stated W3 owned roots. + +### W5 — 17 untracked additions + +| Path | Semantic delta from original W5 | Decision | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------- | +| `.codex-handoff/phase-00-w5.json` | Semantically unchanged old handoff; adds an EOF whitespace defect and omits two current files. | remediate | +| `recovery-events/README.md` | Regenerated with new schedule/census/fail-closed claims; coupled to held evidence. | remediate | +| `recovery-events/command-catalog.json` | Changes all current task/inbox/provider automatic recovery to false and adds census claims; completeness remains unproved. | remediate | +| `recovery-events/effect-recovery-matrix.json` | Expands to 52 crash-labeled schedules and 103 W3-linked effects; fresh-run durability remains unproved. | remediate | +| `recovery-events/estimate-input.json` | Semantically unchanged accepted estimate. | approve | +| `recovery-events/event-cursor-inventory.json` | Semantically unchanged accepted inventory. | approve | +| `recovery-events/evidence.schema.json` | Expanded for new schedules/effects/goldens; coupled to held records. | remediate | +| `recovery-events/fingerprint-goldens.json` | Adds fingerprint-version/default/ordered-array/retained-comparison vectors. | approve | +| `recovery-events/index.json` | Adds supporting mutation-census artifact. | remediate | +| `recovery-events/index.schema.json` | Adds supporting-artifact schema. | remediate | +| `recovery-events/mutation-census.json` | New 54-row artifact generated from the generator-local list. | remediate | +| `recovery-events/mutation-census.schema.json` | New schema for the incomplete census proof. | remediate | +| `recovery-events/snapshot-handoff-scheduler.json` | Expands to 288 mutation/crash pairs with second-attempt replay and durable journal snapshots. | approve | +| `recovery-events/generate-evidence.mjs` | Adds W3 fail-closed effects, census generation, new goldens and expanded reports. | remediate | +| `recovery-events/model.mjs` | Adds snapshot re-entry and inline effect recovery checkpoints; effect restart still preserves closure state. | remediate | +| `recovery-events/fixtures/invalid-command-catalog.json` | Semantic deep comparison equal; adds EOF whitespace defect and is not an omitted-command fixture. | remediate | +| `recovery-events/recovery-events.test.mjs` | Adds schedule/census/fingerprint assertions, but no fresh effect runner or omission completeness test. | remediate | + +Paths in the table are abbreviated below their already stated W5 owned roots. + +## Cross-lane semantic overlap + +There is no filename overlap between W3 and W5. Their semantic overlap is intentional and must be adopted +as one reconciled decision: + +1. **Writer truth controls recovery.** W3 classifies current task/config/native-inbox and selected runtime + writers as uncoordinated, quiescent-only, or incompletely observed. W5 now makes all 103 individual + effects `automaticRecoveryAdmitted: false`; task/messaging candidate operation-ID effects are currently + `non_reconcilable`/`operator_required`. This closes `RW35-003` semantically. +2. **The SQLite transaction seam is future-contingent.** W3 proves current-host Online Backup and a single + worker shape only at `fixture_characterized`. W5's `transactional_local` rows remain + `operator_required_until_transaction_exists`; neither lane proves the production async worker, final + Electron/container ABI, keyring retention, or deployment quiescence. +3. **Snapshot/event and backup share storage mechanics.** W5's same-transaction snapshot/journal and W3's + backup participant both depend on one bounded SQLite authority. W5 must not reinterpret W3's legacy + file backup as a command/event recovery point. +4. **Estimates are not additive.** W3 reports 4.5k-7.25k and W5 reports 4.5k-7.5k under the shared + `EST-RECOVERY-STATE` space. Command/event/storage/backup/keyring fixtures overlap and must be counted + once by the controller. +5. **Final topology remains outside both remediations.** Production worker wiring, final artifact ABI, + deployment drain, recovery-point publication and credential/keyring preservation remain W6/controller + gates, not evidence supplied by these files. + +## Narrow verification + +| Check | Result | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| W3 registry result and actual status census | pass: result `done`; exactly 16 untracked additions; result list equals status list | +| W5 registry/job and actual status census | characterized missing result: stale `running` progress, dead PID, exactly 17 untracked additions | +| W3 production `TeamBackupService` fault suite | pass: 7 tests, 12 cases | +| W3 WAL Online Backup spike | pass: active WAL, 2,000 rows, independent reopen, integrity `ok` | +| W3 external-writer negative fixture | pass: `lostExternalUpdate=true` | +| W3 evidence verifier and architecture test | pass | +| W5 generator `--check` | pass: 9 evidence/supporting files verified | +| W5 recovery-events tests | pass: 7 tests; coverage limitations are findings above | +| Targeted ESLint with `--no-cache` for all owned MJS files | pass for W3 and W5 | +| No-index whitespace check over every dirty file | W3 pass 16/16; W5 fail 2/17 | +| Bounded private-key/token/Bearer/absolute-home scan | pass: no findings in either lane | + +The package-script lint wrapper was also attempted read-only in each producer, but its configured cache +write failed with `EROFS`. The reviewer reran the same targeted ESLint configuration directly with +`--no-cache`; both lanes passed. No broad CI, full lint, typecheck, build, final gate, live runtime, Phase +1 implementation, terminal implementation, integration, commit or push was performed. + +## Registry evidence + +| Record | State | SHA-256 | +| ----------------------- | --------------------------------------------------- | ------------------------------------------------------------------ | +| W3 registry `job.json` | producer job points to `phase-00-remediation-w3-v1` | `bd707941a3e7642e622ce766146b618d1425aa30b369677620b67c076054ef11` | +| W3 `latest-result.json` | `done`, 16 paths, base `0e8431b...` | `22174cfea16eb4d8d9a5bb18c39ebb46ef29e201cd299ccd16632275f03b3824` | +| W5 registry `job.json` | producer job points to `phase-00-remediation-w5-v1` | `af9c3140521615089fefd1b40fd921efa87eb54053e84b9a746ebfbac4eabe56` | +| W5 `progress.json` | stale `running`; no latest result; PID dead | `ce004b8ba50ce2cbeb0e9e3457f7df633ccee884847a7b9bec1918b6d474ce2e` | + +## Controller handoff + +Adopt all 16 approved W3 files together. Keep W3's final-image/production-worker/deployment-recovery +limitations explicit. Return W5 for `R35-R2-001` through `R35-R2-004`; do not adopt its 13 held files. +If the controller permits partial evidence adoption from a remediating lane, only the four explicitly +approved W5 evidence records above are eligible. Repeat this focused reciprocal review after W5 produces +a complete result and regenerated handoff. Do not freeze Phase 0 or start Phase 1 from this review. diff --git a/docs/research/hosted-web/phase-0/recovery-events/README.md b/docs/research/hosted-web/phase-0/recovery-events/README.md new file mode 100644 index 0000000000..eb48d48364 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/README.md @@ -0,0 +1,70 @@ +# Phase 0 W5 recovery and event evidence + +Pinned phase start: `a32f509e6d9bd31ba2135940e336729bf90c3d93`. Packet: `phase-00-r2`. This is Phase 0 evidence and executable modeling only; it does not implement the Phase 1 hosted journal, command registry, or renderer. + +## Findings + +- The current generic HTTP SSE route and renderer EventSource have no durable cursor, event ID, replay, scope, or gap detection. File-watcher team changes are lossy hints. +- Existing OpenCode delivery/bridge journals provide valuable conflict and ambiguity evidence. They are JSON-store/provider-specific, hash raw or partially normalized payloads without retained ADR-34 descriptor/key versions, and cannot serve as the hosted event journal. +- The deterministic snapshot scheduler explored 288 mutation schedules, including actual before/after commit transitions. All converged; lower-C0 schedules deliberately admitted duplicates. Both negative controls reproduced a lost committed event. +- The independent pinned-source census classifies 128 extracted interface members and maps 53 required mutations exactly once to 50 normalized command kinds and 101 owned effects. Bidirectional missing/extra and omitted-descriptor fixtures fail closed. +- The external ownership gate compares 49 required W1/W5 API members against the W1 API parity ledger and fails generation on a missing row or primary command-owner drift. Coordinator effects remain owned by the primary command feature; published secondary effects retain their distinct effect owner. +- The recovery scheduler executed 52 real two-process crash/restart schedules. Every attempt exited at its scheduled boundary, a different PID reloaded only durable command/provider files, and exact post-restart state/effect/compensation/publication counts passed. Stale, coincidentally equal, mismatched-operation and lost-response negative controls all fail closed. +- Current task/inbox/provider lookup and active-writer coordination remain unproved by W3, so those external effects are `non_reconcilable`/`operator_required`; a future operation-ID class remains only a candidate until independently exercised. Same-key changed intent resolves to `idempotency_mismatch`. + +## Accepted handoff contract + +SQLite-only snapshots read the projection, revision vector, and cursor from one transaction. Any external-file projection captures and pins retained C0 before its stable scan and returns C0. SSE registers its wake listener before its first durable query and repeatedly queries the high watermark; wake-ups never carry authority. Reducers deduplicate eventId and fence aggregate generation/revision. + +This is at-least-once convergence, not event sourcing or exactly-once delivery. The durable journal row is an after-commit projection/outbox record; feature repositories remain state authority. + +## Ambiguous effects + +- `git.initialize_repository/run_git_init`: current Git subprocess has no operation-bound acknowledgement after timeout -> `operator_required`. +- `team.launch/provider_launch`: current launch evidence can time out between provider spawn and durable process ownership proof -> `operator_required`. +- `message.send/append_inbox_envelope`: messageId is a durable unique envelope marker -> `operator_required`. +- `message.send/provider_live_delivery`: without provider acknowledgement or unique observable envelope marker a timeout cannot prove acceptance -> `operator_required`. +- `cross_team_message.send/append_cross_team_envelope`: messageId and conversationId uniquely identify the durable envelope -> `operator_required`. +- `cross_team_message.send/provider_live_delivery`: runtime delivery lacks universal durable acknowledgement -> `operator_required`. +- `task.create/write_task_document`: taskId/operationId survives watcher echo and retry -> `operator_required`. +- `task.request_review/notify_review_requested`: operationId uniquely identifies notification/history entry -> `operator_required`. +- `task.update_kanban/write_task_and_kanban`: operationId plus expected task/team revisions -> `operator_required`. +- `task.update_status/write_task_status`: operationId and task history transition marker -> `operator_required`. +- `task.update_owner/write_task_owner`: operationId and task history transition marker -> `operator_required`. +- `task.update_fields/write_task_fields`: operationId and expected revision preserve unrelated fields -> `operator_required`. +- `task.start/notify_task_owner`: notification operationId yields explicit persisted/delivery outcome -> `operator_required`. +- `task.add_comment/append_comment`: commentId/operationId uniquely identifies history entry -> `operator_required`. +- `task.set_clarification/write_clarification`: operationId and expected revision -> `operator_required`. +- `task.soft_delete/write_task_tombstone`: taskId plus tombstone generation -> `operator_required`. +- `task.restore/restore_task_document`: taskId plus tombstone generation -> `operator_required`. +- `task.relationship_add/append_relationship`: operationId deduplicates symmetric history updates -> `operator_required`. +- `task.relationship_remove/remove_relationship`: operationId and expected relationship generation -> `operator_required`. +- `member.add/write_roster`: memberId plus roster generation -> `operator_required`. +- `member.remove/write_member_tombstone`: memberId plus roster generation -> `operator_required`. +- `member.restore/restore_roster_member`: memberId plus tombstone generation -> `operator_required`. +- `member.update_role/write_member_role`: operationId plus roster generation -> `operator_required`. +- `member.restart/provider_member_restart`: spawn may occur before durable provider acknowledgement -> `operator_required`. +- `member.retry_failed_lanes/provider_lane_launch`: current retry candidates can cross spawn boundary before evidence commit -> `operator_required`. +- `member.skip_for_launch/write_launch_skip`: memberId/run generation transition is uniquely journaled -> `operator_required`. +- `approval.decide/provider_permission_delivery`: a timeout can occur after provider accepted the answer but before acknowledgement -> `operator_required`. +- `review.apply_decisions/apply_workspace_patch`: agent-writable workspace equality cannot identify which writer produced bytes -> `operator_required`. +- `review.reject_hunks/replace_workspace_file`: current path-based write has no operation-bound exclusive evidence -> `operator_required`. +- `review.reject_file/replace_workspace_file`: current path-based write has no operation-bound exclusive evidence -> `operator_required`. +- `review.save_edited_file/replace_workspace_file`: current path-based write has no operation-bound exclusive evidence -> `operator_required`. +- `runtime.bootstrap_checkin/accept_runtime_checkin`: runtimeEventId and run/lane credential scope -> `operator_required`. +- `runtime.deliver_message/append_runtime_envelope`: runtime event id and destination message id -> `operator_required`. +- `runtime.task_event/accept_runtime_task_event`: runtimeEventId deduplicates watcher/provider echo -> `operator_required`. +- `runtime.heartbeat/accept_runtime_heartbeat`: runtimeEventId and monotonic run generation -> `operator_required`. + +## Uncertainty and cross-lane dependency + +W3 proves that task/config/native-inbox active writers are uncoordinated or quiescent-only today and that selected OpenCode evidence remains partial. This W5 remediation therefore admits no automatic row whose durable lookup/transaction/exclusivity proof is missing. W3 must still confirm the future single-writer SQLite transaction, retention/backup/keyring preservation, and every effect-specific external-writer seam. The 4.5k-7.5k estimate shares storage fixtures with W3 and must be deduplicated by the controller. + +## Evidence index + +- `P0.W5.EVENT_CURSOR_INVENTORY`: `event-cursor-inventory.json` +- `P0.W5.SNAPSHOT_HANDOFF_SCHEDULER`: `snapshot-handoff-scheduler.json` +- `P0.W5.COMMAND_CATALOG`: `command-catalog.json` +- `P0.W5.EFFECT_RECOVERY_MATRIX`: `effect-recovery-matrix.json` +- `P0.W5.FINGERPRINT_GOLDENS`: `fingerprint-goldens.json` +- `P0.W5.ESTIMATE`: `estimate-input.json` diff --git a/docs/research/hosted-web/phase-0/recovery-events/command-catalog.json b/docs/research/hosted-web/phase-0/recovery-events/command-catalog.json new file mode 100644 index 0000000000..f5d036dbad --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/command-catalog.json @@ -0,0 +1,2646 @@ +{ + "schemaVersion": 1, + "evidenceId": "P0.W5.COMMAND_CATALOG", + "scope": "Required hosted v1 team, task, messaging, review, approval, Git, lifecycle, and runtime-ingress mutations named by the master plan and current TeamsAPI/TeamApprovalsElectronApi/CrossTeamAPI/ReviewAPI/runtime-control seams.", + "descriptorDefaults": { + "claimOrder": "authenticate_authorize_bound_validate_then_claim", + "conflict": "same scope/key with changed descriptor/schema/fingerprint is idempotency_mismatch", + "storedCommandMaterial": "versions_and_hmac_digest_only", + "sensitiveBodyPersistence": false + }, + "coverage": { + "censusArtifact": "mutation-census.json", + "censusDerivation": "independent TypeScript AST extraction bidirectionally checked against mutation-surface-manifest.json; never derived from commands", + "requiredMutationMethods": [ + "deleteTeam", + "restoreTeam", + "permanentlyDeleteTeam", + "deleteDraft", + "initializeGitRepository", + "createInitialGitCommit", + "createTeam", + "cancelProvisioning", + "sendMessage", + "createTask", + "requestReview", + "updateKanban", + "updateKanbanColumnOrder", + "updateTaskStatus", + "updateTaskOwner", + "updateTaskFields", + "startTask", + "startTaskByUser", + "processSend", + "stop", + "createConfig", + "launchTeam", + "updateConfig", + "addMember", + "replaceMembers", + "removeMember", + "restoreMember", + "updateMemberRole", + "addTaskComment", + "setTaskClarification", + "killProcess", + "retryFailedOpenCodeSecondaryLanes", + "restartMember", + "skipMemberForLaunch", + "softDeleteTask", + "restoreTask", + "addTaskRelationship", + "removeTaskRelationship", + "saveTaskAttachment", + "deleteTaskAttachment", + "respondToToolApproval", + "updateToolApprovalSettings", + "crossTeam.send", + "applyDecisions", + "rejectHunks", + "rejectFile", + "saveEditedFile", + "saveDecisions", + "clearDecisions", + "recordOpenCodeRuntimeBootstrapCheckin", + "deliverOpenCodeRuntimeMessage", + "recordOpenCodeRuntimeTaskEvent", + "recordOpenCodeRuntimeHeartbeat" + ], + "aliases": { "crossTeam.send": "CrossTeamAPI.send" }, + "dispositionManifest": "mutation-surface-manifest.json", + "excludedAsQueryOrEphemeral": [ + "TeamsAPI.list", + "TeamsAPI.getData", + "TeamsAPI.getTaskChangePresence", + "TeamsAPI.setChangePresenceTracking", + "TeamsAPI.setToolActivityTracking", + "TeamsAPI.setTaskLogStreamTracking", + "TeamsAPI.getClaudeLogs", + "TeamsAPI.getSavedRequest", + "TeamsAPI.prepareProvisioning", + "TeamsAPI.getWorktreeGitStatus", + "TeamsAPI.getProvisioningStatus", + "TeamsAPI.getLaunchFailureDiagnostics", + "TeamsAPI.getOpenCodeRuntimeDeliveryStatus", + "TeamsAPI.getMessagesPage", + "TeamsAPI.getMemberActivityMeta", + "TeamsAPI.getTask", + "TeamsAPI.processAlive", + "TeamsAPI.aliveList", + "TeamsAPI.getMemberLogs", + "TeamsAPI.getLogsForTask", + "TeamsAPI.getTaskActivity", + "TeamsAPI.getTaskActivityDetail", + "TeamsAPI.getTaskLogStreamSummary", + "TeamsAPI.getTaskLogStream", + "TeamsAPI.getTaskExactLogSummaries", + "TeamsAPI.getTaskExactLogDetail", + "TeamsAPI.getMemberStats", + "TeamsAPI.getAllTasks", + "TeamsAPI.getProjectBranch", + "TeamsAPI.setProjectBranchTracking", + "TeamsAPI.getAttachments", + "TeamsAPI.getLeadActivity", + "TeamsAPI.getLeadContext", + "TeamsAPI.getMemberSpawnStatuses", + "TeamsAPI.getTeamAgentRuntime", + "TeamsAPI.getDeletedTasks", + "TeamsAPI.showMessageNotification", + "TeamsAPI.getTaskAttachment", + "TeamsAPI.onProjectBranchChange", + "TeamsAPI.onTeamChange", + "TeamsAPI.onProvisioningProgress", + "TeamsAPI.validateCliArgs", + "TeamApprovalsElectronApi.onToolApprovalEvent", + "TeamApprovalsElectronApi.readFileForToolApproval", + "CrossTeamAPI.listTargets", + "CrossTeamAPI.getOutbox", + "ReviewAPI.getAgentChanges", + "ReviewAPI.getTaskChanges", + "ReviewAPI.getTeamTaskChangeSummaries", + "ReviewAPI.invalidateTaskChangeSummaries", + "ReviewAPI.getChangeStats", + "ReviewAPI.getFileContent", + "ReviewAPI.checkConflict", + "ReviewAPI.previewReject", + "ReviewAPI.watchFiles", + "ReviewAPI.unwatchFiles", + "ReviewAPI.onExternalFileChange", + "ReviewAPI.loadDecisions", + "ReviewAPI.loadDecisionConflictCandidates", + "ReviewAPI.loadDraftHistory", + "ReviewAPI.loadDraftHistoryConflictCandidates", + "ReviewAPI.onCmdN", + "ReviewAPI.getGitFileLog" + ], + "deferredOutsideHostedV1": [ + "ReviewAPI.executeMutation", + "ReviewAPI.retryMutationRecovery", + "ReviewAPI.restoreHistory", + "ReviewAPI.deleteEditedFile", + "ReviewAPI.restoreRejectedRename", + "ReviewAPI.reapplyRejectedRename", + "ReviewAPI.resolveDecisionConflictCandidate", + "ReviewAPI.saveDraftHistoryEntry", + "ReviewAPI.clearDraftHistory", + "ReviewAPI.resolveDraftHistoryConflictCandidate", + "ReviewAPI.replaceDraftHistoryConflictCandidate", + "OpenCodeRuntimeControlApi.answerOpenCodeRuntimePermission", + "ScheduleAPI mutations", + "terminal input/tab/split commands" + ], + "observedSurfaceCount": 128, + "observedMethodCount": 53, + "dispositionCounts": { + "extracted": 128, + "dispositions": 128, + "required": 53, + "query": 48, + "ephemeral": 15, + "deferred": 12 + }, + "sourceFiles": [ + "src/shared/types/api.ts", + "src/features/team-approvals/contracts/api.ts", + "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts" + ], + "sourceToManifestComplete": true, + "manifestToSourceComplete": true, + "exactlyOnceMapped": true, + "noCatalogMethodOutsideRequiredDisposition": true, + "ownerAgreement": true, + "crossLaneOwnership": { + "authorityArtifact": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json", + "authorityEvidenceId": "P0.W1.API_PARITY_LEDGER", + "comparedRequiredW1W5Members": 49, + "missingW1Rows": 0, + "ownerMismatches": 0, + "ownerAgreement": true + } + }, + "commands": [ + { + "commandKind": "team.soft_delete", + "featureOwner": "team-lifecycle", + "sourceMethods": ["deleteTeam"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "teamGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "move_team_to_tombstone", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact source/destination identity and generation", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.restore", + "featureOwner": "team-lifecycle", + "sourceMethods": ["restoreTeam"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "tombstoneGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "restore_team_files", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus tombstone and restored identity evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.permanent_delete", + "featureOwner": "team-lifecycle", + "sourceMethods": ["permanentlyDeleteTeam"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "teamGeneration", "expectedOwnershipDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_deletion_saga", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "revoke_run_and_remove_owned_artifacts", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "saga step IDs plus ownership catalog and absence proof", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.draft_delete", + "featureOwner": "team-lifecycle", + "sourceMethods": ["deleteDraft"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "draftGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "remove_draft_artifacts", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact draft generation and absence proof", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "git.initialize_repository", + "featureOwner": "workspace-registry", + "sourceMethods": ["initializeGitRepository"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["workspaceId", "repositoryId", "mountGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "workspace-registry", + "effectRole": "coordinator_effect", + "effectId": "commit_git_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "workspace-registry", + "effectRole": "secondary_effect", + "effectId": "run_git_init", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current Git subprocess has no operation-bound acknowledgement after timeout", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "git.create_initial_commit", + "featureOwner": "workspace-registry", + "sourceMethods": ["createInitialGitCommit"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["workspaceId", "repositoryId", "expectedHead", "treeDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "workspace-registry", + "effectRole": "coordinator_effect", + "effectId": "commit_git_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "workspace-registry", + "effectRole": "secondary_effect", + "effectId": "create_commit", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId trailer or exact expected parent/tree/ref transition under workspace guard", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.create_draft", + "featureOwner": "team-lifecycle", + "sourceMethods": ["createTeam", "createConfig"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "workspaceId", "configDigest", "rosterDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "replace_team_config", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "exclusive write intent plus operationId and before/after checksums", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.launch", + "featureOwner": "team-lifecycle", + "sourceMethods": ["launchTeam"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "teamGeneration", + "workspaceId", + "mountGeneration", + "providerPlanDigest", + "launchPreferencesDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_launch_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "provider_launch", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current launch evidence can time out between provider spawn and durable process ownership proof", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.cancel_provisioning", + "featureOwner": "team-lifecycle", + "sourceMethods": ["cancelProvisioning"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "runId", "runGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "cancel_owned_run", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "run credential revocation plus generation-scoped terminal evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.stop", + "featureOwner": "team-lifecycle", + "sourceMethods": ["stop"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "runId", "runGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_stop_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "terminate_owned_processes", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "process ownership record, generation fence, and verified terminal state", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "team.config_update", + "featureOwner": "team-lifecycle", + "sourceMethods": ["updateConfig"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "expectedRevision", "configPatchDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "replace_team_config", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact expected revision and before/after checksums", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "message.send", + "featureOwner": "team-messaging", + "sourceMethods": ["sendMessage", "processSend"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "messageId", + "recipientId", + "contentDigest", + "attachmentDigests" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-messaging", + "effectRole": "coordinator_effect", + "effectId": "commit_message_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "append_inbox_envelope", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "messageId is a durable unique envelope marker", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "provider_live_delivery", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "without provider acknowledgement or unique observable envelope marker a timeout cannot prove acceptance", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "cross_team_message.send", + "featureOwner": "team-messaging", + "sourceMethods": ["crossTeam.send"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "fromTeamId", + "toTeamId", + "recipientId", + "messageId", + "contentDigest", + "taskRefDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-messaging", + "effectRole": "coordinator_effect", + "effectId": "commit_cross_team_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "append_cross_team_envelope", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "messageId and conversationId uniquely identify the durable envelope", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "provider_live_delivery", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "runtime delivery lacks universal durable acknowledgement", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.create", + "featureOwner": "team-task-board", + "sourceMethods": ["createTask"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTeamRevision", "taskIntentDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_document", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "taskId/operationId survives watcher echo and retry", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.request_review", + "featureOwner": "team-task-board", + "sourceMethods": ["requestReview"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "notify_review_requested", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId uniquely identifies notification/history entry", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.update_kanban", + "featureOwner": "team-task-board", + "sourceMethods": ["updateKanban"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision", "patchDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_and_kanban", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId plus expected task/team revisions", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "kanban.reorder_column", + "featureOwner": "team-task-board", + "sourceMethods": ["updateKanbanColumnOrder"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "columnId", + "expectedTeamRevision", + "orderedTaskIdsDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "replace_kanban_order", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "exact before revision and operation-bound after digest", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.update_status", + "featureOwner": "team-task-board", + "sourceMethods": ["updateTaskStatus"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision", "status"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_status", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and task history transition marker", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.update_owner", + "featureOwner": "team-task-board", + "sourceMethods": ["updateTaskOwner"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision", "ownerMemberId"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_owner", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and task history transition marker", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.update_fields", + "featureOwner": "team-task-board", + "sourceMethods": ["updateTaskFields"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision", "fieldPatchDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_fields", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and expected revision preserve unrelated fields", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.start", + "featureOwner": "team-task-board", + "sourceMethods": ["startTask", "startTaskByUser"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision", "ownerMemberId"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_started_interval", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "notify_task_owner", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "notification operationId yields explicit persisted/delivery outcome", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.add_comment", + "featureOwner": "team-task-board", + "sourceMethods": ["addTaskComment"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "commentId", "contentDigest", "taskRefDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "append_comment", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "commentId/operationId uniquely identifies history entry", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.set_clarification", + "featureOwner": "team-task-board", + "sourceMethods": ["setTaskClarification"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision", "clarificationOwner"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_clarification", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and expected revision", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.soft_delete", + "featureOwner": "team-task-board", + "sourceMethods": ["softDeleteTask"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "expectedTaskRevision"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_tombstone", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "taskId plus tombstone generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.restore", + "featureOwner": "team-task-board", + "sourceMethods": ["restoreTask"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "tombstoneGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "restore_task_document", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "taskId plus tombstone generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.relationship_add", + "featureOwner": "team-task-board", + "sourceMethods": ["addTaskRelationship"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "taskId", + "targetTaskId", + "relationshipType", + "expectedTaskRevision" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "append_relationship", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId deduplicates symmetric history updates", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.relationship_remove", + "featureOwner": "team-task-board", + "sourceMethods": ["removeTaskRelationship"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "taskId", + "targetTaskId", + "relationshipType", + "expectedTaskRevision" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "remove_relationship", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and expected relationship generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.attachment_save", + "featureOwner": "agent-attachments", + "sourceMethods": ["saveTaskAttachment"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "attachmentId", "contentDigest", "mediaType"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "agent-attachments", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "agent-attachments", + "effectRole": "secondary_effect", + "effectId": "store_attachment", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "attachmentId plus operation-bound content digest and atomic replace evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "task.attachment_delete", + "featureOwner": "agent-attachments", + "sourceMethods": ["deleteTaskAttachment"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "taskId", "attachmentId", "attachmentGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "agent-attachments", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "agent-attachments", + "effectRole": "secondary_effect", + "effectId": "remove_attachment", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "attachment generation plus operation-bound absence evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.add", + "featureOwner": "team-lifecycle", + "sourceMethods": ["addMember"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "expectedRosterGeneration", + "memberId", + "memberSpecDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "write_roster", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId plus roster generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.replace_roster", + "featureOwner": "team-lifecycle", + "sourceMethods": ["replaceMembers"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "expectedRosterGeneration", "rosterDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "replace_roster", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact before generation and after digest", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.remove", + "featureOwner": "team-lifecycle", + "sourceMethods": ["removeMember"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "memberId", "expectedRosterGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "write_member_tombstone", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId plus roster generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.restore", + "featureOwner": "team-lifecycle", + "sourceMethods": ["restoreMember"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "memberId", "tombstoneGeneration"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "restore_roster_member", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId plus tombstone generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.update_role", + "featureOwner": "team-lifecycle", + "sourceMethods": ["updateMemberRole"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "memberId", "expectedRosterGeneration", "roleDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "write_member_role", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId plus roster generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.restart", + "featureOwner": "team-lifecycle", + "sourceMethods": ["restartMember"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "runId", "runGeneration", "memberId"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_restart_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "provider_member_restart", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "spawn may occur before durable provider acknowledgement", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.retry_failed_lanes", + "featureOwner": "team-runtime-control", + "sourceMethods": ["retryFailedOpenCodeSecondaryLanes"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "runId", "runGeneration", "failedLaneSetDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_retry_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "provider_lane_launch", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current retry candidates can cross spawn boundary before evidence commit", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "member.skip_for_launch", + "featureOwner": "team-lifecycle", + "sourceMethods": ["skipMemberForLaunch"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "runId", "runGeneration", "memberId"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "write_launch_skip", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId/run generation transition is uniquely journaled", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "process.kill", + "featureOwner": "team-runtime-control", + "sourceMethods": ["killProcess"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "runId", "runGeneration", "processRef"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_kill_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "terminate_owned_process", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "opaque processRef ownership plus generation and terminal observation", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "approval.decide", + "featureOwner": "team-approvals", + "sourceMethods": ["respondToToolApproval"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "runId", + "runGeneration", + "approvalRequestId", + "decision", + "decisionDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-approvals", + "effectRole": "coordinator_effect", + "effectId": "claim_approval_decision", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-approvals", + "effectRole": "secondary_effect", + "effectId": "provider_permission_delivery", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "a timeout can occur after provider accepted the answer but before acknowledgement", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "approval.policy_update", + "featureOwner": "team-approvals", + "sourceMethods": ["updateToolApprovalSettings"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "expectedPolicyVersion", "policyDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-approvals", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + } + ] + }, + { + "commandKind": "review.apply_decisions", + "featureOwner": "team-review", + "sourceMethods": ["applyDecisions"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "workspaceId", + "changeSetId", + "expectedSourceGeneration", + "decisionDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "apply_workspace_patch", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "agent-writable workspace equality cannot identify which writer produced bytes", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "review.reject_hunks", + "featureOwner": "team-review", + "sourceMethods": ["rejectHunks"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "workspaceId", + "fileRef", + "expectedContentDigest", + "hunkSelectionDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_workspace_file", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current path-based write has no operation-bound exclusive evidence", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "review.reject_file", + "featureOwner": "team-review", + "sourceMethods": ["rejectFile"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "workspaceId", + "fileRef", + "expectedContentDigest", + "replacementDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_workspace_file", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current path-based write has no operation-bound exclusive evidence", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "review.save_edited_file", + "featureOwner": "team-review", + "sourceMethods": ["saveEditedFile"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "workspaceId", + "fileRef", + "expectedContentDigest", + "replacementDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_workspace_file", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current path-based write has no operation-bound exclusive evidence", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "review.save_decisions", + "featureOwner": "team-review", + "sourceMethods": ["saveDecisions"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "scopeKey", "scopeToken", "decisionDigest"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_review_decisions", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact scope token and after digest", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "review.clear_decisions", + "featureOwner": "team-review", + "sourceMethods": ["clearDecisions"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "command_outcome_plus_receipt_ttl", + "normalizedIntentFields": ["teamId", "scopeKey", "scopeToken"], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "remove_review_decisions", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "scope token plus operation-bound absence evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "runtime.bootstrap_checkin", + "featureOwner": "team-runtime-control", + "sourceMethods": ["recordOpenCodeRuntimeBootstrapCheckin"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "runId", + "runGeneration", + "laneId", + "runtimeEventId", + "evidenceDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "accept_runtime_checkin", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtimeEventId and run/lane credential scope", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "runtime.deliver_message", + "featureOwner": "team-runtime-control", + "sourceMethods": ["deliverOpenCodeRuntimeMessage"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "runId", + "runGeneration", + "laneId", + "runtimeEventId", + "destinationDigest", + "payloadDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "claim_runtime_delivery", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "append_runtime_envelope", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtime event id and destination message id", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "runtime.task_event", + "featureOwner": "team-runtime-control", + "sourceMethods": ["recordOpenCodeRuntimeTaskEvent"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "runId", + "runGeneration", + "laneId", + "runtimeEventId", + "taskEventDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "accept_runtime_task_event", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtimeEventId deduplicates watcher/provider echo", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + }, + { + "commandKind": "runtime.heartbeat", + "featureOwner": "team-runtime-control", + "sourceMethods": ["recordOpenCodeRuntimeHeartbeat"], + "inputSchemaVersion": 1, + "fingerprintVersion": "hmac-sha256-ld-v1", + "idempotencyScope": "deployment_actor_command_kind_key", + "retentionClass": "operator_resolution_plus_receipt_ttl", + "normalizedIntentFields": [ + "teamId", + "runId", + "runGeneration", + "laneId", + "runtimeEventId", + "livenessDigest" + ], + "fingerprintRecordFields": [ + "descriptorId", + "inputSchemaVersion", + "fingerprintVersion", + "keyVersion", + "digest" + ], + "effects": [ + { + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "accept_runtime_heartbeat", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtimeEventId and monotonic run generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] + } + ] +} diff --git a/docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json b/docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json new file mode 100644 index 0000000000..2858af0c00 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/effect-recovery-matrix.json @@ -0,0 +1,4543 @@ +{ + "schemaVersion": 1, + "evidenceId": "P0.W5.EFFECT_RECOVERY_MATRIX", + "stateMachine": "not_started -> attempting -> observed_succeeded | observed_absent | ambiguous; compensating -> compensated | ambiguous", + "retryRule": "attempting is persisted before the boundary; retry only after descriptor proof establishes deduplication or absence", + "proofScope": "fresh Node process crash/restart fixture with durable command and independent external-adapter files; individual catalog rows admit automatic recovery only when automaticRecoveryAdmitted is true", + "ownershipAssertions": { + "everyEffectHasOwner": true, + "everyCoordinatorOwnedByCommandFeature": true, + "everyEffectHasWriterEvidence": true, + "unprovedEffectsFailClosed": true + }, + "faultScheduler": { + "exploredScheduleCount": 52, + "pauses": [ + "before_attempting", + "after_attempting", + "before_external_call", + "after_external_call", + "before_evidence_query", + "after_evidence_query", + "before_command_commit", + "after_command_commit", + "before_compensation", + "after_compensation", + "before_event_publication", + "after_event_publication" + ], + "schedules": [ + { + "recoveryClass": "transactional_local", + "crashPause": "before_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "not_started", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "crash:before_attempting", + "process:start", + "restart:load:not_started", + "recover:not_started:safe_to_begin", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "after_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "crash:after_attempting", + "process:start", + "restart:load:attempting", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "before_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "crash:before_external_call", + "process:start", + "restart:load:attempting", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "after_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "crash:after_external_call", + "process:start", + "restart:load:attempting", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "before_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "boundary:before_evidence_query", + "crash:before_evidence_query", + "process:start", + "restart:load:attempting", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "after_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "crash:after_evidence_query", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:same_transaction", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "before_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "crash:before_command_commit", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:same_transaction", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "after_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "crash:after_command_commit", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "before_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "crash:before_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "transactional_local", + "crashPause": "after_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "same_transaction", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 2, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "stage:transactional_local_effect", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=same_transaction", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication", + "crash:after_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "before_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "not_started", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "crash:before_attempting", + "process:start", + "restart:load:not_started", + "recover:not_started:safe_to_begin", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "after_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "crash:after_attempting", + "process:start", + "restart:load:attempting", + "lookup:operation-1:absent", + "external_effect:operation-1", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "before_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "crash:before_external_call", + "process:start", + "restart:load:attempting", + "lookup:operation-1:absent", + "external_effect:operation-1", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "after_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "crash:after_external_call", + "process:start", + "restart:load:attempting", + "lookup:operation-1:succeeded", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "before_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "crash:before_evidence_query", + "process:start", + "restart:load:attempting", + "lookup:operation-1:succeeded", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "after_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "crash:after_evidence_query", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:durable_operation_lookup", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "before_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "crash:before_command_commit", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:durable_operation_lookup", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "after_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "crash:after_command_commit", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "before_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "crash:before_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "idempotent_by_operation_id", + "crashPause": "after_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "durable_operation_lookup", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 2, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=durable_operation_lookup", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication", + "crash:after_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "before_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "not_started", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "crash:before_attempting", + "process:start", + "restart:load:not_started", + "recover:not_started:safe_to_begin", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "after_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "crash:after_attempting", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:absent", + "external_effect:operation-1", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "before_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "crash:before_external_call", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:absent", + "external_effect:operation-1", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "after_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "crash:after_external_call", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:succeeded", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "before_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "crash:before_evidence_query", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:succeeded", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "after_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "crash:after_evidence_query", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:operation_bound_unique_evidence", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "before_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "crash:before_command_commit", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:operation_bound_unique_evidence", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "after_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "crash:after_command_commit", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "before_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "crash:before_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "reconcilable_by_unique_evidence", + "crashPause": "after_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 2, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication", + "crash:after_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "before_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "not_started", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "crash:before_attempting", + "process:start", + "restart:load:not_started", + "recover:not_started:safe_to_begin", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "after_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "crash:after_attempting", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:absent", + "external_effect:operation-1", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "before_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "crash:before_external_call", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:absent", + "external_effect:operation-1", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "after_external_call", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "crash:after_external_call", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:succeeded", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "before_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "crash:before_evidence_query", + "process:start", + "restart:load:attempting", + "lookup:operation-bound-evidence:succeeded", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "after_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "crash:after_evidence_query", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:operation_bound_unique_evidence", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "before_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "crash:before_command_commit", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:operation_bound_unique_evidence", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "after_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "crash:after_command_commit", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "before_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "crash:before_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "after_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 2, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication", + "crash:after_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "before_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "not_started", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "crash:before_attempting", + "process:start", + "restart:load:not_started", + "recover:not_started:safe_to_begin", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=explicit_in_call_ack", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "after_attempting", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": false + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "crash:after_attempting", + "process:start", + "restart:load:attempting", + "external_effect:operation-1", + "persist:evidenceDisposition=explicit_in_call_ack", + "persist:state=observed_succeeded", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "before_external_call", + "outcome": "operator_required", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "ambiguous", + "commandOutcome": "operator_required", + "journalCommitted": false, + "evidenceDisposition": "unproved", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 0, + "externalEffects": 0, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 0, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "crash:before_external_call", + "process:start", + "restart:load:attempting", + "persist:state=ambiguous", + "persist:evidenceDisposition=unproved", + "persist:commandOutcome=operator_required" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "after_external_call", + "outcome": "operator_required", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "ambiguous", + "commandOutcome": "operator_required", + "journalCommitted": false, + "evidenceDisposition": "unproved", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 0, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "crash:after_external_call", + "process:start", + "restart:load:attempting", + "persist:state=ambiguous", + "persist:evidenceDisposition=unproved", + "persist:commandOutcome=operator_required" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "before_evidence_query", + "outcome": "operator_required", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "attempting", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "none", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "ambiguous", + "commandOutcome": "operator_required", + "journalCommitted": false, + "evidenceDisposition": "unproved", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 0, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "crash:before_evidence_query", + "process:start", + "restart:load:attempting", + "persist:state=ambiguous", + "persist:evidenceDisposition=unproved", + "persist:commandOutcome=operator_required" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "after_evidence_query", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=explicit_in_call_ack", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "crash:after_evidence_query", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:explicit_in_call_ack", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "before_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=explicit_in_call_ack", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "crash:before_command_commit", + "process:start", + "restart:load:observed_succeeded", + "recover:observed_succeeded:explicit_in_call_ack", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "after_command_commit", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=explicit_in_call_ack", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "crash:after_command_commit", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "before_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=explicit_in_call_ack", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "crash:before_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "non_reconcilable", + "crashPause": "after_event_publication", + "outcome": "committed", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "observed_succeeded", + "commandOutcome": "committed", + "journalCommitted": true, + "evidenceDisposition": "explicit_in_call_ack", + "compensationState": "not_applicable", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 0, + "compensationEffects": 0, + "publicationAttempts": 2, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=explicit_in_call_ack", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "boundary:before_command_commit", + "commit:command+outbox:committed", + "boundary:after_command_commit", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication", + "crash:after_event_publication", + "process:start", + "restart:load:observed_succeeded", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "before_compensation", + "outcome": "compensated", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "compensating", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "compensating", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "compensated", + "commandOutcome": "compensated", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "compensated", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 1, + "compensationEffects": 1, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "persist:state=compensating", + "persist:compensationState=compensating", + "boundary:before_compensation", + "crash:before_compensation", + "process:start", + "restart:load:compensating", + "recover:compensating", + "compensation:compensation-1", + "persist:compensationState=compensated", + "persist:state=compensated", + "commit:command+outbox:compensated", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + }, + { + "recoveryClass": "compensatable", + "crashPause": "after_compensation", + "outcome": "compensated", + "restartCount": 1, + "attemptExitCode": 86, + "recoveryExitCode": 0, + "freshProcess": true, + "durableBeforeCrash": { + "state": "compensated", + "commandOutcome": null, + "journalCommitted": false, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "compensated", + "callMayHaveStarted": true + }, + "durableAfterRecovery": { + "state": "compensated", + "commandOutcome": "compensated", + "journalCommitted": true, + "evidenceDisposition": "operation_bound_unique_evidence", + "compensationState": "compensated", + "callMayHaveStarted": true + }, + "externalCallAttempts": 1, + "externalEffects": 1, + "compensationAttempts": 1, + "compensationEffects": 1, + "publicationAttempts": 1, + "duplicateEffect": false, + "committedWithoutEvidence": false, + "trace": [ + "process:start", + "boundary:before_attempting", + "persist:state=attempting", + "persist:callMayHaveStarted=false", + "boundary:after_attempting", + "persist:callMayHaveStarted=true", + "boundary:before_external_call", + "external_effect:operation-1", + "boundary:after_external_call", + "boundary:before_evidence_query", + "persist:evidenceDisposition=operation_bound_unique_evidence", + "persist:state=observed_succeeded", + "boundary:after_evidence_query", + "persist:state=compensating", + "persist:compensationState=compensating", + "boundary:before_compensation", + "compensation:compensation-1", + "persist:compensationState=compensated", + "persist:state=compensated", + "boundary:after_compensation", + "crash:after_compensation", + "process:start", + "restart:load:compensated", + "commit:command+outbox:compensated", + "boundary:before_event_publication", + "publish:wakeup-for-durable-journal", + "boundary:after_event_publication" + ], + "processCount": 2 + } + ], + "negativeControls": [ + { + "id": "stale_unique_evidence", + "recoveryClass": "reconcilable_by_unique_evidence", + "observedEvidence": "stale_generation", + "outcome": "operator_required", + "retryAttempted": false + }, + { + "id": "coincidentally_equal_state", + "recoveryClass": "reconcilable_by_unique_evidence", + "observedEvidence": "desired_bytes_without_operation_identity", + "outcome": "operator_required", + "retryAttempted": false + }, + { + "id": "mismatched_operation_lookup", + "recoveryClass": "idempotent_by_operation_id", + "observedEvidence": "different_operation_id", + "outcome": "operator_required", + "retryAttempted": false + }, + { + "id": "lost_non_reconcilable_response", + "recoveryClass": "non_reconcilable", + "observedEvidence": "attempting_without_ack", + "outcome": "operator_required", + "retryAttempted": false + } + ], + "invariant": "every scheduled boundary performs a durable crash/restart; recovery never duplicates an effect or commits without descriptor-required evidence", + "assertions": { + "realAttemptExitAtEveryBoundary": true, + "freshRecoveryProcessEverySchedule": true, + "exactPostRestartStateAndCounts": true + } + }, + "effects": [ + { + "commandKind": "team.soft_delete", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.soft_delete", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "move_team_to_tombstone", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact source/destination identity and generation", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.restore", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.restore", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "restore_team_files", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus tombstone and restored identity evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.permanent_delete", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_deletion_saga", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.permanent_delete", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "revoke_run_and_remove_owned_artifacts", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "saga step IDs plus ownership catalog and absence proof", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.draft_delete", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.draft_delete", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "remove_draft_artifacts", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact draft generation and absence proof", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "git.initialize_repository", + "effectOwner": "workspace-registry", + "effectRole": "coordinator_effect", + "effectId": "commit_git_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "git.initialize_repository", + "effectOwner": "workspace-registry", + "effectRole": "secondary_effect", + "effectId": "run_git_init", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current Git subprocess has no operation-bound acknowledgement after timeout", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "git.create_initial_commit", + "effectOwner": "workspace-registry", + "effectRole": "coordinator_effect", + "effectId": "commit_git_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "git.create_initial_commit", + "effectOwner": "workspace-registry", + "effectRole": "secondary_effect", + "effectId": "create_commit", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId trailer or exact expected parent/tree/ref transition under workspace guard", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.create_draft", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.create_draft", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "replace_team_config", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "exclusive write intent plus operationId and before/after checksums", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.launch", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_launch_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.launch", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "provider_launch", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current launch evidence can time out between provider spawn and durable process ownership proof", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.cancel_provisioning", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.cancel_provisioning", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "cancel_owned_run", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "run credential revocation plus generation-scoped terminal evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.stop", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_stop_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.stop", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "terminate_owned_processes", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "process ownership record, generation fence, and verified terminal state", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "team.config_update", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "team.config_update", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "replace_team_config", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact expected revision and before/after checksums", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "message.send", + "effectOwner": "team-messaging", + "effectRole": "coordinator_effect", + "effectId": "commit_message_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "message.send", + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "append_inbox_envelope", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "messageId is a durable unique envelope marker", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "message.send", + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "provider_live_delivery", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "without provider acknowledgement or unique observable envelope marker a timeout cannot prove acceptance", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "cross_team_message.send", + "effectOwner": "team-messaging", + "effectRole": "coordinator_effect", + "effectId": "commit_cross_team_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "cross_team_message.send", + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "append_cross_team_envelope", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "messageId and conversationId uniquely identify the durable envelope", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "cross_team_message.send", + "effectOwner": "team-messaging", + "effectRole": "secondary_effect", + "effectId": "provider_live_delivery", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "runtime delivery lacks universal durable acknowledgement", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.create", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.create", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_document", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "taskId/operationId survives watcher echo and retry", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.request_review", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.request_review", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "notify_review_requested", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId uniquely identifies notification/history entry", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.update_kanban", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.update_kanban", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_and_kanban", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId plus expected task/team revisions", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "kanban.reorder_column", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "kanban.reorder_column", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "replace_kanban_order", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "exact before revision and operation-bound after digest", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.update_status", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.update_status", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_status", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and task history transition marker", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.update_owner", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.update_owner", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_owner", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and task history transition marker", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.update_fields", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.update_fields", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_fields", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and expected revision preserve unrelated fields", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.start", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_started_interval", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.start", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "notify_task_owner", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "notification operationId yields explicit persisted/delivery outcome", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.add_comment", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.add_comment", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "append_comment", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "commentId/operationId uniquely identifies history entry", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.set_clarification", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.set_clarification", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_clarification", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and expected revision", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.soft_delete", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.soft_delete", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "write_task_tombstone", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "taskId plus tombstone generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.restore", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.restore", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "restore_task_document", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "taskId plus tombstone generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.relationship_add", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.relationship_add", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "append_relationship", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId deduplicates symmetric history updates", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.relationship_remove", + "effectOwner": "team-task-board", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.relationship_remove", + "effectOwner": "team-task-board", + "effectRole": "secondary_effect", + "effectId": "remove_relationship", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId and expected relationship generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.attachment_save", + "effectOwner": "agent-attachments", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.attachment_save", + "effectOwner": "agent-attachments", + "effectRole": "secondary_effect", + "effectId": "store_attachment", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "attachmentId plus operation-bound content digest and atomic replace evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "task.attachment_delete", + "effectOwner": "agent-attachments", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "task.attachment_delete", + "effectOwner": "agent-attachments", + "effectRole": "secondary_effect", + "effectId": "remove_attachment", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "attachment generation plus operation-bound absence evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.add", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.add", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "write_roster", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId plus roster generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.replace_roster", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.replace_roster", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "replace_roster", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact before generation and after digest", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.remove", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.remove", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "write_member_tombstone", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId plus roster generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.restore", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.restore", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "restore_roster_member", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId plus tombstone generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.update_role", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.update_role", + "effectOwner": "team-lifecycle", + "effectRole": "secondary_effect", + "effectId": "write_member_role", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "operationId plus roster generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.restart", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_restart_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.restart", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "provider_member_restart", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "spawn may occur before durable provider acknowledgement", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.retry_failed_lanes", + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_retry_workflow", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.retry_failed_lanes", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "provider_lane_launch", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current retry candidates can cross spawn boundary before evidence commit", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "member.skip_for_launch", + "effectOwner": "team-lifecycle", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "member.skip_for_launch", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "write_launch_skip", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "memberId/run generation transition is uniquely journaled", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "process.kill", + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_kill_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "process.kill", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "terminate_owned_process", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "opaque processRef ownership plus generation and terminal observation", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "approval.decide", + "effectOwner": "team-approvals", + "effectRole": "coordinator_effect", + "effectId": "claim_approval_decision", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "approval.decide", + "effectOwner": "team-approvals", + "effectRole": "secondary_effect", + "effectId": "provider_permission_delivery", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "a timeout can occur after provider accepted the answer but before acknowledgement", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "approval.policy_update", + "effectOwner": "team-approvals", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "review.apply_decisions", + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "review.apply_decisions", + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "apply_workspace_patch", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "agent-writable workspace equality cannot identify which writer produced bytes", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "review.reject_hunks", + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "review.reject_hunks", + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_workspace_file", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current path-based write has no operation-bound exclusive evidence", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "review.reject_file", + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "review.reject_file", + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_workspace_file", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current path-based write has no operation-bound exclusive evidence", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "review.save_edited_file", + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_review_intent", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "review.save_edited_file", + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_workspace_file", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "non_reconcilable", + "proofRequired": "current path-based write has no operation-bound exclusive evidence", + "currentEvidence": "boundary_can_be_ambiguous", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "uncoordinated or acknowledgement-free external writer", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "review.save_decisions", + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "review.save_decisions", + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "replace_review_decisions", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "operationId plus exact scope token and after digest", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "review.clear_decisions", + "effectOwner": "team-review", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "review.clear_decisions", + "effectOwner": "team-review", + "effectRole": "secondary_effect", + "effectId": "remove_review_decisions", + "recoveryClass": "reconcilable_by_unique_evidence", + "candidateRecoveryClass": "reconcilable_by_unique_evidence", + "proofRequired": "scope token plus operation-bound absence evidence", + "currentEvidence": "missing_operation_bound_before_after_evidence", + "ambiguousOutcome": "prove_absent_or_succeeded_before_retry", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "effect-specific external writer coordination required", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "runtime.bootstrap_checkin", + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "runtime.bootstrap_checkin", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "accept_runtime_checkin", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtimeEventId and run/lane credential scope", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "runtime.deliver_message", + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "claim_runtime_delivery", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "runtime.deliver_message", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "append_runtime_envelope", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtime event id and destination message id", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "runtime.task_event", + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "runtime.task_event", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "accept_runtime_task_event", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtimeEventId deduplicates watcher/provider echo", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + }, + { + "commandKind": "runtime.heartbeat", + "effectOwner": "team-runtime-control", + "effectRole": "coordinator_effect", + "effectId": "commit_state_and_event", + "recoveryClass": "transactional_local", + "candidateRecoveryClass": "transactional_local", + "proofRequired": "command outcome and bounded journal row commit in the same internal transaction", + "currentEvidence": "missing_hosted_internal_storage", + "ambiguousOutcome": "recover_from_transaction", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required_until_transaction_exists", + "writerAuthority": "app-exclusive internal-storage worker", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION:sqlite.mutate" + }, + { + "commandKind": "runtime.heartbeat", + "effectOwner": "team-runtime-control", + "effectRole": "secondary_effect", + "effectId": "accept_runtime_heartbeat", + "recoveryClass": "non_reconcilable", + "candidateRecoveryClass": "idempotent_by_operation_id", + "proofRequired": "runtimeEventId and monotonic run generation", + "currentEvidence": "unproved_durable_lookup_or_writer_coordination", + "ambiguousOutcome": "operator_required", + "automaticRecoveryAdmitted": false, + "currentRecoveryDisposition": "operator_required", + "writerAuthority": "external or compatibility writer; operation lookup unproved", + "writerEvidenceRef": "P0.W3.WRITER_COORDINATION" + } + ] +} diff --git a/docs/research/hosted-web/phase-0/recovery-events/estimate-input.json b/docs/research/hosted-web/phase-0/recovery-events/estimate-input.json new file mode 100644 index 0000000000..de78ee98eb --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/estimate-input.json @@ -0,0 +1 @@ +{"schemaVersion":1,"evidenceId":"P0.W5.ESTIMATE","bucketId":"EST-RECOVERY-STATE","packages":["shared command descriptors/fingerprints","internal-storage command/effect registry","event journal/SSE handoff","renderer reconciliation","provider effect adapters"],"productionLines":{"low":2700,"high":4400},"testLines":{"low":1800,"high":3100},"deletedLines":{"low":200,"high":500},"excludedGeneratedVendorLines":true,"overlap":["W3 owns SQLite coordination, external writer classification, backup, and schema mechanics; do not sum its shared transaction/storage fixtures twice."],"confidence":"medium-low","assumptions":["One hosted journal writer and one internal SQLite substrate are accepted.","Current OpenCode delivery evidence is adapted rather than rewritten wholesale.","Terminal recovery remains excluded from v1.","Workspace/provider ambiguous effects remain operator_required unless later probes prove unique evidence."],"evidenceRefs":["P0.W5.EVENT_CURSOR_INVENTORY","P0.W5.SNAPSHOT_HANDOFF_SCHEDULER","P0.W5.COMMAND_CATALOG","P0.W5.EFFECT_RECOVERY_MATRIX","P0.W5.FINGERPRINT_GOLDENS"],"totalChangedLines":{"low":4500,"high":7500},"reestimateTriggers":["W3 rejects a single SQLite writer/transaction seam","provider launch/delivery cannot expose operation-bound evidence","command catalog expands beyond hosted v1 capability matrix","retention/keyring requires a separate service or migration"]} diff --git a/docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json b/docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json new file mode 100644 index 0000000000..74ae4f481e --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/event-cursor-inventory.json @@ -0,0 +1 @@ +{"schemaVersion":1,"evidenceId":"P0.W5.EVENT_CURSOR_INVENTORY","observedAtSha":"a32f509e6d9bd31ba2135940e336729bf90c3d93","surfaces":[{"id":"generic-http-sse","source":"src/main/http/events.ts:13","producer":"HttpServer.broadcast callers","consumer":"HttpAPIClient EventSource","cursor":"none","durability":"module-global in-memory Set","replay":"none","scope":"all connected clients","finding":"No id/eventId/journal/Last-Event-ID handling; disconnect or commit-before-fanout loses the notification."},{"id":"browser-eventsource","source":"src/renderer/api/httpClient.ts:176","producer":"/api/events","consumer":"renderer channel listeners","cursor":"browser transport only; server emits no id","durability":"none","replay":"automatic reconnect cannot replay without server IDs/journal","scope":"one global route","finding":"JSON callbacks have no event identity, resource revision, subscription locator, gap detection, or resync path."},{"id":"team-file-watcher-ipc-and-sse","source":"src/main/index.ts:1504","producer":"FileWatcher/team reconciliation","consumer":"Electron renderer and generic HTTP broadcast","cursor":"none","durability":"filesystem remains authority; watcher event is a hint","replay":"periodic/focused refresh only","scope":"teamName/type payload","finding":"Forwarding precedes no durable event row and carries no source generation/fileWriterEpoch."},{"id":"renderer-team-reconciler","source":"src/renderer/store/index.ts:1620","producer":"onTeamChange and provisioning progress callbacks","consumer":"Zustand team state","cursor":"none","durability":"memory cache","replay":"throttled refresh and fallback polling","scope":"teamName plus partial runId guards","finding":"Some stale-run guards exist, but no eventId dedupe, opaque epoch cursor, revision vector, or snapshot barrier."},{"id":"opencode-runtime-delivery-journal","source":"src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts:7","producer":"runtime delivery service","consumer":"delivery recovery/status","cursor":"none","durability":"versioned JSON store with lock","replay":"resume pending by key/payload hash","scope":"key/runId/teamName","finding":"Rejects payload conflict and records committed location, but uses unversioned stable hash and retries pending without an ADR-34 per-effect evidence class."},{"id":"opencode-prompt-delivery-ledger","source":"src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts:11","producer":"OpenCode inbox delivery/watchdog","consumer":"delivery status and repair","cursor":"provider pre/post prompt cursors, not application event cursor","durability":"versioned JSON store","replay":"bounded retry/watchdog states","scope":"team/member/lane/run/message","finding":"Rich acceptanceUnknown/evidence exists, but payloadHash is not a versioned normalized-intent HMAC and provider cursors cannot be used as the hosted event barrier."},{"id":"opencode-bridge-command-ledger","source":"src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts:7","producer":"state-changing bridge command service","consumer":"bridge commandStatus recovery","cursor":"none","durability":"versioned JSON store","replay":"completed duplicate resolves via status; unknown timeout blocks retry","scope":"generated idempotency key","finding":"Correctly refuses blind retry after unknown timeout, but requestHash includes raw body and lacks descriptor/schema/fingerprint/key versions and stable actor scope."},{"id":"runtime-control-event-sink","source":"src/main/services/team/runtime-control/RuntimeControlService.ts:154","producer":"provider ack","consumer":"optional runtime event sink","cursor":"provider/runtime event identity only","durability":"sink-dependent and invoked after provider action","replay":"provider-specific","scope":"run/lane/idempotency key","finding":"Action completes before eventSink.record; crash between them demonstrates why hosted state/outbox must be durable before live fanout."}],"requiredTargetContract":{"cursor":"opaque deploymentId/eventEpoch/eventSequence","snapshot":"same-transaction cursor or retained lower C0 plus revision vector","delivery":"listener-before-query durable journal replay with heartbeat/high-watermark requery","reducer":"eventId dedupe plus aggregate generation/revision fencing; gaps refetch","externalFiles":"watch-before-scan, source hash/generation, observation sequence, fileWriterEpoch"},"conclusion":"Current generic HTTP/team-change flow is a lossy notification path and cannot satisfy ADR-33. Existing provider journals are useful salvage evidence, not a hosted event cursor."} diff --git a/docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json b/docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json new file mode 100644 index 0000000000..1878623680 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/evidence.schema.json @@ -0,0 +1,406 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-teams.local/schemas/phase-00-w5-evidence.schema.json", + "title": "Phase 0 W5 evidence", + "type": "object", + "required": ["schemaVersion", "evidenceId"], + "properties": { + "schemaVersion": { "const": 1 }, + "evidenceId": { + "enum": [ + "P0.W5.EVENT_CURSOR_INVENTORY", + "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER", + "P0.W5.COMMAND_CATALOG", + "P0.W5.EFFECT_RECOVERY_MATRIX", + "P0.W5.FINGERPRINT_GOLDENS", + "P0.W5.ESTIMATE" + ] + } + }, + "allOf": [ + { + "if": { "properties": { "evidenceId": { "const": "P0.W5.EVENT_CURSOR_INVENTORY" } } }, + "then": { + "required": ["observedAtSha", "surfaces", "requiredTargetContract", "conclusion"], + "properties": { + "observedAtSha": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "surfaces": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "source", + "producer", + "consumer", + "cursor", + "durability", + "replay", + "scope", + "finding" + ] + } + }, + "requiredTargetContract": { + "type": "object", + "required": ["cursor", "snapshot", "delivery", "reducer", "externalFiles"] + }, + "conclusion": { "type": "string" } + } + } + }, + { + "if": { "properties": { "evidenceId": { "const": "P0.W5.SNAPSHOT_HANDOFF_SCHEDULER" } } }, + "then": { + "required": [ + "model", + "exploredScheduleCount", + "pauses", + "schedules", + "negativeControls", + "conclusion" + ], + "properties": { + "exploredScheduleCount": { "type": "integer", "minimum": 1 }, + "pauses": { "type": "array", "minItems": 12 }, + "schedules": { + "type": "array", + "minItems": 288, + "items": { + "type": "object", + "required": [ + "algorithm", + "mutationPause", + "crashPause", + "restartCount", + "durableBeforeCrash", + "durableAfterRestart", + "converged", + "gap", + "duplicates", + "finalRevision", + "authoritativeRevision", + "mutationCommitTransitions", + "trace" + ], + "properties": { + "restartCount": { "const": 1 }, + "converged": { "const": true }, + "gap": { "const": false }, + "mutationCommitTransitions": { "type": "array", "minItems": 2, "maxItems": 2 } + } + } + }, + "negativeControls": { "type": "array", "minItems": 2 } + } + } + }, + { + "if": { "properties": { "evidenceId": { "const": "P0.W5.COMMAND_CATALOG" } } }, + "then": { + "required": ["scope", "descriptorDefaults", "coverage", "commands"], + "properties": { + "coverage": { + "type": "object", + "required": [ + "censusArtifact", + "censusDerivation", + "dispositionManifest", + "requiredMutationMethods", + "observedSurfaceCount", + "observedMethodCount", + "dispositionCounts", + "sourceFiles", + "sourceToManifestComplete", + "manifestToSourceComplete", + "exactlyOnceMapped", + "noCatalogMethodOutsideRequiredDisposition", + "ownerAgreement", + "crossLaneOwnership" + ], + "properties": { + "censusArtifact": { "const": "mutation-census.json" }, + "sourceToManifestComplete": { "const": true }, + "manifestToSourceComplete": { "const": true }, + "exactlyOnceMapped": { "const": true }, + "noCatalogMethodOutsideRequiredDisposition": { "const": true }, + "ownerAgreement": { "const": true }, + "crossLaneOwnership": { + "type": "object", + "required": [ + "authorityArtifact", + "authorityEvidenceId", + "comparedRequiredW1W5Members", + "missingW1Rows", + "ownerMismatches", + "ownerAgreement" + ], + "properties": { + "authorityArtifact": { + "const": "docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json" + }, + "authorityEvidenceId": { "const": "P0.W1.API_PARITY_LEDGER" }, + "comparedRequiredW1W5Members": { "type": "integer", "minimum": 1 }, + "missingW1Rows": { "const": 0 }, + "ownerMismatches": { "const": 0 }, + "ownerAgreement": { "const": true } + } + } + } + }, + "commands": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "commandKind", + "featureOwner", + "sourceMethods", + "inputSchemaVersion", + "fingerprintVersion", + "idempotencyScope", + "retentionClass", + "normalizedIntentFields", + "fingerprintRecordFields", + "effects" + ], + "properties": { + "effects": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "effectOwner", + "effectRole", + "effectId", + "recoveryClass", + "candidateRecoveryClass", + "proofRequired", + "currentEvidence", + "ambiguousOutcome", + "automaticRecoveryAdmitted", + "currentRecoveryDisposition", + "writerAuthority", + "writerEvidenceRef" + ], + "properties": { + "effectRole": { "enum": ["coordinator_effect", "secondary_effect"] } + } + } + } + } + } + } + } + } + }, + { + "if": { "properties": { "evidenceId": { "const": "P0.W5.EFFECT_RECOVERY_MATRIX" } } }, + "then": { + "required": [ + "stateMachine", + "retryRule", + "proofScope", + "ownershipAssertions", + "faultScheduler", + "effects" + ], + "properties": { + "ownershipAssertions": { + "type": "object", + "required": [ + "everyEffectHasOwner", + "everyCoordinatorOwnedByCommandFeature", + "everyEffectHasWriterEvidence", + "unprovedEffectsFailClosed" + ], + "properties": { + "everyEffectHasOwner": { "const": true }, + "everyCoordinatorOwnedByCommandFeature": { "const": true }, + "everyEffectHasWriterEvidence": { "const": true }, + "unprovedEffectsFailClosed": { "const": true } + } + }, + "faultScheduler": { + "type": "object", + "required": [ + "exploredScheduleCount", + "pauses", + "schedules", + "negativeControls", + "invariant", + "assertions" + ], + "properties": { + "assertions": { + "type": "object", + "required": [ + "realAttemptExitAtEveryBoundary", + "freshRecoveryProcessEverySchedule", + "exactPostRestartStateAndCounts" + ], + "properties": { + "realAttemptExitAtEveryBoundary": { "const": true }, + "freshRecoveryProcessEverySchedule": { "const": true }, + "exactPostRestartStateAndCounts": { "const": true } + } + }, + "schedules": { + "type": "array", + "minItems": 52, + "items": { + "type": "object", + "required": [ + "recoveryClass", + "crashPause", + "outcome", + "restartCount", + "attemptExitCode", + "recoveryExitCode", + "processCount", + "freshProcess", + "durableBeforeCrash", + "durableAfterRecovery", + "externalCallAttempts", + "externalEffects", + "compensationAttempts", + "compensationEffects", + "publicationAttempts", + "duplicateEffect", + "committedWithoutEvidence", + "trace" + ], + "properties": { + "restartCount": { "const": 1 }, + "attemptExitCode": { "const": 86 }, + "recoveryExitCode": { "const": 0 }, + "processCount": { "const": 2 }, + "freshProcess": { "const": true }, + "duplicateEffect": { "const": false }, + "committedWithoutEvidence": { "const": false } + } + } + }, + "negativeControls": { "type": "array", "minItems": 4 } + } + }, + "effects": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "commandKind", + "effectOwner", + "effectRole", + "effectId", + "recoveryClass", + "candidateRecoveryClass", + "proofRequired", + "currentEvidence", + "ambiguousOutcome", + "automaticRecoveryAdmitted", + "currentRecoveryDisposition", + "writerAuthority", + "writerEvidenceRef" + ], + "properties": { + "effectRole": { "enum": ["coordinator_effect", "secondary_effect"] } + } + } + } + } + } + }, + { + "if": { "properties": { "evidenceId": { "const": "P0.W5.FINGERPRINT_GOLDENS" } } }, + "then": { + "required": [ + "encoder", + "algorithm", + "storedMaterial", + "immutableOracle", + "immutableOracleVectorCount", + "cases", + "assertions" + ], + "properties": { + "cases": { + "type": "array", + "minItems": 10, + "items": { + "type": "object", + "required": [ + "id", + "descriptorId", + "schemaVersion", + "fingerprintVersion", + "keyVersion", + "intent", + "digest" + ], + "properties": { "digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" } } + } + }, + "assertions": { + "type": "object", + "required": [ + "fieldOrderEqual", + "changedIntentDiffers", + "omittedDefaultEqualsMaterialized", + "schemaVersionDiffers", + "keyVersionDiffers", + "fingerprintVersionDiffers", + "retainedFingerprintV1StillComputable", + "retainedSameIntentOutcome", + "changedIntentReuseOutcome", + "immutableOracleMatch" + ], + "properties": { + "fieldOrderEqual": { "const": true }, + "changedIntentDiffers": { "const": true }, + "omittedDefaultEqualsMaterialized": { "const": true }, + "schemaVersionDiffers": { "const": true }, + "keyVersionDiffers": { "const": true }, + "fingerprintVersionDiffers": { "const": true }, + "immutableOracleMatch": { "const": true }, + "retainedFingerprintV1StillComputable": { "const": true }, + "retainedSameIntentOutcome": { "const": "same_intent" }, + "changedIntentReuseOutcome": { "const": "idempotency_mismatch" } + } + } + } + } + }, + { + "if": { "properties": { "evidenceId": { "const": "P0.W5.ESTIMATE" } } }, + "then": { + "required": [ + "bucketId", + "packages", + "productionLines", + "testLines", + "deletedLines", + "excludedGeneratedVendorLines", + "overlap", + "confidence", + "assumptions", + "evidenceRefs", + "totalChangedLines", + "reestimateTriggers" + ], + "properties": { + "bucketId": { "const": "EST-RECOVERY-STATE" }, + "excludedGeneratedVendorLines": { "const": true }, + "packages": { "type": "array", "minItems": 1 }, + "overlap": { "type": "array", "minItems": 1 }, + "assumptions": { "type": "array", "minItems": 1 }, + "evidenceRefs": { "type": "array", "minItems": 5 } + } + } + } + ] +} diff --git a/docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json b/docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json new file mode 100644 index 0000000000..ec3a886213 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/fingerprint-goldens.json @@ -0,0 +1 @@ +{"schemaVersion":1,"evidenceId":"P0.W5.FINGERPRINT_GOLDENS","encoder":"recursive UTF-8 byte-length-delimited typed encoding; object keys sorted; safe integers only","algorithm":"HMAC-SHA-256","fixtureKeys":"public test-only keys are held by the generator and never represent production secrets","storedMaterial":"descriptor/schema/fingerprint/key versions and digest only; no command body","immutableOracle":"test/architecture/hosted-web/phase-0/recovery-events/fixtures/fingerprint-oracle-vectors.json","immutableOracleVectorCount":10,"cases":[{"id":"send-v1-field-order-a","descriptorId":"message.send","schemaVersion":1,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","intent":{"teamId":"team_01","messageId":"msg_01","contentDigest":"sha256:aaaa","attachmentDigests":[]},"digest":"bff87d62cce4e461ba8d928177491b3c27f8c2b8c340f8d59976d56414c0013f"},{"id":"send-v1-field-order-b","descriptorId":"message.send","schemaVersion":1,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","intent":{"attachmentDigests":[],"contentDigest":"sha256:aaaa","messageId":"msg_01","teamId":"team_01"},"digest":"bff87d62cce4e461ba8d928177491b3c27f8c2b8c340f8d59976d56414c0013f"},{"id":"send-v1-changed-intent","descriptorId":"message.send","schemaVersion":1,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","intent":{"teamId":"team_01","messageId":"msg_01","contentDigest":"sha256:bbbb","attachmentDigests":[]},"digest":"c3977e7de11c66e0cbf865b37dbbe7357b1d497e346fd9c15688b6a171fb0968"},{"id":"send-v1-ordered-attachment-array","descriptorId":"message.send","schemaVersion":1,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","intent":{"teamId":"team_01","messageId":"msg_02","contentDigest":"sha256:eeee","attachmentDigests":["sha256:one","sha256:two"]},"digest":"edd21cbcaa722321e22ae344b0d0a0e01efcb1a1917fec2e0240fabe7fd3f475"},{"id":"unicode-and-integer-bounds-v1","descriptorId":"task.create","schemaVersion":1,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","intent":{"teamId":"team_雪","taskId":"task_é","expectedTeamRevision":9007199254740991,"taskIntentDigest":"sha256:cccc"},"digest":"422545eaf369b65409bd0d34627f8c6aed5e4c78e0ccef045de721c782aa2a99"},{"id":"launch-default-materialized-v1","descriptorId":"team.launch","schemaVersion":1,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","normalizationCase":"explicit_defaults","intent":{"teamId":"team_01","providerPlanDigest":"sha256:dddd","effort":"medium","fast":false},"digest":"6e576d1a287ce4a9cd230ad9501d64c676ea4fd86f9fe034b0eff4821b7bbf2b"},{"id":"launch-default-omitted-v1","descriptorId":"team.launch","schemaVersion":1,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","normalizationCase":"omitted_defaults_materialized_before_fingerprint","intent":{"teamId":"team_01","providerPlanDigest":"sha256:dddd","effort":"medium","fast":false},"digest":"6e576d1a287ce4a9cd230ad9501d64c676ea4fd86f9fe034b0eff4821b7bbf2b"},{"id":"launch-schema-v2-retained-key-v1","descriptorId":"team.launch","schemaVersion":2,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v1","intent":{"teamId":"team_01","providerPlanDigest":"sha256:dddd","effort":"medium","fast":false,"topologyVersion":2},"digest":"cee5c0e28ac50517e1a01b50f33343b761c063c595b7faddaf715fce96d32cbe"},{"id":"launch-key-rotation-v2","descriptorId":"team.launch","schemaVersion":2,"fingerprintVersion":"hmac-sha256-ld-v1","keyVersion":"fixture-v2","intent":{"teamId":"team_01","providerPlanDigest":"sha256:dddd","effort":"medium","fast":false,"topologyVersion":2},"digest":"2463ce97368473ed48e3803657b8e4bc451e2ef3b600fe26373489a0b0411225"},{"id":"launch-fingerprint-version-v2-retained-key-v1","descriptorId":"team.launch","schemaVersion":2,"fingerprintVersion":"hmac-sha256-ld-v2","keyVersion":"fixture-v1","intent":{"teamId":"team_01","providerPlanDigest":"sha256:dddd","effort":"medium","fast":false,"topologyVersion":2},"digest":"33008b554a9fc846d19f411d4367baca668d91eec7daa9fe7ab7721a55f972d7"}],"assertions":{"fieldOrderEqual":true,"changedIntentDiffers":true,"omittedDefaultEqualsMaterialized":true,"schemaVersionDiffers":true,"keyVersionDiffers":true,"fingerprintVersionDiffers":true,"retainedFingerprintV1StillComputable":true,"retainedSameIntentOutcome":"same_intent","changedIntentReuseOutcome":"idempotency_mismatch","immutableOracleMatch":true}} diff --git a/docs/research/hosted-web/phase-0/recovery-events/index.json b/docs/research/hosted-web/phase-0/recovery-events/index.json new file mode 100644 index 0000000000..0d6694a6c3 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/index.json @@ -0,0 +1 @@ +{"schemaVersion":1,"laneId":"w5","packetRevision":"phase-00-r2","phaseStartSha":"a32f509e6d9bd31ba2135940e336729bf90c3d93","supportingArtifacts":[{"id":"P0.W5.SUPPORTING.MUTATION_CENSUS","path":"mutation-census.json"},{"id":"P0.W5.SUPPORTING.MUTATION_SURFACE_MANIFEST","path":"mutation-surface-manifest.json"}],"evidence":[{"id":"P0.W5.EVENT_CURSOR_INVENTORY","path":"event-cursor-inventory.json"},{"id":"P0.W5.SNAPSHOT_HANDOFF_SCHEDULER","path":"snapshot-handoff-scheduler.json"},{"id":"P0.W5.COMMAND_CATALOG","path":"command-catalog.json"},{"id":"P0.W5.EFFECT_RECOVERY_MATRIX","path":"effect-recovery-matrix.json"},{"id":"P0.W5.FINGERPRINT_GOLDENS","path":"fingerprint-goldens.json"},{"id":"P0.W5.ESTIMATE","path":"estimate-input.json"}]} diff --git a/docs/research/hosted-web/phase-0/recovery-events/index.schema.json b/docs/research/hosted-web/phase-0/recovery-events/index.schema.json new file mode 100644 index 0000000000..69b232099a --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/index.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-teams.local/schemas/phase-00-w5-index.schema.json", + "title": "Phase 0 W5 evidence index", + "type": "object", + "required": [ + "schemaVersion", + "laneId", + "packetRevision", + "phaseStartSha", + "supportingArtifacts", + "evidence" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "laneId": { "const": "w5" }, + "packetRevision": { "const": "phase-00-r2" }, + "phaseStartSha": { "const": "a32f509e6d9bd31ba2135940e336729bf90c3d93" }, + "supportingArtifacts": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "required": ["id", "path"], + "properties": { + "id": { + "enum": [ + "P0.W5.SUPPORTING.MUTATION_CENSUS", + "P0.W5.SUPPORTING.MUTATION_SURFACE_MANIFEST" + ] + }, + "path": { + "enum": ["mutation-census.json", "mutation-surface-manifest.json"] + } + } + } + }, + "evidence": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "items": { + "type": "object", + "required": ["id", "path"], + "properties": { + "id": { "type": "string", "pattern": "^P0\\.W5\\." }, + "path": { "type": "string", "pattern": "\\.json$" } + } + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/recovery-events/mutation-census.json b/docs/research/hosted-web/phase-0/recovery-events/mutation-census.json new file mode 100644 index 0000000000..297b4bac97 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/mutation-census.json @@ -0,0 +1,1249 @@ +{ + "schemaVersion": 2, + "artifactId": "P0.W5.SUPPORTING.MUTATION_CENSUS", + "sourceSnapshotSha256": "fead898e0df82bbe8efe197a392b267412585167d150525fdaaeb3cf452dddf8", + "derivation": "TypeScript AST extraction compared bidirectionally with the independently maintained mutation-surface-manifest.json and command descriptors; sourceSnapshotSha256 is a topology-independent digest over every sorted source path and its exact bytes", + "sourceFiles": [ + "src/shared/types/api.ts", + "src/features/team-approvals/contracts/api.ts", + "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts" + ], + "rowCount": 128, + "dispositionCounts": { + "extracted": 128, + "dispositions": 128, + "required": 53, + "query": 48, + "ephemeral": 15, + "deferred": 12 + }, + "rows": [ + { + "id": "TeamsAPI.list", + "interfaceName": "TeamsAPI", + "sourceMethod": "list", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getData", + "interfaceName": "TeamsAPI", + "sourceMethod": "getData", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskChangePresence", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskChangePresence", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.setChangePresenceTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setChangePresenceTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.setToolActivityTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setToolActivityTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.setTaskLogStreamTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setTaskLogStreamTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getClaudeLogs", + "interfaceName": "TeamsAPI", + "sourceMethod": "getClaudeLogs", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.deleteTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "deleteTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.soft_delete", + "sourceObserved": true + }, + { + "id": "TeamsAPI.restoreTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "restoreTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.restore", + "sourceObserved": true + }, + { + "id": "TeamsAPI.permanentlyDeleteTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "permanentlyDeleteTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.permanent_delete", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getSavedRequest", + "interfaceName": "TeamsAPI", + "sourceMethod": "getSavedRequest", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.deleteDraft", + "interfaceName": "TeamsAPI", + "sourceMethod": "deleteDraft", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.draft_delete", + "sourceObserved": true + }, + { + "id": "TeamsAPI.prepareProvisioning", + "interfaceName": "TeamsAPI", + "sourceMethod": "prepareProvisioning", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getWorktreeGitStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "getWorktreeGitStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.initializeGitRepository", + "interfaceName": "TeamsAPI", + "sourceMethod": "initializeGitRepository", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "workspace-registry", + "commandKind": "git.initialize_repository", + "sourceObserved": true + }, + { + "id": "TeamsAPI.createInitialGitCommit", + "interfaceName": "TeamsAPI", + "sourceMethod": "createInitialGitCommit", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "workspace-registry", + "commandKind": "git.create_initial_commit", + "sourceObserved": true + }, + { + "id": "TeamsAPI.createTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "createTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.create_draft", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getProvisioningStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "getProvisioningStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getLaunchFailureDiagnostics", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLaunchFailureDiagnostics", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.cancelProvisioning", + "interfaceName": "TeamsAPI", + "sourceMethod": "cancelProvisioning", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.cancel_provisioning", + "sourceObserved": true + }, + { + "id": "TeamsAPI.sendMessage", + "interfaceName": "TeamsAPI", + "sourceMethod": "sendMessage", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-messaging", + "commandKind": "message.send", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getOpenCodeRuntimeDeliveryStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "getOpenCodeRuntimeDeliveryStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getMessagesPage", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMessagesPage", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getMemberActivityMeta", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberActivityMeta", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.createTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "createTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.create", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.requestReview", + "interfaceName": "TeamsAPI", + "sourceMethod": "requestReview", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.request_review", + "sourceObserved": true + }, + { + "id": "TeamsAPI.updateKanban", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateKanban", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_kanban", + "sourceObserved": true + }, + { + "id": "TeamsAPI.updateKanbanColumnOrder", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateKanbanColumnOrder", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "kanban.reorder_column", + "sourceObserved": true + }, + { + "id": "TeamsAPI.updateTaskStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateTaskStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_status", + "sourceObserved": true + }, + { + "id": "TeamsAPI.updateTaskOwner", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateTaskOwner", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_owner", + "sourceObserved": true + }, + { + "id": "TeamsAPI.updateTaskFields", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateTaskFields", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_fields", + "sourceObserved": true + }, + { + "id": "TeamsAPI.startTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "startTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.start", + "sourceObserved": true + }, + { + "id": "TeamsAPI.startTaskByUser", + "interfaceName": "TeamsAPI", + "sourceMethod": "startTaskByUser", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.start", + "sourceObserved": true + }, + { + "id": "TeamsAPI.processSend", + "interfaceName": "TeamsAPI", + "sourceMethod": "processSend", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-messaging", + "commandKind": "message.send", + "sourceObserved": true + }, + { + "id": "TeamsAPI.processAlive", + "interfaceName": "TeamsAPI", + "sourceMethod": "processAlive", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.aliveList", + "interfaceName": "TeamsAPI", + "sourceMethod": "aliveList", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.stop", + "interfaceName": "TeamsAPI", + "sourceMethod": "stop", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.stop", + "sourceObserved": true + }, + { + "id": "TeamsAPI.createConfig", + "interfaceName": "TeamsAPI", + "sourceMethod": "createConfig", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.create_draft", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getMemberLogs", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberLogs", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getLogsForTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLogsForTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskActivity", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskActivity", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskActivityDetail", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskActivityDetail", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskLogStreamSummary", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskLogStreamSummary", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskLogStream", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskLogStream", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskExactLogSummaries", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskExactLogSummaries", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskExactLogDetail", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskExactLogDetail", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getMemberStats", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberStats", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.launchTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "launchTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.launch", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getAllTasks", + "interfaceName": "TeamsAPI", + "sourceMethod": "getAllTasks", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.updateConfig", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateConfig", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.config_update", + "sourceObserved": true + }, + { + "id": "TeamsAPI.addMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "addMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.add", + "sourceObserved": true + }, + { + "id": "TeamsAPI.replaceMembers", + "interfaceName": "TeamsAPI", + "sourceMethod": "replaceMembers", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.replace_roster", + "sourceObserved": true + }, + { + "id": "TeamsAPI.removeMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "removeMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.remove", + "sourceObserved": true + }, + { + "id": "TeamsAPI.restoreMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "restoreMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.restore", + "sourceObserved": true + }, + { + "id": "TeamsAPI.updateMemberRole", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateMemberRole", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.update_role", + "sourceObserved": true + }, + { + "id": "TeamsAPI.addTaskComment", + "interfaceName": "TeamsAPI", + "sourceMethod": "addTaskComment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.add_comment", + "sourceObserved": true + }, + { + "id": "TeamsAPI.setTaskClarification", + "interfaceName": "TeamsAPI", + "sourceMethod": "setTaskClarification", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.set_clarification", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getProjectBranch", + "interfaceName": "TeamsAPI", + "sourceMethod": "getProjectBranch", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.setProjectBranchTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setProjectBranchTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getAttachments", + "interfaceName": "TeamsAPI", + "sourceMethod": "getAttachments", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.killProcess", + "interfaceName": "TeamsAPI", + "sourceMethod": "killProcess", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "process.kill", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getLeadActivity", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLeadActivity", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getLeadContext", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLeadContext", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getMemberSpawnStatuses", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberSpawnStatuses", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTeamAgentRuntime", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTeamAgentRuntime", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.retryFailedOpenCodeSecondaryLanes", + "interfaceName": "TeamsAPI", + "sourceMethod": "retryFailedOpenCodeSecondaryLanes", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "member.retry_failed_lanes", + "sourceObserved": true + }, + { + "id": "TeamsAPI.restartMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "restartMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.restart", + "sourceObserved": true + }, + { + "id": "TeamsAPI.skipMemberForLaunch", + "interfaceName": "TeamsAPI", + "sourceMethod": "skipMemberForLaunch", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.skip_for_launch", + "sourceObserved": true + }, + { + "id": "TeamsAPI.softDeleteTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "softDeleteTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.soft_delete", + "sourceObserved": true + }, + { + "id": "TeamsAPI.restoreTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "restoreTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.restore", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getDeletedTasks", + "interfaceName": "TeamsAPI", + "sourceMethod": "getDeletedTasks", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.showMessageNotification", + "interfaceName": "TeamsAPI", + "sourceMethod": "showMessageNotification", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.addTaskRelationship", + "interfaceName": "TeamsAPI", + "sourceMethod": "addTaskRelationship", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.relationship_add", + "sourceObserved": true + }, + { + "id": "TeamsAPI.removeTaskRelationship", + "interfaceName": "TeamsAPI", + "sourceMethod": "removeTaskRelationship", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.relationship_remove", + "sourceObserved": true + }, + { + "id": "TeamsAPI.saveTaskAttachment", + "interfaceName": "TeamsAPI", + "sourceMethod": "saveTaskAttachment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "agent-attachments", + "commandKind": "task.attachment_save", + "sourceObserved": true + }, + { + "id": "TeamsAPI.getTaskAttachment", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskAttachment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.deleteTaskAttachment", + "interfaceName": "TeamsAPI", + "sourceMethod": "deleteTaskAttachment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "agent-attachments", + "commandKind": "task.attachment_delete", + "sourceObserved": true + }, + { + "id": "TeamsAPI.onProjectBranchChange", + "interfaceName": "TeamsAPI", + "sourceMethod": "onProjectBranchChange", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.onTeamChange", + "interfaceName": "TeamsAPI", + "sourceMethod": "onTeamChange", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamsAPI.onProvisioningProgress", + "interfaceName": "TeamsAPI", + "sourceMethod": "onProvisioningProgress", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamApprovalsElectronApi.respondToToolApproval", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "respondToToolApproval", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-approvals", + "commandKind": "approval.decide", + "sourceObserved": true + }, + { + "id": "TeamsAPI.validateCliArgs", + "interfaceName": "TeamsAPI", + "sourceMethod": "validateCliArgs", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamApprovalsElectronApi.onToolApprovalEvent", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "onToolApprovalEvent", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "ephemeral", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "TeamApprovalsElectronApi.updateToolApprovalSettings", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "updateToolApprovalSettings", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-approvals", + "commandKind": "approval.policy_update", + "sourceObserved": true + }, + { + "id": "TeamApprovalsElectronApi.readFileForToolApproval", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "readFileForToolApproval", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "query", + "owner": "team-read", + "sourceObserved": true + }, + { + "id": "CrossTeamAPI.send", + "interfaceName": "CrossTeamAPI", + "sourceMethod": "send", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-messaging", + "commandKind": "cross_team_message.send", + "sourceObserved": true + }, + { + "id": "CrossTeamAPI.listTargets", + "interfaceName": "CrossTeamAPI", + "sourceMethod": "listTargets", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-messaging", + "sourceObserved": true + }, + { + "id": "CrossTeamAPI.getOutbox", + "interfaceName": "CrossTeamAPI", + "sourceMethod": "getOutbox", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-messaging", + "sourceObserved": true + }, + { + "id": "ReviewAPI.getAgentChanges", + "interfaceName": "ReviewAPI", + "sourceMethod": "getAgentChanges", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.getTaskChanges", + "interfaceName": "ReviewAPI", + "sourceMethod": "getTaskChanges", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.getTeamTaskChangeSummaries", + "interfaceName": "ReviewAPI", + "sourceMethod": "getTeamTaskChangeSummaries", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.invalidateTaskChangeSummaries", + "interfaceName": "ReviewAPI", + "sourceMethod": "invalidateTaskChangeSummaries", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.getChangeStats", + "interfaceName": "ReviewAPI", + "sourceMethod": "getChangeStats", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.getFileContent", + "interfaceName": "ReviewAPI", + "sourceMethod": "getFileContent", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.applyDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "applyDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.apply_decisions", + "sourceObserved": true + }, + { + "id": "ReviewAPI.executeMutation", + "interfaceName": "ReviewAPI", + "sourceMethod": "executeMutation", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.retryMutationRecovery", + "interfaceName": "ReviewAPI", + "sourceMethod": "retryMutationRecovery", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.restoreHistory", + "interfaceName": "ReviewAPI", + "sourceMethod": "restoreHistory", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.checkConflict", + "interfaceName": "ReviewAPI", + "sourceMethod": "checkConflict", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.rejectHunks", + "interfaceName": "ReviewAPI", + "sourceMethod": "rejectHunks", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.reject_hunks", + "sourceObserved": true + }, + { + "id": "ReviewAPI.rejectFile", + "interfaceName": "ReviewAPI", + "sourceMethod": "rejectFile", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.reject_file", + "sourceObserved": true + }, + { + "id": "ReviewAPI.previewReject", + "interfaceName": "ReviewAPI", + "sourceMethod": "previewReject", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.saveEditedFile", + "interfaceName": "ReviewAPI", + "sourceMethod": "saveEditedFile", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.save_edited_file", + "sourceObserved": true + }, + { + "id": "ReviewAPI.deleteEditedFile", + "interfaceName": "ReviewAPI", + "sourceMethod": "deleteEditedFile", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.restoreRejectedRename", + "interfaceName": "ReviewAPI", + "sourceMethod": "restoreRejectedRename", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.reapplyRejectedRename", + "interfaceName": "ReviewAPI", + "sourceMethod": "reapplyRejectedRename", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.watchFiles", + "interfaceName": "ReviewAPI", + "sourceMethod": "watchFiles", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.unwatchFiles", + "interfaceName": "ReviewAPI", + "sourceMethod": "unwatchFiles", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.onExternalFileChange", + "interfaceName": "ReviewAPI", + "sourceMethod": "onExternalFileChange", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.loadDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.saveDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "saveDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.save_decisions", + "sourceObserved": true + }, + { + "id": "ReviewAPI.clearDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "clearDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.clear_decisions", + "sourceObserved": true + }, + { + "id": "ReviewAPI.loadDecisionConflictCandidates", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDecisionConflictCandidates", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.resolveDecisionConflictCandidate", + "interfaceName": "ReviewAPI", + "sourceMethod": "resolveDecisionConflictCandidate", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.loadDraftHistory", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDraftHistory", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.saveDraftHistoryEntry", + "interfaceName": "ReviewAPI", + "sourceMethod": "saveDraftHistoryEntry", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.clearDraftHistory", + "interfaceName": "ReviewAPI", + "sourceMethod": "clearDraftHistory", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.loadDraftHistoryConflictCandidates", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDraftHistoryConflictCandidates", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.resolveDraftHistoryConflictCandidate", + "interfaceName": "ReviewAPI", + "sourceMethod": "resolveDraftHistoryConflictCandidate", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.replaceDraftHistoryConflictCandidate", + "interfaceName": "ReviewAPI", + "sourceMethod": "replaceDraftHistoryConflictCandidate", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists", + "sourceObserved": true + }, + { + "id": "ReviewAPI.onCmdN", + "interfaceName": "ReviewAPI", + "sourceMethod": "onCmdN", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "ReviewAPI.getGitFileLog", + "interfaceName": "ReviewAPI", + "sourceMethod": "getGitFileLog", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review", + "sourceObserved": true + }, + { + "id": "OpenCodeRuntimeControlApi.recordOpenCodeRuntimeBootstrapCheckin", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "recordOpenCodeRuntimeBootstrapCheckin", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.bootstrap_checkin", + "sourceObserved": true + }, + { + "id": "OpenCodeRuntimeControlApi.deliverOpenCodeRuntimeMessage", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "deliverOpenCodeRuntimeMessage", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.deliver_message", + "sourceObserved": true + }, + { + "id": "OpenCodeRuntimeControlApi.recordOpenCodeRuntimeTaskEvent", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "recordOpenCodeRuntimeTaskEvent", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.task_event", + "sourceObserved": true + }, + { + "id": "OpenCodeRuntimeControlApi.recordOpenCodeRuntimeHeartbeat", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "recordOpenCodeRuntimeHeartbeat", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.heartbeat", + "sourceObserved": true + }, + { + "id": "OpenCodeRuntimeControlApi.answerOpenCodeRuntimePermission", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "answerOpenCodeRuntimePermission", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "deferred", + "owner": "team-approvals", + "reason": "legacy wrong-direction callback; target is runtime.permission-request ingress plus operator-owned permission.decision outbound", + "sourceObserved": true + } + ], + "assertions": { + "everyRowSourceObserved": true, + "sourceToManifestComplete": true, + "manifestToSourceComplete": true, + "everyMutationMappedExactlyOnce": true, + "noCatalogMethodOutsideRequiredDisposition": true, + "ownerAgreement": true, + "crossLaneOwnerAgreement": true, + "omissionNegativeFixturesRejected": true + } +} diff --git a/docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json b/docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json new file mode 100644 index 0000000000..bae5a43807 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/mutation-census.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-teams.local/schemas/phase-00-w5-mutation-census.schema.json", + "title": "Phase 0 W5 independent mutation census", + "type": "object", + "required": [ + "schemaVersion", + "artifactId", + "sourceSnapshotSha256", + "derivation", + "sourceFiles", + "rowCount", + "rows", + "assertions" + ], + "properties": { + "schemaVersion": { "const": 2 }, + "artifactId": { "const": "P0.W5.SUPPORTING.MUTATION_CENSUS" }, + "sourceSnapshotSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "derivation": { "type": "string" }, + "sourceFiles": { "type": "array", "minItems": 2 }, + "rowCount": { "type": "integer", "minimum": 1 }, + "rows": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "interfaceName", + "sourceMethod", + "disposition", + "owner", + "sourceFile", + "sourceObserved" + ], + "properties": { + "disposition": { + "enum": ["required_hosted_v1_mutation", "query", "ephemeral", "deferred"] + }, + "sourceObserved": { "const": true } + } + } + }, + "assertions": { + "type": "object", + "required": [ + "everyRowSourceObserved", + "sourceToManifestComplete", + "manifestToSourceComplete", + "everyMutationMappedExactlyOnce", + "noCatalogMethodOutsideRequiredDisposition", + "ownerAgreement", + "crossLaneOwnerAgreement", + "omissionNegativeFixturesRejected" + ], + "properties": { + "everyRowSourceObserved": { "const": true }, + "sourceToManifestComplete": { "const": true }, + "manifestToSourceComplete": { "const": true }, + "everyMutationMappedExactlyOnce": { "const": true }, + "noCatalogMethodOutsideRequiredDisposition": { "const": true }, + "ownerAgreement": { "const": true }, + "crossLaneOwnerAgreement": { "const": true }, + "omissionNegativeFixturesRejected": { "const": true } + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json b/docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json new file mode 100644 index 0000000000..7a7c6400d5 --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json @@ -0,0 +1,1122 @@ +{ + "schemaVersion": 1, + "artifactId": "P0.W5.SUPPORTING.MUTATION_SURFACE_MANIFEST", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "remediationBaseSha": "0e8431b1935c71a2e77bea1384b134ee25c8aa12", + "derivation": "reviewed disposition manifest maintained independently from the command generator; exhaustive interface members are verified bidirectionally by TypeScript AST extraction", + "sourceScopes": [ + { + "sourceFile": "src/shared/types/api.ts", + "interfaces": ["TeamsAPI", "CrossTeamAPI", "ReviewAPI"] + }, + { + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "interfaces": ["TeamApprovalsElectronApi"] + }, + { + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "interfaces": ["OpenCodeRuntimeControlApi"] + } + ], + "deferredScopes": [ + { + "scope": "ScheduleAPI mutations", + "reason": "explicitly deferred outside hosted v1 recovery command catalog" + }, + { + "scope": "terminal input/tab/split commands", + "reason": "explicitly deferred to terminal boundary work" + } + ], + "rows": [ + { + "id": "TeamsAPI.list", + "interfaceName": "TeamsAPI", + "sourceMethod": "list", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getData", + "interfaceName": "TeamsAPI", + "sourceMethod": "getData", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTaskChangePresence", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskChangePresence", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.setChangePresenceTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setChangePresenceTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.setToolActivityTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setToolActivityTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.setTaskLogStreamTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setTaskLogStreamTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getClaudeLogs", + "interfaceName": "TeamsAPI", + "sourceMethod": "getClaudeLogs", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.deleteTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "deleteTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.soft_delete" + }, + { + "id": "TeamsAPI.restoreTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "restoreTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.restore" + }, + { + "id": "TeamsAPI.permanentlyDeleteTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "permanentlyDeleteTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.permanent_delete" + }, + { + "id": "TeamsAPI.getSavedRequest", + "interfaceName": "TeamsAPI", + "sourceMethod": "getSavedRequest", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.deleteDraft", + "interfaceName": "TeamsAPI", + "sourceMethod": "deleteDraft", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.draft_delete" + }, + { + "id": "TeamsAPI.prepareProvisioning", + "interfaceName": "TeamsAPI", + "sourceMethod": "prepareProvisioning", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getWorktreeGitStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "getWorktreeGitStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.initializeGitRepository", + "interfaceName": "TeamsAPI", + "sourceMethod": "initializeGitRepository", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "workspace-registry", + "commandKind": "git.initialize_repository" + }, + { + "id": "TeamsAPI.createInitialGitCommit", + "interfaceName": "TeamsAPI", + "sourceMethod": "createInitialGitCommit", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "workspace-registry", + "commandKind": "git.create_initial_commit" + }, + { + "id": "TeamsAPI.createTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "createTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.create_draft" + }, + { + "id": "TeamsAPI.getProvisioningStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "getProvisioningStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getLaunchFailureDiagnostics", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLaunchFailureDiagnostics", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.cancelProvisioning", + "interfaceName": "TeamsAPI", + "sourceMethod": "cancelProvisioning", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.cancel_provisioning" + }, + { + "id": "TeamsAPI.sendMessage", + "interfaceName": "TeamsAPI", + "sourceMethod": "sendMessage", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-messaging", + "commandKind": "message.send" + }, + { + "id": "TeamsAPI.getOpenCodeRuntimeDeliveryStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "getOpenCodeRuntimeDeliveryStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getMessagesPage", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMessagesPage", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getMemberActivityMeta", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberActivityMeta", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.createTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "createTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.create" + }, + { + "id": "TeamsAPI.getTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.requestReview", + "interfaceName": "TeamsAPI", + "sourceMethod": "requestReview", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.request_review" + }, + { + "id": "TeamsAPI.updateKanban", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateKanban", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_kanban" + }, + { + "id": "TeamsAPI.updateKanbanColumnOrder", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateKanbanColumnOrder", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "kanban.reorder_column" + }, + { + "id": "TeamsAPI.updateTaskStatus", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateTaskStatus", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_status" + }, + { + "id": "TeamsAPI.updateTaskOwner", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateTaskOwner", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_owner" + }, + { + "id": "TeamsAPI.updateTaskFields", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateTaskFields", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.update_fields" + }, + { + "id": "TeamsAPI.startTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "startTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.start" + }, + { + "id": "TeamsAPI.startTaskByUser", + "interfaceName": "TeamsAPI", + "sourceMethod": "startTaskByUser", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.start" + }, + { + "id": "TeamsAPI.processSend", + "interfaceName": "TeamsAPI", + "sourceMethod": "processSend", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-messaging", + "commandKind": "message.send" + }, + { + "id": "TeamsAPI.processAlive", + "interfaceName": "TeamsAPI", + "sourceMethod": "processAlive", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.aliveList", + "interfaceName": "TeamsAPI", + "sourceMethod": "aliveList", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.stop", + "interfaceName": "TeamsAPI", + "sourceMethod": "stop", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.stop" + }, + { + "id": "TeamsAPI.createConfig", + "interfaceName": "TeamsAPI", + "sourceMethod": "createConfig", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.create_draft" + }, + { + "id": "TeamsAPI.getMemberLogs", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberLogs", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getLogsForTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLogsForTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTaskActivity", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskActivity", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTaskActivityDetail", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskActivityDetail", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTaskLogStreamSummary", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskLogStreamSummary", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTaskLogStream", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskLogStream", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTaskExactLogSummaries", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskExactLogSummaries", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTaskExactLogDetail", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskExactLogDetail", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getMemberStats", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberStats", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.launchTeam", + "interfaceName": "TeamsAPI", + "sourceMethod": "launchTeam", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.launch" + }, + { + "id": "TeamsAPI.getAllTasks", + "interfaceName": "TeamsAPI", + "sourceMethod": "getAllTasks", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.updateConfig", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateConfig", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "team.config_update" + }, + { + "id": "TeamsAPI.addMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "addMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.add" + }, + { + "id": "TeamsAPI.replaceMembers", + "interfaceName": "TeamsAPI", + "sourceMethod": "replaceMembers", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.replace_roster" + }, + { + "id": "TeamsAPI.removeMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "removeMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.remove" + }, + { + "id": "TeamsAPI.restoreMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "restoreMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.restore" + }, + { + "id": "TeamsAPI.updateMemberRole", + "interfaceName": "TeamsAPI", + "sourceMethod": "updateMemberRole", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.update_role" + }, + { + "id": "TeamsAPI.addTaskComment", + "interfaceName": "TeamsAPI", + "sourceMethod": "addTaskComment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.add_comment" + }, + { + "id": "TeamsAPI.setTaskClarification", + "interfaceName": "TeamsAPI", + "sourceMethod": "setTaskClarification", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.set_clarification" + }, + { + "id": "TeamsAPI.getProjectBranch", + "interfaceName": "TeamsAPI", + "sourceMethod": "getProjectBranch", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.setProjectBranchTracking", + "interfaceName": "TeamsAPI", + "sourceMethod": "setProjectBranchTracking", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getAttachments", + "interfaceName": "TeamsAPI", + "sourceMethod": "getAttachments", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.killProcess", + "interfaceName": "TeamsAPI", + "sourceMethod": "killProcess", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "process.kill" + }, + { + "id": "TeamsAPI.getLeadActivity", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLeadActivity", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getLeadContext", + "interfaceName": "TeamsAPI", + "sourceMethod": "getLeadContext", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getMemberSpawnStatuses", + "interfaceName": "TeamsAPI", + "sourceMethod": "getMemberSpawnStatuses", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.getTeamAgentRuntime", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTeamAgentRuntime", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.retryFailedOpenCodeSecondaryLanes", + "interfaceName": "TeamsAPI", + "sourceMethod": "retryFailedOpenCodeSecondaryLanes", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "member.retry_failed_lanes" + }, + { + "id": "TeamsAPI.restartMember", + "interfaceName": "TeamsAPI", + "sourceMethod": "restartMember", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.restart" + }, + { + "id": "TeamsAPI.skipMemberForLaunch", + "interfaceName": "TeamsAPI", + "sourceMethod": "skipMemberForLaunch", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-lifecycle", + "commandKind": "member.skip_for_launch" + }, + { + "id": "TeamsAPI.softDeleteTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "softDeleteTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.soft_delete" + }, + { + "id": "TeamsAPI.restoreTask", + "interfaceName": "TeamsAPI", + "sourceMethod": "restoreTask", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.restore" + }, + { + "id": "TeamsAPI.getDeletedTasks", + "interfaceName": "TeamsAPI", + "sourceMethod": "getDeletedTasks", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.showMessageNotification", + "interfaceName": "TeamsAPI", + "sourceMethod": "showMessageNotification", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.addTaskRelationship", + "interfaceName": "TeamsAPI", + "sourceMethod": "addTaskRelationship", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.relationship_add" + }, + { + "id": "TeamsAPI.removeTaskRelationship", + "interfaceName": "TeamsAPI", + "sourceMethod": "removeTaskRelationship", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-task-board", + "commandKind": "task.relationship_remove" + }, + { + "id": "TeamsAPI.saveTaskAttachment", + "interfaceName": "TeamsAPI", + "sourceMethod": "saveTaskAttachment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "agent-attachments", + "commandKind": "task.attachment_save" + }, + { + "id": "TeamsAPI.getTaskAttachment", + "interfaceName": "TeamsAPI", + "sourceMethod": "getTaskAttachment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamsAPI.deleteTaskAttachment", + "interfaceName": "TeamsAPI", + "sourceMethod": "deleteTaskAttachment", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "agent-attachments", + "commandKind": "task.attachment_delete" + }, + { + "id": "TeamsAPI.onProjectBranchChange", + "interfaceName": "TeamsAPI", + "sourceMethod": "onProjectBranchChange", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.onTeamChange", + "interfaceName": "TeamsAPI", + "sourceMethod": "onTeamChange", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamsAPI.onProvisioningProgress", + "interfaceName": "TeamsAPI", + "sourceMethod": "onProvisioningProgress", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamApprovalsElectronApi.respondToToolApproval", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "respondToToolApproval", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-approvals", + "commandKind": "approval.decide" + }, + { + "id": "TeamsAPI.validateCliArgs", + "interfaceName": "TeamsAPI", + "sourceMethod": "validateCliArgs", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "TeamApprovalsElectronApi.onToolApprovalEvent", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "onToolApprovalEvent", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "ephemeral", + "owner": "team-read" + }, + { + "id": "TeamApprovalsElectronApi.updateToolApprovalSettings", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "updateToolApprovalSettings", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-approvals", + "commandKind": "approval.policy_update" + }, + { + "id": "TeamApprovalsElectronApi.readFileForToolApproval", + "interfaceName": "TeamApprovalsElectronApi", + "sourceMethod": "readFileForToolApproval", + "sourceFile": "src/features/team-approvals/contracts/api.ts", + "disposition": "query", + "owner": "team-read" + }, + { + "id": "CrossTeamAPI.send", + "interfaceName": "CrossTeamAPI", + "sourceMethod": "send", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-messaging", + "commandKind": "cross_team_message.send" + }, + { + "id": "CrossTeamAPI.listTargets", + "interfaceName": "CrossTeamAPI", + "sourceMethod": "listTargets", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-messaging" + }, + { + "id": "CrossTeamAPI.getOutbox", + "interfaceName": "CrossTeamAPI", + "sourceMethod": "getOutbox", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-messaging" + }, + { + "id": "ReviewAPI.getAgentChanges", + "interfaceName": "ReviewAPI", + "sourceMethod": "getAgentChanges", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.getTaskChanges", + "interfaceName": "ReviewAPI", + "sourceMethod": "getTaskChanges", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.getTeamTaskChangeSummaries", + "interfaceName": "ReviewAPI", + "sourceMethod": "getTeamTaskChangeSummaries", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.invalidateTaskChangeSummaries", + "interfaceName": "ReviewAPI", + "sourceMethod": "invalidateTaskChangeSummaries", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review" + }, + { + "id": "ReviewAPI.getChangeStats", + "interfaceName": "ReviewAPI", + "sourceMethod": "getChangeStats", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.getFileContent", + "interfaceName": "ReviewAPI", + "sourceMethod": "getFileContent", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.applyDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "applyDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.apply_decisions" + }, + { + "id": "ReviewAPI.executeMutation", + "interfaceName": "ReviewAPI", + "sourceMethod": "executeMutation", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.retryMutationRecovery", + "interfaceName": "ReviewAPI", + "sourceMethod": "retryMutationRecovery", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.restoreHistory", + "interfaceName": "ReviewAPI", + "sourceMethod": "restoreHistory", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.checkConflict", + "interfaceName": "ReviewAPI", + "sourceMethod": "checkConflict", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.rejectHunks", + "interfaceName": "ReviewAPI", + "sourceMethod": "rejectHunks", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.reject_hunks" + }, + { + "id": "ReviewAPI.rejectFile", + "interfaceName": "ReviewAPI", + "sourceMethod": "rejectFile", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.reject_file" + }, + { + "id": "ReviewAPI.previewReject", + "interfaceName": "ReviewAPI", + "sourceMethod": "previewReject", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.saveEditedFile", + "interfaceName": "ReviewAPI", + "sourceMethod": "saveEditedFile", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.save_edited_file" + }, + { + "id": "ReviewAPI.deleteEditedFile", + "interfaceName": "ReviewAPI", + "sourceMethod": "deleteEditedFile", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.restoreRejectedRename", + "interfaceName": "ReviewAPI", + "sourceMethod": "restoreRejectedRename", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.reapplyRejectedRename", + "interfaceName": "ReviewAPI", + "sourceMethod": "reapplyRejectedRename", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.watchFiles", + "interfaceName": "ReviewAPI", + "sourceMethod": "watchFiles", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review" + }, + { + "id": "ReviewAPI.unwatchFiles", + "interfaceName": "ReviewAPI", + "sourceMethod": "unwatchFiles", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review" + }, + { + "id": "ReviewAPI.onExternalFileChange", + "interfaceName": "ReviewAPI", + "sourceMethod": "onExternalFileChange", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review" + }, + { + "id": "ReviewAPI.loadDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.saveDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "saveDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.save_decisions" + }, + { + "id": "ReviewAPI.clearDecisions", + "interfaceName": "ReviewAPI", + "sourceMethod": "clearDecisions", + "sourceFile": "src/shared/types/api.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-review", + "commandKind": "review.clear_decisions" + }, + { + "id": "ReviewAPI.loadDecisionConflictCandidates", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDecisionConflictCandidates", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.resolveDecisionConflictCandidate", + "interfaceName": "ReviewAPI", + "sourceMethod": "resolveDecisionConflictCandidate", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.loadDraftHistory", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDraftHistory", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.saveDraftHistoryEntry", + "interfaceName": "ReviewAPI", + "sourceMethod": "saveDraftHistoryEntry", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.clearDraftHistory", + "interfaceName": "ReviewAPI", + "sourceMethod": "clearDraftHistory", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.loadDraftHistoryConflictCandidates", + "interfaceName": "ReviewAPI", + "sourceMethod": "loadDraftHistoryConflictCandidates", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "ReviewAPI.resolveDraftHistoryConflictCandidate", + "interfaceName": "ReviewAPI", + "sourceMethod": "resolveDraftHistoryConflictCandidate", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.replaceDraftHistoryConflictCandidate", + "interfaceName": "ReviewAPI", + "sourceMethod": "replaceDraftHistoryConflictCandidate", + "sourceFile": "src/shared/types/api.ts", + "disposition": "deferred", + "owner": "team-review", + "reason": "hosted HTTP explicitly rejects this mutation and no admitted command descriptor exists" + }, + { + "id": "ReviewAPI.onCmdN", + "interfaceName": "ReviewAPI", + "sourceMethod": "onCmdN", + "sourceFile": "src/shared/types/api.ts", + "disposition": "ephemeral", + "owner": "team-review" + }, + { + "id": "ReviewAPI.getGitFileLog", + "interfaceName": "ReviewAPI", + "sourceMethod": "getGitFileLog", + "sourceFile": "src/shared/types/api.ts", + "disposition": "query", + "owner": "team-review" + }, + { + "id": "OpenCodeRuntimeControlApi.recordOpenCodeRuntimeBootstrapCheckin", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "recordOpenCodeRuntimeBootstrapCheckin", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.bootstrap_checkin" + }, + { + "id": "OpenCodeRuntimeControlApi.deliverOpenCodeRuntimeMessage", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "deliverOpenCodeRuntimeMessage", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.deliver_message" + }, + { + "id": "OpenCodeRuntimeControlApi.recordOpenCodeRuntimeTaskEvent", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "recordOpenCodeRuntimeTaskEvent", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.task_event" + }, + { + "id": "OpenCodeRuntimeControlApi.recordOpenCodeRuntimeHeartbeat", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "recordOpenCodeRuntimeHeartbeat", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "required_hosted_v1_mutation", + "owner": "team-runtime-control", + "commandKind": "runtime.heartbeat" + }, + { + "id": "OpenCodeRuntimeControlApi.answerOpenCodeRuntimePermission", + "interfaceName": "OpenCodeRuntimeControlApi", + "sourceMethod": "answerOpenCodeRuntimePermission", + "sourceFile": "src/main/services/team/runtime-control/application/OpenCodeRuntimeControlApi.ts", + "disposition": "deferred", + "owner": "team-approvals", + "reason": "legacy wrong-direction callback; target is runtime.permission-request ingress plus operator-owned permission.decision outbound" + } + ] +} diff --git a/docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json b/docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json new file mode 100644 index 0000000000..8640261bdd --- /dev/null +++ b/docs/research/hosted-web/phase-0/recovery-events/snapshot-handoff-scheduler.json @@ -0,0 +1 @@ +{"schemaVersion":1,"evidenceId":"P0.W5.SNAPSHOT_HANDOFF_SCHEDULER","model":"single durable state mutation plus same-row journal event; in-memory fanout is a hint","exploredScheduleCount":288,"pauses":["before_cursor","after_cursor","before_read","after_read","before_commit","after_commit","before_serialization","after_serialization","before_listener","after_listener","before_replay","after_replay"],"acceptedAlgorithms":[{"id":"sqlite_same_transaction","invariant":"projection, revision vector, and lower cursor share one SQLite read snapshot"},{"id":"external_lower_c0","invariant":"retained C0 is captured before scan; snapshot may overlap replay"}],"schedules":[{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_cursor","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_cursor","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_read","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-2","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-2","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-2","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_read","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","boundary:before_commit:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_commit","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_commit","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:concurrent_commit_deferred_from_snapshot_view","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_serialization","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_serialization","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_listener","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_listener","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"before_replay","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"sqlite_same_transaction","mutationPause":"after_replay","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","boundary:after_replay:attempt-1","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_cursor","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","mutation:start:before_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":1,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_cursor","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","mutation:start:after_cursor","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_read:attempt-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":1,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":1,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_read","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","mutation:start:before_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","read:revision-1","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-1:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_read","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","mutation:start:after_read","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_commit","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","mutation:start:before_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_commit","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","mutation:start:after_commit","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_serialization","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","mutation:start:before_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_serialization","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","mutation:start:after_serialization","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_listener","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","mutation:start:before_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-2","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_listener","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","mutation:start:after_listener","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","boundary:before_replay:attempt-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"before_replay","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","mutation:start:before_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1","boundary:after_replay:attempt-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"before_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","crash:before_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"after_cursor","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","crash:after_cursor:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"before_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","crash:before_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"after_read","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","crash:after_read:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"before_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","crash:before_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"after_commit","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","crash:after_commit:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"before_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","crash:before_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"after_serialization","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","crash:after_serialization:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"before_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","crash:before_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"after_listener","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","crash:after_listener:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"before_replay","restartCount":1,"durableBeforeCrash":{"cursor":0,"revision":0,"journalRows":0},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","crash:before_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:0","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-0","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","replay:event-1"]},{"algorithm":"external_lower_c0","mutationPause":"after_replay","crashPause":"after_replay","restartCount":1,"durableBeforeCrash":{"cursor":1,"revision":1,"journalRows":1},"durableAfterRestart":{"cursor":1,"revision":1,"journalRows":1},"converged":true,"gap":false,"duplicates":0,"finalRevision":1,"authoritativeRevision":1,"mutationCommitTransitions":["transition:before_commit:revision-0:cursor-0","transition:after_commit:revision-1:cursor-1"],"trace":["boundary:before_cursor:attempt-1","cursor:0","boundary:after_cursor:attempt-1","boundary:before_read:attempt-1","read:revision-0","boundary:after_read:attempt-1","boundary:before_commit:attempt-1","boundary:after_commit:attempt-1","boundary:before_serialization:attempt-1","serialize:revision-0:barrier-0","boundary:after_serialization:attempt-1","boundary:before_listener:attempt-1","listener:registered-before-query","boundary:after_listener:attempt-1","boundary:before_replay:attempt-1","boundary:after_replay:attempt-1","mutation:start:after_replay","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","crash:after_replay:discard-partial-snapshot","restart:1:reload-durable-journal","boundary:before_cursor:attempt-2","cursor:1","boundary:after_cursor:attempt-2","boundary:before_read:attempt-2","read:revision-1","boundary:after_read:attempt-2","boundary:before_commit:attempt-2","boundary:after_commit:attempt-2","boundary:before_serialization:attempt-2","serialize:revision-1:barrier-1","boundary:after_serialization:attempt-2","boundary:before_listener:attempt-2","listener:registered-before-query","boundary:after_listener:attempt-2","boundary:before_replay:attempt-2","boundary:after_replay:attempt-2"]}],"negativeControls":[{"id":"negative_cursor_after_read","reproduced":true,"gap":true,"trace":["read:revision-0","mutation:start:unspecified","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","cursor:1","listener:registered","replay:rows>1:none"]},{"id":"negative_query_then_listen","reproduced":true,"gap":true,"trace":["cursor:0","read:revision-0","replay-query:rows>0:none","mutation:start:unspecified","transition:before_commit:revision-0:cursor-0","commit:state+journal:event-1","transition:after_commit:revision-1:cursor-1","wake-up:dropped-no-listener","listener:registered","broken-tail:no-requery"]}],"exceptionalSchedules":[{"id":"retention_overtakes_c0","outcome":"snapshot_retry_or_resync_required"},{"id":"external_generation_changes_during_scan","outcome":"discard_and_retry"},{"id":"foreign_old_or_ahead_epoch_cursor","outcome":"resync_required"},{"id":"listener_wakeup_lost_or_coalesced","outcome":"durable_high_watermark_requery_converges"}],"conclusion":"All accepted schedules converge with zero gaps. Duplicate replay is expected for lower-C0 snapshots; both prohibited algorithms lose the committed mutation."} diff --git a/docs/research/hosted-web/phase-0/salvage-ledger.md b/docs/research/hosted-web/phase-0/salvage-ledger.md new file mode 100644 index 0000000000..105d7c9d11 --- /dev/null +++ b/docs/research/hosted-web/phase-0/salvage-ledger.md @@ -0,0 +1,33 @@ +# Phase 0 salvage ledger + +## Policy + +The implementation branch descends only from pinned base +`cbe501ad0f1fa0e51a038e832ad35fce4120321b`. Closed PR #250 is reference-only: no merge, rebase or +whole-commit cherry-pick is allowed. A future salvage row must name source commit/file, target owner, +reason, modifications and focused test evidence. + +## 0A entries + +No closed-PR production source, test, build artifact or dependency was salvaged during 0A. + +| Salvage ID | Source commit/file | Target owner/path | Reason | Modifications | Test evidence | Disposition | +| --- | --- | --- | --- | --- | --- | --- | +| `SALVAGE-0A-NONE` | None | None | 0A records base, plan and gate evidence only. | None | Scope inspection and Git diff show only the seven controller-owned evidence files. | `not_applicable` | + +The source plan commit `16c156db8a85e75a6b679f6919e1013af74fb112` and its content-equivalent +adoption as `f1ad7a8cba2f26abf5f42ddd206937c24d143f77` are planning provenance, not product salvage. +Likewise, the two narrow base prerequisites between the base and current integration head are base +stabilization, not PR #250 salvage. + +## Required future row fields + +Every non-empty row added after lane review must include: + +- stable salvage ID; +- source PR/commit and exact file or hunk; +- owning feature and target path; +- why the knowledge or asset survives the rejected architecture; +- manual changes needed to conform to the new contract and threat boundary; +- focused verification at the new public seam; +- `accepted`, `rejected`, or `superseded` disposition and reviewer. diff --git a/docs/research/hosted-web/phase-0/state-writers/backup-behavior.json b/docs/research/hosted-web/phase-0/state-writers/backup-behavior.json new file mode 100644 index 0000000000..a71362fa0f --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/backup-behavior.json @@ -0,0 +1,133 @@ +{ + "$schema": "./evidence.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W3.BACKUP_BEHAVIOR", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "currentService": { + "classification": "legacy_unverified safety copy", + "enumeration": "nine team root files, flat inbox/review directories, recursive .opencode-runtime and members trees, app attachment trees, and flat task JSON", + "asyncPath": "process-local team promise mutex; validates/enumerates then copies files independently; per-file errors may log and continue", + "shutdownPath": "separate synchronous enumeration/copy implementation with broader error swallowing", + "identityMutation": "best-effort _backupIdentityId patch into CLI-owned config.json; identity can rotate on resurrection", + "publication": "manifest and thin registry are independent atomic file replacements; there is no immutable stage or commit marker", + "restore": "config-validity gate, identityId heuristic, per-file validity/mtime partial restore, and automatic full restore after missing/corrupt config", + "prune": "removes per-file stale copies and old team directories without an ADR-32 committed-run invariant", + "coordinationFailure": "does not fence provider/CLI writers and does not include SQLite Online Backup API", + "sourceRefs": ["src/main/services/team/TeamBackupService.ts"] + }, + "fixture": { + "kind": "marker-owned production-service fault characterization", + "path": "test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs", + "command": "node --import tsx test/architecture/hosted-web/phase-0/state-writers/team-backup-service-faults.test.mjs", + "sourceUnderTest": "src/main/services/team/TeamBackupService.ts", + "markerPrefix": "agent-teams-w3-team-backup-", + "userStateAccessed": false, + "result": { "tests": 7, "cases": 12, "passed": 7, "failed": 0 } + }, + "faultMatrix": [ + { + "caseId": "TB-01", + "fault": "missing required config name followed by a valid config", + "observedResult": "invalid config blocks backup; valid config yields matching async/sync enumeration and mutates _backupIdentityId to the manifest identity", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-02", + "fault": "async enumeration reports an error and one injected source disappears before copy", + "observedResult": "stale-file prune is suppressed, the failed file is absent, other changed files copy, and manifest/registry publication continues", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-03", + "fault": "stale backup file after an error-free enumeration", + "observedResult": "the stale file is removed before the independently published manifest/registry sequence", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-04", + "fault": "async manifest atomic publication throws after file copies", + "observedResult": "backupTeam rejects; copied config remains while manifest and registry entry are absent", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-05", + "fault": "async registry atomic publication throws after manifest publication", + "observedResult": "backupTeam rejects; manifest exists while on-disk registry is absent and the in-memory registry is ahead", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-06", + "fault": "shutdown enumeration includes one missing file beside a valid changed file", + "observedResult": "sync copy swallows the missing-file error, copies the valid file, and still publishes manifest/registry", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-07", + "fault": "shutdown manifest publication throws", + "observedResult": "the per-team shutdown wrapper swallows the failure and the outer shutdown path still saves the thin registry", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-08", + "fault": "on-disk registry contains corrupt JSON", + "observedResult": "initialize rebuilds the active in-memory registry from independently readable per-team manifests", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-09", + "fault": "source config and team directory are missing while a valid backup manifest/config exists", + "observedResult": "initialize performs a full restore and activates copied config and team metadata", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-10", + "fault": "source config is corrupt with both newer and older non-config source files", + "observedResult": "full restore replaces config, preserves a newer source file by mtime, and overwrites an older source file", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-11", + "fault": "identity-matching valid config with one valid, one corrupt, and one missing JSON participant", + "observedResult": "partial restore preserves valid JSON and restores corrupt/missing JSON independently", + "asserted": true, + "recoveryPointSafe": false + }, + { + "caseId": "TB-12", + "fault": "one retention-expired and one recent deleted-team backup", + "observedResult": "prune deletes the expired directory/registry entry, retains the recent entry, and publishes registry independently", + "asserted": true, + "recoveryPointSafe": false + } + ], + "requiredProducts": [ + { + "kind": "coordination_backup", + "promise": "consistent app-owned SQLite snapshot plus explicitly named identity/coordination participants only", + "writerRequirement": "fence/flush app participants; unrelated provider writers may remain active", + "restoreEligibility": "migration/repair-specific only" + }, + { + "kind": "deployment_recovery_point", + "promise": "quiesced verified publication across all catalogued non-ephemeral state", + "writerRequirement": "stop/refuse uncoordinated writers, close observation watermarks, freeze generations", + "restoreEligibility": "only committed v2 input for replace_deployment" + } + ], + "proofLevel": "fixture_characterized", + "limitations": [ + "The fixture characterizes existing TeamBackupService behavior; it does not make the independent file copies a coordinated recovery point.", + "Provider replacement between stat/read/copy remains covered by the separate external-writer negative fixture, not by an unsafe real provider launch.", + "Final-image SQLite ABI, async worker wire operation, deployment quiescence, and immutable recovery-point publication remain W6/controller gates outside this remediation lane." + ] +} diff --git a/docs/research/hosted-web/phase-0/state-writers/estimate-input.json b/docs/research/hosted-web/phase-0/state-writers/estimate-input.json new file mode 100644 index 0000000000..c997dcf824 --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/estimate-input.json @@ -0,0 +1,88 @@ +{ + "$schema": "./evidence.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W3.ESTIMATE", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "buckets": [ + { + "bucketId": "EST-W3-COMPAT-CATALOG", + "packages": [ + "features/internal-storage", + "hosted state compatibility contracts", + "team compatibility adapters" + ], + "productionLines": { "min": 650, "max": 1050 }, + "testLines": { "min": 450, "max": 750 }, + "deletedLines": { "min": 50, "max": 200 }, + "excludedGeneratedVendorLines": true, + "overlap": ["W2 provider fixtures", "W5 recovery descriptors"], + "confidence": "medium", + "assumptions": [ + "one generated StateCompatibilityManifest source covers all app-owned families", + "provider-owned JSON remains in compatibility adapters" + ], + "evidenceRefs": ["P0.W3.STATE_FAMILY_CATALOG", "P0.W3.SCHEMA_UNKNOWN_FIELDS"] + }, + { + "bucketId": "EST-W3-WRITER-COORDINATION", + "packages": [ + "team state coordinators", + "external observation registrations", + "provider compatibility adapters" + ], + "productionLines": { "min": 900, "max": 1450 }, + "testLines": { "min": 650, "max": 1000 }, + "deletedLines": { "min": 150, "max": 400 }, + "excludedGeneratedVendorLines": true, + "overlap": ["W4 instance lease", "W5 command/effect journal and observation scheduler"], + "confidence": "medium-low", + "assumptions": [ + "no universal repository", + "active Claude direct mutations remain quiescent-only unless W2 proves a protocol" + ], + "evidenceRefs": ["P0.W3.WRITER_COORDINATION"] + }, + { + "bucketId": "EST-W3-SQLITE-BACKUP", + "packages": ["features/internal-storage worker", "backup participant adapter"], + "productionLines": { "min": 350, "max": 600 }, + "testLines": { "min": 450, "max": 700 }, + "deletedLines": { "min": 0, "max": 50 }, + "excludedGeneratedVendorLines": true, + "overlap": ["W5 durable BackupRun orchestration"], + "confidence": "medium-high", + "assumptions": [ + "better-sqlite3#backup remains pinned and packaged", + "one typed async worker op with dedicated deadline" + ], + "evidenceRefs": ["P0.W3.SQLITE_ONLINE_BACKUP_SPIKE"] + }, + { + "bucketId": "EST-W3-BACKUP-PARTICIPANTS", + "packages": [ + "deployment backup participants", + "legacy backup retirement/ratchet", + "restore verifier" + ], + "productionLines": { "min": 550, "max": 900 }, + "testLines": { "min": 500, "max": 800 }, + "deletedLines": { "min": 250, "max": 600 }, + "excludedGeneratedVendorLines": true, + "overlap": ["W5 BackupRun workflow", "W6 credential exclusion and artifact manifest"], + "confidence": "low", + "assumptions": [ + "publication/restore orchestration is counted once in W5", + "legacy backup remains diagnostic until participant replacement is complete" + ], + "evidenceRefs": ["P0.W3.BACKUP_BEHAVIOR", "P0.W3.STATE_FAMILY_CATALOG"] + } + ], + "totals": { + "productionLines": { "min": 2450, "max": 4000 }, + "testLines": { "min": 2050, "max": 3250 }, + "deletedLines": { "min": 450, "max": 1250 }, + "changedLines": { "min": 4500, "max": 7250 } + }, + "parentBucketFit": "Fits EST-RECOVERY-STATE 4.5k-7.5k only if W5 owns shared BackupRun/command/event workflow once; otherwise overlap can exceed the parent ceiling.", + "proofLevel": "source-based-range-estimate" +} diff --git a/docs/research/hosted-web/phase-0/state-writers/evidence.schema.json b/docs/research/hosted-web/phase-0/state-writers/evidence.schema.json new file mode 100644 index 0000000000..0afd32ab85 --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/evidence.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-teams.local/schemas/phase-0-w3-evidence-v1.json", + "title": "Phase 0 W3 evidence record", + "type": "object", + "required": ["schemaVersion", "evidenceId", "phaseStartSha", "proofLevel"], + "properties": { + "$schema": { "type": "string", "const": "./evidence.schema.json" }, + "schemaVersion": { "type": "integer", "const": 1 }, + "evidenceId": { "type": "string", "pattern": "^P0\\.W3\\." }, + "phaseStartSha": { "type": "string", "const": "a32f509e6d9bd31ba2135940e336729bf90c3d93" }, + "proofLevel": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true +} diff --git a/docs/research/hosted-web/phase-0/state-writers/index.md b/docs/research/hosted-web/phase-0/state-writers/index.md new file mode 100644 index 0000000000..18b0f8f8eb --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/index.md @@ -0,0 +1,68 @@ +# Phase 0 W3 — State, External Writers, and Backup + +Phase start `a32f509e6d9bd31ba2135940e336729bf90c3d93` was verified before edits. This +lane characterizes current state; it does not implement hosted mutation or replace the legacy backup. +All executable evidence uses marker-owned directories below the operating-system temporary root. + +## Evidence index + +| Evidence ID | Authority | Result | Proof level | +| ---------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| `P0.W3.STATE_FAMILY_CATALOG` | `state-family-catalog.json` | 17 families: 16 current/fallback and one Phase 1-required identity/lifecycle family | `source_observed` | +| `P0.W3.WRITER_COORDINATION` | `writer-coordination.json` | 12 mutation/backup operations classified; six writer identities remain unresolved | `fixture_characterized` | +| `P0.W3.SCHEMA_UNKNOWN_FIELDS` | `schema-unknown-fields.json` | CLI JSON must preserve unknowns; app projections need version/refuse policy; SQLite future-version writes remain a gap | `source_observed` | +| `P0.W3.BACKUP_BEHAVIOR` | `backup-behavior.json`, `team-backup-service-faults.test.mjs` | 12 fault cases construct production `TeamBackupService`; it remains `legacy_unverified`, not a recovery point | `fixture_characterized` | +| `P0.W3.SQLITE_ONLINE_BACKUP_SPIKE` | `sqlite-online-backup-results.json`, `sqlite-online-backup-spike.md` | WAL-active online backup, independent reopen, BUSY/corrupt/deadline fail-closed behavior | `fixture_characterized` | +| `P0.W3.ESTIMATE` | `estimate-input.json` | 4.5k–7.25k changed lines if shared W5 workflow is counted once | `source_observed` | + +The catalog's unresolved-writer count is eight because two grouped runtime/transcript families and the +future identity family also name unknown owners. The handoff reports six actionable external writer +identities after collapsing those catalog details into controller decisions. + +## Findings that constrain Phase 1 + +1. `TeamTaskWriter`'s promise lock is process-local. The negative child-process fixture deterministically + overwrites the external update after the app's stale read. Atomic rename prevents torn bytes, not lost + updates. +2. No source evidence proves that Claude-native task/config/inbox writers share the app's locks. Those + active-run direct mutations are `quiescent-only`, not cooperative. +3. OpenCode inbox delivery is provider-mediated and has useful ledgers, but transport delivery is not a + semantic mutation acknowledgement. The exact user-inbox reply writer remains unresolved. +4. `TeamKanbanManager` performs unlocked read-modify-write and rewrites a sanitized projection. It becomes + app-exclusive only after the controller lease, per-team coordinator, and expected revision exist. +5. Launch state and summary are two sequential replacements. The boundary is process-local and its + written-run guard disappears on restart, so neither pairwise atomicity nor hosted exclusivity is proven. +6. Internal storage is a single worker connection in WAL mode at `user_version=3`. A future + `user_version` is currently opened without closing known-table mutation, leaving ADR-23 implementation + open. +7. The marker-owned production-service fixture exercises 12 cases across config readiness, matching + async/sync enumeration, copy/enumeration errors, stale and retention pruning, identity mutation, + split manifest/registry publication, corrupt-registry rebuild, shutdown error swallowing, and + missing/corrupt/partial/mtime restore. The observed partial states confirm that `TeamBackupService` + remains `legacy_unverified`; its mutex still does not fence an external provider writer. +8. `better-sqlite3#backup` is sufficient for the SQLite participant. The Phase 1 worker operation must be + async/awaited, have its own bounded deadline/progress cancellation, reopen/integrity-check the result, + and have no raw DB/WAL/SHM or checkpoint fallback. + +## ADR recommendations + +- ADR-23: retain/accept the machine-readable compatibility design, but keep implementation open. Add a + negative fixture that proves a future `user_version` cannot mutate known tables. +- ADR-24: retain. Generic file observations stay team-scoped `ExternalFileActor`; unresolved paths cannot + acquire a current RunId from UI selection, mtime, or claimed JSON fields. +- ADR-29: accept. Freeze the per-operation matrix. Any uncharacterized provider/version defaults to + `uncoordinated_external`; Claude config/task/native-inbox direct active writes remain unavailable. +- ADR-32: accept with two products. The spike closes only Online Backup API feasibility. Full + `deployment_recovery_point` remains gated on W4 process drain, ADR-24 watermark closure, participant + manifests, immutable publication, credential exclusions, and final-image ABI proof. + +## Uncertainty and integration dependencies + +No real provider was launched and no user state was inspected. W2 must supply sanitized provider/version +fixtures and runtime topology before the unresolved writer rows can be narrowed. W4 supplies the hosted +lease and proven process drain. W5 owns shared durable command/BackupRun/event orchestration, so its lines +must not be counted again in W3. W6/integration must repeat the SQLite probe against the final packaged +Node ABI/addon in the supported container image. + +There is no lane-local blocker. The unresolved items are explicit Phase 1 admission gates rather than a +reason to weaken the writer or backup contract. diff --git a/docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json b/docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json new file mode 100644 index 0000000000..78edabdab9 --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/schema-unknown-fields.json @@ -0,0 +1,105 @@ +{ + "$schema": "./evidence.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W3.SCHEMA_UNKNOWN_FIELDS", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "policies": [ + { + "familyId": "cli-team-config", + "currentBehavior": "mixed full-document rewrites and object patching", + "requiredBehavior": "preserve unknown keys by patching the bounded parsed raw object", + "futureVersionDisposition": "read-only diagnostic unless a compatibility manifest admits read/write", + "proofLevel": "source-characterized", + "sourceRefs": [ + "src/main/services/team/TeamDataService.ts", + "src/main/services/team/TeamConfigReader.ts" + ] + }, + { + "familyId": "team-metadata", + "currentBehavior": "normalizes known fields and rewrites complete documents", + "requiredBehavior": "explicit version, migrate-known or refuse; never silently discard future identity/member fields", + "futureVersionDisposition": "mutation blocked", + "proofLevel": "source-characterized", + "sourceRefs": [ + "src/main/services/team/TeamMembersMetaStore.ts", + "src/main/services/team/provisioning/TeamProvisioningConfigMaintenance.ts" + ] + }, + { + "familyId": "cli-tasks", + "currentBehavior": "many updates spread parsed raw task, while creation/full relationship writes serialize the resulting object", + "requiredBehavior": "preserve unknown keys in every existing-record mutation; property/fixture proof per provider version", + "futureVersionDisposition": "active direct mutation unavailable until characterized", + "proofLevel": "source-characterized", + "sourceRefs": ["src/main/services/team/TeamTaskWriter.ts"] + }, + { + "familyId": "cli-inboxes", + "currentBehavior": "read-modify-write retains entries as parsed objects, but normalization/dedupe can alter selected entries", + "requiredBehavior": "retain unknown entry fields and unrecognized entries; corrupt or future container schema must not be replaced as empty", + "futureVersionDisposition": "read-only/blocked, not empty-array overwrite", + "proofLevel": "source-characterized", + "sourceRefs": [ + "src/main/services/team/TeamInboxWriter.ts", + "src/main/services/team/TeamInboxReader.ts" + ] + }, + { + "familyId": "kanban-projection", + "currentBehavior": "sanitizer selects known fields and rewrite drops unknowns", + "requiredBehavior": "version and migrate or declare rebuildable derived data; never call it forward-compatible", + "futureVersionDisposition": "rebuild only when its authority is proven derived, otherwise mutation blocked", + "proofLevel": "source-characterized", + "sourceRefs": ["src/main/services/team/TeamKanbanManager.ts"] + }, + { + "familyId": "review-decisions", + "currentBehavior": "store-owned serialization with v2 scope naming but no artifact-wide compatibility manifest", + "requiredBehavior": "schema/version plus safe clear/migration behavior", + "futureVersionDisposition": "mutation blocked; workspace effects always remain separately preconditioned", + "proofLevel": "source-characterized", + "sourceRefs": ["src/main/services/team/ReviewDecisionStore.ts"] + }, + { + "familyId": "launch-state", + "currentBehavior": "normalization returns known projection and drops unknowns; summary is regenerated", + "requiredBehavior": "version provider evidence; preserve or refuse future evidence before changing lifecycle", + "futureVersionDisposition": "launch/mutation blocked while diagnostic read may remain", + "proofLevel": "source-characterized", + "sourceRefs": ["src/main/services/team/TeamLaunchStateStore.ts"] + }, + { + "familyId": "bootstrap-and-runtime-evidence", + "currentBehavior": "VersionedJsonStore validates/quarantines selected stores; other files have local parsers", + "requiredBehavior": "each manifest entry declares schema and recovery policy", + "futureVersionDisposition": "unknown store/manifest versions cannot authorize runtime state", + "proofLevel": "partly test-proven", + "sourceRefs": [ + "src/main/services/team/opencode/store/VersionedJsonStore.ts", + "test/main/services/team/VersionedJsonStore.test.ts" + ] + }, + { + "familyId": "internal-storage-sqlite", + "currentBehavior": "user_version greater than current returns the database and known operations may still write known tables", + "requiredBehavior": "ADR-23 preflight closes mutation when write range excludes discovered version", + "futureVersionDisposition": "read-only diagnostics only where reader range permits", + "proofLevel": "source-characterized-negative-gap", + "sourceRefs": [ + "src/features/internal-storage/main/infrastructure/worker/InternalStorageWorkerCore.ts", + "src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts" + ] + }, + { + "familyId": "legacy-team-backups", + "currentBehavior": "manifest/registry v1 parsing is permissive and copied payloads have family-specific behavior", + "requiredBehavior": "retain only as legacy_unverified; never infer compatibility from parse success/mtime", + "futureVersionDisposition": "salvage diagnostic only", + "proofLevel": "source-characterized", + "sourceRefs": ["src/main/services/team/TeamBackupService.ts"] + } + ], + "negativeFinding": "InternalStorageWorkerCore intentionally opens a future user_version and returns it without migration, but it does not yet prevent known-table mutations. ADR-23 therefore remains open for Phase 1.", + "proofLevel": "source-characterized" +} diff --git a/docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json b/docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json new file mode 100644 index 0000000000..53e668ef57 --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-results.json @@ -0,0 +1,45 @@ +{ + "$schema": "./evidence.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W3.SQLITE_ONLINE_BACKUP_SPIKE", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "environment": { + "runtime": "Node.js ABI 137", + "driver": "better-sqlite3 12.11.1", + "filesystem": "temporary marker-owned Linux fixture" + }, + "implementation": "scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs", + "results": [ + { + "case": "wal-active snapshot", + "expected": "Database#backup completes while source WAL exists; copied DB independently reopens and integrity_check=ok", + "status": "verified-by-targeted-test" + }, + { + "case": "SQLITE_BUSY/SQLITE_LOCKED", + "expected": "typed backup_busy, partial destination removed, no DB/WAL/SHM raw-copy fallback", + "status": "verified-by-fault-injected-adapter" + }, + { + "case": "source corruption", + "expected": "source_corrupt before backup starts and no destination publication", + "status": "verified-by-corrupt-file-test" + }, + { + "case": "bounded deadline", + "expected": "progress callback aborts with backup_deadline and removes partial destination", + "status": "verified-by-fault-injected-clock" + }, + { + "case": "production worker async wire", + "expected": "worker entry must await the Promise before postMessage and use a backup-specific timeout", + "status": "not-implemented-phase0-by-design" + } + ], + "forbiddenFallback": "The spike contains no copyFile/readFile/writeFile/checkpoint path for database bytes or -wal/-shm; any error removes partial destination and propagates typed failure.", + "proofLevel": "executable-spike", + "limitations": [ + "BUSY is deterministically injected at the Database#backup adapter boundary because inducing a portable real SQLite Online Backup BUSY across supported builds is timing-dependent.", + "This proves the Node ABI available in the lane environment, not the final packaged Electron ABI or final container image; W6/integration must repeat the artifact probe." + ] +} diff --git a/docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md b/docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md new file mode 100644 index 0000000000..8bfef768b7 --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.md @@ -0,0 +1,49 @@ +# SQLite Online Backup Spike + +Evidence ID: `P0.W3.SQLITE_ONLINE_BACKUP_SPIKE` + +## Environment + +- Linux `6.8.0-124-generic`, x86_64 +- Node `v24.16.0`, module ABI `137` +- `better-sqlite3` `12.11.1` +- Temporary marker-owned fixtures only; no user `CLAUDE_ROOT` access + +This is a Phase 0 feasibility spike. It deliberately does not modify the internal-storage worker or +production backup behavior. + +## Primitive + +`sqlite-online-backup-spike.mjs` accepts an already-open source connection, first requires source +`integrity_check=ok`, then awaits `Database#backup(destination, { progress })`. Its progress callback +enforces a deadline and bounded pages per iteration. Completion is not accepted until a new read-only +connection independently opens the destination and returns `integrity_check=ok`. + +On `SQLITE_BUSY` or `SQLITE_LOCKED`, the primitive returns typed `backup_busy`, removes any partial +destination and propagates failure. Corruption fails before publication. Deadline abort behaves the same +way. There is no `copyFile`, database-byte read/write, `wal_checkpoint`, or `VACUUM INTO` fallback. + +## Exact checks + +```text +node scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs +node scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs +node scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs +node --test test/architecture/hosted-web/phase-0/state-writers/state-writers.test.mjs +``` + +The test suite proves: + +- the source has `journal_mode=wal` and a live `-wal` sidecar when backup begins; +- the destination reopens independently with 2,000 fixture rows and passes integrity checking; +- injected `SQLITE_BUSY` produces `backup_busy` and leaves no DB/WAL/SHM destination; +- a corrupt SQLite file produces `source_corrupt` before destination publication; +- an injected expired clock produces `backup_deadline` and removes partial output; +- source scanning rejects a raw-copy/checkpoint fallback; +- both executable fixtures are temporary-root-only and clean themselves. + +The deterministic BUSY case is injected at the `Database#backup` adapter boundary. A portable real-lock +BUSY is timing- and SQLite-build-dependent because the Online Backup API can proceed through ordinary WAL +writes. Phase 1 still needs integration faults in the final worker and final container. The production +worker entry currently posts `core.handle()` synchronously; returning a Promise would put a Promise on the +wire. Phase 1 must introduce a typed async operation and await it before processing/posting the response. diff --git a/docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json b/docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json new file mode 100644 index 0000000000..fc7dcf8db2 --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/state-family-catalog.json @@ -0,0 +1,397 @@ +{ + "$schema": "./evidence.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W3.STATE_FAMILY_CATALOG", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "scope": "Hosted team coordination state and the app-owned durable stores that govern it; workspace source files are catalogued as an external effect, not backed-up state.", + "families": [ + { + "id": "app-preferences", + "pathPattern": "${CLAUDE_ROOT}/agent-teams-config.json", + "implementationStatus": "current", + "authority": "desktop application configuration", + "writers": ["ConfigManager", "legacy-config migration copy"], + "unresolvedWriters": [], + "schemaVersion": "unversioned AppConfig object", + "maxBytes": "not explicitly bounded", + "lockingModel": "none; fire-and-forget async writes", + "atomicity": "single-file temp write, best-effort temp fsync, rename; no parent-directory fsync", + "unknownFieldPolicy": "lost when normalized AppConfig is serialized", + "corruptionPolicy": "default configuration is loaded; corrupt bytes are not a mutation authority", + "backupRole": "coordination_backup participant only if a future migration needs settings; not provider state", + "secretClass": "may contain provider and SSH settings; redact/exclude secret values", + "sourceRefs": [ + "src/main/services/infrastructure/ConfigManager.ts", + "src/main/utils/atomicWrite.ts" + ] + }, + { + "id": "cli-team-config", + "pathPattern": "${CLAUDE_ROOT}/teams//config.json", + "implementationStatus": "current", + "authority": "Claude-compatible team layout and provider launch input; not canonical TeamId/roster authority", + "writers": [ + "TeamDataService", + "TeamConfigReader write helpers", + "TeamTranscriptProjectResolver", + "TeamProvisioning* config maintenance", + "TeamBackupService", + "Claude-compatible CLI/runtime" + ], + "unresolvedWriters": [ + "exact Claude CLI versions/operations that write config.json", + "whether any provider writer honors an app lock" + ], + "schemaVersion": "unversioned provider-compatible JSON object", + "maxBytes": "reader-specific; no single writer-wide limit", + "lockingModel": "multiple app paths; provider writers have no proven common lock", + "atomicity": "individual app replacements are rename-atomic; multi-file provisioning is not transactional", + "unknownFieldPolicy": "must preserve raw unknown fields; current full-document writers are inconsistent", + "corruptionPolicy": "launch/readiness blocks or degrades by caller; backup currently treats missing/corrupt config as restore trigger", + "backupRole": "deployment_recovery_point only after writer quiescence; legacy copies are legacy_unverified", + "secretClass": "provider/team metadata; paths and provider settings require redaction", + "sourceRefs": [ + "src/main/services/team/TeamDataService.ts", + "src/main/services/team/TeamConfigReader.ts", + "src/main/services/team/TeamTranscriptProjectResolver.ts", + "src/main/services/team/TeamBackupService.ts" + ] + }, + { + "id": "team-metadata", + "pathPattern": "${CLAUDE_ROOT}/teams//{team.meta.json,members.meta.json}", + "implementationStatus": "current", + "authority": "app draft/display and member launch compatibility metadata; never TeamId authority", + "writers": ["TeamDataService", "TeamProvisioningConfigMaintenance", "TeamMembersMetaStore"], + "unresolvedWriters": ["whether external CLI releases rewrite members.meta.json"], + "schemaVersion": "version fields exist in selected metadata shapes but enforcement is incomplete", + "maxBytes": "bounded by selected readers, not by a shared contract", + "lockingModel": "process-local serialization varies by callsite", + "atomicity": "single-file rename replacement; team and member metadata do not commit together", + "unknownFieldPolicy": "known-field normalization rewrites complete documents and drops unknown fields", + "corruptionPolicy": "fallback to config/inbox-derived metadata; mutation ambiguity must block", + "backupRole": "app participant; include only with explicit schema and identity mapping", + "secretClass": "non-secret metadata with user paths/models requiring redaction", + "sourceRefs": [ + "src/main/services/team/TeamDataService.ts", + "src/main/services/team/TeamMembersMetaStore.ts", + "src/main/services/team/provisioning/TeamProvisioningConfigMaintenance.ts" + ] + }, + { + "id": "cli-tasks", + "pathPattern": "${CLAUDE_ROOT}/tasks//*.json", + "implementationStatus": "current", + "authority": "Claude-compatible task records and relationship projection", + "writers": [ + "TeamTaskWriter", + "TeamKanbanManager-triggered task flows", + "Claude-compatible CLI/agents", + "MCP team tools" + ], + "unresolvedWriters": [ + "provider/version-specific task tool writer implementation", + "active writer liveness evidence" + ], + "schemaVersion": "unversioned TeamTask/CLI-compatible JSON", + "maxBytes": "reader bounds vary; writer has no explicit maximum", + "lockingModel": "per-path in-process promise locks; no external exclusion", + "atomicity": "one file per rename; relationship edges use two independent renames", + "unknownFieldPolicy": "most mutations patch parsed raw records, but creation/full rewrites require fixture proof", + "corruptionPolicy": "read/operation fails or skips invalid task; asymmetric relationships have no intent recovery", + "backupRole": "deployment_recovery_point only after uncoordinated writers are drained", + "secretClass": "may contain prompts/descriptions; sensitive content", + "sourceRefs": [ + "src/main/services/team/TeamTaskWriter.ts", + "src/main/services/team/TeamTaskReader.ts" + ] + }, + { + "id": "cli-inboxes", + "pathPattern": "${CLAUDE_ROOT}/teams//inboxes/*.json", + "implementationStatus": "current", + "authority": "Claude-compatible message queue/projection; delivery outcome requires provider-specific evidence", + "writers": [ + "TeamInboxWriter", + "TeamProvisioning inbox maintenance", + "Claude-compatible agents/tools", + "OpenCode delivery compatibility path" + ], + "unresolvedWriters": [ + "which native teammates honor .lock files", + "whether user.json replies always traverse the app/MCP writer" + ], + "schemaVersion": "unversioned array of InboxMessage-compatible objects", + "maxBytes": "10 MiB reader bound", + "lockingModel": "cross-process .lock plus in-process lock for TeamInboxWriter only; external adoption unproven", + "atomicity": "single-file replacement plus post-read verification; append is read-modify-write", + "unknownFieldPolicy": "raw array entries are generally retained, but normalization paths need provider fixtures", + "corruptionPolicy": "readers return empty/diagnostic; writers must not silently replace corrupt evidence", + "backupRole": "deployment_recovery_point after writer quiescence; excluded from coordination_backup", + "secretClass": "message content and attachments metadata are sensitive", + "sourceRefs": [ + "src/main/services/team/TeamInboxWriter.ts", + "src/main/services/team/TeamInboxReader.ts", + "src/main/services/team/fileLock.ts", + "src/main/services/team/inboxLock.ts" + ] + }, + { + "id": "kanban-projection", + "pathPattern": "${CLAUDE_ROOT}/teams//kanban-state.json", + "implementationStatus": "current", + "authority": "app review-column/order projection, subordinate to task existence", + "writers": ["TeamKanbanManager", "TeamDataService workflows"], + "unresolvedWriters": [], + "schemaVersion": "unversioned KanbanState", + "maxBytes": "512 KiB read bound", + "lockingModel": "none across concurrent read-modify-write calls", + "atomicity": "single-file rename replacement; read-modify-write is not compare-and-swap", + "unknownFieldPolicy": "sanitization and rewrite drop unknown top-level and task fields", + "corruptionPolicy": "returns empty default, risking overwrite if mutation proceeds", + "backupRole": "derived participant; rebuild policy must be explicit before inclusion", + "secretClass": "non-secret workflow metadata", + "sourceRefs": ["src/main/services/team/TeamKanbanManager.ts"] + }, + { + "id": "review-decisions", + "pathPattern": "${CLAUDE_ROOT}/teams//review-decisions/*.json", + "implementationStatus": "current", + "authority": "app review-decision UI state; workspace file effects remain separate authority", + "writers": ["ReviewDecisionStore"], + "unresolvedWriters": [], + "schemaVersion": "v2 filename/scope convention; payload compatibility is reader-defined", + "maxBytes": "not explicitly bounded", + "lockingModel": "no cross-process lock", + "atomicity": "single-file replacement; clear/delete independent", + "unknownFieldPolicy": "complete store serialization; unknown fields not guaranteed", + "corruptionPolicy": "logs and returns empty; must not imply workspace rollback safety", + "backupRole": "optional app participant; never substitutes for Git/workspace truth", + "secretClass": "may expose workspace paths and diff context", + "sourceRefs": [ + "src/main/services/team/ReviewDecisionStore.ts", + "src/main/services/team/ReviewApplierService.ts" + ] + }, + { + "id": "message-dedupe-history", + "pathPattern": "${CLAUDE_ROOT}/teams//{sentMessages.json,sent-cross-team.json}", + "implementationStatus": "current", + "authority": "app delivery/deduplication history, not provider acknowledgement", + "writers": ["TeamSentMessagesStore", "cross-team message services"], + "unresolvedWriters": [], + "schemaVersion": "unversioned bounded JSON arrays/objects", + "maxBytes": "store-specific bounded retention", + "lockingModel": "app process-local coordination", + "atomicity": "single-file atomic replacement", + "unknownFieldPolicy": "store-owned records; unknown future fields are not guaranteed", + "corruptionPolicy": "sent-message store refuses to overwrite corrupt history", + "backupRole": "coordination/deployment participant only if dedupe retention requires it", + "secretClass": "message identifiers and routing metadata; sensitive", + "sourceRefs": [ + "src/main/services/team/TeamSentMessagesStore.ts", + "src/main/services/team/TeamBackupService.ts" + ] + }, + { + "id": "launch-state", + "pathPattern": "${CLAUDE_ROOT}/teams//launch-state.json", + "implementationStatus": "current", + "authority": "provider execution/bootstrap facts only; not accepted LifecycleRun authority", + "writers": ["TeamLaunchStateStore", "TeamProvisioningLaunchStateStoreBoundary"], + "unresolvedWriters": ["all legacy provisioning paths that bypass the boundary"], + "schemaVersion": "normalized persisted launch snapshot without durable manifest version", + "maxBytes": "reader-specific bound", + "lockingModel": "boundary serializes within one process; in-memory run guard is restart-volatile", + "atomicity": "launch state and summary are sequential independent replacements", + "unknownFieldPolicy": "normalization drops unknown fields", + "corruptionPolicy": "read returns null; writes log/swallow selected failures and missing-directory races", + "backupRole": "deployment participant as provider evidence only after quiescence", + "secretClass": "runtime diagnostics/process metadata; redact paths/commands", + "sourceRefs": [ + "src/main/services/team/TeamLaunchStateStore.ts", + "src/main/services/team/provisioning/TeamProvisioningLaunchStateStoreBoundary.ts" + ] + }, + { + "id": "launch-summary", + "pathPattern": "${CLAUDE_ROOT}/teams//launch-summary.json", + "implementationStatus": "current", + "authority": "derived launch-state projection", + "writers": ["TeamLaunchStateStore"], + "unresolvedWriters": [], + "schemaVersion": "derived unversioned summary", + "maxBytes": "small derived document; no explicit contract", + "lockingModel": "same process path as launch state", + "atomicity": "written after launch-state, so pair can disagree", + "unknownFieldPolicy": "fully regenerated", + "corruptionPolicy": "rebuild from authoritative launch facts", + "backupRole": "exclude as rebuildable derived cache", + "secretClass": "redacted diagnostic summary", + "sourceRefs": ["src/main/services/team/TeamLaunchStateStore.ts"] + }, + { + "id": "bootstrap-and-runtime-evidence", + "pathPattern": "${CLAUDE_ROOT}/teams//{bootstrap-state.json,bootstrap-journal.jsonl,.opencode-runtime/**,members/**}", + "implementationStatus": "current", + "authority": "provider-specific launch/bootstrap/delivery evidence according to each manifest", + "writers": [ + "TeamProvisioning bootstrap/runtime stores", + "OpenCode bridge/runtime adapters", + "provider child processes" + ], + "unresolvedWriters": [ + "provider-version ownership for every members/** artifact", + "which files can change after process drain" + ], + "schemaVersion": "mixed: manifest/versioned JSON stores plus unversioned JSONL diagnostics", + "maxBytes": "store-specific; recursive legacy backup only caps individual files at 20 MiB", + "lockingModel": "mixed atomic stores, journals, and provider-owned appenders", + "atomicity": "per-store only; manifests may bind entries but directory is not transactional", + "unknownFieldPolicy": "VersionedJsonStore rejects/quarantines incompatible state; other stores vary", + "corruptionPolicy": "manifest mismatch/quarantine or recovery-blocking diagnostics by artifact", + "backupRole": "catalog each manifest entry; deployment recovery only after provider drain", + "secretClass": "may contain diagnostics, session references and payload hashes; redact/exclude raw secrets", + "sourceRefs": [ + "src/main/services/team/opencode/store/VersionedJsonStore.ts", + "src/main/services/team/opencode/store/RuntimeStoreManifest.ts", + "src/main/services/team/TeamBootstrapStateReader.ts", + "src/main/services/team/TeamBackupService.ts" + ] + }, + { + "id": "provider-transcripts", + "pathPattern": "${CLAUDE_ROOT}/projects/**/*.jsonl and team member transcript/trace paths", + "implementationStatus": "current", + "authority": "provider-owned append history; read-only application input", + "writers": ["Claude/Codex/OpenCode provider runtimes"], + "unresolvedWriters": ["retention and flush guarantees by provider/version"], + "schemaVersion": "provider/version-specific JSONL event schemas", + "maxBytes": "streamed/bounded readers; files themselves unbounded", + "lockingModel": "provider-owned append; app observes without writer fence", + "atomicity": "append records; tail may be partial on crash", + "unknownFieldPolicy": "readers should ignore/preserve unknown event fields without rewriting", + "corruptionPolicy": "bounded line parsing and last-valid projection; never app-repair provider logs", + "backupRole": "excluded from v1 recovery point unless an explicit encrypted history-export policy is adopted", + "secretClass": "highly sensitive prompts/tool output/history", + "sourceRefs": [ + "src/main/services/team/TeamTranscriptProjectResolver.ts", + "src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts" + ] + }, + { + "id": "attachment-blobs", + "pathPattern": "${APP_DATA}/attachments//* and ${APP_DATA}/task-attachments//**", + "implementationStatus": "current", + "authority": "app-owned attachment blob content addressed/referenced by messages/tasks", + "writers": ["agent-attachments feature adapters"], + "unresolvedWriters": [], + "schemaVersion": "blob plus feature metadata contract", + "maxBytes": "feature upload/storage limits; legacy backup caps individual files at 20 MiB", + "lockingModel": "app-owned file operations; no deployment lease today", + "atomicity": "individual blob publication; references and blobs are not one transaction", + "unknownFieldPolicy": "not applicable to opaque blobs", + "corruptionPolicy": "hash/metadata verification required; missing blobs degrade attachment only", + "backupRole": "explicit file participant with hashes; never recursive path inference", + "secretClass": "user content; sensitive", + "sourceRefs": [ + "src/features/agent-attachments/main", + "src/main/services/team/TeamBackupService.ts" + ] + }, + { + "id": "internal-storage-sqlite", + "pathPattern": "${ELECTRON_USER_DATA}/internal-storage/app.db", + "implementationStatus": "current", + "authority": "app-owned stall/comment/member-work-sync journals and outbox tables", + "writers": ["single internal-storage worker request queue"], + "unresolvedWriters": [], + "schemaVersion": "PRAGMA user_version=3, append-only migrations", + "maxBytes": "not explicitly bounded; table retention is feature-specific", + "lockingModel": "one worker connection in WAL mode; SQLite locking across connections", + "atomicity": "SQLite transactions per worker operation", + "unknownFieldPolicy": "future user_version is opened read/write for known tables today; ADR-23 must close mutation readiness", + "corruptionPolicy": "integrity_check then rename db/-wal/-shm as forensic corrupt copies and recreate; selector may fall back to legacy JSON", + "backupRole": "coordination_backup and deployment_recovery_point use Online Backup API only", + "secretClass": "coordination payload JSON can contain sensitive task/message data; exclude auth secrets unless explicitly designed", + "sourceRefs": [ + "src/features/internal-storage/contracts/internalStorageContracts.ts", + "src/features/internal-storage/main/infrastructure/worker/InternalStorageWorkerCore.ts", + "src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts" + ] + }, + { + "id": "legacy-json-journals", + "pathPattern": "${CLAUDE_ROOT}/teams//{comment-notification-journal.json,task-stall-journal.json and member work-sync journals}", + "implementationStatus": "current_fallback", + "authority": "fallback/import source when SQLite backend is unavailable; authority selected once per session", + "writers": ["legacy JSON journal stores", "member-work-sync compatibility stores"], + "unresolvedWriters": [], + "schemaVersion": "store-specific unversioned or versioned JSON/JSONL", + "maxBytes": "store-specific", + "lockingModel": "app process-local coordination", + "atomicity": "single JSON replacement or JSONL append", + "unknownFieldPolicy": "store-specific; comment journal treats corruption as an error, stall journal imports corrupt as empty", + "corruptionPolicy": "inconsistent by store; import archives must retain evidence", + "backupRole": "include only when selected authority; do not back up SQLite and fallback as two coequal truths", + "secretClass": "task/member coordination metadata; sensitive", + "sourceRefs": [ + "src/features/internal-storage/main/adapters/output/CommentJournalLegacyJsonSource.ts", + "src/features/internal-storage/main/adapters/output/StallJournalLegacyJsonSource.ts", + "src/features/internal-storage/main/composition/InternalStorageBackendSelector.ts" + ] + }, + { + "id": "legacy-team-backups", + "pathPattern": "${CLAUDE_ROOT}/backups/{registry.json,teams//**}", + "implementationStatus": "current_legacy_unverified", + "authority": "diagnostic safety copy and legacy restore input only", + "writers": [ + "TeamBackupService periodic async path", + "TeamBackupService shutdown sync path", + "file watcher/debounce triggers" + ], + "unresolvedWriters": [], + "schemaVersion": "registry version 1; unversioned manifest", + "maxBytes": "20 MiB per copied source file", + "lockingModel": "process-local per-team promise mutex; no provider/CLI fence", + "atomicity": "independent file copies, manifest and registry replacements; no commit marker", + "unknownFieldPolicy": "copied JSON bytes usually preserved; config _backupIdentityId is patched; restore semantics are file-specific", + "corruptionPolicy": "registry rebuilds from manifests; per-file errors can be swallowed; partial/mtime restore", + "backupRole": "legacy_unverified only; never replace_deployment input", + "secretClass": "recursively copied content may be sensitive; current exclusion policy is incomplete", + "sourceRefs": ["src/main/services/team/TeamBackupService.ts"] + }, + { + "id": "canonical-identities-and-lifecycle", + "pathPattern": "future app-owned SQLite identity/lifecycle tables plus team.identity.json portable anchor", + "implementationStatus": "phase1_required", + "authority": "stable DeploymentId/WorkspaceId/TeamId/MemberId, currentRunRef and fileWriterEpoch", + "writers": ["future hosted controller under ADR-16 lease"], + "unresolvedWriters": ["final table/schema owner packages after Phase 0 integration"], + "schemaVersion": "must be declared in StateCompatibilityManifest before first write", + "maxBytes": "bounded typed records", + "lockingModel": "SQLite transaction plus write-once anchor publication and checksum agreement", + "atomicity": "SQLite row is local authority; portable anchor replication is journaled and disagreement blocks", + "unknownFieldPolicy": "future schema/version refuses mutation outside read/write range", + "corruptionPolicy": "identity disagreement or unknown schema fails closed", + "backupRole": "required identity inventory in both backup products", + "secretClass": "identifiers non-secret; paths/mount facts redacted; no credentials", + "sourceRefs": ["docs/hosted-web-e2e-completion-plan.md"] + } + ], + "counts": { + "total": 17, + "currentOrFallback": 16, + "phase1Required": 1, + "withUnresolvedWriterIdentity": 8, + "externalOrPotentialExternalWriter": 6, + "legacyUnverifiedBackup": 1 + }, + "proofLevel": "source-characterized", + "limitations": [ + "No real provider or user CLAUDE_ROOT was inspected.", + "Provider/version writer identities remain source-inferred until W2 sanitized fixtures and target versions are integrated.", + "The catalog groups manifest-bound runtime artifacts into families; Phase 1 must enumerate each manifest entry before it can join a recovery point." + ] +} diff --git a/docs/research/hosted-web/phase-0/state-writers/writer-coordination.json b/docs/research/hosted-web/phase-0/state-writers/writer-coordination.json new file mode 100644 index 0000000000..8cb898b5df --- /dev/null +++ b/docs/research/hosted-web/phase-0/state-writers/writer-coordination.json @@ -0,0 +1,189 @@ +{ + "$schema": "./evidence.schema.json", + "schemaVersion": 1, + "evidenceId": "P0.W3.WRITER_COORDINATION", + "phaseStartSha": "a32f509e6d9bd31ba2135940e336729bf90c3d93", + "classes": ["app-exclusive", "cooperative", "provider-mediated", "quiescent-only", "unavailable"], + "operations": [ + { + "id": "config.create-or-edit", + "familyId": "cli-team-config", + "provider": "claude-compatible/*", + "possibleActiveWriters": ["hosted controller", "Claude CLI/runtime", "legacy desktop paths"], + "adr29Class": "uncoordinated_external", + "coordinationClass": "quiescent-only", + "activeWriterEvidence": "all ADR-22 units drained plus ADR-24 watermark/rescan closed", + "currentProof": "no shared lock or provider acknowledgement found", + "hostedDisposition": "refuse direct active-run mutation; re-read after quiescence", + "sourceRefs": [ + "src/main/services/team/TeamDataService.ts", + "src/main/services/team/provisioning" + ] + }, + { + "id": "task.create-update-delete-relate", + "familyId": "cli-tasks", + "provider": "claude-compatible/*", + "possibleActiveWriters": ["TeamTaskWriter", "Claude task tools", "MCP team tools"], + "adr29Class": "uncoordinated_external", + "coordinationClass": "quiescent-only", + "activeWriterEvidence": "provider process/agent may be alive for the team", + "currentProof": "withTaskLock is in-process only; two-file relationships are independent", + "hostedDisposition": "active direct browser mutation unavailable until a real provider-mediated/cooperative protocol is proven", + "sourceRefs": ["src/main/services/team/TeamTaskWriter.ts"] + }, + { + "id": "inbox.append-native", + "familyId": "cli-inboxes", + "provider": "claude-native/uncharacterized-version", + "possibleActiveWriters": ["TeamInboxWriter", "native teammate/CLI tools"], + "adr29Class": "uncoordinated_external", + "coordinationClass": "quiescent-only", + "activeWriterEvidence": "native teammate/process ownership unit is live", + "currentProof": "app lock file exists, but native adoption is unproven", + "hostedDisposition": "do not claim active-run lossless direct append", + "sourceRefs": [ + "src/main/services/team/TeamInboxWriter.ts", + "src/main/services/team/fileLock.ts" + ] + }, + { + "id": "inbox.deliver-opencode", + "familyId": "cli-inboxes", + "provider": "opencode/target-base-adapter", + "possibleActiveWriters": ["hosted controller inbox writer", "OpenCode runtime bridge"], + "adr29Class": "cooperative_external", + "coordinationClass": "provider-mediated", + "activeWriterEvidence": "runtime manifest plus bridge command ledger and observed delivery/reply evidence", + "currentProof": "adapter has persisted delivery ledgers, but exact reply writer path is unresolved", + "hostedDisposition": "accepted/running/observed states only; never equate transport delivery with semantic success", + "sourceRefs": [ + "src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts", + "src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts" + ] + }, + { + "id": "kanban.update-order", + "familyId": "kanban-projection", + "provider": "app", + "possibleActiveWriters": ["concurrent renderer/cleanup calls"], + "adr29Class": "app_exclusive", + "coordinationClass": "app-exclusive", + "activeWriterEvidence": "single ADR-16 controller lease and per-team coordinator", + "currentProof": "current code lacks coordinator/expectedRevision", + "hostedDisposition": "unavailable until lease plus coordinator; then expectedRevision mutation", + "sourceRefs": ["src/main/services/team/TeamKanbanManager.ts"] + }, + { + "id": "review-decision.save-clear", + "familyId": "review-decisions", + "provider": "app", + "possibleActiveWriters": ["ReviewDecisionStore"], + "adr29Class": "app_exclusive", + "coordinationClass": "app-exclusive", + "activeWriterEvidence": "single ADR-16 controller lease", + "currentProof": "one app store, but current desktop has no hosted lease", + "hostedDisposition": "admit after lease and expected revision are implemented", + "sourceRefs": ["src/main/services/team/ReviewDecisionStore.ts"] + }, + { + "id": "review.apply-workspace", + "familyId": "review-decisions", + "provider": "workspace/git/external-editor", + "possibleActiveWriters": ["ReviewApplierService", "Git", "editor", "provider process"], + "adr29Class": "uncoordinated_external", + "coordinationClass": "unavailable", + "activeWriterEvidence": "workspace guard and Git preconditions are outside this lane", + "currentProof": "hash checks detect some conflicts but writes are raw workspace effects", + "hostedDisposition": "no Phase 0 hosted mutation; require W4/W5/Git protocol", + "sourceRefs": ["src/main/services/team/ReviewApplierService.ts"] + }, + { + "id": "launch-state.publish", + "familyId": "launch-state", + "provider": "app/provisioning", + "possibleActiveWriters": [ + "TeamLaunchStateStore", + "provisioning boundary", + "legacy direct callers" + ], + "adr29Class": "app_exclusive", + "coordinationClass": "app-exclusive", + "activeWriterEvidence": "ADR-16 controller lease plus one durable run-generation coordinator", + "currentProof": "only process-local boundary and restart-volatile writtenRunId guard", + "hostedDisposition": "unavailable until durable coordinator; summary remains derived", + "sourceRefs": [ + "src/main/services/team/TeamLaunchStateStore.ts", + "src/main/services/team/provisioning/TeamProvisioningLaunchStateStoreBoundary.ts" + ] + }, + { + "id": "runtime-evidence.write", + "familyId": "bootstrap-and-runtime-evidence", + "provider": "opencode/target-base-adapter", + "possibleActiveWriters": ["controller stores", "OpenCode managed host"], + "adr29Class": "cooperative_external", + "coordinationClass": "cooperative", + "activeWriterEvidence": "versioned RuntimeStoreManifest entry plus provider run/lane identity", + "currentProof": "VersionedJsonStore/manifest contracts exist for selected stores only", + "hostedDisposition": "admit only per characterized manifest entry; unknown entries default unavailable", + "sourceRefs": [ + "src/main/services/team/opencode/store/RuntimeStoreManifest.ts", + "src/main/services/team/opencode/store/VersionedJsonStore.ts" + ] + }, + { + "id": "sqlite.mutate", + "familyId": "internal-storage-sqlite", + "provider": "app", + "possibleActiveWriters": ["internal-storage worker"], + "adr29Class": "app_exclusive", + "coordinationClass": "app-exclusive", + "activeWriterEvidence": "single awaited worker request queue under controller lease", + "currentProof": "current worker serializes requests; hosted lease belongs to W4", + "hostedDisposition": "admit after compatibility preflight and lease", + "sourceRefs": [ + "src/features/internal-storage/main/infrastructure/InternalStorageWorkerClient.ts", + "src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerEntry.ts" + ] + }, + { + "id": "backup.coordination", + "familyId": "internal-storage-sqlite", + "provider": "app", + "possibleActiveWriters": ["internal-storage worker"], + "adr29Class": "app_exclusive", + "coordinationClass": "cooperative", + "activeWriterEvidence": "awaited worker op and SQLite Online Backup API progress/deadline", + "currentProof": "Phase 0 standalone spike proves driver primitive; production async worker op is intentionally not implemented", + "hostedDisposition": "Phase 1 implementation candidate", + "sourceRefs": ["scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs"] + }, + { + "id": "backup.deployment-recovery-point", + "familyId": "legacy-team-backups", + "provider": "all", + "possibleActiveWriters": ["controller", "provider/CLI writers", "legacy backup service"], + "adr29Class": "uncoordinated_external", + "coordinationClass": "quiescent-only", + "activeWriterEvidence": "deployment mutation fence, drained ownership units, closed watcher watermarks and frozen participant generations", + "currentProof": "TeamBackupService has only a process-local mutex and independent copies", + "hostedDisposition": "current backup unavailable as recovery point; new ADR-32 workflow required", + "sourceRefs": ["src/main/services/team/TeamBackupService.ts"] + } + ], + "unresolvedWriterIdentities": [ + "Claude CLI/provider versions and exact config.json mutation verbs", + "Claude task-tool writer implementation and lock behavior", + "native teammate inbox lock-file adoption", + "OpenCode reply-to-user inbox writer path and acknowledgement boundary", + "provider ownership/flush guarantees for members/** and transcript artifacts", + "legacy config/launch paths that bypass the intended coordinator" + ], + "negativeFixture": { + "command": "node scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs", + "expected": "lostExternalUpdate=true", + "meaning": "an in-process promise lock cannot prevent a child process from replacing the file between read and rename" + }, + "proofLevel": "source-characterized-plus-executable-negative-control" +} diff --git a/docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json b/docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json new file mode 100644 index 0000000000..85433d1deb --- /dev/null +++ b/docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json @@ -0,0 +1,77 @@ +{ + "$schema": "./controller-artifact-contract.schema.json", + "schemaVersion": 1, + "contractId": "P0.CONTROLLER.W4_W6.ARTIFACT_CONTRACT.V1", + "controllerOwner": "phase-00-controller", + "status": "contract_only_no_production_composition", + "artifactFields": [ + "artifactId", + "finalImagePath", + "binarySha256", + "protocolManifestPath", + "protocolSha256", + "spikeSourcePath", + "spikeSourceSha256", + "buildRecipeId", + "builderImageDigest", + "compilerIdentity", + "targetAbi", + "uid", + "gid", + "mode", + "imageOrder" + ], + "artifacts": [ + { + "artifactId": "agent-teams-instance-lock", + "finalImagePath": "/app/bin/agent-teams-instance-lock", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json", + "protocolSha256": "ded8949371646d490ba5175cf2992cb308df6fdca44537d91d728345be3b139f", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c", + "spikeSourceSha256": "d9cc83ae82e3a1c11e654db39ecf6ddceea78f23f455db707cb5876652118c57", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "pre_node_contract_only" + }, + { + "artifactId": "agent-teams-process-anchor", + "finalImagePath": "/app/bin/agent-teams-process-anchor", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + "protocolSha256": "17e8b63b294a88959b7907cb931ea828a23b139abbbb3959b36ff77966cf41de", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c", + "spikeSourceSha256": "8fd3414ca172a2947eb2545457417f42b16b76612e91162f74a51e8728ebff20", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "after_lease_contract_only" + }, + { + "artifactId": "agent-teams-workspace-guard", + "finalImagePath": "/app/bin/agent-teams-workspace-guard", + "binarySha256": null, + "protocolManifestPath": "docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json", + "protocolSha256": "d73abe4570fb87f42824c22e84a184fd617bd42384f097e4fae341c1d33b02c3", + "spikeSourcePath": "scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c", + "spikeSourceSha256": "66f7c58d1188fd8d4be09bdd890458002dbbe40bf94b153155f1f9a54366ab43", + "buildRecipeId": "w4-native-c17-v1", + "builderImageDigest": null, + "compilerIdentity": null, + "targetAbi": "linux-x64-gnu", + "uid": null, + "gid": null, + "mode": null, + "imageOrder": "per_effect_contract_only" + } + ] +} diff --git a/docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json b/docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json new file mode 100644 index 0000000000..38432fce10 --- /dev/null +++ b/docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "P0.CONTROLLER.W4_W6.ARTIFACT_CONTRACT_SCHEMA.V1", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "contractId", + "controllerOwner", + "status", + "artifactFields", + "artifacts" + ], + "properties": { + "$schema": { "const": "./controller-artifact-contract.schema.json" }, + "schemaVersion": { "const": 1 }, + "contractId": { "const": "P0.CONTROLLER.W4_W6.ARTIFACT_CONTRACT.V1" }, + "controllerOwner": { "const": "phase-00-controller" }, + "status": { "const": "contract_only_no_production_composition" }, + "artifactFields": { + "type": "array", + "const": [ + "artifactId", + "finalImagePath", + "binarySha256", + "protocolManifestPath", + "protocolSha256", + "spikeSourcePath", + "spikeSourceSha256", + "buildRecipeId", + "builderImageDigest", + "compilerIdentity", + "targetAbi", + "uid", + "gid", + "mode", + "imageOrder" + ] + }, + "artifacts": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "artifactId", + "finalImagePath", + "binarySha256", + "protocolManifestPath", + "protocolSha256", + "spikeSourcePath", + "spikeSourceSha256", + "buildRecipeId", + "builderImageDigest", + "compilerIdentity", + "targetAbi", + "uid", + "gid", + "mode", + "imageOrder" + ], + "properties": { + "artifactId": { "type": "string", "pattern": "^agent-teams-" }, + "finalImagePath": { "type": "string", "pattern": "^/app/bin/agent-teams-" }, + "binarySha256": { "type": "null" }, + "protocolManifestPath": { "type": "string" }, + "protocolSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "spikeSourcePath": { "type": "string" }, + "spikeSourceSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "buildRecipeId": { "const": "w4-native-c17-v1" }, + "builderImageDigest": { "type": "null" }, + "compilerIdentity": { "type": "null" }, + "targetAbi": { "const": "linux-x64-gnu" }, + "uid": { "type": "null" }, + "gid": { "type": "null" }, + "mode": { "type": "null" }, + "imageOrder": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json b/docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json new file mode 100644 index 0000000000..1492444d54 --- /dev/null +++ b/docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1", + "title": "Controller-owned W4/W6 drain-evidence envelope", + "description": "The sole Phase 0 authority for a successful, generation-bound W4 process drain that a W6 reset consumer may accept.", + "type": "object", + "additionalProperties": false, + "required": ["envelopeId", "ready", "drained"], + "properties": { + "envelopeId": { + "const": "P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1" + }, + "ready": { "$ref": "#/$defs/ready" }, + "drained": { "$ref": "#/$defs/drained" } + }, + "$defs": { + "ready": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocolVersion", + "spawnNonceHash", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "anchorIdentity", + "mainPidfdReady", + "ownedProcessGroupReady" + ], + "properties": { + "protocolVersion": { "const": 1 }, + "spawnNonceHash": { "type": "string", "minLength": 1 }, + "purpose": { "type": "string", "minLength": 1 }, + "resetGeneration": { "type": "integer", "minimum": 0 }, + "deploymentGeneration": { "type": "string", "minLength": 1 }, + "processAnchorGeneration": { "type": "string", "minLength": 1 }, + "anchorIdentity": { "type": "string", "minLength": 1 }, + "mainPidfdReady": { "const": true }, + "ownedProcessGroupReady": { "const": true } + } + }, + "drained": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocolVersion", + "kind", + "outcome", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "classificationId", + "residuals" + ], + "properties": { + "protocolVersion": { "const": 1 }, + "kind": { "const": "process_drain_outcome_v1" }, + "outcome": { "const": "drained" }, + "purpose": { "type": "string", "minLength": 1 }, + "resetGeneration": { "type": "integer", "minimum": 0 }, + "deploymentGeneration": { "type": "string", "minLength": 1 }, + "processAnchorGeneration": { "type": "string", "minLength": 1 }, + "classificationId": { "type": "string", "minLength": 1 }, + "residuals": { "type": "array", "maxItems": 0 } + } + } + }, + "x-controllerOwner": "phase-00-controller", + "x-failClosedInvariants": [ + "exact_envelope_ready_and_drained_fields", + "ready_and_drained_protocol_version_equal", + "ready_and_drained_purpose_equal", + "ready_and_drained_reset_generation_equal", + "ready_and_drained_deployment_generation_equal", + "ready_and_drained_process_anchor_generation_equal", + "ready_pidfd_and_process_group_true", + "drained_outcome_only", + "drained_residuals_empty" + ] +} diff --git a/docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json b/docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json new file mode 100644 index 0000000000..c4b4bc506f --- /dev/null +++ b/docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "subphase": "P1.S0", + "phaseStartSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "status": "verified", + "toolVersions": { + "git": "2.43.0", + "node": "v24.16.0", + "pnpm": "10.33.4" + }, + "fingerprintAlgorithm": { + "name": "sha256-normalized-utf8-stdout-v1", + "stderrRule": "stderr must be empty", + "normalization": [ + "Replace Node test parenthesized elapsed milliseconds with (DURATION)", + "Replace the Node test duration_ms numeric value with DURATION", + "Preserve all other UTF-8 bytes including the final newline" + ] + }, + "orderingPolicy": "Checks are ordered lexicographically by id", + "checks": [ + { + "id": "orchestration-contract-focused", + "cwd": "scripts/hosted-web/orchestration", + "command": "node --test --test-isolation=none ../../../test/architecture/hosted-web/orchestration/worker-start-contract.test.mjs ../../../test/architecture/hosted-web/orchestration/orchestration-state.test.mjs", + "exitCode": 0, + "classification": "passing", + "failureDisposition": null, + "normalizedStdoutSha256": "2d6a1227d583673a8b496d824f79200662d201782c014033ba17da6ff4369d5e", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summary": { + "cancelled": 0, + "failed": 0, + "passed": 37, + "skipped": 0, + "tests": 37, + "todo": 0 + } + }, + { + "id": "phase-0-estimate-ledger", + "cwd": "scripts/hosted-web/orchestration", + "command": "node ../../../docs/research/hosted-web/phase-0/estimate-reconciliation/verify-ledger.mjs", + "exitCode": 0, + "classification": "passing", + "failureDisposition": null, + "normalizedStdoutSha256": "a3609eef5e24209f1ffb941a28a60970120d52f32522a837ea31add77404e6d6", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summary": "9 unique v1 buckets; 34 unique bucket allocations; 11 W3/W4/W5 scope allocations; 38300-62100 gross integrated changed lines; confidence 7/10 to 5/10; 23 source hashes verified" + }, + { + "id": "phase-0-freeze-indexes", + "cwd": "scripts/hosted-web/orchestration", + "command": "node ../../../docs/research/hosted-web/phase-0/freeze/current-canonical/verify-indexes.mjs", + "exitCode": 0, + "classification": "passing", + "failureDisposition": null, + "normalizedStdoutSha256": "189476b3d73cb34e6c95c6bf864cd597aedfd45d4022ba31408b8573fbfddcf5", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summary": "5 schemas; 40 evidence IDs; 5 accepted authorities; 3 focused negatives" + }, + { + "id": "worker-admission", + "cwd": "scripts/hosted-web/orchestration", + "command": "node validate-worker-admission.mjs --contract --state ", + "exitCode": 0, + "classification": "passing", + "failureDisposition": null, + "normalizedStdoutSha256": "ac93d0c19a764b399745f57e9699e96e068026affa403ba715d7c43101b135a9", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summary": "Exactly one matching queued registry record" + } + ], + "failureSummary": { + "acceptedInherited": 0, + "blocking": 0, + "newlyIntroduced": 0, + "unclassified": 0 + } +} diff --git a/docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md b/docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md new file mode 100644 index 0000000000..0394436b21 --- /dev/null +++ b/docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md @@ -0,0 +1,39 @@ +# Phase 1 P1.S0 bootstrap report + +Status: verified bootstrap metadata. Phase start: +`5f30df49e052d1cc1d0e7efd03aa105673b5b614`. Packet revision: +`phase-01-s0-bootstrap-r1`. + +## Outputs and sources + +| Output | Source authority | Result | +| ---------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `phase-start.json` | Worker-start contract, `EXECUTION_INDEX.json`, controller packet, accepted Phase 0 canonical freeze | Binds the exact worktree start SHA, canonical/base provenance, accepted freeze authorities, and S0-only authorization. | +| `packet-revision.json` | Controller packet, architecture proposal, conformance proposal, gap register, and lane packet | Freezes the packet revision, stable IDs, contract values, dependency order, and all ten gap dispositions while keeping every successor proposal-only. | +| `ownership-manifest.json` | Execution DAG, architecture ownership table, conformance fixture rules, review order, and lane packet | Resolves all downstream writer sets to exact no-glob paths, assigns the synthetic fixture corpus to P1.1C before P1.1D consumption, and freezes reciprocal review pairings. | +| `baseline-fingerprints.json` | Runtime contract checks and contract-listed verification scripts/tests | Records reproducible normalized fingerprints. The orchestration contract baseline passes all 37 tests; all four focused baselines pass with no inherited, new, blocking, or unclassified failures. | +| `estimate-allocation.json` | Execution DAG unique buckets and accepted Phase 0 estimate authority | Replaces the stale S0 planning range with the actual 783-line bootstrap and reconciles Phase 1 to 1,903-2,783 unique gross changed lines without changing the accepted Phase 0 38,300-62,100 range. | +| `bootstrap-report.md` | All authorities above | Summarizes provenance, verification, scope, and the closed S0 boundary. | + +## Decisions + +- The exact downstream dependency chain is `P1.1A -> (P1.1B + P1.1C) -> P1.R1 -> P1.1D -> P1.R2 -> P1.I` after a separate S0 integration and router advance. +- P1.1C owns the exact synthetic, in-memory team-lifecycle fixture corpus so it exists before the accepted R1 gate admits P1.1D; P1.1D consumes those fixture paths read-only. +- Both transport-shaped adapters remain test-only. Phase 1 has no production IPC, preload, renderer API, HTTP registration, filesystem adapter, dependency, or lockfile authority. +- Every named negative control has a frozen owner, exact fixture path or explicit deferral, positive neighbor, mutation, stable diagnostic, and focused command. +- `P1.NEG.TEST_ROOT_ESCAPE` remains explicitly deferred to the Phase 2 first filesystem-backed adapter. Any Phase 1 path-taking or filesystem-backed surface reopens `P1-GAP-009` and stops work. +- The actual S0 bootstrap is 783 gross lines across exactly the six owned outputs. The resulting 1,903-2,783 unique Phase 1 allocation does not overlap the parent 900-1,600 range: its low endpoint is 1,003 lines (111.44%) above the parent low, its high endpoint is 1,183 lines (73.94%) above the parent high, and its low endpoint is 303 lines above the parent high. The variance is explicit and retained; review/evidence lines are not dropped and the accepted Phase 0 estimate is not reopened. + +## Verification summary + +The worker-start contract and exactly one queued admission record validated. The accepted Phase 0 +canonical-index verifier and estimate-ledger verifier passed, as did all 37 focused orchestration +contract/state tests. Every created JSON file was parsed, the six owned paths were checked exactly, +identifiers and writer paths were checked for uniqueness and disjointness, estimate arithmetic was recomputed, and +the baseline fingerprints were reproduced. Exact Prettier, diff, owned-path, secret/private-path, and +scope checks were run over the six outputs. + +No file beneath `src/` was created or changed. No real project, credential, provider runtime, live +team, terminal runtime, or smoke flow was used. No P1.S1-or-later contract, worktree, task, preload, +refill, or producer was created or admitted. Passing S0 does not authorize a successor; all later +work remains blocked until reviewed integration and an explicit router transition. diff --git a/docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json b/docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json new file mode 100644 index 0000000000..ba5d775cdb --- /dev/null +++ b/docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "subphase": "P1.S0", + "status": "reconciled-with-explicit-phase-1-variance", + "unit": "gross changed lines including implementation, tests, reviews, and evidence", + "allocationPolicy": "Each line is counted once in exactly one unique bucket", + "buckets": [ + { + "bucketId": "P1.S0", + "owner": "serial bootstrap", + "actual": 783 + }, + { + "bucketId": "P1.1A", + "owner": "contract kernel", + "low": 180, + "high": 300 + }, + { + "bucketId": "P1.1B", + "owner": "route and capability conventions", + "low": 180, + "high": 320 + }, + { + "bucketId": "P1.1C", + "owner": "conformance, ratchets, and synthetic fixtures", + "low": 240, + "high": 420 + }, + { + "bucketId": "P1.1D", + "owner": "first read proof", + "low": 300, + "high": 520 + }, + { + "bucketId": "P1.R1", + "owner": "architecture review", + "low": 60, + "high": 110 + }, + { + "bucketId": "P1.R2", + "owner": "semantic review", + "low": 60, + "high": 110 + }, + { + "bucketId": "P1.I", + "owner": "serialized integration", + "low": 100, + "high": 220 + } + ], + "phase1UniqueTotal": { + "low": 1903, + "high": 2783 + }, + "parentPhase1PlanningRange": { + "low": 900, + "high": 1600, + "source": "docs/hosted-web-phases/phase-01/execution-dag.md" + }, + "reconciliation": { + "overlap": null, + "overlapGap": 303, + "lowEndpointDelta": 1003, + "lowEndpointVariancePercent": 111.44, + "highEndpointDelta": 1183, + "highEndpointVariancePercent": 73.94, + "disposition": "Retain the unique 1903-2783 allocation, including the actual 783-line S0 bootstrap, as an explicit Phase 1 planning variance. Do not omit review or evidence lines, reopen the accepted Phase 0 estimate, or infer authority to start a successor lane." + }, + "acceptedPhase0Estimate": { + "authorityCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "grossNonTerminalV1": { + "low": 38300, + "high": 62100 + }, + "changedByThisReconciliation": false + }, + "targetImageNarrowingReopened": false, + "arithmetic": { + "high": "783+300+320+420+520+110+110+220=2783", + "low": "783+180+180+240+300+60+60+100=1903" + } +} diff --git a/docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json b/docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json new file mode 100644 index 0000000000..7f4b63d965 --- /dev/null +++ b/docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json @@ -0,0 +1,387 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "subphase": "P1.S0", + "status": "frozen-proposal-only", + "phaseStartSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "ownedBootstrapPaths": [ + "docs/research/hosted-web/phase-1/bootstrap/phase-start.json", + "docs/research/hosted-web/phase-1/bootstrap/packet-revision.json", + "docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json", + "docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json", + "docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json", + "docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md" + ], + "successorAuthorization": { + "admitted": [], + "blocked": ["P1.1A", "P1.1B", "P1.1C", "P1.1D", "P1.R1", "P1.R2", "P1.I"], + "condition": "Integrated P1.S0 bootstrap plus an explicit router and worker-authority transition" + }, + "writers": [ + { + "ownerId": "P1.1A", + "mission": "Minimal shared contract kernel and parser/version negatives", + "dependsOn": ["P1.S0-integrated-and-router-advanced"], + "evidenceIds": ["P1.1A.KERNEL", "P1.1A.VERSION"], + "writablePaths": [ + ".codex-handoff/phase-01-p1-1a.json", + "src/shared/contracts/hosted/app-error.ts", + "src/shared/contracts/hosted/identifiers.ts", + "src/shared/contracts/hosted/index.ts", + "src/shared/contracts/hosted/query-context.ts", + "src/shared/contracts/hosted/revision.ts", + "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json", + "test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "test/architecture/hosted-web/phase-1/contracts/query-context.test.ts", + "test/architecture/hosted-web/phase-1/contracts/revision.test.ts" + ], + "focusedChecks": [ + "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts", + "pnpm typecheck" + ] + }, + { + "ownerId": "P1.1B", + "mission": "Route catalog assertions and separate capability cross-reference", + "dependsOn": ["P1.1A-integrated"], + "evidenceIds": ["P1.1B.CAPABILITIES", "P1.1B.ROUTES"], + "writablePaths": [ + ".codex-handoff/phase-01-p1-1b.json", + "src/main/composition/hosted/routing/RouteCatalog.ts", + "src/main/composition/hosted/routing/index.ts", + "src/main/composition/hosted/routing/route-types.ts", + "test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts", + "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts", + "test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts", + "test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts" + ], + "focusedChecks": [ + "pnpm exec vitest run test/architecture/hosted-web/phase-1/routes", + "pnpm typecheck" + ] + }, + { + "ownerId": "P1.1C", + "mission": "Semantic harness, ADR-19/20 ratchets, and synthetic fixture corpus", + "dependsOn": ["P1.1A-integrated"], + "evidenceIds": ["P1.1C.CONFORMANCE", "P1.1C.RATCHETS"], + "writablePaths": [ + ".codex-handoff/phase-01-p1-1c.json", + "scripts/hosted-web/phase-1/check-feature-dependencies.ts", + "scripts/hosted-web/phase-1/check-parity-references.ts", + "scripts/hosted-web/phase-1/check-renderer-boundaries.ts", + "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts", + "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts", + "test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts", + "test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts", + "test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts", + "test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts", + "test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts", + "test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts", + "test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts", + "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts", + "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json" + ], + "focusedChecks": [ + "pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance test/architecture/hosted-web/phase-1/dependencies test/architecture/hosted-web/phase-1/parity test/architecture/hosted-web/phase-1/renderer-boundaries", + "pnpm typecheck" + ] + }, + { + "ownerId": "P1.R1", + "mission": "Independent architecture and negative-control review of P1.1B plus P1.1C", + "dependsOn": ["P1.1B", "P1.1C"], + "evidenceIds": ["P1.R1.ARCH_REVIEW"], + "writablePaths": [ + ".codex-handoff/phase-01-p1-r1.json", + "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md" + ], + "focusedChecks": ["pnpm exec vitest run test/architecture/hosted-web/phase-1"] + }, + { + "ownerId": "P1.1D", + "mission": "List query, feature contracts and port, in-memory reader, and isolated test adapters", + "dependsOn": ["P1.R1-accepted"], + "evidenceIds": ["P1.1D.LIST_SLICE", "P1.1D.TRANSPORTS"], + "readOnlyFixturePaths": [ + "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json", + "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json" + ], + "writablePaths": [ + ".codex-handoff/phase-01-p1-1d.json", + "src/features/team-lifecycle/contracts/capabilityDescriptor.ts", + "src/features/team-lifecycle/contracts/index.ts", + "src/features/team-lifecycle/contracts/listTeamLifecycleSummaries.ts", + "src/features/team-lifecycle/contracts/routeDescriptor.ts", + "src/features/team-lifecycle/core/application/ListTeamLifecycleSummaries.ts", + "src/features/team-lifecycle/core/application/TeamLifecycleSummaryReader.ts", + "src/features/team-lifecycle/index.ts", + "test/features/team-lifecycle/conformance/direct-list-adapter.ts", + "test/features/team-lifecycle/conformance/http-shaped-list-adapter.ts", + "test/features/team-lifecycle/conformance/in-memory-team-lifecycle-summary-reader.ts", + "test/features/team-lifecycle/conformance/ipc-shaped-list-adapter.ts", + "test/features/team-lifecycle/conformance/listTeamLifecycleSummaries.conformance.test.ts", + "test/features/team-lifecycle/conformance/test-composition.ts", + "test/features/team-lifecycle/contracts/listTeamLifecycleSummaries.test.ts", + "test/features/team-lifecycle/core/ListTeamLifecycleSummaries.test.ts" + ], + "focusedChecks": ["pnpm exec vitest run test/features/team-lifecycle", "pnpm typecheck"] + }, + { + "ownerId": "P1.R2", + "mission": "Independent semantic, auth, error, cursor, and kernel-size review", + "dependsOn": ["P1.1D"], + "evidenceIds": ["P1.R2.SEMANTIC_REVIEW"], + "writablePaths": [ + ".codex-handoff/phase-01-p1-r2.json", + "docs/research/hosted-web/phase-1/reviews/list-semantics.md" + ], + "focusedChecks": [ + "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts test/features/team-lifecycle" + ] + }, + { + "ownerId": "P1.I", + "mission": "Serialized adoption, full gates, rollback proof, and evidence freeze", + "dependsOn": ["P1.R2-accepted"], + "evidenceIds": ["P1.I.INTEGRATION", "P1.I.ROLLBACK"], + "writablePaths": [ + ".codex-handoff/phase-01-p1-i.json", + "docs/research/hosted-web/phase-1/decision-register.json", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "docs/research/hosted-web/phase-1/evidence-index.json", + "docs/research/hosted-web/phase-1/integration-report.json" + ], + "focusedChecks": [ + "pnpm exec vitest run test/features/team-lifecycle test/architecture/hosted-web/phase-1", + "pnpm typecheck" + ] + } + ], + "reviewPairings": [ + { + "reviewId": "P1.R1", + "producerIds": ["P1.1B", "P1.1C"], + "reviewerMustDifferFromEveryProducer": true, + "reviewEvidencePath": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md" + }, + { + "reviewId": "P1.R2", + "producerIds": ["P1.1A", "P1.1D"], + "reviewerMustDifferFromEveryProducer": true, + "reviewEvidencePath": "docs/research/hosted-web/phase-1/reviews/list-semantics.md" + } + ], + "negativeControls": [ + { + "fixtureId": "P1.NEG.CAPABILITY_MOUNT", + "status": "required", + "ownerId": "P1.1B", + "fixturePath": "test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "mutation": "Set production support true or mount a testOnly route", + "expectedDiagnostic": "phase1-test-capability-production-mount", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts" + }, + { + "fixtureId": "P1.NEG.CORE_SIDE_EFFECT", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "mutation": "Add a watcher, repair, process, notification, runtime overlay, or transport logger dependency", + "expectedDiagnostic": "phase1-core-side-effect-forbidden", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts" + }, + { + "fixtureId": "P1.NEG.HOSTED_ELECTRON_API", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts", + "mutation": "Make the hosted facet structurally assignable to ElectronAPI", + "expectedDiagnostic": "phase1-hosted-electron-api-forbidden", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts" + }, + { + "fixtureId": "P1.NEG.ID_REVISION_CURSOR", + "status": "required", + "ownerId": "P1.1D", + "fixturePath": "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json", + "positiveNeighbor": "test/features/team-lifecycle/contracts/listTeamLifecycleSummaries.test.ts", + "mutation": "Cross kinds or reuse raw, stale, foreign, or snapshot-mismatched opaque values", + "expectedDiagnostic": "phase1-opaque-value-kind-or-scope-mismatch", + "command": "pnpm exec vitest run test/features/team-lifecycle/contracts/listTeamLifecycleSummaries.test.ts" + }, + { + "fixtureId": "P1.NEG.IMPORT_FORBIDDEN", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "mutation": "Import a framework, Node built-in, @main, renderer, preload, or infrastructure module from core/contracts", + "expectedDiagnostic": "phase1-core-import-forbidden", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts" + }, + { + "fixtureId": "P1.NEG.LEGACY_GOD_DTO", + "status": "required", + "ownerId": "P1.1C", + "semanticOwnerId": "P1.1D", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts", + "positiveNeighbor": "test/features/team-lifecycle/contracts/listTeamLifecycleSummaries.test.ts", + "mutation": "Add a legacy aggregate, raw name/path field, or all-parity surface", + "expectedDiagnostic": "phase1-legacy-god-dto-forbidden", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/features/team-lifecycle/contracts/listTeamLifecycleSummaries.test.ts" + }, + { + "fixtureId": "P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts", + "positiveNeighbor": "test/features/team-lifecycle/conformance/in-memory-team-lifecycle-summary-reader.ts", + "mutation": "Add a path/root parameter, filesystem import, ambient-root lookup, watcher, repair, or cleanup", + "expectedDiagnostic": "phase1-filesystem-adapter-forbidden", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts" + }, + { + "fixtureId": "P1.NEG.PARITY_DRIFT", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "mutation": "Change a pinned legacy signature or omit an owner/action/route/entrypoint/test reference", + "expectedDiagnostic": "phase1-parity-reference-drift", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts" + }, + { + "fixtureId": "P1.NEG.PATH_SECRET_LEAK", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "mutation": "Place a host path, auth/provider payload, command body, or canary in a safe outcome or diagnostic", + "expectedDiagnostic": "phase1-path-secret-leak", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts" + }, + { + "fixtureId": "P1.NEG.PERFORMANCE_DEGRADATION", + "status": "required", + "ownerId": "P1.1D", + "fixturePath": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json", + "positiveNeighbor": "test/features/team-lifecycle/conformance/listTeamLifecycleSummaries.conformance.test.ts", + "mutation": "Exceed an item, byte, or latency limit; duplicate invocation; ignore cancellation; or hide fallback/source metadata", + "expectedDiagnostic": "phase1-performance-or-determinism-budget", + "command": "pnpm exec vitest run test/features/team-lifecycle/conformance/listTeamLifecycleSummaries.conformance.test.ts" + }, + { + "fixtureId": "P1.NEG.PRODUCTION_ADAPTER_MOUNT", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "mutation": "Import or mount the IPC-shaped or HTTP-shaped test adapter from production composition", + "expectedDiagnostic": "phase1-test-adapter-production-import", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts" + }, + { + "fixtureId": "P1.NEG.RATCHET_REGRESSION", + "status": "required", + "ownerId": "P1.1C", + "fixturePath": "test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "mutation": "Increase path/symbol debt or retain an expired quarantine row", + "expectedDiagnostic": "phase1-ratchet-regression", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts" + }, + { + "fixtureId": "P1.NEG.ROUTE_DRIFT", + "status": "required", + "ownerId": "P1.1B", + "fixturePath": "test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts", + "mutation": "Duplicate a route/method/path or remove a handler/schema/policy/client/test reference", + "expectedDiagnostic": "phase1-route-catalog-drift", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts" + }, + { + "fixtureId": "P1.NEG.SCHEMA_VERSION", + "status": "required", + "ownerId": "P1.1A", + "fixturePath": "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json", + "positiveNeighbor": "test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "mutation": "Supply malformed, missing, future, incompatible, or disallowed unknown fields", + "expectedDiagnostic": "phase1-schema-version-invalid-or-unsupported", + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts" + }, + { + "fixtureId": "P1.NEG.SEMANTIC_OUTCOME", + "status": "required", + "ownerId": "P1.1D", + "fixtureOwnerId": "P1.1C", + "fixturePath": "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json", + "positiveNeighbor": "test/features/team-lifecycle/conformance/listTeamLifecycleSummaries.conformance.test.ts", + "mutation": "Change an applicable oracle, omit an audited state, or accept a fail-closed non-applicable outcome", + "expectedDiagnostic": "phase1-semantic-outcome-mismatch", + "command": "pnpm exec vitest run test/features/team-lifecycle/conformance/listTeamLifecycleSummaries.conformance.test.ts" + }, + { + "fixtureId": "P1.NEG.TEST_ROOT_ESCAPE", + "status": "deferred-to-phase-2", + "ownerId": "Phase 2 first filesystem-backed adapter", + "fixturePath": null, + "positiveNeighbor": null, + "mutation": "Attempt an unmarked, ambient, home-scoped, pre-existing, or symlink-escaped root", + "expectedDiagnostic": "phase2-test-root-not-admitted", + "command": null, + "reopenCondition": "P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1 fails or any Phase 1 filesystem/path surface appears" + } + ], + "globalReadOnlyPaths": [ + "package.json", + "pnpm-lock.yaml", + "src/main/http/index.ts", + "src/main/http/teams.ts", + "src/main/ipc/teams.ts", + "src/main/services/infrastructure/HttpServer.ts", + "src/main/standalone.ts", + "src/preload/constants/ipcChannels.ts", + "src/preload/index.ts", + "src/renderer/api/index.ts", + "tsconfig.json" + ], + "invariants": { + "exactNoGlobPaths": true, + "filesystemAdaptersAllowedInPhase1": false, + "newDependenciesAllowed": false, + "ownershipDisjoint": true, + "productionTransportRegistrationAllowed": false, + "realProjectsAllowed": false, + "sharedWriterCount": 1 + } +} diff --git a/docs/research/hosted-web/phase-1/bootstrap/packet-revision.json b/docs/research/hosted-web/phase-1/bootstrap/packet-revision.json new file mode 100644 index 0000000000..e03bf8a178 --- /dev/null +++ b/docs/research/hosted-web/phase-1/bootstrap/packet-revision.json @@ -0,0 +1,153 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "subphase": "P1.S0", + "packetRevision": "phase-01-s0-bootstrap-r1", + "status": "frozen-bootstrap-metadata", + "phaseStartSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "controllerPacket": "docs/hosted-web-phases/phase-01/controller-packet.md", + "lanePacket": "docs/hosted-web-phases/phase-01/lanes/p1-s0-serial-bootstrap.md", + "dependencyChain": ["P1.S0", "P1.1A", "P1.1B+P1.1C", "P1.R1", "P1.1D", "P1.R2", "P1.I", "P1.F"], + "downstreamStatus": "proposal-only-blocked-until-router-advance", + "resolvedIdentifiers": { + "actionIds": ["team.lifecycle.list"], + "evidenceIds": [ + "P1.1A.KERNEL", + "P1.1A.VERSION", + "P1.1B.CAPABILITIES", + "P1.1B.ROUTES", + "P1.1C.CONFORMANCE", + "P1.1C.RATCHETS", + "P1.1D.LIST_SLICE", + "P1.1D.TRANSPORTS", + "P1.I.INTEGRATION", + "P1.I.ROLLBACK", + "P1.R1.ARCH_REVIEW", + "P1.R2.SEMANTIC_REVIEW", + "P1.S0.BASELINE", + "P1.S0.BOOTSTRAP" + ], + "facetIds": ["teamRead"], + "gapIds": [ + "P1-GAP-001", + "P1-GAP-002", + "P1-GAP-003", + "P1-GAP-004", + "P1-GAP-005", + "P1-GAP-006", + "P1-GAP-007", + "P1-GAP-008", + "P1-GAP-009", + "P1-GAP-010" + ], + "ipcSpecimenOperationIds": ["teamLifecycle:listSummaries"], + "negativeFixtureIds": [ + "P1.NEG.CAPABILITY_MOUNT", + "P1.NEG.CORE_SIDE_EFFECT", + "P1.NEG.HOSTED_ELECTRON_API", + "P1.NEG.ID_REVISION_CURSOR", + "P1.NEG.IMPORT_FORBIDDEN", + "P1.NEG.LEGACY_GOD_DTO", + "P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1", + "P1.NEG.PARITY_DRIFT", + "P1.NEG.PATH_SECRET_LEAK", + "P1.NEG.PERFORMANCE_DEGRADATION", + "P1.NEG.PRODUCTION_ADAPTER_MOUNT", + "P1.NEG.RATCHET_REGRESSION", + "P1.NEG.ROUTE_DRIFT", + "P1.NEG.SCHEMA_VERSION", + "P1.NEG.SEMANTIC_OUTCOME", + "P1.NEG.TEST_ROOT_ESCAPE" + ], + "routeIds": ["team-lifecycle.list-summaries.v1"], + "schemaIds": ["team-lifecycle.list.request.v1", "team-lifecycle.list.response.v1"] + }, + "resolvedContractValues": { + "applicationQuery": "ListTeamLifecycleSummaries", + "authPolicyId": "browser.session.read", + "httpMethod": "GET", + "httpSpecimenPath": "/api/v1/team-lifecycle/teams", + "maximumPageSize": 200, + "responseSchemaVersion": 1, + "testOnlyTransportAdapters": ["http-shaped", "ipc-shaped"] + }, + "gapDispositions": [ + { + "gapId": "P1-GAP-001", + "status": "resolved-for-phase-1", + "evidenceOwner": "P1.1C", + "requiredEvidenceId": "P1.NEG.PRODUCTION_ADAPTER_MOUNT" + }, + { + "gapId": "P1-GAP-002", + "status": "resolved-for-phase-1", + "evidenceOwner": "P1.1D", + "requiredEvidenceId": "P1.NEG.LEGACY_GOD_DTO" + }, + { + "gapId": "P1-GAP-003", + "status": "acceptance-gated", + "evidenceOwner": "P1.1D", + "requiredEvidenceId": "P1.NEG.SEMANTIC_OUTCOME" + }, + { + "gapId": "P1-GAP-004", + "status": "resolved-for-phase-1", + "evidenceOwner": "P1.1B", + "requiredEvidenceId": "P1.NEG.CAPABILITY_MOUNT" + }, + { + "gapId": "P1-GAP-005", + "status": "acceptance-gated", + "evidenceOwner": "P1.1A", + "requiredEvidenceId": "P1.NEG.SCHEMA_VERSION" + }, + { + "gapId": "P1-GAP-006", + "status": "resolved-by-exact-path-manifest", + "evidenceOwner": "P1.S0", + "requiredEvidenceId": "P1.S0.BOOTSTRAP" + }, + { + "gapId": "P1-GAP-007", + "status": "acceptance-gated", + "evidenceOwner": "P1.1C", + "requiredEvidenceId": "P1.1C.RATCHETS" + }, + { + "gapId": "P1-GAP-008", + "status": "acceptance-gated", + "evidenceOwner": "P1.I", + "requiredEvidenceId": "P1.NEG.RATCHET_REGRESSION" + }, + { + "gapId": "P1-GAP-009", + "status": "deferred-to-phase-2", + "evidenceOwner": "Phase 2 first filesystem-backed adapter", + "requiredEvidenceId": "P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1", + "reopenCondition": "Any Phase 1 path-taking or filesystem-backed port, reader, adapter, fixture, or command" + }, + { + "gapId": "P1-GAP-010", + "status": "acceptance-gated", + "evidenceOwner": "P1.1D", + "requiredEvidenceId": "P1.NEG.PERFORMANCE_DEGRADATION" + } + ], + "orderingPolicy": { + "identifierArrays": "lexicographic except dependencyChain, which is dependency order", + "gapDispositions": "ascending gapId", + "ownership": "dependency order with exact paths lexicographically ordered per owner" + }, + "sourceReferences": [ + "docs/hosted-web-phases/EXECUTION_INDEX.json", + "docs/hosted-web-phases/phase-01/architecture-and-contracts.md", + "docs/hosted-web-phases/phase-01/conformance-and-tests.md", + "docs/hosted-web-phases/phase-01/controller-packet.md", + "docs/hosted-web-phases/phase-01/execution-dag.md", + "docs/hosted-web-phases/phase-01/execution-packet-templates.md", + "docs/hosted-web-phases/phase-01/lanes/p1-s0-serial-bootstrap.md", + "docs/hosted-web-phases/phase-01/operations-and-risk.md", + "docs/hosted-web-phases/phase-01/packet-inputs.md" + ] +} diff --git a/docs/research/hosted-web/phase-1/bootstrap/phase-start.json b/docs/research/hosted-web/phase-1/bootstrap/phase-start.json new file mode 100644 index 0000000000..d3aaf9246a --- /dev/null +++ b/docs/research/hosted-web/phase-1/bootstrap/phase-start.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "subphase": "P1.S0", + "laneId": "p1-s0", + "status": "frozen", + "phaseStartSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "canonicalSha": "42ec333848e29e97c41699b9fed73ed199740e3f", + "baseSha": "42ec333848e29e97c41699b9fed73ed199740e3f", + "acceptedPhase0Freeze": { + "commit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "status": "accepted-frozen", + "acceptedAuthorities": { + "estimate": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "navigation": "f32be6a6fcb2da7a47ef3553476430ef8052e19a", + "orchestration": "1587615c751c3cb12b5078ab4b7264b6e9fd42ad" + } + }, + "workerStartIdentity": { + "jobId": "agent-teams-hosted-web-refactor-phase-1-s0-bootstrap-r1", + "packetRevision": "phase-01-s0-bootstrap-r1", + "registryStatusAtAdmission": "queued", + "workKey": "1eeaf872a24cced043473be2fcd66ceccaedef38f5063c8af3b58ae79ae015ea" + }, + "authority": { + "controllerPacket": "docs/hosted-web-phases/phase-01/controller-packet.md", + "lanePacket": "docs/hosted-web-phases/phase-01/lanes/p1-s0-serial-bootstrap.md", + "router": "docs/hosted-web-phases/EXECUTION_INDEX.json" + }, + "authorization": { + "allowed": ["P1.S0"], + "blockedUntilRouterAdvance": ["P1.S1", "P1.S2", "P1.S3", "P1.S4", "P1.S5"], + "productSourceImplementationAuthorized": false + } +} diff --git a/docs/research/hosted-web/phase-1/decision-register.json b/docs/research/hosted-web/phase-1/decision-register.json new file mode 100644 index 0000000000..c11e860555 --- /dev/null +++ b/docs/research/hosted-web/phase-1/decision-register.json @@ -0,0 +1,164 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.I.INTEGRATION", + "laneId": "p1-i", + "packetRevision": "phase-01-p1-i-integration-r3", + "routerRevision": "phase-01-p1-i-format-remediation-router-r1", + "baseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "canonicalSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "planBundleCommit": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "phaseStartSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "headSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "terminalState": "HOLD", + "status": "verified", + "evidenceIds": ["P1.I.INTEGRATION", "P1.I.ROLLBACK"], + "decisions": [ + { + "id": "P1.D.AUTHORITY", + "disposition": "accepted", + "decision": "Bind this producer and every authority field to clean pushed commit 5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f." + }, + { + "id": "P1.D.INPUT_MANIFEST", + "disposition": "accepted", + "decision": "Consume exactly the ordered 68-path phase1CanonicalInputs manifest followed by the accepted lint-remediation handoff as ordinal 69." + }, + { + "id": "P1.D.FORMAT_PROVENANCE", + "disposition": "accepted-narrowing", + "decision": "Retain the accepted format-remediation handoff as integration provenance only; exclude it from the P1.I input count and exact 74-path gate." + }, + { + "id": "P1.D.REJECTED_ATTEMPT", + "disposition": "quarantined-never-integrate", + "decision": "The terminal r1 patch and all five blocked outputs remain provenance only and were not materialized, copied, compared for adoption, repaired, or reused." + }, + { + "id": "P1.D.PREDECESSORS", + "disposition": "accepted", + "decision": "Freeze accepted P1.R1, accepted P1.R2, accepted lint remediation, and accepted format remediation as immutable predecessors." + }, + { + "id": "P1.D.TYPECHECK", + "disposition": "accepted-inherited-baseline", + "decision": "Retain exactly seven inherited Phase 0 diagnostics; owned and unexpected counts are zero." + }, + { + "id": "P1.D.LINT", + "disposition": "accepted", + "decision": "Full lint exits zero with zero errors; warning-only repository debt is not widened by the five JSON outputs." + }, + { + "id": "P1.D.TEST_ROOT_ESCAPE", + "disposition": "deferred-to-phase-2", + "decision": "Keep the root-escape control deferred only while P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1 passes; any Phase 1 filesystem surface reopens the gate." + }, + { + "id": "P1.D.PRODUCTION_REACHABILITY", + "disposition": "not-claimed", + "decision": "Phase 1 proves an unregistered read-only specimen, not production IPC, HTTP, preload, renderer, filesystem, runtime, provider, or auth behavior." + }, + { + "id": "P1.D.ROLLBACK", + "disposition": "accepted-candidate", + "decision": "The exact 54 payload paths round-trip only in scratch: forward bytes equal authority and reverse state has all 54 paths absent." + }, + { + "id": "P1.D.ESTIMATE", + "disposition": "variance-recorded-pending-independent-review", + "decision": "Record actual five-output evidence size without dropping evidence or claiming hidden product scope." + }, + { + "id": "P1.D.EVIDENCE_FREEZE", + "disposition": "generated-pending-independent-review", + "decision": "Freeze this five-output candidate with all 14 producer gates passed; independent acceptance and integration are not claimed." + }, + { + "id": "P1.D.SUCCESSORS", + "disposition": "blocked", + "decision": "P1.F, Phase 2+, unrelated work, integration, lifecycle action, and successor-controller authority remain blocked." + } + ], + "gateDecisions": [ + { + "id": "P1.GATE.PROVENANCE", + "status": "passed", + "evidence": "authority equality, 69-input manifest hash, and provenance-only format handoff exclusion" + }, + { + "id": "P1.GATE.PREDECESSORS", + "status": "passed", + "evidence": "accepted P1.R1, P1.R2, lint-remediation, and format-remediation authority present" + }, + { + "id": "P1.GATE.SCOPE", + "status": "passed", + "evidence": "exact five untracked outputs, zero tracked/staged paths" + }, + { + "id": "P1.GATE.TESTS", + "status": "passed", + "evidence": "13/13 files and 60/60 tests" + }, + { + "id": "P1.GATE.TYPECHECK", + "status": "passed", + "evidence": "seven inherited, zero owned, zero unexpected diagnostics" + }, + { + "id": "P1.GATE.LINT", + "status": "passed", + "evidence": "full lint exit 0 with zero errors" + }, + { + "id": "P1.GATE.FORMAT", + "status": "passed", + "evidence": "exact 74-path pinned Prettier exit 0" + }, + { + "id": "P1.GATE.NEGATIVES", + "status": "passed", + "evidence": "complete named negative inventory proved or explicitly deferred behind the passing no-filesystem gate" + }, + { + "id": "P1.GATE.RATCHET", + "status": "passed", + "evidence": "focused 1/1 file and 3/3 tests" + }, + { + "id": "P1.GATE.SECURITY", + "status": "passed", + "evidence": "exact 74-path classified scans plus UTF-8, NUL, and MIME proof" + }, + { + "id": "P1.GATE.ROLLBACK", + "status": "passed", + "evidence": "exact 54-path scratch forward equality and reverse absence" + }, + { + "id": "P1.GATE.ESTIMATE", + "status": "passed", + "evidence": "unique P1.I output allocation and variance reconciliation" + }, + { + "id": "P1.GATE.DECISIONS", + "status": "passed", + "evidence": "complete freeze-candidate decision register" + }, + { + "id": "P1.GATE.EVIDENCE_FREEZE", + "status": "passed", + "evidence": "input hashes, evidence lifecycle rows, owned output hashes, and self-review" + } + ], + "unverifiedClaims": [ + "independent P1.I acceptance", + "integration of these five outputs", + "production transport or runtime behavior", + "P1.F authorization", + "Phase 2+ behavior" + ], + "blockedSuccessors": ["P1.F", "Phase 2+", "unrelated product work", "successor controllers"], + "nextAction": "independent-verification" +} diff --git a/docs/research/hosted-web/phase-1/estimate-reconciliation.json b/docs/research/hosted-web/phase-1/estimate-reconciliation.json new file mode 100644 index 0000000000..1862739cfb --- /dev/null +++ b/docs/research/hosted-web/phase-1/estimate-reconciliation.json @@ -0,0 +1,110 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.I.INTEGRATION", + "laneId": "p1-i", + "packetRevision": "phase-01-p1-i-integration-r3", + "routerRevision": "phase-01-p1-i-format-remediation-router-r1", + "baseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "canonicalSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "planBundleCommit": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "phaseStartSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "headSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "terminalState": "HOLD", + "status": "verified", + "evidenceId": "P1.I.INTEGRATION", + "unit": "gross changed lines for fresh P1.I outputs; canonical input line counts are a separate non-overlapping artifact census", + "acceptedPlanningAllocation": { + "source": "docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json", + "phase1UniqueTotal": { + "low": 1903, + "high": 2783 + }, + "p1iPlannedRange": { + "bucketId": "P1.I", + "owner": "serialized integration", + "low": 100, + "high": 220 + }, + "acceptedPhase0Estimate": { + "authorityCommit": "f4fa24aac9615a4ce10632965a2244a2e11a273e", + "grossNonTerminalV1": { + "low": 38300, + "high": 62100 + }, + "changedByThisReconciliation": false + } + }, + "canonicalInputArtifactCensus": { + "inputPathCount": 69, + "distinctPathCount": 69, + "groups": [ + { + "group": "bootstrap", + "pathCount": 6, + "lineCount": 783 + }, + { + "group": "p11a", + "pathCount": 12, + "lineCount": 460 + }, + { + "group": "p11aRemediationProvenance", + "pathCount": 1, + "lineCount": 185 + }, + { + "group": "p11b", + "pathCount": 9, + "lineCount": 605 + }, + { + "group": "p11c", + "pathCount": 28, + "lineCount": 1461 + }, + { + "group": "p1r1", + "pathCount": 1, + "lineCount": 184 + }, + { + "group": "p11d", + "pathCount": 9, + "lineCount": 1763 + }, + { + "group": "p1r2", + "pathCount": 2, + "lineCount": 476 + }, + { + "group": "acceptedLintRemediationHandoff", + "pathCount": 1, + "lineCount": 231 + } + ], + "totalLineCount": 6148, + "countedAsP1IChangedLines": false + }, + "p1iActual": { + "uniqueBucketId": "P1.I", + "outputPathCount": 5, + "actualGrossChangedLines": 1746, + "plannedLow": 100, + "plannedHigh": 220, + "deltaAbovePlannedHigh": 1526, + "variancePercentAbovePlannedHigh": 693.64, + "scopeExpansionDetected": false + }, + "reconciliation": { + "uniqueBucketCount": 1, + "duplicateBucketCount": 0, + "allFiveOutputsCountedOnce": true, + "inputsDoubleCountedAsOutputs": false, + "disposition": "record-actual-variance-pending-independent-review", + "rationale": "The evidence freeze records complete required proof rather than suppressing lines to fit the planning range." + }, + "nextAction": "independent-verification" +} diff --git a/docs/research/hosted-web/phase-1/evidence-index.json b/docs/research/hosted-web/phase-1/evidence-index.json new file mode 100644 index 0000000000..75d78846ca --- /dev/null +++ b/docs/research/hosted-web/phase-1/evidence-index.json @@ -0,0 +1,705 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.I.INTEGRATION", + "laneId": "p1-i", + "packetRevision": "phase-01-p1-i-integration-r3", + "routerRevision": "phase-01-p1-i-format-remediation-router-r1", + "baseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "canonicalSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "planBundleCommit": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "phaseStartSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "headSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "terminalState": "HOLD", + "status": "verified", + "catalogStatus": "generated-pending-independent-review", + "lifecyclePolicy": "docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md", + "hashAlgorithm": "sha256", + "canonicalInputManifest": { + "pathCount": 69, + "distinctPathCount": 69, + "sha256": "623c24be71898e7557257beae2693175ce2300be16c387a1051feb0bbf26bc44", + "formatRemediationHandoffExcluded": true, + "entries": [ + { + "ordinal": 1, + "group": "bootstrap", + "path": "docs/research/hosted-web/phase-1/bootstrap/phase-start.json", + "sha256": "69b5424c27c61cddb013c32618117adbc8a5298a8bf0501ea784cd75a3326f40" + }, + { + "ordinal": 2, + "group": "bootstrap", + "path": "docs/research/hosted-web/phase-1/bootstrap/packet-revision.json", + "sha256": "47044ef4dfc18fc245eaf8051e7fa29a9e82c2d7f9057ac55304f633827faa1f" + }, + { + "ordinal": 3, + "group": "bootstrap", + "path": "docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json", + "sha256": "5ae7d32c2ca7c0e1d1c6f62ed47bc1d4fd960ea22f68bacfd9b2c5a1748d8ac0" + }, + { + "ordinal": 4, + "group": "bootstrap", + "path": "docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json", + "sha256": "03e166cc87c1ca1ae1a8ed41cc7de76e2af02685951661267706f536f8fb5319" + }, + { + "ordinal": 5, + "group": "bootstrap", + "path": "docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json", + "sha256": "35208784d336fb276a89be44a90a8e1de1ff6e5d3aa14587a01ebae05568605a" + }, + { + "ordinal": 6, + "group": "bootstrap", + "path": "docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md", + "sha256": "31b179e9e50ac0bfa2bbb9b64aefcbdec26cff132655628ecab70122ff8f14f4" + }, + { + "ordinal": 7, + "group": "p11a", + "path": ".codex-handoff/phase-01-p1-1a.json", + "sha256": "b8c3d99eed6b09a66ad7b2dff79f2b916af570b3674cae82971b048b70671a0d" + }, + { + "ordinal": 8, + "group": "p11a", + "path": "src/shared/contracts/hosted/app-error.ts", + "sha256": "838c76670512e4178b9b7507e3ce34356a39e2f57a9761fd92583ccd78253820" + }, + { + "ordinal": 9, + "group": "p11a", + "path": "src/shared/contracts/hosted/identifiers.ts", + "sha256": "87ddee58f597734adaaac645d1c3b9fb2ebcf05c2f0fd1ba39a1fe49b98d05ea" + }, + { + "ordinal": 10, + "group": "p11a", + "path": "src/shared/contracts/hosted/index.ts", + "sha256": "4a8e23dfa61dd6c3aeaea811846d7154ca6217a8d93be931a5f1478691659857" + }, + { + "ordinal": 11, + "group": "p11a", + "path": "src/shared/contracts/hosted/query-context.ts", + "sha256": "431bf6d87b54da2f7fbe2181a3a9e64bdfbc1d8c9c5a32a7973f72d4a9360277" + }, + { + "ordinal": 12, + "group": "p11a", + "path": "src/shared/contracts/hosted/revision.ts", + "sha256": "3ae55b8d8ae9e92789313cd6c611ffbdaee4554a195e989de8d2e51bd6f958e6" + }, + { + "ordinal": 13, + "group": "p11a", + "path": "test/architecture/hosted-web/phase-1/contracts/app-error.test.ts", + "sha256": "52ee29acbca32a864b600ef3c833785fc11af382e515ec941fe0437ab3ccdf83" + }, + { + "ordinal": 14, + "group": "p11a", + "path": "test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json", + "sha256": "f5799f7437434d51346647f8e1c7525f79d154b7e84d1b2e27124de1d6c1169f" + }, + { + "ordinal": 15, + "group": "p11a", + "path": "test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json", + "sha256": "b48ecb344888c0c85d3d35c33ec5bffe94922405a61323bf49f4515e658fc29f" + }, + { + "ordinal": 16, + "group": "p11a", + "path": "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "sha256": "6441c8d6075ef568f4cf67355edb2aedf867bf073e3ce340c59127b715da046c" + }, + { + "ordinal": 17, + "group": "p11a", + "path": "test/architecture/hosted-web/phase-1/contracts/query-context.test.ts", + "sha256": "1dcf7503abf545ebb7f77c508698b8cf13f55dec7ac84191cdf64c9c6529b1ab" + }, + { + "ordinal": 18, + "group": "p11a", + "path": "test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "sha256": "608fd717e7eece76c6ea0f3fcc37af26d6047ff94da7e60a34eb4c5deb061e42" + }, + { + "ordinal": 19, + "group": "p11aRemediationProvenance", + "path": ".codex-handoff/phase-01-p1-s1-schema-version-remediation.json", + "sha256": "13670cdbe3dc796e856d3fb7106f594397382fff132db2faa4fd53b24deaa788" + }, + { + "ordinal": 20, + "group": "p11b", + "path": ".codex-handoff/phase-01-p1-1b.json", + "sha256": "d439023efd35695dcd338810841158ab4be4863ab5782ebbfbbe3ba4e45c3824" + }, + { + "ordinal": 21, + "group": "p11b", + "path": "src/main/composition/hosted/routing/RouteCatalog.ts", + "sha256": "2b304771137dbe3b8047158771fdda2d8592d91c0687f4caa8bb88457d86e3fe" + }, + { + "ordinal": 22, + "group": "p11b", + "path": "src/main/composition/hosted/routing/index.ts", + "sha256": "188ef3cbf87a96956ca03779bd74ddc7c46f21217172f057a8a3b0a5df75aa6a" + }, + { + "ordinal": 23, + "group": "p11b", + "path": "src/main/composition/hosted/routing/route-types.ts", + "sha256": "53a8c83e6d42b9ee9d8a6b7a4a64b93ff1fe853b42d396c16dca6ef87dc0d878" + }, + { + "ordinal": 24, + "group": "p11b", + "path": "test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts", + "sha256": "86713d834e4e3474fb13386ab05617aec6600f8c4eba353e7761bfb576579ab6" + }, + { + "ordinal": 25, + "group": "p11b", + "path": "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "sha256": "d0d7a2728ac4478130fad0aadebdc5f6534f7ff83cc88d49ab0732e056afefa1" + }, + { + "ordinal": 26, + "group": "p11b", + "path": "test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts", + "sha256": "3311c2781f3bdde1235b64163d34d3d19da7961d28109482a6c1a2ee7797a40e" + }, + { + "ordinal": 27, + "group": "p11b", + "path": "test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts", + "sha256": "fde1f2b3737bb9fd8d3dbad44be2964249814374f1ef2a9b63a66af39279ffe5" + }, + { + "ordinal": 28, + "group": "p11b", + "path": "test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts", + "sha256": "1c6eac046aa441e90b3a7818800ee7ef23c8925695deb8975dc75b6e035e7011" + }, + { + "ordinal": 29, + "group": "p11c", + "path": ".codex-handoff/phase-01-p1-1c.json", + "sha256": "573db0ffff7b45edcce834d34da23e5f8f24aa6dab96dc06b7b0641eb9d991a2" + }, + { + "ordinal": 30, + "group": "p11c", + "path": "scripts/hosted-web/phase-1/check-feature-dependencies.ts", + "sha256": "55da0d4482be98b42b3593aecb33a829ddd20107f3c89001ee1fe7d1860a74bb" + }, + { + "ordinal": 31, + "group": "p11c", + "path": "scripts/hosted-web/phase-1/check-parity-references.ts", + "sha256": "b29a2152c9e0cf106d7e575ad0d8e8e2ffe877f11e0899d95b21d75d0105bec0" + }, + { + "ordinal": 32, + "group": "p11c", + "path": "scripts/hosted-web/phase-1/check-renderer-boundaries.ts", + "sha256": "d4960be8879a90d7e1b2634c39bcdb064fb6dbcb4587bd8f8fc1c5e2456404a3" + }, + { + "ordinal": 33, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "sha256": "b71bed9df2f99612d0f1aa14ae690eab9976539f0b1c8d5690c7c6bf9c9752a1" + }, + { + "ordinal": 34, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts", + "sha256": "92dff015dccca5f0f9d2b743d701f88c496a99b71e5baf0221b27d4e574435dc" + }, + { + "ordinal": 35, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts", + "sha256": "345d9946c526e776ef93dbce9dfabfda2a1c38f413b7a2a1f72180b4a29b186a" + }, + { + "ordinal": 36, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts", + "sha256": "5ecc201ede7104e77e9fba7689ef92f0ef71bda4ab7cf855b72640bed5bc90c9" + }, + { + "ordinal": 37, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts", + "sha256": "de5695f3bd825b2fa169434a0423f6488fbe3d8aa01969acad5b0f83cde3f329" + }, + { + "ordinal": 38, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts", + "sha256": "6276cc61248bc20db71bf928ac0cae142374507db2d218205aa37f7ff0f659e6" + }, + { + "ordinal": 39, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts", + "sha256": "0ca26159a5ea6a2dc4d57190322743c3d83f93de5d1c75ec412aec85080ba419" + }, + { + "ordinal": 40, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts", + "sha256": "754c40dea9b2683aebd9d6e22b147d4f16194642f2b040aa22f3778309e5b271" + }, + { + "ordinal": 41, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts", + "sha256": "6aa2a176173a436f1c8a180cd7d4f01799b5089701d1e6c29151bd7244afc117" + }, + { + "ordinal": 42, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts", + "sha256": "28f7817de7251ef9e6d11a7d6def5625a0efa256ad6b4d81c1d2ae70f62f4b62" + }, + { + "ordinal": 43, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts", + "sha256": "5afcde69d1ad33c6ecf1a7031e4e5a7d5b8e5870253092b20b86f8abced7edbc" + }, + { + "ordinal": 44, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "sha256": "5e85a09294bef3c28278f04ea1e4896dacbd14fe438cd0a40b99781570f212c1" + }, + { + "ordinal": 45, + "group": "p11c", + "path": "test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts", + "sha256": "1eabeaae77ba5a6b59d2e6376263726c2b27e5f55e01faed021ef25c3e144e59" + }, + { + "ordinal": 46, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json", + "sha256": "1fc62ffb444c8ebb1f3b7d4ad35303d7c501539ab8cac4e220b88972bc6e46d5" + }, + { + "ordinal": 47, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json", + "sha256": "98a99c4f03af0a407b979b3f606bdfe2d55ba2c544e330c95e51312106fcec07" + }, + { + "ordinal": 48, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json", + "sha256": "2c8378bcd7e928e9ab2fb77f46cdda897e6c350bf130bf376123de961458d2b7" + }, + { + "ordinal": 49, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json", + "sha256": "95f032d6a434a4cd4bbda3d701beb4a99a1657f8ad4e4ed5e3d3ddeb70aacb8e" + }, + { + "ordinal": 50, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json", + "sha256": "f6f582303ffae9919abc2e7b30f794de609808fa69b76101e8d89bc879c9baef" + }, + { + "ordinal": 51, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json", + "sha256": "4aec77f80650895ec7e81dc900c00b17b0fc1c38e44c81a347e40f035c88f3db" + }, + { + "ordinal": 52, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json", + "sha256": "272ac470b7b843018a94d700f40336b4536d6f373839cc9fa12261c27d2083d8" + }, + { + "ordinal": 53, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json", + "sha256": "f4534d45b0523ec16d713be97e94c698804092e153de242e093500eaa422255a" + }, + { + "ordinal": 54, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json", + "sha256": "40e57bfb5af87ff6a1dd019f2ffb8058f09c5e54c88546e971f97a5316425d4b" + }, + { + "ordinal": 55, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json", + "sha256": "e37e636ca979fec6e8cb5372da01e9953674d7b30c772be2c707dc0bc5463247" + }, + { + "ordinal": 56, + "group": "p11c", + "path": "test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json", + "sha256": "beaabfd29b6a7447403b3054a1e7a08247410e21f65be4563f543144bc3d9ea4" + }, + { + "ordinal": 57, + "group": "p1r1", + "path": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "sha256": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71" + }, + { + "ordinal": 58, + "group": "p11d", + "path": ".codex-handoff/phase-01-p1-1d.json", + "sha256": "023444ae516dc2f0c6d37c0c57a21c6203cfa8f80f775dbe44b90ed7eb758ce7" + }, + { + "ordinal": 59, + "group": "p11d", + "path": "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "sha256": "9407de573f0fdcabf4cf77fd5faf4b2fa229e4cff7d34b25096d6f69cd2a3df8" + }, + { + "ordinal": 60, + "group": "p11d", + "path": "src/features/team-lifecycle/contracts/index.ts", + "sha256": "6ce70ee187f8b8da58e2d1722b5de13efade6da536f5186cb2aaf5f5ed232bf3" + }, + { + "ordinal": 61, + "group": "p11d", + "path": "src/features/team-lifecycle/core/application/ListTeamLifecycle.ts", + "sha256": "1e6e72911615af3207e04f48f9abf16c57f262abf1ffdcd33e72e66662b9c435" + }, + { + "ordinal": 62, + "group": "p11d", + "path": "src/features/team-lifecycle/core/application/index.ts", + "sha256": "ed348a14f02aa070ae6437aef5c0b4932fd7bfa2f2cd43004b86d8c2da255bdd" + }, + { + "ordinal": 63, + "group": "p11d", + "path": "src/features/team-lifecycle/index.ts", + "sha256": "beff7b458a9b9eafe247c35f1a449f083696376cb83e2abda3e73d917212b03c" + }, + { + "ordinal": 64, + "group": "p11d", + "path": "test/features/team-lifecycle/core/ListTeamLifecycle.test.ts", + "sha256": "d17fa39e0dce56c6f80a2f98d36ec368168f7c29df84238ca4811b128b06fa66" + }, + { + "ordinal": 65, + "group": "p11d", + "path": "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "sha256": "1e16e6a0fdd8bb559ae5c6d5d3973b6ca9d85ada51e18d52c8620354994b36a5" + }, + { + "ordinal": 66, + "group": "p11d", + "path": "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts", + "sha256": "2f7d971aa6743f3980f60c1693fe97513cdb5d07831ebc91a8739153c58ffe29" + }, + { + "ordinal": 67, + "group": "p1r2", + "path": ".codex-handoff/phase-01-p1-r2.json", + "sha256": "bbf61b20dad577af7536f78f108116531acd17925ca9a17140c4d9fadb7d038c" + }, + { + "ordinal": 68, + "group": "p1r2", + "path": "docs/research/hosted-web/phase-1/reviews/list-semantics.md", + "sha256": "de4b1e4fc0f633a40703e6af106bcdfcd2ab3a7d524b28f19df376a4052cdc70" + }, + { + "ordinal": 69, + "group": "acceptedLintRemediationHandoff", + "path": ".codex-handoff/phase-01-p1-i-lint-remediation.json", + "sha256": "df8d0a64711941e23d59e96d90518dc0b8660eb8242c50a118a1b42edf259182" + } + ] + }, + "provenanceOnlyInputs": [ + { + "path": ".codex-handoff/phase-01-p1-i-format-remediation.json", + "sha256": "57a99bdf3c71e0a8f13692e988b9367cdfe484dbe21aaeb57b96e6a5a6947a42", + "role": "accepted integration provenance only", + "p1iInputOrdinal": null, + "excludedFromCanonicalInputCount": true + } + ], + "evidence": [ + { + "id": "P1.S0.BASELINE", + "path": "docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json", + "phaseId": "phase-01", + "laneId": "p1-s0", + "authority": "canonical", + "producer": "p1-s0", + "producerBaseSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "contentSha256": "03e166cc87c1ca1ae1a8ed41cc7de76e2af02685951661267706f536f8fb5319", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.S0.BOOTSTRAP", + "path": "docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md", + "phaseId": "phase-01", + "laneId": "p1-s0", + "authority": "canonical", + "producer": "p1-s0", + "producerBaseSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "contentSha256": "31b179e9e50ac0bfa2bbb9b64aefcbdec26cff132655628ecab70122ff8f14f4", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1A.KERNEL", + "path": "src/shared/contracts/hosted/index.ts", + "phaseId": "phase-01", + "laneId": "p1-1a", + "authority": "canonical", + "producer": "p1-1a", + "producerBaseSha": "50db3d6537abf5342162cd8f5cc12bbab6d38067", + "contentSha256": "4a8e23dfa61dd6c3aeaea811846d7154ca6217a8d93be931a5f1478691659857", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1A.VERSION", + "path": "test/architecture/hosted-web/phase-1/contracts/revision.test.ts", + "phaseId": "phase-01", + "laneId": "p1-1a", + "authority": "canonical", + "producer": "p1-1a", + "producerBaseSha": "50db3d6537abf5342162cd8f5cc12bbab6d38067", + "contentSha256": "608fd717e7eece76c6ea0f3fcc37af26d6047ff94da7e60a34eb4c5deb061e42", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.I.LINT.REMEDIATION", + "path": ".codex-handoff/phase-01-p1-i-lint-remediation.json", + "phaseId": "phase-01", + "laneId": "p1-i-lint-remediation", + "authority": "canonical", + "producer": "p1-i-lint-remediation", + "producerBaseSha": "92b8c34722f61d5e65bfbe606c325d13792cef5c", + "contentSha256": "df8d0a64711941e23d59e96d90518dc0b8660eb8242c50a118a1b42edf259182", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1B.ROUTES", + "path": "src/main/composition/hosted/routing/RouteCatalog.ts", + "phaseId": "phase-01", + "laneId": "p1-1b", + "authority": "canonical", + "producer": "p1-1b", + "producerBaseSha": "041b5c7c2d3225b7dc2eca9e9b7b71aa33217060", + "contentSha256": "2b304771137dbe3b8047158771fdda2d8592d91c0687f4caa8bb88457d86e3fe", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1B.CAPABILITIES", + "path": "test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts", + "phaseId": "phase-01", + "laneId": "p1-1b", + "authority": "canonical", + "producer": "p1-1b", + "producerBaseSha": "041b5c7c2d3225b7dc2eca9e9b7b71aa33217060", + "contentSha256": "d0d7a2728ac4478130fad0aadebdc5f6534f7ff83cc88d49ab0732e056afefa1", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1C.CONFORMANCE", + "path": "test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts", + "phaseId": "phase-01", + "laneId": "p1-1c", + "authority": "canonical", + "producer": "p1-1c", + "producerBaseSha": "041b5c7c2d3225b7dc2eca9e9b7b71aa33217060", + "contentSha256": "b71bed9df2f99612d0f1aa14ae690eab9976539f0b1c8d5690c7c6bf9c9752a1", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1C.RATCHETS", + "path": "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "phaseId": "phase-01", + "laneId": "p1-1c", + "authority": "canonical", + "producer": "p1-1c", + "producerBaseSha": "041b5c7c2d3225b7dc2eca9e9b7b71aa33217060", + "contentSha256": "5e85a09294bef3c28278f04ea1e4896dacbd14fe438cd0a40b99781570f212c1", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.R1.ARCH_REVIEW", + "path": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md", + "phaseId": "phase-01", + "laneId": "p1-r1", + "authority": "canonical", + "producer": "p1-r1", + "producerBaseSha": "a3f1ba92d8bd4989373a0b6deb4718123c129d09", + "contentSha256": "3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1D.TEAM_LIFECYCLE_READ_CONTRACT", + "path": "src/features/team-lifecycle/contracts/team-lifecycle-read.ts", + "phaseId": "phase-01", + "laneId": "p1-1d", + "authority": "canonical", + "producer": "p1-1d", + "producerBaseSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "contentSha256": "9407de573f0fdcabf4cf77fd5faf4b2fa229e4cff7d34b25096d6f69cd2a3df8", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1D.TEAM_LIFECYCLE_READ_USE_CASE", + "path": "src/features/team-lifecycle/core/application/ListTeamLifecycle.ts", + "phaseId": "phase-01", + "laneId": "p1-1d", + "authority": "canonical", + "producer": "p1-1d", + "producerBaseSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "contentSha256": "1e6e72911615af3207e04f48f9abf16c57f262abf1ffdcd33e72e66662b9c435", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.1D.TEAM_LIFECYCLE_SEMANTIC_PROOF", + "path": "test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts", + "phaseId": "phase-01", + "laneId": "p1-1d", + "authority": "canonical", + "producer": "p1-1d", + "producerBaseSha": "3405da177b040c65caad10ef2df4d4f4338feed0", + "contentSha256": "1e16e6a0fdd8bb559ae5c6d5d3973b6ca9d85ada51e18d52c8620354994b36a5", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.R2.SEMANTIC_REVIEW", + "path": "docs/research/hosted-web/phase-1/reviews/list-semantics.md", + "phaseId": "phase-01", + "laneId": "p1-r2", + "authority": "canonical", + "producer": "p1-r2", + "producerBaseSha": "f6794b607609c57dc92def696d05946c9c96856a", + "contentSha256": "de4b1e4fc0f633a40703e6af106bcdfcd2ab3a7d524b28f19df376a4052cdc70", + "regenerationCommand": null, + "reviewDisposition": "approved", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.NEG.RATCHET_REGRESSION", + "path": "test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "phaseId": "phase-01", + "laneId": "p1-i", + "authority": "generated", + "producer": "p1-i", + "producerBaseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "contentSha256": "5e85a09294bef3c28278f04ea1e4896dacbd14fe438cd0a40b99781570f212c1", + "regenerationCommand": "pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "reviewDisposition": "pending", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.I.INTEGRATION", + "path": "docs/research/hosted-web/phase-1/integration-report.json", + "phaseId": "phase-01", + "laneId": "p1-i", + "authority": "generated", + "producer": "p1-i", + "producerBaseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "contentSha256": "a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f", + "regenerationCommand": "fresh P1.I producer from the exact ordered 69-input manifest; blocked outputs are ineligible carriers", + "reviewDisposition": "pending", + "supersedes": [], + "supersededBy": null + }, + { + "id": "P1.I.ROLLBACK", + "path": "docs/research/hosted-web/phase-1/integration-report.json", + "phaseId": "phase-01", + "laneId": "p1-i", + "authority": "generated", + "producer": "p1-i", + "producerBaseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "contentSha256": "a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f", + "regenerationCommand": "scratch-only exact 54-path forward apply, byte comparison to authority, and reverse absence proof", + "reviewDisposition": "pending", + "supersedes": [], + "supersededBy": null + } + ], + "ownedOutputDigests": { + "docs/research/hosted-web/phase-1/decision-register.json": "1d275a95a189d7840a6d75591d90c138b0ec5399747db41794697de0cde32ba9", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json": "941c58195b9955b9807b896aedf7f46ea1a4ed455dc6713241ffefb074405328", + "docs/research/hosted-web/phase-1/integration-report.json": "a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f", + "docs/research/hosted-web/phase-1/evidence-index.json": null, + ".codex-handoff/phase-01-p1-i.json": null + }, + "selfHashPolicy": "The evidence index and handoff omit self-hashes; all other owned outputs are hashed after final generation.", + "lifecycleValidation": { + "stableEvidenceIds": true, + "canonicalRowsApproved": true, + "generatedRowsPendingIndependentReview": true, + "reciprocalSupersessionLinksValid": true, + "unknownEvidenceIds": 0 + }, + "nextAction": "independent-verification" +} diff --git a/docs/research/hosted-web/phase-1/integration-report.json b/docs/research/hosted-web/phase-1/integration-report.json new file mode 100644 index 0000000000..e5e5c3815a --- /dev/null +++ b/docs/research/hosted-web/phase-1/integration-report.json @@ -0,0 +1,441 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-01", + "nodeId": "P1.I.INTEGRATION", + "laneId": "p1-i", + "packetRevision": "phase-01-p1-i-integration-r3", + "routerRevision": "phase-01-p1-i-format-remediation-router-r1", + "baseSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "canonicalSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "planBundleCommit": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "phaseStartSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "headSha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "terminalState": "HOLD", + "status": "verified", + "evidenceIds": ["P1.I.INTEGRATION", "P1.I.ROLLBACK"], + "provenance": { + "authoritySha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "authorityHeadEqual": true, + "initialWorktreeClean": true, + "canonicalInputPathCount": 69, + "distinctCanonicalInputPathCount": 69, + "inputManifestSha256": "623c24be71898e7557257beae2693175ce2300be16c387a1051feb0bbf26bc44", + "phase1CanonicalManifestPathCount": 68, + "acceptedLintRemediationHandoffPath": ".codex-handoff/phase-01-p1-i-lint-remediation.json", + "formatRemediationHandoff": { + "path": ".codex-handoff/phase-01-p1-i-format-remediation.json", + "sha256": "57a99bdf3c71e0a8f13692e988b9367cdfe484dbe21aaeb57b96e6a5a6947a42", + "role": "accepted integration provenance only", + "p1iInputOrdinal": null, + "excludedFromCanonicalInputCount": true + }, + "rejectedAttemptQuarantine": { + "job": "agent-teams-hosted-web-refactor-p1-i-integration-v17-r1", + "status": "BLOCKED", + "terminalState": "HOLD", + "patchSha256": "d94f8dfa6548427e007402e8771c469c8e661cd64de3a8728dec042a509aebbe", + "manifestSha256": "1b88a6e8e53199f0b1905d4f4c194525bcb86db185f0e4748acf60f69bb78f94", + "materializedCopiedComparedRepairedOrReused": false + } + }, + "predecessors": { + "p1r1": { + "disposition": "ACCEPT", + "path": "docs/research/hosted-web/phase-1/reviews/routes-ratchets.md" + }, + "p1r2": { + "disposition": "ACCEPT", + "path": "docs/research/hosted-web/phase-1/reviews/list-semantics.md", + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + } + }, + "lintRemediation": { + "status": "verified", + "path": ".codex-handoff/phase-01-p1-i-lint-remediation.json", + "fullLintErrorCount": 0 + }, + "formatRemediation": { + "status": "accepted-and-integrated-at-authority", + "provenancePath": ".codex-handoff/phase-01-p1-i-format-remediation.json", + "p1iInput": false + } + }, + "scope": { + "expectedOutputPaths": [ + ".codex-handoff/phase-01-p1-i.json", + "docs/research/hosted-web/phase-1/decision-register.json", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "docs/research/hosted-web/phase-1/evidence-index.json", + "docs/research/hosted-web/phase-1/integration-report.json" + ], + "expectedOutputPathCount": 5, + "actualOutputPathCount": 5, + "trackedDiffPathCount": 0, + "stagedPathCount": 0, + "untrackedPaths": [ + ".codex-handoff/phase-01-p1-i.json", + "docs/research/hosted-web/phase-1/decision-register.json", + "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "docs/research/hosted-web/phase-1/evidence-index.json", + "docs/research/hosted-web/phase-1/integration-report.json" + ], + "outputLineCount": 1746, + "productOrTestEdits": 0, + "pathResolution": { + "arrayName": "p1_paths", + "inputManifestSource": "EXECUTION_INDEX.json.phase1CanonicalInputs exact group and path order followed by acceptedLintRemediationHandoffPath", + "outputManifestSource": "EXECUTION_INDEX.json.downstreamP1IOutputs.writablePaths writer order", + "inputPathCount": 69, + "outputPathCount": 5, + "totalPathCount": 74, + "distinctPathCount": 74, + "resolvedPathManifestSha256": "0e8e2b82125eb3b8e559f9fa439e8942e0eea89d75da4cccc35d75099e868223" + } + }, + "gates": [ + { + "id": "P1.GATE.PROVENANCE", + "status": "passed", + "evidence": "authority equality, 69-input manifest hash, and provenance-only format handoff exclusion" + }, + { + "id": "P1.GATE.PREDECESSORS", + "status": "passed", + "evidence": "accepted P1.R1, P1.R2, lint-remediation, and format-remediation authority present" + }, + { + "id": "P1.GATE.SCOPE", + "status": "passed", + "evidence": "exact five untracked outputs, zero tracked/staged paths" + }, + { + "id": "P1.GATE.TESTS", + "status": "passed", + "evidence": "13/13 files and 60/60 tests" + }, + { + "id": "P1.GATE.TYPECHECK", + "status": "passed", + "evidence": "seven inherited, zero owned, zero unexpected diagnostics" + }, + { + "id": "P1.GATE.LINT", + "status": "passed", + "evidence": "full lint exit 0 with zero errors" + }, + { + "id": "P1.GATE.FORMAT", + "status": "passed", + "evidence": "exact 74-path pinned Prettier exit 0" + }, + { + "id": "P1.GATE.NEGATIVES", + "status": "passed", + "evidence": "complete named negative inventory proved or explicitly deferred behind the passing no-filesystem gate" + }, + { + "id": "P1.GATE.RATCHET", + "status": "passed", + "evidence": "focused 1/1 file and 3/3 tests" + }, + { + "id": "P1.GATE.SECURITY", + "status": "passed", + "evidence": "exact 74-path classified scans plus UTF-8, NUL, and MIME proof" + }, + { + "id": "P1.GATE.ROLLBACK", + "status": "passed", + "evidence": "exact 54-path scratch forward equality and reverse absence" + }, + { + "id": "P1.GATE.ESTIMATE", + "status": "passed", + "evidence": "unique P1.I output allocation and variance reconciliation" + }, + { + "id": "P1.GATE.DECISIONS", + "status": "passed", + "evidence": "complete freeze-candidate decision register" + }, + { + "id": "P1.GATE.EVIDENCE_FREEZE", + "status": "passed", + "evidence": "input hashes, evidence lifecycle rows, owned output hashes, and self-review" + } + ], + "checks": [ + { + "command": "pnpm exec vitest run test/features/team-lifecycle test/architecture/hosted-web/phase-1", + "exitCode": 0, + "testFiles": { + "passed": 13, + "total": 13 + }, + "tests": { + "passed": 60, + "total": 60 + } + }, + { + "command": "pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts", + "exitCode": 0, + "testFiles": { + "passed": 1, + "total": 1 + }, + "tests": { + "passed": 3, + "total": 3 + }, + "evidenceId": "P1.NEG.RATCHET_REGRESSION", + "diagnostic": "phase1-ratchet-regression" + }, + { + "command": "pnpm typecheck", + "exitCode": 1, + "classification": { + "inherited": 7, + "owned": 0, + "unexpected": 0, + "matchesFrozenBaseline": true + }, + "diagnostics": [ + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7016", + "location": "25:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "66:31" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS18046", + "location": "117:68" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "413:48" + }, + { + "path": "test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "code": "TS7031", + "location": "733:10" + }, + { + "path": "test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "code": "TS7016", + "location": "12:8" + }, + { + "path": "test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts", + "code": "TS2352", + "location": "162:44" + } + ] + }, + { + "command": "pnpm lint", + "exitCode": 0, + "errorCount": 0, + "warningCount": 3031 + }, + { + "command": "pnpm exec prettier --check \"${p1_paths[@]}\"", + "exitCode": 0, + "matchedPathCount": 74 + }, + { + "command": "git diff --check", + "exitCode": 0 + }, + { + "command": "git diff --cached --quiet", + "exitCode": 0 + } + ], + "negativeControls": [ + { + "id": "P1.NEG.CAPABILITY_MOUNT", + "diagnostic": "phase1-test-capability-production-mount", + "status": "passed" + }, + { + "id": "P1.NEG.CORE_SIDE_EFFECT", + "diagnostic": "phase1-core-side-effect-forbidden", + "status": "passed" + }, + { + "id": "P1.NEG.HOSTED_ELECTRON_API", + "diagnostic": "phase1-hosted-electron-api-forbidden", + "status": "passed" + }, + { + "id": "P1.NEG.ID_REVISION_CURSOR", + "diagnostic": "phase1-opaque-value-kind-or-scope-mismatch", + "status": "passed" + }, + { + "id": "P1.NEG.IMPORT_FORBIDDEN", + "diagnostic": "phase1-core-import-forbidden", + "status": "passed" + }, + { + "id": "P1.NEG.LEGACY_GOD_DTO", + "diagnostic": "phase1-legacy-god-dto-forbidden", + "status": "passed" + }, + { + "id": "P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1", + "diagnostic": "phase1-filesystem-adapter-forbidden", + "status": "passed" + }, + { + "id": "P1.NEG.PARITY_DRIFT", + "diagnostic": "phase1-parity-reference-drift", + "status": "passed" + }, + { + "id": "P1.NEG.PATH_SECRET_LEAK", + "diagnostic": "phase1-path-secret-leak", + "status": "passed" + }, + { + "id": "P1.NEG.PERFORMANCE_DEGRADATION", + "diagnostic": "phase1-performance-or-determinism-budget", + "status": "passed" + }, + { + "id": "P1.NEG.PRODUCTION_ADAPTER_MOUNT", + "diagnostic": "phase1-test-adapter-production-import", + "status": "passed" + }, + { + "id": "P1.NEG.RATCHET_REGRESSION", + "diagnostic": "phase1-ratchet-regression", + "status": "passed" + }, + { + "id": "P1.NEG.ROUTE_DRIFT", + "diagnostic": "phase1-route-catalog-drift", + "status": "passed" + }, + { + "id": "P1.NEG.SCHEMA_VERSION", + "diagnostic": "phase1-schema-version-invalid-or-unsupported", + "status": "passed" + }, + { + "id": "P1.NEG.SEMANTIC_OUTCOME", + "diagnostic": "phase1-semantic-outcome-drift", + "status": "passed" + }, + { + "id": "P1.NEG.TEST_ROOT_ESCAPE", + "diagnostic": "phase2-test-root-not-admitted", + "status": "deferred-to-phase-2-while-no-filesystem-gate-passes" + } + ], + "classifiedScans": { + "scopePathCount": 74, + "inputPathCount": 69, + "outputPathCount": 5, + "secretCredential": { + "command": "rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer|cookie|authorization)' \"${p1_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 27, + "classification": "canonical control text, synthetic negative-control terms, required scan-command text, and evidence metadata only; no secret or credential value", + "allMatchesClassified": true + }, + "provider": { + "command": "rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])' \"${p1_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 46, + "classification": "repository-relative paths, inherited diagnostic paths, required profile/process labels, synthetic negative-control text, and scan-command text only; no auth/provider payload", + "allMatchesClassified": true + }, + "privatePath": { + "command": "rg -n '(/Users/|/home/|/root/|/tmp/|~/|[A-Za-z]:\\\\Users\\\\|real[-_ ]project)' \"${p1_paths[@]}\"", + "exitCode": 0, + "observedMatchLineCount": 13, + "classification": "canonical provenance/control text and recorded scan-command text only; no private, real-project, home, or task-temporary path value", + "allMatchesClassified": true + }, + "mime": { + "command": "file --mime-type \"${p1_paths[@]}\"", + "exitCode": 0, + "textFileCount": 74, + "binaryFileCount": 0 + }, + "utf8Nul": { + "command": "node exact-74-path fatal UTF-8 decode and NUL-byte assertion", + "exitCode": 0, + "validUtf8PathCount": 74, + "nulBytePathCount": 0 + } + }, + "rollback": { + "bootstrapSha": "5f30df49e052d1cc1d0e7efd03aa105673b5b614", + "authoritySha": "5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f", + "payloadPathCount": 54, + "distinctPayloadPathCount": 54, + "bootstrapAbsentPathCount": 54, + "forwardApplyCheckExitCode": 0, + "forwardApplyExitCode": 0, + "forwardByteEqualPathCount": 54, + "reverseApplyCheckExitCode": 0, + "reverseApplyExitCode": 0, + "reverseAbsentPathCount": 54, + "patchSha256": "bd0b8cce323c72e9104b620a899add44919c13f913d5fbbe169b6445d79ffb50", + "scratchOnly": true, + "workspaceApply": false, + "evidenceId": "P1.I.ROLLBACK", + "pathManifestSource": "EXECUTION_INDEX.json.rollbackPayload.paths exact declared order", + "pathManifestSha256": "d67e76fa0b63f51260bc6c3bdd59568a9115a730709f2508231df46da5ca880e", + "commands": [ + "git diff --binary --full-index 5f30df49e052d1cc1d0e7efd03aa105673b5b614 5d588b73f6c9860e8aeb25f8975e14a7c73d6a2f -- \"${rollback_paths[@]}\"", + "git apply --no-index --check ", + "git apply --no-index ", + "git apply --no-index -R --check ", + "git apply --no-index -R " + ] + }, + "evidenceFreeze": { + "decisionRegisterPath": "docs/research/hosted-web/phase-1/decision-register.json", + "estimateReconciliationPath": "docs/research/hosted-web/phase-1/estimate-reconciliation.json", + "evidenceIndexPath": "docs/research/hosted-web/phase-1/evidence-index.json", + "integrationReportPath": "docs/research/hosted-web/phase-1/integration-report.json", + "handoffPath": ".codex-handoff/phase-01-p1-i.json", + "freshGeneration": true, + "independentAcceptanceClaimed": false, + "integrationClaimed": false + }, + "selfReview": { + "all69CanonicalInputsRereadInExactOrder": true, + "formatHandoffKeptProvenanceOnly": true, + "rejectedAttemptQuarantineVerified": true, + "all14GatesReread": true, + "allScanMatchesClassified": true, + "completeFiveOutputDiffReread": true, + "outputHashesRecomputed": true, + "writerScopeVerified": true, + "unsupportedClaims": false, + "scopeExpansion": false + }, + "unverifiedClaims": [ + "independent acceptance", + "integration of the five outputs", + "production authorization or transport reachability", + "P1.F", + "Phase 2+" + ], + "blockers": [], + "nextAction": "independent-verification" +} diff --git a/docs/research/hosted-web/phase-1/reviews/list-semantics.md b/docs/research/hosted-web/phase-1/reviews/list-semantics.md new file mode 100644 index 0000000000..27dced7795 --- /dev/null +++ b/docs/research/hosted-web/phase-1/reviews/list-semantics.md @@ -0,0 +1,185 @@ +# P1.R2 list semantics formal review + +> **Post-freeze amendment (2026-07-16).** A post-freeze review fix moved `QueryContext` out of +> the wire request: `ListTeamLifecycleRequest` is now `{ schemaVersion, cursor, expectedRevision }` +> only, and the host assembles the context and passes it to `execute(request, context)` separately. +> Statements below about request parsing validating a nested query context describe the reviewed +> pre-fix snapshot, not the current contract. + +Disposition: ACCEPT + +Finding counts: P0 0 / P1 0 / P2 0. + +## Reviewer and independence + +- Identity: `codex-thread:019f64ac-241c-7ee3-8ec8-7ba99c7360fa` +- Controller job: `agent-teams-hosted-web-refactor-p1-r2-formal-review-v17-r3` +- Worktree: `/var/data/agent-teams-hosted-web-refactor/worktrees/p1-r2-formal-review-v17-r3` +- Profile: model `gpt-5.6-sol`, reasoning effort `xhigh`, service tier `default`, Fast disabled +- Capacity: one fresh reviewer; no concurrent duplicate +- Independence: the root/controller admission allocated this fresh r3 identity, isolated job, and + isolated worktree as distinct from (1) the P1.R2 router author, (2) all P1.1A/P1.1D producers, + remediation workers, and reviewers, (3) all PR #252 conflict-resolution producers and reviewers, + and (4) every prior Phase 1 formal reviewer. + +## Authority and provenance + +- Router-authoring provenance only: `packetBaseSha` + `48d79e2b13e258fc82ad55723875f15d6e162872`. +- Resolved `postIntegrationAuthoritySha`, broker-returned-and-pushed commit, + `expectedSourceCommit`, base, canonical, plan bundle, phase start, and local `HEAD` are all + `f6794b607609c57dc92def696d05946c9c96856a`. +- Root immutable pre-start attestation: remote `origin`, ref + `refs/heads/refactor/hosted-web-feature-boundaries`, command + `git ls-remote origin refs/heads/refactor/hosted-web-feature-boundaries`, exit `0`, exact output + `f6794b607609c57dc92def696d05946c9c96856a\trefs/heads/refactor/hosted-web-feature-boundaries`, + equality `true`, canonical worktree clean `true`, and admission `expectedSourceCommit` + `f6794b607609c57dc92def696d05946c9c96856a`. +- Local authority checks retained `HEAD` equality. No reviewer remote or network query was made. +- Separate `reviewedProductSnapshotSha`: + `666042037a9c91df572b1d8274bf6024f8d00f40`. +- Ordered snapshot parents: + `c3135d40c6e70e4b2ddc905dc815407397197634`, then + `3b48f9391b4bff1d82bc85ef01a2d5e0e5b50e95`. +- PR #252 conflict gate and P1.1D remain accepted predecessors. +- Controller/lane packet hashes matched the execution index: + `9b4b27f5029df8e21214fe6d4a372cbfc3e6d6c6f6506351954243026c33291d` and + `8b089ab8337467da201be884fc8f52bfff6d6377f63722f6f8d1aa6d5f6778c3`. + +## Exact scope + +The execution-index manifest proved 12 P1.1A + 9 P1.1D + 11 semantic-corpus = 32 paths, with 32 +distinct entries. `git diff --exit-code 666042037a9c91df572b1d8274bf6024f8d00f40 HEAD -- +"${review_input_paths[@]}"` exited `0`, proving every reviewed input byte-identical at the snapshot +and authority `HEAD`. `git diff --exit-code HEAD -- "${review_input_paths[@]}"` also exited `0`. + +Both evidence outputs were initially absent. The initial canonical status was empty. Final status is +exactly these two untracked writer-authority paths, in lexical Git order, with no staged or tracked +diff: + +1. `.codex-handoff/phase-01-p1-r2.json` +2. `docs/research/hosted-web/phase-1/reviews/list-semantics.md` + +## Semantic findings + +1. Pass — request parsing admits only the exact versioned top-level and nested context fields, + rejects unknown own string and symbol keys, validates actor/session/deployment/boot/request IDs, + authorized scope, deadline, and cancellation, and performs no ambient authorization lookup. +2. Pass — `ListTeamLifecycle` invokes its injected value-only source exactly once per valid request + and zero times for an invalid request. Its product surface contains no filesystem, adapter, + transport, runtime, provider, production mount, or global-state dependency. +3. Pass — same-version success, failure, and inapplicable responses validate all known fields before + additive discard and return fresh frozen known-field-only projections, including item and safe + error projections. Additive own string and symbol fields are not retained. +4. Pass — the success parser captures the untrusted item length once, rejects values above 1,000, + rejects sparse and duplicate-ID arrays, reads each dense index once, parses each item, and builds a + fresh plain array. It does not dispatch input-owned map, iterator, constructor, or species + behavior; deterministic sort and freeze operate on the trusted array. +5. Pass — all ten manifest scenarios retain the accepted outcome, deterministic order, + revision/cursor values, safe fields, retryability, and empty-versus-error distinction. The + deliberate mismatch still rejects with `phase1-semantic-outcome-drift`. +6. Pass — safe errors remain limited to accepted application categories and bounded safe fields. + Unsupported versions, malformed known fields, source throws, and invalid source responses fail + closed with static reasons/diagnostics and no raw messages, stacks, authorization/provider data, + command bodies, or private paths. +7. Pass — revisions and cursors remain opaque, kind-separated tokens. The contract does not parse, + increment, sort, derive, or use them as display/cache keys and never converts an invalid cursor to + page one. Production cursor integrity, scope, and snapshot binding remain unverified. +8. Pass — the two public feature entrypoints expose the narrow team-lifecycle contract and use case. + They expose no legacy aggregate, universal envelope, transport status, route/capability metadata, + provider/path values, production identity, or implementation-file entrypoint. +9. Pass — the shared kernel remains exactly five product files and five primitive families. The five + files total exactly 159 lines and 7,242 bytes and are unchanged from the reviewed snapshot. The + accepted P1.1A handoff remains 299 gross owned lines. No sixth primitive family or unproved export + was found. +10. Pass — `P1.NEG.SCHEMA_VERSION` still rejects with + `phase1-schema-version-invalid-or-unsupported`; its valid same-version neighbors pass. No + production authorization, transport parity, adapter integrity, filesystem/runtime integration, + production mount, or full Phase 1 completion is claimed. + +There are no P0, P1, or P2 findings. + +## Commands and observations + +- Focused command: + `pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts test/features/team-lifecycle` + — exit `0`; Vitest `3.2.6`; exactly 5/5 files and 14/14 tests passed. +- `pnpm typecheck` — exit `1`; exactly seven inherited diagnostics, zero owned, zero unexpected: + - `auth-artifacts-spike.test.ts` — TS7016 at 25:8; TS7031 at 66:31; TS18046 at 117:68; + TS7031 at 413:48; TS7031 at 733:10. + - `evidence-scanner.test.ts` — TS7016 at 12:8. + - `scan-runtime-surfaces.test.ts` — TS2352 at 162:44. +- Exact 34-path Prettier check below — exit `0`; 34 files observed. + +```bash +pnpm exec prettier --check \ + .codex-handoff/phase-01-p1-1a.json \ + src/shared/contracts/hosted/app-error.ts \ + src/shared/contracts/hosted/identifiers.ts \ + src/shared/contracts/hosted/index.ts \ + src/shared/contracts/hosted/query-context.ts \ + src/shared/contracts/hosted/revision.ts \ + test/architecture/hosted-web/phase-1/contracts/app-error.test.ts \ + test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json \ + test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json \ + test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts \ + test/architecture/hosted-web/phase-1/contracts/query-context.test.ts \ + test/architecture/hosted-web/phase-1/contracts/revision.test.ts \ + .codex-handoff/phase-01-p1-1d.json \ + src/features/team-lifecycle/contracts/team-lifecycle-read.ts \ + src/features/team-lifecycle/contracts/index.ts \ + src/features/team-lifecycle/core/application/ListTeamLifecycle.ts \ + src/features/team-lifecycle/core/application/index.ts \ + src/features/team-lifecycle/index.ts \ + test/features/team-lifecycle/core/ListTeamLifecycle.test.ts \ + test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts \ + test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts \ + test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json \ + test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json \ + .codex-handoff/phase-01-p1-r2.json \ + docs/research/hosted-web/phase-1/reviews/list-semantics.md +``` + +- `git diff --check` — exit `0`. +- `git diff --cached --quiet` — exit `0`. +- `git diff --exit-code` — exit `0`. +- `git status --short` — exit `0`; exactly the two untracked output paths listed above. +- Shared-kernel `wc -l -c` — exit `0`; total 159 lines and 7,242 bytes. + +## Classified scans + +All scans used the same exact 34-path array: all 32 reviewed inputs plus both outputs. + +- Secret/credential scan + `rg -n -i '(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|bearer)'`: + exit `0`, two match lines. Both are the required scan-command text recorded in the two reviewer + outputs; neither is a credential or secret value. +- Provider-term scan + `rg -n -i '(provider|anthropic|claude|openai|opencode|gpt-[0-9])'`: exit `0`, 17 match lines. Every + match is classified as required model/profile metadata, prior-process provenance or + safety/unverified-boundary labels in the P1.1D handoff, synthetic forbidden-surface + negative-control text in the boundary test, or scan-command/classification text in these two + outputs. No match is a provider payload or raw provider value. +- Private-path scan + `rg -n '(/Users/|/home/|/root/|~/|[A-Za-z]:\\Users\\|real[-_ ]project)'`: exit `0`, two match + lines. Both are the required scan-command text recorded in the two reviewer outputs; neither is a + private user path or user-project path value. + +## Unverified and blocked boundaries + +Production authorization; production cursor integrity, scope, and snapshot binding; transport +parity; adapters; IPC/HTTP, preload, and renderer behavior; filesystem/runtime integration; +production mounts; full Phase 1 completion; and Phase 2+ behavior remain unverified. + +P1.I, P1.F, Phase 2+, all product workers, integration, and successor controllers remain blocked. +Only a later separately reviewed docs router may authorize P1.I, and it must not reintegrate either +P1.R2 evidence path. Next action is `controller-hold`. Terminal state is `HOLD`. diff --git a/docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md b/docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md new file mode 100644 index 0000000000..688c2a1654 --- /dev/null +++ b/docs/research/hosted-web/phase-1/reviews/phase-1-freeze.md @@ -0,0 +1,372 @@ +# Phase 1 P1.F milestone freeze + +Disposition: ACCEPT + +Finding counts: P0 0 / P1 0 / P2 0. + +Terminal state: HOLD. + +## Reviewer independence and authority + +This was one fresh independent P1.F milestone-freeze review under model gpt-5.6-sol, reasoning effort +xhigh, service tier default, with Fast disabled. No subagent or additional reviewer was used. This +reviewer is independent of the P1.F router author and reviewer, every P1.I producer, remediation +producer, reviewer and integration actor, and all earlier Phase 1 producers and reciprocal reviewers. + +Local HEAD, admission expectedSourceCommit, and every P1.F handoff authority field equal +f13b7f886ccd2379674506eeecf5fb077495601e. The initial worktree was clean. The authority descends +from router authoring base 69c4219b7ce3c7ad99e469ecd537a42e4bb4d2b5. + +The fresh immutable root/broker remote attestation was fully inspected and its compact bytes +recomputed to SHA-256 d547dd6ba6419ba0559ce5ed8c337681f436af48f2a8c3dd182ebb3372df5016. It records: + +- schema version 1 and kind root-broker-remote-attestation; +- exact command git ls-remote origin refs/heads/refactor/hosted-web-feature-boundaries; +- process exit 0; +- exact single-ref output f13b7f886ccd2379674506eeecf5fb077495601e followed by + refs/heads/refactor/hosted-web-feature-boundaries; +- timestamp 2026-07-16T06:03:04Z; +- root actor provenance root hosted-web throughput watchdog; +- broker/tool provenance read-only SSH diagnostic via codex-workers-eu-01; +- local HEAD and broker-returned commit both f13b7f886ccd2379674506eeecf5fb077495601e; and +- worktree status byte count zero. + +The reviewer made no sandbox remote query. + +## P1.I integration, true merge, and router proof + +The exact deterministic path diff for +134f64f0c5c7bbbab0552eddf08df1508118f4bb^..134f64f0c5c7bbbab0552eddf08df1508118f4bb +is exactly the five frozen P1.I outputs in writer order: + +1. .codex-handoff/phase-01-p1-i.json +2. docs/research/hosted-web/phase-1/decision-register.json +3. docs/research/hosted-web/phase-1/estimate-reconciliation.json +4. docs/research/hosted-web/phase-1/evidence-index.json +5. docs/research/hosted-web/phase-1/integration-report.json + +Accepted true merge 20706bd067ce5ccbf13697700411904faa2a00c8 has exactly two ordered parents: + +1. 134f64f0c5c7bbbab0552eddf08df1508118f4bb +2. 6bf43f140878f8b79f7ee17349bd21b177df901d + +The first-parent ref equals the accepted P1.I integration commit and both parents are ancestors. The +commit is not squash, one-parent, octopus, or reversed history. All five P1.I blobs are byte-identical +at the integration commit, accepted merge, and current authority. + +The second-parent-to-merge diff has 327 paths and ordered path-list SHA-256 +33464213cc50bc3d53dd33b340ac64c417cbde320c98f85acb47c2145ce0cd3b. It is accumulated +current-base history and was never used as the exact P1.I proof. + +The diff from 69c4219b7ce3c7ad99e469ecd537a42e4bb4d2b5 to current authority contains exactly the +seven execution-index routerExclusiveOwnership paths. It contains no P1.I, product, or test path. + +## Frozen P1.I output hashes + +- .codex-handoff/phase-01-p1-i.json — + be6ca8a01fba06871b9246ae2baaf230e7b95222bb0da3eec8548016c5639903 +- docs/research/hosted-web/phase-1/decision-register.json — + 1d275a95a189d7840a6d75591d90c138b0ec5399747db41794697de0cde32ba9 +- docs/research/hosted-web/phase-1/estimate-reconciliation.json — + 941c58195b9955b9807b896aedf7f46ea1a4ed455dc6713241ffefb074405328 +- docs/research/hosted-web/phase-1/evidence-index.json — + 07a17cb6674916f65713e337f15deeb3f5405d36fbcccbbcdada3b5895724590 +- docs/research/hosted-web/phase-1/integration-report.json — + a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f + +## Exact 74-path manifest + +The execution-index path array contains 74 distinct existing regular UTF-8 text files, with zero +symlinks and zero NUL bytes. Its compact JSON-array SHA-256 is +0e8e2b82125eb3b8e559f9fa439e8942e0eea89d75da4cccc35d75099e868223. The fresh ordered compact +JSON digest of all 74 path/SHA-256 objects is +3eb215b1ded06d8cb43c267c51272f88b41eb34e2bed3046dc277f6081020d8f. The first 69 paths and +hashes match the frozen canonicalInputManifest, and the final five match the integration range and +required hashes. + +Current hashes in exact manifest order: + +1. docs/research/hosted-web/phase-1/bootstrap/phase-start.json — + 69b5424c27c61cddb013c32618117adbc8a5298a8bf0501ea784cd75a3326f40 +2. docs/research/hosted-web/phase-1/bootstrap/packet-revision.json — + 47044ef4dfc18fc245eaf8051e7fa29a9e82c2d7f9057ac55304f633827faa1f +3. docs/research/hosted-web/phase-1/bootstrap/ownership-manifest.json — + 5ae7d32c2ca7c0e1d1c6f62ed47bc1d4fd960ea22f68bacfd9b2c5a1748d8ac0 +4. docs/research/hosted-web/phase-1/bootstrap/baseline-fingerprints.json — + 03e166cc87c1ca1ae1a8ed41cc7de76e2af02685951661267706f536f8fb5319 +5. docs/research/hosted-web/phase-1/bootstrap/estimate-allocation.json — + 35208784d336fb276a89be44a90a8e1de1ff6e5d3aa14587a01ebae05568605a +6. docs/research/hosted-web/phase-1/bootstrap/bootstrap-report.md — + 31b179e9e50ac0bfa2bbb9b64aefcbdec26cff132655628ecab70122ff8f14f4 +7. .codex-handoff/phase-01-p1-1a.json — + b8c3d99eed6b09a66ad7b2dff79f2b916af570b3674cae82971b048b70671a0d +8. src/shared/contracts/hosted/app-error.ts — + 838c76670512e4178b9b7507e3ce34356a39e2f57a9761fd92583ccd78253820 +9. src/shared/contracts/hosted/identifiers.ts — + 87ddee58f597734adaaac645d1c3b9fb2ebcf05c2f0fd1ba39a1fe49b98d05ea +10. src/shared/contracts/hosted/index.ts — + 4a8e23dfa61dd6c3aeaea811846d7154ca6217a8d93be931a5f1478691659857 +11. src/shared/contracts/hosted/query-context.ts — + 431bf6d87b54da2f7fbe2181a3a9e64bdfbc1d8c9c5a32a7973f72d4a9360277 +12. src/shared/contracts/hosted/revision.ts — + 3ae55b8d8ae9e92789313cd6c611ffbdaee4554a195e989de8d2e51bd6f958e6 +13. test/architecture/hosted-web/phase-1/contracts/app-error.test.ts — + 52ee29acbca32a864b600ef3c833785fc11af382e515ec941fe0437ab3ccdf83 +14. test/architecture/hosted-web/phase-1/contracts/fixtures/invalid-contract-values.json — + f5799f7437434d51346647f8e1c7525f79d154b7e84d1b2e27124de1d6c1169f +15. test/architecture/hosted-web/phase-1/contracts/fixtures/valid-contract-values.json — + b48ecb344888c0c85d3d35c33ec5bffe94922405a61323bf49f4515e658fc29f +16. test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts — + 6441c8d6075ef568f4cf67355edb2aedf867bf073e3ce340c59127b715da046c +17. test/architecture/hosted-web/phase-1/contracts/query-context.test.ts — + 1dcf7503abf545ebb7f77c508698b8cf13f55dec7ac84191cdf64c9c6529b1ab +18. test/architecture/hosted-web/phase-1/contracts/revision.test.ts — + 608fd717e7eece76c6ea0f3fcc37af26d6047ff94da7e60a34eb4c5deb061e42 +19. .codex-handoff/phase-01-p1-s1-schema-version-remediation.json — + 13670cdbe3dc796e856d3fb7106f594397382fff132db2faa4fd53b24deaa788 +20. .codex-handoff/phase-01-p1-1b.json — + d439023efd35695dcd338810841158ab4be4863ab5782ebbfbbe3ba4e45c3824 +21. src/main/composition/hosted/routing/RouteCatalog.ts — + 2b304771137dbe3b8047158771fdda2d8592d91c0687f4caa8bb88457d86e3fe +22. src/main/composition/hosted/routing/index.ts — + 188ef3cbf87a96956ca03779bd74ddc7c46f21217172f057a8a3b0a5df75aa6a +23. src/main/composition/hosted/routing/route-types.ts — + 53a8c83e6d42b9ee9d8a6b7a4a64b93ff1fe853b42d396c16dca6ef87dc0d878 +24. test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts — + 86713d834e4e3474fb13386ab05617aec6600f8c4eba353e7761bfb576579ab6 +25. test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts — + d0d7a2728ac4478130fad0aadebdc5f6534f7ff83cc88d49ab0732e056afefa1 +26. test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts — + 3311c2781f3bdde1235b64163d34d3d19da7961d28109482a6c1a2ee7797a40e +27. test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts — + fde1f2b3737bb9fd8d3dbad44be2964249814374f1ef2a9b63a66af39279ffe5 +28. test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts — + 1c6eac046aa441e90b3a7818800ee7ef23c8925695deb8975dc75b6e035e7011 +29. .codex-handoff/phase-01-p1-1c.json — + 573db0ffff7b45edcce834d34da23e5f8f24aa6dab96dc06b7b0641eb9d991a2 +30. scripts/hosted-web/phase-1/check-feature-dependencies.ts — + 55da0d4482be98b42b3593aecb33a829ddd20107f3c89001ee1fe7d1860a74bb +31. scripts/hosted-web/phase-1/check-parity-references.ts — + b29a2152c9e0cf106d7e575ad0d8e8e2ffe877f11e0899d95b21d75d0105bec0 +32. scripts/hosted-web/phase-1/check-renderer-boundaries.ts — + d4960be8879a90d7e1b2634c39bcdb064fb6dbcb4587bd8f8fc1c5e2456404a3 +33. test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts — + b71bed9df2f99612d0f1aa14ae690eab9976539f0b1c8d5690c7c6bf9c9752a1 +34. test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts — + 92dff015dccca5f0f9d2b743d701f88c496a99b71e5baf0221b27d4e574435dc +35. test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts — + 345d9946c526e776ef93dbce9dfabfda2a1c38f413b7a2a1f72180b4a29b186a +36. test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts — + 5ecc201ede7104e77e9fba7689ef92f0ef71bda4ab7cf855b72640bed5bc90c9 +37. test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts — + de5695f3bd825b2fa169434a0423f6488fbe3d8aa01969acad5b0f83cde3f329 +38. test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts — + 6276cc61248bc20db71bf928ac0cae142374507db2d218205aa37f7ff0f659e6 +39. test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts — + 0ca26159a5ea6a2dc4d57190322743c3d83f93de5d1c75ec412aec85080ba419 +40. test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts — + 754c40dea9b2683aebd9d6e22b147d4f16194642f2b040aa22f3778309e5b271 +41. test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts — + 6aa2a176173a436f1c8a180cd7d4f01799b5089701d1e6c29151bd7244afc117 +42. test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts — + 28f7817de7251ef9e6d11a7d6def5625a0efa256ad6b4d81c1d2ae70f62f4b62 +43. test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts — + 5afcde69d1ad33c6ecf1a7031e4e5a7d5b8e5870253092b20b86f8abced7edbc +44. test/architecture/hosted-web/phase-1/parity/parity-references.test.ts — + 5e85a09294bef3c28278f04ea1e4896dacbd14fe438cd0a40b99781570f212c1 +45. test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts — + 1eabeaae77ba5a6b59d2e6376263726c2b27e5f55e01faed021ef25c3e144e59 +46. test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json — + 1fc62ffb444c8ebb1f3b7d4ad35303d7c501539ab8cac4e220b88972bc6e46d5 +47. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json — + 98a99c4f03af0a407b979b3f606bdfe2d55ba2c544e330c95e51312106fcec07 +48. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json — + 2c8378bcd7e928e9ab2fb77f46cdda897e6c350bf130bf376123de961458d2b7 +49. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json — + 95f032d6a434a4cd4bbda3d701beb4a99a1657f8ad4e4ed5e3d3ddeb70aacb8e +50. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json — + f6f582303ffae9919abc2e7b30f794de609808fa69b76101e8d89bc879c9baef +51. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json — + 4aec77f80650895ec7e81dc900c00b17b0fc1c38e44c81a347e40f035c88f3db +52. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json — + 272ac470b7b843018a94d700f40336b4536d6f373839cc9fa12261c27d2083d8 +53. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json — + f4534d45b0523ec16d713be97e94c698804092e153de242e093500eaa422255a +54. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json — + 40e57bfb5af87ff6a1dd019f2ffb8058f09c5e54c88546e971f97a5316425d4b +55. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json — + e37e636ca979fec6e8cb5372da01e9953674d7b30c772be2c707dc0bc5463247 +56. test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json — + beaabfd29b6a7447403b3054a1e7a08247410e21f65be4563f543144bc3d9ea4 +57. docs/research/hosted-web/phase-1/reviews/routes-ratchets.md — + 3a891699856bd9035aee86e6fc6776a7bb3ada2103609ec073e9958566187e71 +58. .codex-handoff/phase-01-p1-1d.json — + 023444ae516dc2f0c6d37c0c57a21c6203cfa8f80f775dbe44b90ed7eb758ce7 +59. src/features/team-lifecycle/contracts/team-lifecycle-read.ts — + 9407de573f0fdcabf4cf77fd5faf4b2fa229e4cff7d34b25096d6f69cd2a3df8 +60. src/features/team-lifecycle/contracts/index.ts — + 6ce70ee187f8b8da58e2d1722b5de13efade6da536f5186cb2aaf5f5ed232bf3 +61. src/features/team-lifecycle/core/application/ListTeamLifecycle.ts — + 1e6e72911615af3207e04f48f9abf16c57f262abf1ffdcd33e72e66662b9c435 +62. src/features/team-lifecycle/core/application/index.ts — + ed348a14f02aa070ae6437aef5c0b4932fd7bfa2f2cd43004b86d8c2da255bdd +63. src/features/team-lifecycle/index.ts — + beff7b458a9b9eafe247c35f1a449f083696376cb83e2abda3e73d917212b03c +64. test/features/team-lifecycle/core/ListTeamLifecycle.test.ts — + d17fa39e0dce56c6f80a2f98d36ec368168f7c29df84238ca4811b128b06fa66 +65. test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-contract.test.ts — + 1e16e6a0fdd8bb559ae5c6d5d3973b6ca9d85ada51e18d52c8620354994b36a5 +66. test/architecture/hosted-web/phase-1/team-lifecycle/team-lifecycle-read-boundaries.test.ts — + 2f7d971aa6743f3980f60c1693fe97513cdb5d07831ebc91a8739153c58ffe29 +67. .codex-handoff/phase-01-p1-r2.json — + bbf61b20dad577af7536f78f108116531acd17925ca9a17140c4d9fadb7d038c +68. docs/research/hosted-web/phase-1/reviews/list-semantics.md — + de4b1e4fc0f633a40703e6af106bcdfcd2ab3a7d524b28f19df376a4052cdc70 +69. .codex-handoff/phase-01-p1-i-lint-remediation.json — + df8d0a64711941e23d59e96d90518dc0b8660eb8242c50a118a1b42edf259182 +70. .codex-handoff/phase-01-p1-i.json — + be6ca8a01fba06871b9246ae2baaf230e7b95222bb0da3eec8548016c5639903 +71. docs/research/hosted-web/phase-1/decision-register.json — + 1d275a95a189d7840a6d75591d90c138b0ec5399747db41794697de0cde32ba9 +72. docs/research/hosted-web/phase-1/estimate-reconciliation.json — + 941c58195b9955b9807b896aedf7f46ea1a4ed455dc6713241ffefb074405328 +73. docs/research/hosted-web/phase-1/evidence-index.json — + 07a17cb6674916f65713e337f15deeb3f5405d36fbcccbbcdada3b5895724590 +74. docs/research/hosted-web/phase-1/integration-report.json — + a64cc23427dd049e0ede0ce217a7401a5ec6f6df51ec6cb9b5ca3ef5458f4e8f + +## Evidence lifecycle + +The 14 required Phase 1 acceptance evidence IDs occur exactly once and in required relative order: + +1. P1.S0.BASELINE +2. P1.S0.BOOTSTRAP +3. P1.1A.KERNEL +4. P1.1A.VERSION +5. P1.1B.ROUTES +6. P1.1B.CAPABILITIES +7. P1.1C.CONFORMANCE +8. P1.1C.RATCHETS +9. P1.R1.ARCH_REVIEW +10. P1.1D.TEAM_LIFECYCLE_READ_CONTRACT +11. P1.1D.TEAM_LIFECYCLE_READ_USE_CASE +12. P1.1D.TEAM_LIFECYCLE_SEMANTIC_PROOF +13. P1.R2.SEMANTIC_REVIEW +14. P1.NEG.RATCHET_REGRESSION + +P1.I.LINT.REMEDIATION, P1.I.INTEGRATION, and P1.I.ROLLBACK are also present. The catalog has exactly +17 distinct rows, no unknown ID, current hashes for every row, valid authority/disposition and +supersession rules, and no lifecycle mismatch. The immutable pending fields in the frozen P1.I index +were not changed. These P1.F outputs record only P1.F.FREEZE and P1.F.PHASE_EXIT and do not claim an +index mutation or supersession. + +## Current checks + +- Phase 1 plus team-lifecycle Vitest: exact command exit 0; Vitest 3.2.6; Node v24.16.0; pnpm + 10.33.4; 13/13 files and 60/60 tests; wrapper duration 3863.25 ms. +- Focused ratchet Vitest: exact command exit 0; 1/1 file and 3/3 tests; wrapper duration 2166.98 ms. +- Full lint: pnpm lint exit 0; ESLint 9.39.4; 0 errors and 3043 warnings; duration 610110.28 ms. +- Exact-74 Prettier: exit 0; Prettier 3.8.1; 74 matched paths; duration 3594.92 ms. +- Exact-two P1.F Prettier: exit 0; Prettier 3.8.1; two matched paths; duration 1271.38 ms. + +The exact normalizer command was attempted once locally from 2026-07-16T06:14:02.176133358Z through +2026-07-16T06:15:57.266775779Z. Its compiler child could not spawn: +spawnSync /usr/local/bin/node EPERM. The process exited 1 and produced no valid local gate report. + +That recorded sandbox EPERM made the fresh immutable root-attested normalizer input eligible. Its +compact structured report hashes to +2f0e7547b98f88117e606807750995667dcf8561b65da6c66477c25283ddcc25. The root execution used the +exact command at the clean f13b7f886ccd2379674506eeecf5fb077495601e authority and the normalizer +process exited 0. The report says passed true, compiler raw exit 2, observed/normalized inherited 7, +resolved 0, effective 0, unexpected 0, and unparsed 0. All seven file, line, column, code, and +normalized message tuples match the checked-in baseline. Root timestamps, actor/tool provenance, and +capture hashes were inspected. No generic root substitution, network enablement, or replacement of +another sandbox-compatible check occurred. + +## Fourteen current gates + +1. P1.GATE.PROVENANCE — passed from f13b7f8 authority, remote attestation, ancestry and manifests. +2. P1.GATE.PREDECESSORS — passed from accepted P1.I provenance, exact five range, merge and bytes. +3. P1.GATE.SCOPE — passed from exact-two P1.F scope and no staged, tracked, P1.I/product/test change. +4. P1.GATE.TESTS — passed from the current 13/13 and 60/60 run. +5. P1.GATE.TYPECHECK — passed from recorded EPERM plus eligible root-attested exact 7/0/0 result. +6. P1.GATE.LINT — passed from the current full lint exit 0 and zero errors. +7. P1.GATE.FORMAT — passed from current exact-74 and exact-two pinned Prettier checks. +8. P1.GATE.NEGATIVES — passed from the current architecture suite and frozen negative inventory. +9. P1.GATE.RATCHET — passed from current focused 1/1 file and 3/3 tests. +10. P1.GATE.SECURITY — passed from exact-76 classified and text/MIME scans. +11. P1.GATE.ROLLBACK — passed from current exact-54 scratch round trip. +12. P1.GATE.ESTIMATE — passed from revalidated census, unique allocation, and variance arithmetic. +13. P1.GATE.DECISIONS — passed from 13 distinct decisions and 14 ordered gate decisions. +14. P1.GATE.EVIDENCE_FREEZE — passed from 74 hashes, 17 lifecycle rows, packet hashes and self-review. + +## Rollback and current-base proof + +The exact 54 rollback paths are distinct and all absent at bootstrap +5f30df49e052d1cc1d0e7efd03aa105673b5b614. Their ordered compact JSON-array hash is +d67e76fa0b63f51260bc6c3bdd59568a9115a730709f2508231df46da5ca880e. + +A binary/full-index patch from bootstrap to accepted true merge +20706bd067ce5ccbf13697700411904faa2a00c8 was generated with exit 0 and SHA-256 +bd0b8cce323c72e9104b620a899add44919c13f913d5fbbe169b6445d79ffb50. In a newly created +marker-owned external scratch directory, forward check and apply exited 0, all 54 resulting paths +were byte-identical to the accepted merge, reverse check and reverse apply exited 0, and all 54 paths +were absent again. Cleanup exited 0. The proof was scratch-only; nothing was applied to the worktree. + +This rollback proof does not substitute for the exact P1.I integration range. The ordered merge and +accumulated current-base history classification are recorded separately above. + +## JSON, hashes, links, scope, text, and classified scans + +The candidate set is the exact 74 manifest paths followed by the exact two P1.F outputs: 76 distinct +paths. It has 31 JSON files and four Markdown files. All JSON parsed; the five P1.I and two P1.F +records passed required-field/schema validation. The parser does not expose duplicate keys, so no +unsupported duplicate-key claim is made. All repository-relative Markdown targets resolve. + +Historical P1.I lane SHA-256 is +3f81d6e65f9848b6b3db593dda6eb87e5eeb7276af9e76d2fe79ba3fc6f094fe. Current controller and lane +packet hashes are a39fe79dfc483018b6f798d781dadb70139cd27089eb72524b351eaf898118bd and +9a8d4e6572a58ca557b0e2d630f4af9f556732daf82c51be27f2c212f0afe748; both match the execution +index. + +Final scope is exactly the two untracked declared outputs, with zero staged and zero tracked changed +paths; `git diff --check` exited 0. All 76 candidates are regular UTF-8 text with no symlink, binary, +or NUL byte: MIME classification found 31 JSON, 40 JavaScript/TypeScript, and five plain-text paths. + +The exact classified scans exited 0. Secret/credential scanning returned 29 lines, provider scanning +54, private-path scanning 16, and placeholder/raw-body scanning 99. Every match was inspected and +classified as scanner/control text, synthetic fixture canaries, required model/profile or historical +provenance, TypeScript generic/comparison syntax, immutable historical command metavariables, a +zero-valued todo baseline field, repository-relative paths, or explicit prohibited/unverified-boundary +language. No secret, credential, auth/provider payload, private or real-project value, task-temporary +path, raw sensitive body, unresolved placeholder, symlink, binary, NUL byte, or invalid UTF-8 content +is present. + +Raw output hashes are recomputed after final bytes and broker-captured. A self-referential raw +SHA-256 is intentionally not embedded in its own output. + +## Findings, phase exit, and HOLD + +P0 findings: none. + +P1 findings: none. + +P2 findings: none. + +Every mandatory P1.F proof passed, so the disposition is ACCEPT and Phase 1 phase exit is accepted. +The two evidence outputs are not yet integrated or pushed. Phase 2 remains blocked. This review claims +no new remote equality after P1.F integration, Phase 2 router authority, successor launch, production +hosted transport/auth/runtime/filesystem/preload/renderer behavior, or Phase 2 product behavior. + +The conditional next action is broker integration of exactly these two evidence outputs after root +mechanical validation and mark_reviewed. Terminal state remains HOLD. + +## Self-review + +The final self-review confirms fresh independence; exact f13b7f8 authority; complete remote +attestation inspection; conditional normalizer attestation inspection after recorded EPERM; the +bounded normalizer rule; independent execution of every other sandbox-compatible check; all 74 frozen +bytes; all evidence IDs and gates; exact test, typecheck, lint and format counts; rollback and +current-base proof; exact-two ownership; no P1.I/product/test change; zero findings; and no unsupported +integration or successor claim. Final scan classifications, complete output rereads, and complete +diff reread are recorded as complete in the final handoff update. diff --git a/docs/research/hosted-web/phase-1/reviews/routes-ratchets.md b/docs/research/hosted-web/phase-1/reviews/routes-ratchets.md new file mode 100644 index 0000000000..7c855bd9cc --- /dev/null +++ b/docs/research/hosted-web/phase-1/reviews/routes-ratchets.md @@ -0,0 +1,184 @@ +# Formal P1.R1 routes and ratchets review + +Disposition: ACCEPT + +## Review identity and independence + +- Reviewer runtime identity: `agent-teams-hosted-web-refactor-p1-r1-review-v16-r1` (`/root`; no subagent, producer, repairer, or prior-review work was delegated or reused). +- Controller/job binding: subscription-runtime builtin `worker-start-v1`, job `agent-teams-hosted-web-refactor-p1-r1-review-v16-r1`, packet `phase-01-p1-r1-review-r1`, bound to `phaseStartSha` and `planBundleCommit` `a3f1ba92d8bd4989373a0b6deb4718123c129d09`. +- Source worktree: `/var/data/agent-teams-hosted-web-refactor/worktrees/p1-r1-review-v16-r1`. +- The worktree `HEAD` is `a3f1ba92d8bd4989373a0b6deb4718123c129d09`; it is the isolated review worktree named for this P1.R1 job. +- Exclusion 1: this reviewer/job/worktree is not the P1.1B routes producer responsible for `74038b54eee23e93798b3aa5d11411d3f7e9adcf`. +- Exclusion 2: this reviewer/job/worktree is not the P1.1C conformance producer responsible for `6a9e9ab714359638fb93a6880855a53c9e8ef4be`. +- Exclusion 3: this identity is distinct from admission reviewer `agent-teams-hosted-web-refactor-p1-s2-admission-review-v15-r2` and did not rely on that review as the formal disposition. +- Independence proof: the controller supplied a new review-only job and uniquely named isolated worktree at the integrated router SHA; the canonical inputs were already committed, the initial worktree was clean, every gate below was rerun in this job, and no input was edited. The producer commits and the admission decision are provenance only. + +## Provenance and exact scope + +- Accepted predecessor/base reviewed: `6a9e9ab714359638fb93a6880855a53c9e8ef4be`. +- Canonical tree: `22020029327465ed389cd4479db340082ae81601`. +- P1.S2 router/start: `a0dc964e9a71b782b1bbad4769db62a691e50c97`. +- P1.1B producer: `74038b54eee23e93798b3aa5d11411d3f7e9adcf`. +- P1.1C producer and canonical combined input: `6a9e9ab714359638fb93a6880855a53c9e8ef4be`. +- Admission input: `02a6b3ac5ac2baaad55c413f8547252dddee4d41`; its tree is byte-identical to canonical P1.S2. +- Admission reviewer/disposition: `agent-teams-hosted-web-refactor-p1-s2-admission-review-v15-r2` / `ACCEPT`. +- Integrated review router `phaseStartSha`: `a3f1ba92d8bd4989373a0b6deb4718123c129d09`. +- Review packet revision: `phase-01-p1-r1-review-r1`. +- Evidence IDs: `P1.1B.ROUTES`, `P1.1B.CAPABILITIES`, `P1.1C.CONFORMANCE`, and `P1.1C.RATCHETS`. + +The routes diff contains the exact listed 9 paths. The conformance diff contains the exact listed 28 paths. The sets are disjoint and their union is exactly 37 paths. The combined diff contains no 36th/38th path. Both admission and canonical commits resolve to tree `22020029327465ed389cd4479db340082ae81601`, and their diff is empty. Canonical P1.S2 to the review router changes exactly the seven authorized router documentation paths. The review began with no tracked or untracked change. + +### Scope and provenance commands + +| Exact command | Exit | Observation | +| -------------------------------------------------------------------------------------------------------------------------------------------- | ---: | -------------------------------------------------------- | +| `git merge-base --is-ancestor a0dc964e9a71b782b1bbad4769db62a691e50c97 74038b54eee23e93798b3aa5d11411d3f7e9adcf` | 0 | Routes producer descends from the accepted P1.S2 router. | +| `git merge-base --is-ancestor 74038b54eee23e93798b3aa5d11411d3f7e9adcf 6a9e9ab714359638fb93a6880855a53c9e8ef4be` | 0 | Canonical combined input descends from routes. | +| `git diff --name-only a0dc964e9a71b782b1bbad4769db62a691e50c97..74038b54eee23e93798b3aa5d11411d3f7e9adcf` | 0 | Exactly 9 listed P1.1B paths. | +| `git diff --name-only 74038b54eee23e93798b3aa5d11411d3f7e9adcf..6a9e9ab714359638fb93a6880855a53c9e8ef4be` | 0 | Exactly 28 listed P1.1C paths. | +| `git diff --name-only a0dc964e9a71b782b1bbad4769db62a691e50c97..6a9e9ab714359638fb93a6880855a53c9e8ef4be` | 0 | Exactly the 37-path union. | +| `git diff --exit-code 02a6b3ac5ac2baaad55c413f8547252dddee4d41..6a9e9ab714359638fb93a6880855a53c9e8ef4be` | 0 | No byte difference. | +| `git rev-parse 02a6b3ac5ac2baaad55c413f8547252dddee4d41^{tree}` | 0 | `22020029327465ed389cd4479db340082ae81601`. | +| `git rev-parse 6a9e9ab714359638fb93a6880855a53c9e8ef4be^{tree}` | 0 | `22020029327465ed389cd4479db340082ae81601`. | +| `git diff --name-only 6a9e9ab714359638fb93a6880855a53c9e8ef4be..a3f1ba92d8bd4989373a0b6deb4718123c129d09` | 0 | Exactly the 7 authorized router paths. | +| `git diff --exit-code a3f1ba92d8bd4989373a0b6deb4718123c129d09 -- . ':(exclude)docs/research/hosted-web/phase-1/reviews/routes-ratchets.md'` | 0 | No non-owned worktree diff before or after review. | +| `git status --short` | 0 | Initially empty; final status is only the owned result. | + +Supplemental provenance checks also passed: `git rev-parse HEAD` exited 0 with `a3f1ba92d8bd4989373a0b6deb4718123c129d09`; `git merge-base --is-ancestor 6a9e9ab714359638fb93a6880855a53c9e8ef4be a3f1ba92d8bd4989373a0b6deb4718123c129d09` exited 0; `git merge-base --is-ancestor 041b5c7c2d3225b7dc2eca9e9b7b71aa33217060 a0dc964e9a71b782b1bbad4769db62a691e50c97` exited 0; and `git diff --name-only 041b5c7c2d3225b7dc2eca9e9b7b71aa33217060..a0dc964e9a71b782b1bbad4769db62a691e50c97` exited 0 with the eight contract-owned P1.S2 router paths recorded by both handoffs. + +The subscription runner initially supplied a restricted login `PATH`. The first invocations of the two required `git merge-base` commands therefore exited 127 with `git: command not found`. After setting `PATH=/usr/local/bin:/usr/bin:/bin:$PATH`, the exact commands above were rerun and both exited 0. This was runner command discovery, not an input, provenance, or architecture failure. + +## Architecture and focused verification + +| Exact command | Exit | Observed result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---: | ----------------------- | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/routes` | 0 | 2/2 files; 16/16 tests. | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance test/architecture/hosted-web/phase-1/dependencies test/architecture/hosted-web/phase-1/parity test/architecture/hosted-web/phase-1/renderer-boundaries` | 0 | 4/4 files; 13/13 tests. | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts` | 0 | 1/1 file; 12/12 tests. | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts` | 0 | 1/1 file; 4/4 tests. | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts` | 0 | 1/1 file; 4/4 tests. | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts` | 0 | 1/1 file; 3/3 tests. | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/parity/parity-references.test.ts` | 0 | 1/1 file; 3/3 tests. | +| `pnpm exec vitest run test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts` | 0 | 1/1 file; 3/3 tests. | + +Manual architecture review of all 37 inputs passed: + +1. `RouteCatalog` is a frozen assertion collection over frozen immutable descriptors. It has no dispatch, mutation, cache, production registry, client generation, or business orchestration. +2. Capability/action descriptors are separately asserted and feature-owned. Production support is fixed to `absent`; production mounting of a `testOnly` route rejects. +3. The semantic harness consumes already-imported values, uses a fixed synthetic principal/clock and opaque IDs, and has no transport, filesystem, process, cache, watcher, or production registration. The corpus is deterministic and does not implement the P1.1D list use case. +4. Dependency, parity, and renderer scanners are caller-supplied-content test tooling. The parity ratchets pin exact content and hashes. None is imported or mounted by production changes. +5. The 37-path diff adds no product IPC, HTTP, preload, renderer registration, filesystem adapter, dependency/config change, legacy aggregate contract, secret/path-bearing product contract, real-project access, or Phase 1 completion claim. +6. The two P1.1D-owned positive neighbors remain absent. Their semantics and `P1.NEG.SEMANTIC_OUTCOME` remain explicitly unverified; this is the required P1.S2 boundary, not a defect. + +## Complete negative matrix + +| Negative ID | Required/observed diagnostic | Result and positive neighbor | +| ------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `P1.NEG.ROUTE_DRIFT` | `phase1-route-catalog-drift` | Duplicate ID, duplicate method/path, and missing-reference fixtures reject; adjacent valid descriptors accept. | +| `P1.NEG.CAPABILITY_MOUNT` | `phase1-test-capability-production-mount` | Production-support and production-mount cases reject; the test catalog with absent production support accepts. | +| `P1.NEG.CORE_SIDE_EFFECT` | `phase1-core-side-effect-forbidden` | Rejects; pure value-only core neighbor verified. | +| `P1.NEG.HOSTED_ELECTRON_API` | `phase1-hosted-electron-api-forbidden` | Rejects; narrow value-only hosted facet verified. | +| `P1.NEG.IMPORT_FORBIDDEN` | `phase1-core-import-forbidden` | Rejects; pure core neighbor verified. | +| `P1.NEG.LEGACY_GOD_DTO` | `phase1-legacy-god-dto-forbidden` | Fixture/scanner half verified; P1.1D-owned positive neighbor unverified. | +| `P1.NEG.NO_FILESYSTEM_ADAPTER_PHASE1` | `phase1-filesystem-adapter-forbidden` | Fixture/scanner half verified; P1.1D-owned positive neighbor unverified. | +| `P1.NEG.PARITY_DRIFT` | `phase1-parity-reference-drift` | Incomplete reference rejects; pinned references accept. | +| `P1.NEG.PATH_SECRET_LEAK` | `phase1-path-secret-leak` | Synthetic path/credential canary rejects; clean synthetic corpus verified. | +| `P1.NEG.PRODUCTION_ADAPTER_MOUNT` | `phase1-test-adapter-production-import` | Test-adapter import rejects; all eight frozen production boundaries are clean. | +| `P1.NEG.RATCHET_REGRESSION` | `phase1-ratchet-regression` | Expired and over-count ratchets reject; pinned current ratchets accept. | + +No absent P1.1D positive-neighbor test was created or run. No `P1.NEG.SEMANTIC_OUTCOME` or future feature-conformance claim is made. + +## Handoff, patch, and hash validation + +Both handoffs parse as JSON and are internally consistent with the canonical bytes. This exact parse command exited 0 and printed `handoff-json: 2/2 parsed`: + +```bash +node -e "const fs=require('node:fs'); for (const p of ['.codex-handoff/phase-01-p1-1b.json','.codex-handoff/phase-01-p1-1c.json']) JSON.parse(fs.readFileSync(p,'utf8')); console.log('handoff-json: 2/2 parsed')" +``` + +An independent invariant validator over both parsed handoffs exited 0 with `ok:true`, P1.1B status `verified`, P1.1C status `characterized`, path counts 9 and 28, union 37, 11 unique negative IDs, and all four expected evidence IDs at `target_verified`. It checked schema/version, phase/lane, producer packet revisions, common base `041b5c7c2d3225b7dc2eca9e9b7b71aa33217060`, plan bundle/start `a0dc964e9a71b782b1bbad4769db62a691e50c97`, evidence IDs/proof levels, disjoint scope, unique negative count, and the two recorded patch hashes. + +```bash +node -e "const fs=require('node:fs'); const b=JSON.parse(fs.readFileSync('.codex-handoff/phase-01-p1-1b.json','utf8')); const c=JSON.parse(fs.readFileSync('.codex-handoff/phase-01-p1-1c.json','utf8')); const ids=x=>x.evidence.map(e=>e.id+':'+e.proofLevel).sort(); const unique=new Set([...b.changedPaths,...c.changedPaths]); const neg=new Set([...b.negativeResultMatrix,...c.negativeResultMatrix].map(x=>x.id)); const ok=b.schemaVersion===1&&c.schemaVersion===1&&b.phaseId==='phase-01'&&c.phaseId==='phase-01'&&b.laneId==='p1-1b'&&c.laneId==='p1-1c'&&b.packetRevision==='phase-01-s2-routes-r1'&&c.packetRevision==='phase-01-s2-conformance-r1'&&b.baseSha==='041b5c7c2d3225b7dc2eca9e9b7b71aa33217060'&&c.baseSha===b.baseSha&&b.planBundleCommit==='a0dc964e9a71b782b1bbad4769db62a691e50c97'&&c.planBundleCommit===b.planBundleCommit&&b.phaseStartSha===b.planBundleCommit&&c.phaseStartSha===b.planBundleCommit&&b.changedPaths.length===9&&c.changedPaths.length===28&&unique.size===37&&ids(b).join(',')==='P1.1B.CAPABILITIES:target_verified,P1.1B.ROUTES:target_verified'&&ids(c).join(',')==='P1.1C.CONFORMANCE:target_verified,P1.1C.RATCHETS:target_verified'&&neg.size===11&&b.patchManifest.sha256==='30cf407200af5ea320d268bba4089de7015a294e4e8c7f21cbf41780f7abf24e'&&c.patchManifest.sha256==='1beb4adc55d879d0089140f57c3ce3f7a92647a1c3021b8f901879f3d6adb1e1'; console.log(JSON.stringify({ok,bStatus:b.status,cStatus:c.status,bPaths:b.changedPaths.length,cPaths:c.changedPaths.length,union:unique.size,negativeIds:neg.size,evidence:[...ids(b),...ids(c)]})); if(!ok) process.exit(1)" +``` + +The recorded commands, negative matrices, check counts, base/start lineage, evidence paths, blocked/unverified claims, and inherited typecheck classification agree with the independent reruns. In particular, both handoffs leave production support, P1.1D behavior/positive neighbors, formal P1.R1, integration, and Phase 1 completion unverified. P1.1C truthfully records its inherited typecheck blocker while making no P1.S2-owned typecheck claim. + +All 35 recorded non-handoff per-file SHA-256 values were recomputed with `sha256sum` in the handoff path order; command exit 0, 35/35 matched. The handoff files intentionally have no self-hash. All 11 `fixtureHashes` in P1.1C also matched their corresponding per-file values. + +```bash +sha256sum src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json +``` + +The exact P1.1B patch reconstruction command was: + +```bash +files=(src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts) +for file in "${files[@]}"; do git diff --binary --full-index --no-index /dev/null "$file" || [[ $? -eq 1 ]]; done | sha256sum +``` + +It exited 0 and produced `30cf407200af5ea320d268bba4089de7015a294e4e8c7f21cbf41780f7abf24e`, exactly the P1.1B patch manifest. + +The exact P1.1C reconstruction used its 27 non-handoff paths, in `changedPaths` order: + +```bash +files=(scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json) +for file in "${files[@]}"; do git diff --binary --no-index -- /dev/null "$file" || [[ $? -eq 1 ]]; done | sha256sum +``` + +It exited 0 and produced `1beb4adc55d879d0089140f57c3ce3f7a92647a1c3021b8f901879f3d6adb1e1`, exactly the P1.1C patch manifest. + +## Quality and inherited diagnostics + +The exact lint command exited 0: + +```bash +pnpm lint:fast:files -- src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts +``` + +`pnpm typecheck` exited 1 with exactly seven unchanged inherited Phase 0 diagnostics and no diagnostic in any P1.S2-owned path: + +- `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts`: TS7016 at 25:8; TS7031 at 66:31; TS18046 at 117:68; TS7031 at 413:48; TS7031 at 733:10. +- `test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts`: TS7016 at 12:8. +- `test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts`: TS2352 at 162:44. + +This is the exact accepted inherited set: 7 diagnostics in 3 Phase 0 files, unchanged by the 37 inputs and the seven-path router. It is not a P1.S2 finding. + +The exact Prettier command exited 0 with all 37 matched files using Prettier style: + +```bash +pnpm exec prettier --check .codex-handoff/phase-01-p1-1b.json src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts .codex-handoff/phase-01-p1-1c.json scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json +``` + +`git diff --check` exited 0 before the result and again after the result. + +## Safety and ownership + +The safety scan uses the exact ordered 37-path input list plus this result as `review_paths`. The final high-signal lexical command was: + +```bash +review_paths=(.codex-handoff/phase-01-p1-1b.json src/main/composition/hosted/routing/RouteCatalog.ts src/main/composition/hosted/routing/index.ts src/main/composition/hosted/routing/route-types.ts test/architecture/hosted-web/phase-1/routes/RouteCatalog.test.ts test/architecture/hosted-web/phase-1/routes/capability-descriptors.test.ts test/architecture/hosted-web/phase-1/routes/fixtures/duplicate-route.ts test/architecture/hosted-web/phase-1/routes/fixtures/missing-reference.ts test/architecture/hosted-web/phase-1/routes/fixtures/test-only-production-route.ts .codex-handoff/phase-01-p1-1c.json scripts/hosted-web/phase-1/check-feature-dependencies.ts scripts/hosted-web/phase-1/check-parity-references.ts scripts/hosted-web/phase-1/check-renderer-boundaries.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.test.ts test/architecture/hosted-web/phase-1/conformance/semantic-harness.ts test/architecture/hosted-web/phase-1/dependencies/feature-dependencies.test.ts test/architecture/hosted-web/phase-1/fixtures/core-side-effect.ts test/architecture/hosted-web/phase-1/fixtures/filesystem-adapter.ts test/architecture/hosted-web/phase-1/fixtures/forbidden-core-import.ts test/architecture/hosted-web/phase-1/fixtures/hosted-electron-api.ts test/architecture/hosted-web/phase-1/fixtures/legacy-god-dto.ts test/architecture/hosted-web/phase-1/fixtures/path-secret-leak.ts test/architecture/hosted-web/phase-1/fixtures/production-adapter-mount.ts test/architecture/hosted-web/phase-1/fixtures/ratchet-regression.ts test/architecture/hosted-web/phase-1/parity/parity-references.test.ts test/architecture/hosted-web/phase-1/renderer-boundaries/renderer-boundaries.test.ts test/fixtures/hosted-web/phase-1/team-lifecycle/manifest.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/corrupt.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/draft.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/empty.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/not-found-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/partial.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/provisioning-inapplicable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/stale.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/success.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unavailable.json test/fixtures/hosted-web/phase-1/team-lifecycle/outcomes/unexpected.json docs/research/hosted-web/phase-1/reviews/routes-ratchets.md) +``` + +```bash +rg -n -i '(secret[[:space:]]*[:=]|api[_-]?key[[:space:]]*[:=]|access[_-]?token[[:space:]]*[:=]|refresh[_-]?token[[:space:]]*[:=]|password[[:space:]]*[:=]|cookie[[:space:]]*[:=]|authorization[[:space:]]*[:=]|bearer[[:space:]]+[A-Za-z0-9]|-----BEGIN [A-Z ]*PR[I]VATE KEY-----|/U[s]ers/|/h[o]me/|/r[o]ot/|[A-Za-z]:\\U[s]ers\\|claude[-]runtime|auth[_-]?payload[[:space:]]*[:=]|provider[_-]?payload[[:space:]]*[:=]|raw[_-]?(command|runtime)[_-]?body[[:space:]]*[:=])' "${review_paths[@]}" +``` + +It exited 0 with two matching input lines and no match in the owned result. Both are benign control-language records, not values: P1.1B handoff line 114 records its earlier broad scanner expression containing a slash-delimited user-home pattern, and P1.1C handoff line 268 records the conclusion of its earlier safety scan using a slash-delimited home-category phrase. These are the complete high-signal lexical matches. + +Manual classification of the broader lexical surface found only the following benign categories: the `private` RouteTrustKind enum; producer handoff commands/results and the inherited provider-runtime diagnostic path; test and diagnostic identifiers for the path/credential negative; scanner denylist expressions; the legacy DTO fixture's `providerStatus` field; and the fully synthetic canary assembled from fixture-only fragments. The canary contains no real identity, project, credential, or command body and is required to prove `phase1-path-secret-leak`. The semantic corpus contains only opaque fixture IDs and synthetic values. No credential, secret value, authorization/auth/provider payload, private, home, or real-project location, raw command/runtime payload, or production data was found. + +The binary gate command `file --mime-type "${review_paths[@]}"` exited 0 for all 38 files; every file was textual (`application/javascript`, `application/json`, or `text/plain`), with zero binaries. + +Ownership checks passed. Before writing, `git status --short` was empty. After writing, it exits 0 with the sole untracked directory summary `?? docs/research/hosted-web/phase-1/reviews/`; `git ls-files --others --exclude-standard -- docs/research/hosted-web/phase-1/reviews` exits 0 and resolves that summary to the single owned file `docs/research/hosted-web/phase-1/reviews/routes-ratchets.md`. `git diff --exit-code a3f1ba92d8bd4989373a0b6deb4718123c129d09 -- . ':(exclude)docs/research/hosted-web/phase-1/reviews/routes-ratchets.md'` exits 0. No input was repaired, reformatted, staged, committed, pushed, or integrated. + +## Findings and blocked successors + +- P0 findings: 0. +- P1 findings: 0. +- P2 findings: 0. +- Inherited diagnostics: exactly the seven unchanged Phase 0 typecheck diagnostics classified above. + +P1.1D, P1.R2, integration/P1.I, P1.F, and Phase 2+ remain blocked. This `ACCEPT` does not authorize any of them; it must first be returned to the controller, integrated by separately authorized work, and followed by a later reviewed router transition. + +The only safe next action is to return this formal result to the controller for watchdog verification and a later router decision. No repair, integration, successor launch, P1.1D implementation, commit, or push is authorized. diff --git a/docs/research/hosted-web/phase-2/foundation-integration.json b/docs/research/hosted-web/phase-2/foundation-integration.json new file mode 100644 index 0000000000..0a837156ee --- /dev/null +++ b/docs/research/hosted-web/phase-2/foundation-integration.json @@ -0,0 +1,328 @@ +{ + "schemaVersion": 1, + "phaseId": "phase-02", + "nodeId": "P2.IF.INTEGRATION", + "laneId": "p2-if-integration", + "packetRevision": "phase-02-jit-router-r1", + "baseSha": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "canonicalSha": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "headSha": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "status": "verified", + "terminalState": "HOLD", + "evidence": [ + { + "id": "P2.IF.INTEGRATION", + "path": "docs/research/hosted-web/phase-2/foundation-integration.json", + "proofLevel": "target_verified" + } + ], + "evidenceIds": ["P2.IF.INTEGRATION"], + "authority": { + "canonicalHead": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "workspaceHead": "11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc", + "authorityHeadEqual": true, + "authoritySource": "launch-authorized pre-start admission receipt", + "workspaceMode": "verified_input_patch", + "packetRevisionMatches": true, + "mandatoryReadOrderCompletedByThisContinuedAttempt": true, + "authorityMismatchCount": 0 + }, + "artifactReconciliation": { + "p2F0Producer": { + "taskId": "agent-teams-hosted-web-refactor-p2-identity-foundation-producer-v17-r1", + "baseSha": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "patchSha256": "659b9eb9286b6c9b40e45e43d9aa9871ddae9d26af903d8296d4db7d64f0094f", + "manifestSha256": "d608ffa0de187918a1a23b8a8113669169e072442d25d12b51072c85070e1899", + "summarySha256": "fe0ccf6a079e2c800ac34b56ef12be9c50db351de717e473cb3ecdae91ed97fd", + "artifactHashesRecomputedAndMatched": true + }, + "p2R0Review": { + "taskId": "agent-teams-hosted-web-refactor-p2-foundation-arch-security-review-v17-r1", + "baseSha": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "patchSha256": "fa896f654b352f3b0027180d87ab5b4e2ec1b0b66f821183a417f0235d1d46d1", + "manifestSha256": "48bc27492edfcdcc60cc5e75ca2c1a7fb1bd15747c28997afea175ce56c77749", + "summarySha256": "f426f60cfcc803fd7154d6cbe889ad1e84a90e6ad0e115fcc0a354550606ff64", + "artifactHashesRecomputedAndMatched": true + }, + "integrationAdmission": { + "inputPatchArtifactSha256": "fa896f654b352f3b0027180d87ab5b4e2ec1b0b66f821183a417f0235d1d46d1", + "expectedWorkspaceStagedPatchSha256": "53c4a1ad71c58643f5e3229d2a358038d52597f0bc3ce3defc8ecb55270afcac", + "workspaceStagedPatchSha256": "53c4a1ad71c58643f5e3229d2a358038d52597f0bc3ce3defc8ecb55270afcac", + "workspaceStagedPatchByteEqualsPreservedHandoffArtifact": true, + "preservedWorkspaceHandoffManifestSha256": "100cc533bba4964d70da327ed9fff47543f2ca4ae3b049ac5935fb87fc4fd4a6", + "preservedWorkspaceHandoffSummarySha256": "809f10950fbced85159e0f99aa498916bbc0af585894d2e8b60e119bf075f5ba", + "receiptManifestSha256": "0474caea399683dd0eb3f491533345b4712fe887c286c1a345a4c54bbf8bfb82", + "contractSha256": "d05262106ce1db5e0a8d7905a727291e555e550f9a9335daaf05436336d9a75b", + "stateSha256": "50d41ef28f7afb629a106ba01d60961ab63428f19ffe67ba4e4ef67768ad1d4e", + "promptSha256": "fbad21c10ab46fd8e6dba8b393eed7c6febe514a3dcefaf6ad8285860a7a29b7", + "admissionHashesMatched": true + } + }, + "predecessors": { + "p2F0": { + "path": ".codex-handoff/phase-02-p2-f0.json", + "sha256": "53ce2854cad51ac973775d9d43e5573a6d87e1708ae672dc80d7fda9bb6ade3f", + "status": "verified", + "disposition": "ACCEPT", + "terminalState": "HOLD" + }, + "p2R0": { + "path": ".codex-handoff/phase-02-p2-r0.json", + "sha256": "209903b5ab6e99a43b38ae282bac08cffdc60cc7e3273b117c47156e9bba4dee", + "status": "verified", + "disposition": "ACCEPT", + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "terminalState": "HOLD", + "reviewEvidencePath": "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + "reviewEvidenceSha256": "e207883a5a6ae5975cf2500df9b4738f600bca0ef6a220ad77fb2cd7ba34ccc0" + } + }, + "acceptedInputs": { + "pathCount": 7, + "distinctPathCount": 7, + "pathListSha256": "237d2bcb27e556070541617aeb8082ae2dda95b78d379f118d8a1986cd8a289e", + "contentManifestSha256": "800315b2cc5493cdef842a0dbabd77bd7a4a56f160bfe8d7dbdfcd546f1148d0", + "allStaged": true, + "allRegularFilesMode100644": true, + "unstagedByteDifferenceCount": 0, + "paths": [ + { + "path": ".codex-handoff/phase-02-p2-f0.json", + "sha256": "53ce2854cad51ac973775d9d43e5573a6d87e1708ae672dc80d7fda9bb6ade3f" + }, + { + "path": ".codex-handoff/phase-02-p2-r0.json", + "sha256": "209903b5ab6e99a43b38ae282bac08cffdc60cc7e3273b117c47156e9bba4dee" + }, + { + "path": "docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md", + "sha256": "e207883a5a6ae5975cf2500df9b4738f600bca0ef6a220ad77fb2cd7ba34ccc0" + }, + { + "path": "src/shared/contracts/hosted/identifiers.ts", + "sha256": "73978dd8871f3af363810b9a90b4a42b464982a25898eac082677b9557d1dc41" + }, + { + "path": "src/shared/contracts/hosted/index.ts", + "sha256": "a11b722edd3a9fb1b4ea451bbbf2f01703a93897515092ceefc6bef5157bbac2" + }, + { + "path": "test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts", + "sha256": "7e8b88816245e95921dbcef8c444c572ec2d610cf32dee6d4934a65d33d63b42" + }, + { + "path": "test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "sha256": "19c327b55a2d407c623e8eae6bb60d20913eca60d737499f161d180d212ee511" + } + ] + }, + "materialization": { + "mode": "clean targeted sandbox reconstruction from canonical HEAD", + "sourcePatchSha256": "53c4a1ad71c58643f5e3229d2a358038d52597f0bc3ce3defc8ecb55270afcac", + "checkExitCode": 0, + "applyExitCode": 0, + "materializedPathCount": 7, + "materializedContentManifestMatchesWorkspace": true, + "acceptedR0ArtifactReconstructionBaseSha": "39f98d62089a345c58f10bead0e5610b5c2a0b2d", + "acceptedR0ArtifactCheckExitCode": 0, + "acceptedR0ArtifactApplyExitCode": 0, + "acceptedR0ArtifactContentManifestMatchesWorkspace": true, + "realProjectAccessed": false + }, + "activation": { + "foundationAuthority": "activated", + "activationScope": "controller-consumable accepted foundation evidence at canonical authority", + "acceptedInputBytesMutated": false, + "productSourceOrTestEditedByIntegrator": false, + "gitCommitCreated": false, + "gitPushPerformed": false, + "controllerIntegrationPerformed": false, + "successorLaunched": false, + "successorStopped": false, + "otherWorkerReviewed": false + }, + "scope": { + "acceptedInputPathCount": 7, + "integrationOutputPaths": [ + "docs/research/hosted-web/phase-2/foundation-integration.json", + ".codex-handoff/phase-02-p2-if.json" + ], + "integrationOutputPathCount": 2, + "expectedChangedPathCount": 9, + "actualChangedPathCount": 9, + "stagedAcceptedPathCount": 7, + "untrackedIntegrationOutputPathCount": 2, + "unexpectedPathCount": 0, + "onlyAcceptedInputsAndDeclaredIntegrationEvidenceChanged": true + }, + "gates": [ + { + "id": "P2.IF.GATE.AUTHORITY", + "status": "passed", + "evidence": "canonical admission and workspace HEAD are both 11db7aea9ba9c7c4c6eabc777a4aa75e4cf302dc" + }, + { + "id": "P2.IF.GATE.ARTIFACTS", + "status": "passed", + "evidence": "producer/reviewer artifact, manifest, summary, handoff and evidence hashes reconcile" + }, + { + "id": "P2.IF.GATE.PREDECESSORS", + "status": "passed", + "evidence": "P2.F0 and independent P2.R0 are verified ACCEPT; R0 findings are P0/P1/P2 0/0/0" + }, + { + "id": "P2.IF.GATE.MATERIALIZATION", + "status": "passed", + "evidence": "clean targeted check/apply exited 0 and reproduced all seven accepted content hashes" + }, + { + "id": "P2.IF.GATE.TESTS", + "status": "passed", + "evidence": "2/2 focused files and 36/36 tests passed" + }, + { + "id": "P2.IF.GATE.LINT", + "status": "passed", + "evidence": "exact four-TypeScript-path fast lint exited 0" + }, + { + "id": "P2.IF.GATE.TYPECHECK", + "status": "passed", + "evidence": "typecheck exited 0 at integrated canonical authority" + }, + { + "id": "P2.IF.GATE.FORMAT", + "status": "passed", + "evidence": "exact nine-path Prettier check exited 0" + }, + { + "id": "P2.IF.GATE.SCOPE", + "status": "passed", + "evidence": "exactly seven staged accepted paths plus two untracked P2.IF evidence paths" + }, + { + "id": "P2.IF.GATE.SECURITY", + "status": "passed", + "evidence": "final exact nine-path secret and private-path scans were fully classified" + } + ], + "checks": [ + { + "id": "identity-tests", + "command": "cd .. && pnpm exec vitest run test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "cwd": "src", + "exitCode": 0, + "testFilesPassed": 2, + "testFilesTotal": 2, + "testsPassed": 36, + "testsTotal": 36, + "toolVersion": "vitest 3.2.6" + }, + { + "id": "lint", + "command": "cd .. && pnpm lint:fast:files -- src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts", + "cwd": "src", + "exitCode": 0 + }, + { + "id": "typecheck", + "command": "cd .. && pnpm typecheck", + "cwd": "src", + "exitCode": 0, + "diagnosticCount": 0, + "classification": "clean at canonical integrated authority" + }, + { + "id": "prettier", + "command": "cd .. && pnpm exec prettier --check src/shared/contracts/hosted/identifiers.ts src/shared/contracts/hosted/index.ts test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts .codex-handoff/phase-02-p2-f0.json .codex-handoff/phase-02-p2-r0.json .codex-handoff/phase-02-p2-if.json docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md docs/research/hosted-web/phase-2/foundation-integration.json", + "cwd": "src", + "exitCode": 0, + "checkedPathCount": 9 + }, + { + "id": "diff", + "command": "cd .. && git diff --check", + "cwd": "src", + "exitCode": 0 + }, + { + "id": "status", + "command": "cd .. && git status --short", + "cwd": "src", + "exitCode": 0, + "classification": "exactly seven staged accepted inputs and two untracked integration evidence outputs" + }, + { + "id": "accepted-staged-diff", + "command": "git diff --cached --check", + "exitCode": 0, + "classification": "all seven immutable staged accepted inputs passed the material whitespace check" + } + ], + "classifiedScans": { + "secretCredentialScan": { + "scopePathCount": 9, + "exitCode": 0, + "observedMatchLineCount": 8, + "allMatchesClassified": true, + "classification": "scanner command text, security findings, and explicit prohibited-payload language only; no secret, credential, cookie, bearer, password or authorization payload" + }, + "privatePathScan": { + "scopePathCount": 9, + "exitCode": 0, + "observedMatchLineCount": 7, + "allMatchesClassified": true, + "classification": "scanner command text and explicit path-rejection or real-project prohibition language only; no private, home, task-temporary or real-project path value" + }, + "highConfidenceSecretSignatureScan": { + "scopePathCount": 9, + "exitCode": 1, + "observedMatchLineCount": 0, + "classification": "no high-confidence secret or private-key signature" + } + }, + "finalValidation": { + "jsonParsePassed": true, + "acceptedHashesRecomputedAfterFinalOutputWrites": true, + "acceptedBytesRemainByteIdentical": true, + "finalNinePathScopeConfirmed": true, + "completeIntegrationOutputReread": true, + "completeScopeDiffReread": true + }, + "selfReview": { + "cleanArchitectureDddSolidConclusionsPreserved": true, + "browserSafeSharedContractBoundaryPreserved": true, + "transportNeutralityPreserved": true, + "safeStaticErrorsPreserved": true, + "phaseOneCompatibilityPreserved": true, + "noMutableIdentityDerivationIntroduced": true, + "noProductSourceOrTestMutationByIntegrator": true, + "exactScopeConfirmed": true, + "secretAndPrivatePathClassificationsConfirmed": true, + "unsupportedClaimsAbsent": true + }, + "findingCounts": { + "P0": 0, + "P1": 0, + "P2": 0 + }, + "findings": { + "P0": [], + "P1": [], + "P2": [] + }, + "unverifiedClaims": [ + "canonical identifier allocation, uniqueness, entropy and collision handling", + "restart, rename, display-name-change, remount, persistence, registry and legacy-adoption stability", + "production filesystem, persistence, transport, Electron, main-process, provider and composition behavior", + "successor execution, Phase 2 serial integration, milestone acceptance, commit and push" + ], + "blockers": [], + "generatedAt": "2026-07-16T11:22:27Z", + "nextAction": "controller may admit P2.A through P2.E subject to the fixed DAG, product slots and host budget" +} diff --git a/docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md b/docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md new file mode 100644 index 0000000000..36f4ce7808 --- /dev/null +++ b/docs/research/hosted-web/phase-2/reviews/foundation-architecture-security.md @@ -0,0 +1,143 @@ +# P2.R0 identity-foundation architecture and security review + +Disposition: ACCEPT + +Finding counts: P0 0 / P1 0 / P2 0. + +Terminal state: HOLD. + +## Reviewer independence and authority + +This is an independent architecture/security review of the immutable broker-materialized P2.F0 +candidate identified by SHA-256 +`659b9eb9286b6c9b40e45e43d9aa9871ddae9d26af903d8296d4db7d64f0094f`. The reviewer did not +produce or repair the candidate and used no subagent or additional reviewer. + +The reviewed worktree HEAD is the task's canonical base +`39f98d62089a345c58f10bead0e5610b5c2a0b2d`. It has the single parent +`d5afa87e79b1f2badd69e65262e5699c0fb61de7`, and its commit diff is exactly the 12 router paths +declared by `routerExclusiveOwnership`. The packet revision is `phase-02-jit-router-r1`. The accepted +Phase 1 predecessor handoff and freeze review both record ACCEPT with P0/P1/P2 `0/0/0`. + +The mandatory authority was read completely in the prescribed order, followed by the P2.F0 packet +and its six numbered source/test reads. No authority, dependency, packet revision, ownership, or path +mismatch was found. + +## Exact reviewed scope + +The complete candidate diff against canonical HEAD contains exactly these five P2.F0 writable paths: + +1. `src/shared/contracts/hosted/identifiers.ts` — SHA-256 + `73978dd8871f3af363810b9a90b4a42b464982a25898eac082677b9557d1dc41`. +2. `src/shared/contracts/hosted/index.ts` — SHA-256 + `a11b722edd3a9fb1b4ea451bbbf2f01703a93897515092ceefc6bef5157bbac2`. +3. `test/architecture/hosted-web/phase-1/contracts/identifiers.test.ts` — SHA-256 + `7e8b88816245e95921dbcef8c444c572ec2d610cf32dee6d4934a65d33d63b42`. +4. `test/architecture/hosted-web/phase-2/identity/canonical-identifiers.test.ts` — SHA-256 + `19c327b55a2d407c623e8eae6bb60d20913eca60d737499f161d180d212ee511`. +5. `.codex-handoff/phase-02-p2-f0.json` — SHA-256 + `53ce2854cad51ac973775d9d43e5573a6d87e1708ae672dc80d7fda9bb6ade3f`. + +The five candidate paths are staged and have no unstaged byte differences. The reviewer has written +only this report and `.codex-handoff/phase-02-p2-r0.json`; the final workspace scope is therefore the +five immutable candidate paths plus exactly two reviewer evidence paths, seven paths total. No +candidate source, test, or producer-handoff byte was altered. + +## Architecture and identity gate + +1. **Opaque and kind-separated bytes — pass.** `TeamId` and `WorkspaceId` are distinct branded string + types. Canonical parsers require the kind prefix and exactly 32 lowercase hexadecimal payload + bytes. The focused type assertion proves compile-time separation; cross-kind values fail at + runtime. The payload is accepted only as an opaque value and is never decoded or interpreted. +2. **Bounded fail-closed parsing — pass.** Parsing performs a string type check, exact total-length + check, exact prefix check, and anchored fixed-length payload check. It has no unbounded scan, + normalization, coercion, truncation, fallback, or exception reflection. Failures expose only the + static `hosted-contract-canonical-identifier-invalid` diagnostic. +3. **Byte-stable serialization — pass.** Valid canonical TeamId and WorkspaceId values survive JSON + serialization, parse, canonical reparse, and reserialization byte-for-byte. The parser returns the + original primitive string bytes. +4. **Negative identity controls — pass.** The focused suite rejects non-strings, empty, short, long, + uppercase, leading/trailing/embedded whitespace, display-name, slug/name, POSIX path, Windows + path, traversal-shaped, and opposite-kind values for both ID kinds. +5. **Phase 1 compatibility — pass.** The original actor/session/deployment/boot/request fixtures + remain unchanged and green. `parseSyntheticTeamId` remains an explicitly named compatibility + surface for frozen Phase 1 list-contract values; the same synthetic value is rejected by the new + canonical TeamId parser. The existing Phase 1 team-lifecycle contract continues to import only + the explicit synthetic parser. +6. **No mutable-data derivation — pass.** Neither production source file accepts, reads, hashes, or + transforms `teamName`, display name, legacy directory key, `projectPath`, root, registration key, + or any other mutable name/path into identity. The change only brands and validates caller-supplied + canonical bytes. +7. **Dependency and process boundaries — pass.** The identifier module has no imports. The shared + barrel only re-exports browser-safe types and parsers through relative contract paths. There is no + filesystem, persistence, repository, clock, random generator, crypto allocator, transport, IPC, + HTTP, Electron, Fastify, main-process, composition, provider, or runtime dependency or side + effect. This is a bounded value/validation responsibility consistent with Clean Architecture, + DDD, and SOLID. +8. **Security and private-path safety — pass.** The candidate is five regular ASCII/JSON text files + with no symlink or binary. Classified scans found no secret, credential, token, cookie, bearer, + authorization payload, private key, provider payload, private/home/task-temporary path, or real + project path. The `/srv/...` and `C:\\...` strings in the test are synthetic rejection canaries, + not observed private paths. +9. **Producer self-review — pass.** The P2.F0 handoff records exact base/revision, five-path scope, + commands and exit codes, proof levels, positive and named negative cases, explicit unverified + claims, zero blockers/findings, full Clean Architecture/DDD/SOLID/security/scope self-review, and + strict `terminalState: HOLD`. It does not claim independent P2.R0 acceptance or integration. + +Canonical allocation, uniqueness, entropy and collision handling are intentionally not implemented +by this parser foundation. Restart, rename, remount, persistence, registry and legacy-adoption +stability also remain explicitly unverified for their later owning lanes. These are not P2.F0 gate +failures and no broader behavior is inferred. + +## Independent replay + +- Focused Vitest command: exit `0`; Vitest 3.2.6; 2/2 files and 36/36 tests passed. +- Exact four-TypeScript-path fast lint: exit `0`; no lint error. +- `pnpm typecheck`: exit `1`; exactly seven diagnostics, all inherited from the frozen Phase 0 + baseline, zero P2.F0-owned and zero new/unexpected diagnostics. +- Exact five-path Prettier check: exit `0`. +- `git diff --check`: exit `0`; because the immutable candidate is staged, the material check + `git diff --cached --check` also exited `0`. +- Exact ownership/status proof: exit `0`; exactly the five declared P2.F0 paths existed before the + reviewer outputs. +- Candidate secret/credential scan: exit `0`, two match lines, both the scan command and its benign + classification in the producer handoff. +- Candidate private-path scan: exit `0`, two match lines, both the scan command and its benign + classification in the producer handoff. +- Additional high-confidence secret/private-key scan: no match. +- Forbidden-dependency scan of the two product contract files: no match; the mutable-data scan found + only the compatibility comment's word `legacy`. + +The exact inherited typecheck classification is: + +1. `test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts:25:8` + `TS7016` — missing declaration for the Phase 0 `auth-artifacts-spike.mjs` module; implicit `any`. +2. The same file at `66:31`, `TS7031` — binding element `code` implicitly has type `any`. +3. The same file at `117:68`, `TS18046` — `session` is of type `unknown`. +4. The same file at `413:48`, `TS7031` — binding element `operation` implicitly has type `any`. +5. The same file at `733:10`, `TS7031` — binding element `artifactId` implicitly has type `any`. +6. `test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts:12:8` + `TS7016` — missing declaration for the Phase 0 `scan-evidence.mjs` module; implicit `any`. +7. `test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts:162:44` + `TS2352` — the synthetic `providerBackends.gemini` fixture value does not overlap the accepted + `RuntimeConfig` union. + +All seven file/line/column/code/normalized-message identities match the checked-in inherited +diagnostic record and accepted Phase 1 integration/review evidence. No diagnostic names a P2.F0 or +P2.R0 path. + +## Findings, disposition, and HOLD + +P0 findings: none. + +P1 findings: none. + +P2 findings: none. + +Every P2.F0 architecture/security gate is proved by the current candidate source, complete diff, +focused replay, baseline classification, scans, and complete producer self-review. The formal +disposition is ACCEPT with zero unresolved P0/P1 findings. + +The only next action is `P2.IF.INTEGRATION`, which must reconcile and integrate these accepted bytes +without mutation. This review does not commit, push, integrate, launch a successor, claim remote +equality, or claim Phase 2 milestone acceptance. Terminal state remains HOLD. diff --git a/docs/research/hosted-web/phase-2/reviews/parallel-wave-architecture-security.md b/docs/research/hosted-web/phase-2/reviews/parallel-wave-architecture-security.md new file mode 100644 index 0000000000..63b51994df --- /dev/null +++ b/docs/research/hosted-web/phase-2/reviews/parallel-wave-architecture-security.md @@ -0,0 +1,202 @@ +# P2.R1 parallel-wave architecture and security review + +Disposition: ACCEPT + +Finding counts: P0 0 / P1 0 / P2 0. + +Terminal state: HOLD. + +## Authority, independence, and immutable input + +This is an independent combined architecture/security review of the five broker-approved Phase 2 +parallel lanes P2.A through P2.E. The reviewer did not produce or repair any lane, used no subagent +or additional reviewer, and changed none of the 35 aggregate product, test, or producer-handoff +paths. + +The reviewed worktree HEAD is the required canonical base +`bd6ac038c920180ee5398b96f2dbdc3d6f035e77`. The broker aggregate is 531,340 bytes with ordered +SHA-256 `d4f80a5c60f9fc7925ccaeb4480cf4349bc3d267d71e76413d3f4b41b402fade`; its provenance SHA-256 is +`172f0a87e6607d1a9fe2f88d57ccdb9644049250a938502d41b2910a11f97d3f`. The independently +recomputed hash of the materialized staged Git diff is +`a322c6c8312ca9163b7e4578bc0b8cb59239b8cc0e443edaf6b81e00a42d1468`, matching the broker's +pre-start admission receipt. The aggregate artifact and the staged reconstruction are separate +canonical encodings, so their hashes are intentionally recorded separately. + +The canonical navigation, current Phase 2 controller and execution DAG, all five lane packets and +handoffs, the complete 13,738-line aggregate patch, every current version of the 35 changed paths, +the full diff of the one modified pre-existing path, and the foundation integration evidence were +read. The accepted identifier foundation bytes remain the recorded +`73978dd8871f3af363810b9a90b4a42b464982a25898eac082677b9557d1dc41` identifier module and +`a11b722edd3a9fb1b4ea451bbbf2f01703a93897515092ceefc6bef5157bbac2` hosted-contract entrypoint. +No authority, base, dependency, or packet-revision mismatch was found. + +## Five-lane validity and ownership proof + +The broker provenance declares exactly five ordered reviewed outputs: + +| Lane | Reviewed output ID | Paths | Producer self-review | Findings | +| ---- | ------------------------------------------------------------------ | ----: | -------------------- | -------- | +| P2.A | `58ac7a2cf712aec3254f8184ce6217def09a3b3200e0921f726a4f2f902b75f6` | 5 | complete, HOLD | 0/0/0 | +| P2.B | `684bd31df38eaba57259ead6003a8596d37442bd58fc797961d51b2a2395d21d` | 5 | complete, HOLD | 0/0/0 | +| P2.C | `349546a6232433cc96381f109cfd24996230b23b2a99c36fec6a52843adc9d09` | 8 | complete, HOLD | 0/0/0 | +| P2.D | `ba27e423827f9923b48157338dafbe2765a6ec405c9dc8ba2bf747fe46e8c47f` | 7 | complete, HOLD | 0/0/0 | +| P2.E | `e3e56150f4fe8eed39c0504011ffb44d3b17610324420c5d2fb3394b34a5b2b7` | 10 | complete, HOLD | 0/0/0 | + +The lane cardinalities sum to 35 and the union contains 35 unique paths. All ten pairwise +intersections are empty. The five producer handoffs have the exact canonical base, report `status` +as `verified` and `terminalState` as `HOLD`, contain complete self-review fields and empty blocker +arrays, and record zero P0/P1/P2 findings. + +Sibling independence is also proved semantically, not only by path arithmetic. No aggregate path is +a shared feature barrel, bootstrap, composition root, Electron/preload/renderer surface, IPC/HTTP +route, or provider integration. Product import inspection found no lane consuming another sibling's +implementation output. P2.D depends on its own application ports plus the accepted identifier +kernel, not P2.B/P2.C implementation source; P2.E's internal read use cases and adapter depend only +on its feature contracts, existing list use case, and shared QueryContext/identifier contracts. +Serial composition remains owned by P2.I. + +## Architecture and security gates + +### Opaque identity and bounded contracts + +- P2.A uses the accepted opaque `DeploymentId` and `BootId` types and opaque branded root + references. Exact-record validation rejects unknown fields, custom prototypes, excessive root + references, excessive workspace-reference counts, and mutable caller-owned collections. Returned + state is defensively copied and deeply frozen. +- P2.B stores canonical `TeamId` and `WorkspaceId` values and an exact bounded ASCII legacy key; the + key grammar rejects paths and Windows reserved names. Its SQLite schema enforces unique identity, + legacy-key, directory-fingerprint, and published-checksum ownership and rejects illegal state + transitions or physical record deletion. +- P2.C derives stable registration keys/root fingerprints independently of display names and issues + exact boot-scoped mount generations. Registration and operation arrays are bounded before + iteration and must be dense and exact. +- P2.E accepts canonical opaque identifiers in exact request records. Response projection copies + only known fields, enforces list caps and uniqueness, and never exposes a legacy directory key, + raw path, provider payload, credential, or unbounded additive object. + +### Safe-root admission and TOCTOU defense + +P2.C admits only caller-supplied roots carrying a fresh owned marker and descriptor-derived evidence; +it rejects unmarked/pre-existing roots, ambient temporary roots, home/current/real-project roots, +lexical escapes, parent/final symlinks, and cleanup-marker mismatches before reading the startup +manifest. Authorization revalidates the binding synchronously at operation time and creates the +private-field operation intent without an await between revalidation, policy checks, and intent +creation. The production live revalidator and composition are explicitly deferred rather than +falsely claimed. + +P2.D repeats safe-root and marker checks, uses no-follow descriptor bindings, verifies containment +and `(dev, ino)` identity before and after relevant effects, and fails closed on link, type, owner, +size, or metadata drift. Leaf-swap tests prove that validated and replacement objects remain +distinguishable and recoverable; outside-root symlink sentinels are unchanged. + +### Durable publication and recovery + +P2.B implements the durable `prepared -> file_published -> committed` protocol with exact checksums, +timestamps, uniqueness, transactional state transitions, tombstones, and restart/retry/tamper +tests. P2.D publishes `team.identity.json` with exclusive no-follow create and mode `0600`, writes and +fsyncs the file, fsyncs the containing team directory, and re-proves the bound directory. Commit is +permitted only after publication evidence has been durably recorded and freshly reread as +`file_published`; missing, mismatched, or tampered evidence blocks. A committed-but-missing identity +file never silently republishes. + +### Previously identified risk 1: bounded identity reads + +Resolved and independently rechecked. `TeamIdentityFileStore` fixes identity input at 4 KiB and root +markers at 2 KiB. Its reader allocates only `maxBytes + 1`, reads at most that capacity, and never +allocates from attacker-controlled file size. It opens with no-follow semantics, verifies regular +single-link descriptor and parent binding, performs capped reads, restats the file and parent, and +rejects over-cap input or any `(dev, ino, size, mtime, ctime)` drift before UTF-8 decoding or JSON +parsing. The directory lifecycle reader applies the same bounded pattern to 2 KiB markers and 4 KiB +attempt-ownership records. Oversize, sparse, symlink, link-count, replacement, and metadata-race +cases fail closed in the focused P2.D suite. + +### Previously identified risk 2: deletion-free invisible removal + +Resolved and independently rechecked. There is no `rm`, `unlink`, or `rmdir` call in any P2.D +production path. Team removal first requires a durable tombstone, descriptor-proves the parent and +the original team directory, and atomically renames that exact directory to +`/.p2-d-removal-quarantine/`. The fixed hidden container is config-less at its own +level; the config-bearing team directory is one level below it, so the real nonrecursive +`TeamConfigReader` does not list it. + +When the container is first created, P2.D opens and re-proves it, fsyncs the container, then fsyncs +the parent before use. After the team rename it proves the original name absent, proves the moved +entry is not a symlink, proves its `(dev, ino)` equals the still-open expected directory descriptor, +proves the parent and container bindings and canonical containment, fsyncs the container, then +fsyncs the parent, and repeats the complete moved-inode/container re-proof. Thus both the namespace +move and its ordering are durably evidenced before success. + +The focused test imports the real `TeamConfigReader`: it sees the team before removal, sees no team +after removal, sees only the hidden config-less container directly under the team root, and proves +that nested `config.json` plus a sentinel remain readable. It then manually renames the nested team +directory out and re-reads the sentinel, demonstrating recoverability. Separate team and attempt +leaf-swap tests retain both validated and replacement inode sentinels. Tombstone refusal leaves the +logical directory in place, and a durable tombstone prevents legacy-key reuse. Physical quarantine +garbage collection remains deliberately outside this lane; no hidden physical delete is present. + +### QueryContext, reads, and transport boundary + +P2.E's legacy read source executes synchronous authorization first, then cancellation, then deadline +validation before every identity, legacy-data, or runtime I/O. After every awaited read it repeats +preflight before any follow-up I/O. The exact same `QueryContext` object is passed to each port. +Tests prove unauthorized, already-cancelled, and expired requests perform zero I/O, and that +cancellation or deadline expiry after an awaited step prevents the next read. Returned legacy team +and runtime/alive bindings must match the canonical requested identity; mismatches fail closed. + +The read API has four read-only operations and contains no route, status-code, IPC, HTTP, Electron, +Fastify, renderer, callback, process, filesystem, mutation, overlay, cache invalidation, or provider +semantics. No aggregate path wires hosted composition or adds a hidden hosted mutation. + +### Leakage review + +High-confidence private-key/API-token signatures produced no candidate payload match. Apparent secret +and absolute-path matches are only literal scanner patterns recorded in producer handoffs. The only +provider-word product matches are the pre-existing domain name `claudeRoot` and its root-kind literal; +they carry an opaque runtime root reference, not a provider credential or payload. The P2.D internal +`rootPath` is a main-process admission input, not a browser DTO, and is never returned by P2.E. +Classified scans and manual DTO/error review found no secret, credential, token, cookie, bearer, +authorization payload, private/home/task path, real-project path, legacy directory key, provider +payload, or unsafe reflected exception in an exposed contract or evidence artifact. + +## Independent replay + +Every required lane check was replayed on the canonical aggregate: + +- P2.A focused suite: 2/2 files, 37/37 tests; foundation regression: 2/2 files, 36/36 tests. +- P2.B focused suite: 1/1 file, 17/17 tests; worker-core regression: 1/1 file, 14/14 tests. +- P2.C focused suite: 3/3 files, 28/28 tests. +- P2.D focused suite: 2/2 files, 25/25 tests, including both remediated security risks. +- P2.E focused suite: 3/3 files, 22/22 tests; Phase 1 lifecycle regressions: 2/2 files, 16/16 + tests; QueryContext regression: 1/1 file, 1/1 test. +- Total: 17/17 test files and 196/196 tests passed under Vitest 3.2.6. +- All five exact lane `lint:fast:files` commands passed (4, 4, 7, 6, and 9 TypeScript paths). +- `pnpm typecheck` passed with zero diagnostics. +- The exact aggregate 35-path Prettier check passed. +- `git diff --check`, `git diff --cached --check`, and `git diff HEAD --check` passed. +- Ownership arithmetic proved counts `5/5/8/7/10`, 35 total, 35 unique, and ten empty + pairwise intersections. Import/path scans proved no shared entrypoint or sibling implementation + dependency. +- Aggregate secret, private-path, provider, transport, destructive-delete, and unsafe DTO scans were + completed and every textual match was manually classified as above. + +## Findings, limitations, and disposition + +P0 findings: none. + +P1 findings: none. + +P2 findings: none. + +Exactly five valid, independently self-reviewed, pairwise-disjoint lanes are present. Their current +bytes, focused and regression tests, architecture boundaries, durability protocol, race defenses, +bounded reads, removal quarantine, QueryContext enforcement, transport neutrality, and leakage +controls satisfy P2.R1 with no unresolved P0 or P1. The formal disposition is ACCEPT. + +Production composition, the concrete cross-lane adapters, live filesystem revalidation/openat2 +topology, transport wiring, backup recovery as a coordinated recovery point, and quarantine +retention/garbage collection remain explicitly unverified and owned by later work. This review does +not infer activation from isolated lane evidence. + +The only authorized next controller action is P2.I serial integration. This review does not commit, +push, integrate, launch a successor, claim remote equality, or claim Phase 2 activation or milestone +acceptance. Terminal state remains strict HOLD. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d3b494caa6..12b3e4be02 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -251,7 +251,9 @@ export default defineConfig({ }, output: { format: 'cjs', - entryFileNames: '[name].js' + // The repository root is type=module, so the preload must retain an + // explicit CommonJS extension for Electron to execute its require(). + entryFileNames: '[name].cjs' } } } diff --git a/eslint.config.js b/eslint.config.js index 6795ed741b..a09d0886a2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -385,7 +385,7 @@ export default defineConfig([ '@features/agent-graph/renderer/**', ], message: - 'Import agent-graph only through its public entrypoint: @features/agent-graph/renderer.', + 'Import agent-graph only through public entrypoints: @features/agent-graph or @features/agent-graph/renderer.', }, ], }, diff --git a/landing/package.json b/landing/package.json index bda58f220a..776d18bb0d 100644 --- a/landing/package.json +++ b/landing/package.json @@ -65,11 +65,11 @@ "js-yaml@4": "4.3.1", "linkify-it": "5.0.2", "minimatch@9": "9.0.7", + "nanoid@3": "3.3.18", "postcss": "8.5.23", "shell-quote": "1.10.0", "svgo": "4.0.2", "tar": "7.5.22", - "axios": "1.18.1", - "nanoid@3": "3.3.18" + "axios": "1.18.1" } } diff --git a/native/hosted/instance-lock/agent_teams_instance_lock.c b/native/hosted/instance-lock/agent_teams_instance_lock.c new file mode 100644 index 0000000000..f13f9f9210 --- /dev/null +++ b/native/hosted/instance-lock/agent_teams_instance_lock.c @@ -0,0 +1,478 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef CLOSE_RANGE_CLOEXEC +#define CLOSE_RANGE_CLOEXEC (1U << 2) +#endif + +enum { + INSTANCE_LEASE_FD = 3, + INSTANCE_LEASE_CONTROL_FD = 4, + EXIT_USAGE = 64, + EXIT_LEASE_BUSY = 73, + EXIT_ANCHOR_REJECTED = 74, + EXIT_CHILD_LAUNCH_FAILED = 75, +}; + +static volatile sig_atomic_t child_pid_for_signal = -1; + +#ifdef AGENT_TEAMS_INSTANCE_LOCK_TEST_HOOKS +static int test_reap_gate_fd = -1; +static int test_forward_audit_fd = -1; + +static int test_fd_from_environment(const char *name) { + const char *value = getenv(name); + if (value == NULL || value[0] == '\0') { + return -1; + } + char *end = NULL; + errno = 0; + const long parsed = strtol(value, &end, 10); + if (errno != 0 || end == value || *end != '\0' || parsed < 0 || parsed > INT_MAX) { + return -1; + } + return (int)parsed; +} + +static void configure_test_hooks(void) { + test_reap_gate_fd = test_fd_from_environment("AGENT_TEAMS_TEST_REAP_GATE_FD"); + test_forward_audit_fd = + test_fd_from_environment("AGENT_TEAMS_TEST_FORWARD_AUDIT_FD"); +} + +static void pause_at_unreaped_child_boundary(void) { + if (test_reap_gate_fd < 0) { + return; + } + const char ready = 'R'; + if (write(test_reap_gate_fd, &ready, 1) != 1) { + return; + } + char release = '\0'; + ssize_t result; + do { + result = read(test_reap_gate_fd, &release, 1); + } while (result == -1 && errno == EINTR); + if (result != 1 || release != 'G') { + _exit(EXIT_CHILD_LAUNCH_FAILED); + } +} +#else +static void configure_test_hooks(void) {} +static void pause_at_unreaped_child_boundary(void) {} +#endif + +static void write_diagnostic(const char *message) { + const size_t length = strlen(message); + ssize_t ignored = write(STDERR_FILENO, message, length); + (void)ignored; +} + +static void forward_signal(int signal_number) { + const int saved_errno = errno; + const pid_t child_pid = (pid_t)child_pid_for_signal; + if (child_pid > 0) { +#ifdef AGENT_TEAMS_INSTANCE_LOCK_TEST_HOOKS + if (test_forward_audit_fd >= 0) { + const char forwarded = 'F'; + ssize_t ignored = write(test_forward_audit_fd, &forwarded, 1); + (void)ignored; + } +#endif + (void)kill(child_pid, signal_number); + } + errno = saved_errno; +} + +static int reject_anchor(const char *message) { + fprintf(stderr, "instance_lock:anchor_rejected:%s\n", message); + return EXIT_ANCHOR_REJECTED; +} + +static bool parse_kernel_identity(const char *value, uintmax_t *result) { + if (value == NULL || value[0] == '\0' || value[0] == '-') { + return false; + } + + char *end = NULL; + errno = 0; + const uintmax_t parsed = strtoumax(value, &end, 10); + if (errno != 0 || end == value || *end != '\0') { + return false; + } + *result = parsed; + return true; +} + +static bool is_safe_anchor_name(const char *name) { + return name != NULL && name[0] != '\0' && strcmp(name, ".") != 0 && + strcmp(name, "..") != 0 && strchr(name, '/') == NULL; +} + +static bool parent_is_safe(const struct stat *parent_stat) { + return S_ISDIR(parent_stat->st_mode) && parent_stat->st_uid == 0 && + (parent_stat->st_mode & (S_IWGRP | S_IWOTH)) == 0; +} + +static bool anchor_is_safe(const struct stat *anchor_stat, uintmax_t expected_device, + uintmax_t expected_inode) { + return S_ISREG(anchor_stat->st_mode) && anchor_stat->st_uid == 0 && + (anchor_stat->st_mode & (S_IWGRP | S_IWOTH)) == 0 && + anchor_stat->st_nlink == 1 && (uintmax_t)anchor_stat->st_dev == expected_device && + (uintmax_t)anchor_stat->st_ino == expected_inode; +} + +static bool path_still_names_anchor(int parent_fd, const char *anchor_name, + const struct stat *opened_anchor_stat) { + struct stat path_stat; + if (fstatat(parent_fd, anchor_name, &path_stat, AT_SYMLINK_NOFOLLOW) == -1) { + return false; + } + return S_ISREG(path_stat.st_mode) && path_stat.st_uid == 0 && path_stat.st_nlink == 1 && + path_stat.st_dev == opened_anchor_stat->st_dev && + path_stat.st_ino == opened_anchor_stat->st_ino; +} + +static int mark_all_non_protocol_fds_close_on_exec(void) { +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, ~0U, CLOSE_RANGE_CLOEXEC) == 0) { + return 0; + } + if (errno != ENOSYS && errno != EINVAL) { + return -1; + } +#endif + + struct rlimit limit; + if (getrlimit(RLIMIT_NOFILE, &limit) == -1) { + return -1; + } + rlim_t maximum = limit.rlim_cur; + if (maximum == RLIM_INFINITY) { + maximum = 1024U * 1024U; + } + for (int fd = 3; (rlim_t)fd < maximum; fd += 1) { + const int flags = fcntl(fd, F_GETFD); + if (flags >= 0 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == -1) { + return -1; + } + if (flags == -1 && errno != EBADF) { + return -1; + } + } + return 0; +} + +static int install_reserved_fd(int source_fd, int reserved_fd) { + if (source_fd == reserved_fd) { + const int flags = fcntl(source_fd, F_GETFD); + return flags == -1 ? -1 : fcntl(source_fd, F_SETFD, flags & ~FD_CLOEXEC); + } + return dup3(source_fd, reserved_fd, 0); +} + +static int restore_child_signal_state(const sigset_t *controller_mask) { + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = SIG_DFL; + sigemptyset(&action.sa_mask); + if (sigaction(SIGTERM, &action, NULL) == -1 || sigaction(SIGINT, &action, NULL) == -1 || + sigprocmask(SIG_SETMASK, controller_mask, NULL) == -1) { + return -1; + } + return 0; +} + +static void report_child_setup_error_and_exit(int error_fd, int error_number) { + const int value = error_number == 0 ? EIO : error_number; + ssize_t ignored = write(error_fd, &value, sizeof(value)); + (void)ignored; + _exit(EXIT_CHILD_LAUNCH_FAILED); +} + +static void exec_controller(int lease_fd, int control_read_fd, int control_write_fd, + int start_read_fd, int start_write_fd, int error_read_fd, + int error_write_fd, char *const child_argv[], + const sigset_t *controller_mask) { + (void)close(control_write_fd); + (void)close(start_write_fd); + (void)close(error_read_fd); + + char start_byte = '\0'; + ssize_t start_length; + do { + start_length = read(start_read_fd, &start_byte, 1); + } while (start_length == -1 && errno == EINTR); + if (start_length != 1 || start_byte != 'G') { + report_child_setup_error_and_exit(error_write_fd, EPROTO); + } + (void)close(start_read_fd); + + const int child_lease_fd = fcntl(lease_fd, F_DUPFD_CLOEXEC, 200); + if (child_lease_fd == -1) { + report_child_setup_error_and_exit(error_write_fd, errno); + } + const int child_control_fd = fcntl(control_read_fd, F_DUPFD_CLOEXEC, 200); + if (child_control_fd == -1) { + report_child_setup_error_and_exit(error_write_fd, errno); + } + + if (mark_all_non_protocol_fds_close_on_exec() == -1 || + install_reserved_fd(child_lease_fd, INSTANCE_LEASE_FD) == -1 || + install_reserved_fd(child_control_fd, INSTANCE_LEASE_CONTROL_FD) == -1) { + report_child_setup_error_and_exit(error_write_fd, errno); + } + + if (restore_child_signal_state(controller_mask) == -1) { + report_child_setup_error_and_exit(error_write_fd, errno); + } + execv(child_argv[0], child_argv); + report_child_setup_error_and_exit(error_write_fd, errno); +} + +static bool write_all(int fd, const char *buffer, size_t length) { + size_t written = 0; + while (written < length) { + const ssize_t result = write(fd, buffer + written, length - written); + if (result > 0) { + written += (size_t)result; + continue; + } + if (result == -1 && errno == EINTR) { + continue; + } + return false; + } + return true; +} + +static int child_exit_status(int status) { + if (WIFEXITED(status)) { + return WEXITSTATUS(status); + } + if (WIFSIGNALED(status)) { + return 128 + WTERMSIG(status); + } + return EXIT_CHILD_LAUNCH_FAILED; +} + +static int wait_for_child_without_pid_reuse(pid_t child_pid, + const sigset_t *forwarding_signals) { + siginfo_t child_info; + memset(&child_info, 0, sizeof(child_info)); + int wait_result; + do { + wait_result = waitid(P_PID, (id_t)child_pid, &child_info, WEXITED | WNOWAIT); + } while (wait_result == -1 && errno == EINTR); + if (wait_result == -1) { + sigset_t ignored_mask; + (void)sigprocmask(SIG_BLOCK, forwarding_signals, &ignored_mask); + child_pid_for_signal = -1; + return EXIT_CHILD_LAUNCH_FAILED; + } + + sigset_t mask_before_reap; + if (sigprocmask(SIG_BLOCK, forwarding_signals, &mask_before_reap) == -1) { + child_pid_for_signal = -1; + return EXIT_CHILD_LAUNCH_FAILED; + } + child_pid_for_signal = -1; + pause_at_unreaped_child_boundary(); + + int status = 0; + while (waitpid(child_pid, &status, 0) == -1) { + if (errno != EINTR) { + (void)sigprocmask(SIG_SETMASK, &mask_before_reap, NULL); + return EXIT_CHILD_LAUNCH_FAILED; + } + } + if (sigprocmask(SIG_SETMASK, &mask_before_reap, NULL) == -1) { + return EXIT_CHILD_LAUNCH_FAILED; + } + return child_exit_status(status); +} + +int main(int argc, char **argv) { + configure_test_hooks(); + if (argc < 7 || strcmp(argv[5], "--") != 0 || argv[1][0] != '/' || + argv[6][0] != '/' || !is_safe_anchor_name(argv[2])) { + fprintf(stderr, + "usage: %s ABS_PARENT ANCHOR EXPECTED_DEVICE EXPECTED_INODE -- " + "ABS_NODE [ARG ...]\n", + argv[0]); + return EXIT_USAGE; + } + + uintmax_t expected_device = 0; + uintmax_t expected_inode = 0; + if (!parse_kernel_identity(argv[3], &expected_device) || + !parse_kernel_identity(argv[4], &expected_inode) || expected_inode == 0) { + return reject_anchor("invalid_expected_identity"); + } + + sigset_t forwarding_signals; + sigemptyset(&forwarding_signals); + sigaddset(&forwarding_signals, SIGTERM); + sigaddset(&forwarding_signals, SIGINT); + // A caller's inherited mask must not disable bounded shutdown. Until a child + // exists, the default TERM/INT dispositions safely release any acquired FD. + if (sigprocmask(SIG_UNBLOCK, &forwarding_signals, NULL) == -1) { + return reject_anchor("signal_unmask_failed"); + } + + const int parent_fd = open(argv[1], O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (parent_fd == -1) { + return reject_anchor("parent_open_failed"); + } + struct stat parent_stat; + if (fstat(parent_fd, &parent_stat) == -1 || !parent_is_safe(&parent_stat)) { + (void)close(parent_fd); + return reject_anchor("unsafe_parent"); + } + + const int lease_fd = + openat(parent_fd, argv[2], O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); + if (lease_fd == -1) { + (void)close(parent_fd); + return reject_anchor("anchor_open_failed"); + } + struct stat anchor_stat; + if (fstat(lease_fd, &anchor_stat) == -1 || + !anchor_is_safe(&anchor_stat, expected_device, expected_inode) || + !path_still_names_anchor(parent_fd, argv[2], &anchor_stat)) { + (void)close(lease_fd); + (void)close(parent_fd); + return reject_anchor("unsafe_or_replaced_anchor"); + } + + if (flock(lease_fd, LOCK_EX | LOCK_NB) == -1) { + const int lock_errno = errno; + (void)close(lease_fd); + (void)close(parent_fd); + if (lock_errno == EWOULDBLOCK || lock_errno == EAGAIN) { + write_diagnostic("instance_lock:lease_busy\n"); + return EXIT_LEASE_BUSY; + } + return reject_anchor("lock_failed"); + } + + if (!path_still_names_anchor(parent_fd, argv[2], &anchor_stat)) { + (void)close(lease_fd); + (void)close(parent_fd); + return reject_anchor("anchor_changed_after_lock"); + } + + int control_pipe[2]; + int start_pipe[2]; + int exec_error_pipe[2]; + if (pipe2(control_pipe, O_CLOEXEC | O_NONBLOCK) == -1 || + pipe2(start_pipe, O_CLOEXEC) == -1 || + pipe2(exec_error_pipe, O_CLOEXEC) == -1) { + (void)close(lease_fd); + (void)close(parent_fd); + return reject_anchor("pipe_setup_failed"); + } + + sigset_t controller_mask; + if (sigprocmask(SIG_BLOCK, &forwarding_signals, &controller_mask) == -1) { + return reject_anchor("signal_mask_failed"); + } + + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = forward_signal; + sigemptyset(&action.sa_mask); + if (sigaction(SIGTERM, &action, NULL) == -1 || sigaction(SIGINT, &action, NULL) == -1) { + return reject_anchor("signal_handler_failed"); + } + action.sa_handler = SIG_DFL; + if (sigaction(SIGCHLD, &action, NULL) == -1) { + return reject_anchor("child_signal_handler_failed"); + } + + const pid_t child_pid = fork(); + if (child_pid == -1) { + return reject_anchor("fork_failed"); + } + if (child_pid == 0) { + exec_controller(lease_fd, control_pipe[0], control_pipe[1], start_pipe[0], start_pipe[1], + exec_error_pipe[0], exec_error_pipe[1], &argv[6], &controller_mask); + } + + child_pid_for_signal = (sig_atomic_t)child_pid; + if (sigprocmask(SIG_SETMASK, &controller_mask, NULL) == -1) { + (void)kill(child_pid, SIGKILL); + (void)wait_for_child_without_pid_reuse(child_pid, &forwarding_signals); + return reject_anchor("signal_unmask_failed"); + } + (void)close(control_pipe[0]); + (void)close(start_pipe[0]); + (void)close(exec_error_pipe[1]); + (void)close(parent_fd); + + char evidence[512]; + const int evidence_length = snprintf( + evidence, sizeof(evidence), + "{\"protocolVersion\":1,\"launcherPid\":%ld,\"controllerPid\":%ld," + "\"device\":\"%" PRIuMAX "\",\"inode\":\"%" PRIuMAX + "\",\"mode\":%ju,\"uid\":%ju,\"nlink\":%ju}\n", + (long)getpid(), (long)child_pid, (uintmax_t)anchor_stat.st_dev, + (uintmax_t)anchor_stat.st_ino, (uintmax_t)anchor_stat.st_mode, + (uintmax_t)anchor_stat.st_uid, (uintmax_t)anchor_stat.st_nlink); + if (evidence_length <= 0 || (size_t)evidence_length >= sizeof(evidence) || + !write_all(control_pipe[1], evidence, (size_t)evidence_length)) { + (void)kill(child_pid, SIGKILL); + (void)wait_for_child_without_pid_reuse(child_pid, &forwarding_signals); + (void)close(control_pipe[1]); + (void)close(exec_error_pipe[0]); + (void)close(lease_fd); + return EXIT_CHILD_LAUNCH_FAILED; + } + if (!write_all(start_pipe[1], "G", 1)) { + (void)kill(child_pid, SIGKILL); + (void)wait_for_child_without_pid_reuse(child_pid, &forwarding_signals); + (void)close(start_pipe[1]); + (void)close(control_pipe[1]); + (void)close(exec_error_pipe[0]); + (void)close(lease_fd); + return EXIT_CHILD_LAUNCH_FAILED; + } + (void)close(start_pipe[1]); + + int exec_error = 0; + ssize_t exec_error_length; + do { + exec_error_length = read(exec_error_pipe[0], &exec_error, sizeof(exec_error)); + } while (exec_error_length == -1 && errno == EINTR); + (void)close(exec_error_pipe[0]); + if (exec_error_length != 0) { + (void)wait_for_child_without_pid_reuse(child_pid, &forwarding_signals); + (void)close(control_pipe[1]); + (void)close(lease_fd); + fprintf(stderr, "instance_lock:child_exec_failed:%d\n", + exec_error_length == (ssize_t)sizeof(exec_error) ? exec_error : EIO); + return EXIT_CHILD_LAUNCH_FAILED; + } + + const int child_status = wait_for_child_without_pid_reuse(child_pid, &forwarding_signals); + (void)close(control_pipe[1]); + (void)close(lease_fd); + return child_status; +} diff --git a/opencode-hosted-runtime.lock.json b/opencode-hosted-runtime.lock.json new file mode 100644 index 0000000000..72db252199 --- /dev/null +++ b/opencode-hosted-runtime.lock.json @@ -0,0 +1,62 @@ +{ + "schemaVersion": 2, + "runtime": "opencode", + "version": "1.18.4-agentteams.1", + "tag": "v1.18.4-agentteams.1", + "productionEligible": false, + "source": { + "repository": "777genius/opencode-anomaly", + "baseCommit": "49c69c5ed3ccf706b61b3febb43c8aaff7f8325e", + "commit": "476b667c385210b19fbd15bcb57456cacb0ae9e7", + "reviewedPatchSha256": "dbd8b2c1eda38043e3bfc9e2b809f4ef393fa075349ed219109a7deaca0c590e" + }, + "releaseRepository": "777genius/opencode-anomaly", + "platforms": { + "darwin-arm64": { + "status": "available", + "file": "opencode-darwin-arm64.zip", + "archiveKind": "zip", + "binaryName": "opencode", + "archiveSha256": "152c615f6dd54162924e82b6127fabc4fab4e6bd99ba9c24e0ae91e31c381081", + "binarySha256": "bea0796d34281f4515bb8a53ed8e2b8a18a3110b8b55819a73984008f69b25ef", + "assetUrl": "https://github.com/777genius/opencode-anomaly/releases/download/v1.18.4-agentteams.1/opencode-darwin-arm64.zip" + }, + "darwin-x64": { + "status": "available", + "file": "opencode-darwin-x64.zip", + "archiveKind": "zip", + "binaryName": "opencode", + "archiveSha256": "5103b67017174d0a309f66c2bdf60142b7e8f4e2f25486fbfb57e96e6dcf3054", + "binarySha256": "1059aa3b7edcda0802cdcd3ad238264c743551d321b00e76a227322a5d0cb849", + "assetUrl": "https://github.com/777genius/opencode-anomaly/releases/download/v1.18.4-agentteams.1/opencode-darwin-x64.zip" + }, + "linux-arm64": { + "status": "available", + "file": "opencode-linux-arm64.tar.gz", + "archiveKind": "tar.gz", + "binaryName": "opencode", + "archiveSha256": "e7572b14fa74baa2f07f9ec6c783f2391365a94037f028ebe6e827b664693415", + "binarySha256": "8a5508fe5e8bb0ed72949ee8f3c506edae4c26aae805af6ff0da7daae2c681a7", + "assetUrl": "https://github.com/777genius/opencode-anomaly/releases/download/v1.18.4-agentteams.1/opencode-linux-arm64.tar.gz" + }, + "linux-x64": { + "status": "available", + "file": "opencode-linux-x64.tar.gz", + "archiveKind": "tar.gz", + "binaryName": "opencode", + "archiveSha256": "86bb966110001cd3bb5b90b33cbb413f03f207c9a5e4a23241a8a23038464923", + "binarySha256": "7858adb4fdf140d7a3bc0a982e559418482333feb9b3d75389d25a0828a8a32d", + "assetUrl": "https://github.com/777genius/opencode-anomaly/releases/download/v1.18.4-agentteams.1/opencode-linux-x64.tar.gz" + }, + "win32-arm64": { "status": "unavailable", "reason": "artifact_digests_pending" }, + "win32-x64": { + "status": "available", + "file": "opencode-windows-x64.zip", + "archiveKind": "zip", + "binaryName": "opencode.exe", + "archiveSha256": "9260a567fdee287c4abc34cac8e2f0fb58651862e9f8258af5cdc2b0d5ae8ae9", + "binarySha256": "0b90c98c6e068371c133f5dd485bd9045b0798a16a6fd1de0ac0e067f8cdbadc", + "assetUrl": "https://github.com/777genius/opencode-anomaly/releases/download/v1.18.4-agentteams.1/opencode-windows-x64.zip" + } + } +} diff --git a/package.json b/package.json index ba8c0bfff0..5f7bbde219 100644 --- a/package.json +++ b/package.json @@ -74,17 +74,21 @@ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"", "build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", - "test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test", - "test:workspace:ci": "pnpm test:ci && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test", + "test:workspace": "pnpm test && pnpm test:arch:node && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test", + "test:workspace:ci": "pnpm test:ci && pnpm test:arch:node && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test", "check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e", "check:workspace:ci": "pnpm typecheck:workspace && pnpm test:workspace:ci && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e", - "check": "pnpm guard:source-file-size && pnpm guard:team-provisioning-architecture && pnpm check:workspace && pnpm lint && pnpm lint:mcp", - "check:ci": "pnpm guard:source-file-size && pnpm guard:team-provisioning-architecture && pnpm check:workspace:ci && pnpm lint && pnpm lint:mcp", - "validate:ci": "pnpm typecheck:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e", + "check": "pnpm guard:source-file-size && pnpm guard:feature-architecture && pnpm guard:team-provisioning-architecture && pnpm check:workspace && pnpm lint && pnpm lint:mcp", + "check:ci": "pnpm guard:source-file-size && pnpm guard:feature-architecture && pnpm guard:team-provisioning-architecture && pnpm check:workspace:ci && pnpm lint && pnpm lint:mcp", + "validate:ci": "pnpm guard:source-file-size:ci && pnpm guard:hosted-phase0-evidence && pnpm guard:feature-architecture && pnpm guard:team-provisioning-architecture && pnpm typecheck:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e", "fix": "pnpm lint:fix && pnpm format", "quality": "pnpm check && pnpm format:check && npx knip", + "guard:feature-architecture": "node ./scripts/ci/verify-feature-architecture.mjs", + "guard:feature-architecture:baseline": "node ./scripts/ci/update-feature-architecture-baseline.mjs --write", + "guard:hosted-phase0-evidence": "node ./scripts/ci/verify-hosted-phase0-evidence-integrity.mjs", "guard:runtime-artifacts": "node ./scripts/ci/forbid-runtime-artifacts.cjs", "guard:source-file-size": "node ./scripts/ci/check-source-file-size.mjs", + "guard:source-file-size:ci": "node ./scripts/ci/verify-source-file-size.mjs --require-baseline", "guard:team-provisioning-architecture": "node ./scripts/ci/check-team-provisioning-architecture.mjs", "test:chunks": "tsx test/test-chunk-building.ts", "test:semantic": "tsx test/test-semantic-steps.ts", @@ -96,15 +100,17 @@ "i18n:types": "i18next-cli types --quiet", "test": "vitest run", "test:ci": "vitest run --maxWorkers=1", + "test:arch:node": "tsx --test \"test/architecture/**/*.test.mjs\"", "test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts", "test:e2e:change-review": "node scripts/e2e/change-review-hunk-desktop.mjs", "test:e2e:change-review:dev-mcp": "node scripts/e2e/change-review-hunk-desktop.mjs --dev-mcp --single-window", "test:e2e:change-review:dev-mcp:restart": "node scripts/e2e/change-review-hunk-desktop.mjs --dev-mcp", + "test:hosted:e2e": "node --import tsx scripts/e2e/hosted-v1/run.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts", "standalone": "tsx src/main/standalone.ts", - "standalone:build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build && node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --config docker/vite.standalone.config.ts", + "standalone:build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --config docker/vite.hosted-renderer.config.ts && node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --config docker/vite.standalone.config.ts", "standalone:start": "node dist-standalone/index.cjs", "prepare": "husky", "postinstall": "electron-rebuild -f -o node-pty,ssh2,cpu-features,better-sqlite3 || echo 'native Electron rebuild failed (terminal/ssh/sqlite features may be degraded)'; node ./scripts/ensure-electron-install.cjs" @@ -216,6 +222,7 @@ "pica": "9.0.1", "pidusage": "4.0.1", "posthog-js": "^1.396.6", + "proper-lockfile": "4.1.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-grid-layout": "^2.2.2", @@ -245,6 +252,7 @@ "@electron/rebuild": "^4.0.4", "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", "@eslint/js": "^9.39.2", + "@playwright/test": "1.62.1", "@sentry/vite-plugin": "^5.1.1", "@tailwindcss/typography": "^0.5.19", "@types/better-sqlite3": "^7.6.13", @@ -252,6 +260,7 @@ "@types/mdast": "^4.0.4", "@types/node": "^24.12.4", "@types/pidusage": "2.0.5", + "@types/proper-lockfile": "4.1.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/ssh2": "^1.15.5", diff --git a/patches/minimatch@3.1.4.patch b/patches/minimatch@3.1.4.patch new file mode 100644 index 0000000000..24b1a490bb --- /dev/null +++ b/patches/minimatch@3.1.4.patch @@ -0,0 +1,12 @@ +diff --git a/minimatch.js b/minimatch.js +--- a/minimatch.js ++++ b/minimatch.js +@@ -9,4 +9,6 @@ + var GLOBSTAR = minimatch.GLOBSTAR = Minimatch.GLOBSTAR = {} +-var expand = require('brace-expansion') +- ++var braceExpansion = require('brace-expansion') ++var expand = typeof braceExpansion === 'function' ++ ? braceExpansion ++ : braceExpansion.expand + var plTypes = { diff --git a/patches/minimatch@5.1.8.patch b/patches/minimatch@5.1.8.patch new file mode 100644 index 0000000000..183b2f64d9 --- /dev/null +++ b/patches/minimatch@5.1.8.patch @@ -0,0 +1,12 @@ +diff --git a/minimatch.js b/minimatch.js +--- a/minimatch.js ++++ b/minimatch.js +@@ -18,4 +18,6 @@ + minimatch.GLOBSTAR = GLOBSTAR +-const expand = require('brace-expansion') +- ++const braceExpansion = require('brace-expansion') ++const expand = typeof braceExpansion === 'function' ++ ? braceExpansion ++ : braceExpansion.expand + const plTypes = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfc2384209..6cd0ea0116 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,8 @@ patchedDependencies: '@radix-ui/react-tooltip@1.2.8': 92cb648a695f616d3b7222b90053cb36e162bab4303abf0fe39b517e1d9dd6b8 brace-expansion@5.0.9: 9ef3b00122b6a8ab9921733d7e330b30e18388b591246cc7c5dbadd9ee8f1b06 fastmcp@3.35.0: 7175182f509e3d149c517648f2b8e6d04a023a3c8fe126ca4ec58426b9d606df + minimatch@3.1.4: be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0 + minimatch@5.1.8: ff3e7e29cf7eb70a0dcdea5d74206193b3894b1a4979f647e0d576484db57405 importers: @@ -387,6 +389,9 @@ importers: posthog-js: specifier: ^1.396.6 version: 1.396.6 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 react: specifier: ^19.0.0 version: 19.2.4 @@ -469,6 +474,9 @@ importers: '@eslint/js': specifier: ^9.39.2 version: 9.39.2 + '@playwright/test': + specifier: 1.62.1 + version: 1.62.1 '@sentry/vite-plugin': specifier: ^5.1.1 version: 5.1.1(encoding@0.1.13)(rollup@4.59.0)(supports-color@10.2.2) @@ -490,6 +498,9 @@ importers: '@types/pidusage': specifier: 2.0.5 version: 2.0.5 + '@types/proper-lockfile': + specifier: 4.1.4 + version: 4.1.4 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -2958,6 +2969,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4413,6 +4429,9 @@ packages: '@types/pidusage@2.0.5': resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -4427,6 +4446,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} @@ -6864,6 +6886,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -7225,6 +7248,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -9136,6 +9164,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -12336,7 +12374,7 @@ snapshots: dependencies: commander: 5.1.0 glob: 7.2.3 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) '@electron/fuses@1.8.0': dependencies: @@ -12556,7 +12594,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3(supports-color@10.2.2) - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) transitivePeerDependencies: - supports-color @@ -12600,7 +12638,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.3.1 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -14421,6 +14459,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -15917,6 +15959,10 @@ snapshots: '@types/pidusage@2.0.5': {} + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -15931,6 +15977,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/retry@0.12.5': {} + '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 @@ -18009,7 +18057,7 @@ snapshots: dir-compare@4.2.0: dependencies: - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) p-limit: 3.1.0 dlv@1.1.3: {} @@ -18514,7 +18562,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -18562,7 +18610,7 @@ snapshots: hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -18594,7 +18642,7 @@ snapshots: estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 @@ -18732,7 +18780,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -18773,7 +18821,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -19086,7 +19134,7 @@ snapshots: filelist@1.0.4: dependencies: - minimatch: 5.1.8 + minimatch: 5.1.8(patch_hash=ff3e7e29cf7eb70a0dcdea5d74206193b3894b1a4979f647e0d576484db57405) fill-range@7.1.1: dependencies: @@ -19225,6 +19273,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -19340,7 +19391,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.4 + minimatch: 3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0) once: 1.4.0 path-is-absolute: 1.0.1 @@ -20945,11 +20996,11 @@ snapshots: dependencies: brace-expansion: 5.0.9(patch_hash=9ef3b00122b6a8ab9921733d7e330b30e18388b591246cc7c5dbadd9ee8f1b06) - minimatch@3.1.4: + minimatch@3.1.4(patch_hash=be819dc5ca2a6bbc67fa13033eeb080a5160aa969bd4baf133e56b8d3a214bd0): dependencies: brace-expansion: 5.0.9(patch_hash=9ef3b00122b6a8ab9921733d7e330b30e18388b591246cc7c5dbadd9ee8f1b06) - minimatch@5.1.8: + minimatch@5.1.8(patch_hash=ff3e7e29cf7eb70a0dcdea5d74206193b3894b1a4979f647e0d576484db57405): dependencies: brace-expansion: 5.0.9(patch_hash=9ef3b00122b6a8ab9921733d7e330b30e18388b591246cc7c5dbadd9ee8f1b06) @@ -21799,6 +21850,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -22447,7 +22506,7 @@ snapshots: readdir-glob@1.1.3: dependencies: - minimatch: 5.1.8 + minimatch: 5.1.8(patch_hash=ff3e7e29cf7eb70a0dcdea5d74206193b3894b1a4979f647e0d576484db57405) readdirp@3.6.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cb44453a28..358711d2f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,9 +6,9 @@ packages: minimumReleaseAge: 4320 minimumReleaseAgeExclude: - '@esbuild/*' + - '@fastify/static@10.1.2' - brace-expansion@5.0.9 - esbuild - - fast-uri@3.1.4 - find-my-way@9.7.0 - tmp@0.2.6 strictPeerDependencies: true @@ -116,4 +116,6 @@ patchedDependencies: '@radix-ui/react-menu@2.1.16': 'patches/@radix-ui__react-menu@2.1.16.patch' '@radix-ui/react-checkbox@1.3.3': 'patches/@radix-ui__react-checkbox@1.3.3.patch' 'fastmcp@3.35.0': 'patches/fastmcp@3.35.0.patch' + 'minimatch@3.1.4': 'patches/minimatch@3.1.4.patch' + 'minimatch@5.1.8': 'patches/minimatch@5.1.8.patch' brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch diff --git a/scripts/ci/check-hosted-opencode-upstream.d.mts b/scripts/ci/check-hosted-opencode-upstream.d.mts new file mode 100644 index 0000000000..a2582be28d --- /dev/null +++ b/scripts/ci/check-hosted-opencode-upstream.d.mts @@ -0,0 +1,15 @@ +export interface HostedOpenCodeUpstreamResult { + readonly drifted: boolean; + readonly latestTag: string; + readonly latestUrl: string; + readonly pinnedTag: string; +} + +export function compareVersions(left: readonly number[], right: readonly number[]): number; + +export function inspectOpenCodeUpstream( + lock: unknown, + release: unknown +): HostedOpenCodeUpstreamResult; + +export function renderReport(result: HostedOpenCodeUpstreamResult): string; diff --git a/scripts/ci/check-hosted-opencode-upstream.mjs b/scripts/ci/check-hosted-opencode-upstream.mjs new file mode 100644 index 0000000000..4ecfcdec83 --- /dev/null +++ b/scripts/ci/check-hosted-opencode-upstream.mjs @@ -0,0 +1,75 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/u; +const DOWNSTREAM_VERSION = /^(\d+)\.(\d+)\.(\d+)-agentteams\.\d+$/u; + +export function compareVersions(left, right) { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +export function inspectOpenCodeUpstream(lock, release) { + const downstream = DOWNSTREAM_VERSION.exec(lock?.version ?? ''); + const upstream = STABLE_TAG.exec(release?.tag_name ?? ''); + if ( + lock?.runtime !== 'opencode' || + lock?.source?.repository !== '777genius/opencode-anomaly' || + !downstream || + !upstream || + typeof release?.html_url !== 'string' + ) { + throw new Error('hosted_opencode_upstream_metadata_invalid'); + } + const pinned = downstream.slice(1).map(Number); + const latest = upstream.slice(1).map(Number); + return Object.freeze({ + drifted: compareVersions(pinned, latest) < 0, + latestTag: release.tag_name, + latestUrl: release.html_url, + pinnedTag: `v${pinned.join('.')}`, + }); +} + +export function renderReport(result) { + const status = result.drifted ? 'UPDATE REQUIRED' : 'CURRENT'; + return ( + `# OpenCode upstream tracking\n\n` + + `Status: **${status}**\n\n` + + `- Agent Teams downstream base: \`${result.pinnedTag}\`\n` + + `- Latest upstream stable: [\`${result.latestTag}\`](${result.latestUrl})\n` + + `- Policy: \`docs/hosted-opencode-downstream-policy.md\`\n\n` + + (result.drifted + ? 'Port only the bounded hosted-approval patch, rebuild immutable artifacts, run compatibility and sandbox actual-owner E2E, then update both product and orchestrator pins.\n' + : 'No downstream port is currently required.\n') + ); +} + +function valueAfter(args, flag) { + const index = args.indexOf(flag); + if (index < 0 || index === args.length - 1) throw new Error(`missing_argument:${flag}`); + return resolve(args[index + 1]); +} + +async function main(args) { + const lockPath = valueAfter(args, '--lock'); + const releasePath = valueAfter(args, '--upstream-release'); + const reportPath = valueAfter(args, '--report'); + const [lock, release] = await Promise.all( + [lockPath, releasePath].map(async (path) => JSON.parse(await readFile(path, 'utf8'))) + ); + const result = inspectOpenCodeUpstream(lock, release); + const report = renderReport(result); + await writeFile(reportPath, report, { mode: 0o600 }); + if (process.env.GITHUB_STEP_SUMMARY) { + await writeFile(process.env.GITHUB_STEP_SUMMARY, report, { flag: 'a' }); + } + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exitCode = result.drifted ? 2 : 0; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${resolve(process.argv[1])}`).href) { + await main(process.argv.slice(2)); +} diff --git a/scripts/ci/check-source-file-size.mjs b/scripts/ci/check-source-file-size.mjs index 65775f7889..ebb77e177a 100644 --- a/scripts/ci/check-source-file-size.mjs +++ b/scripts/ci/check-source-file-size.mjs @@ -10,6 +10,10 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const SOURCE_EXTENSION_PATTERN = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|vue|css|scss|sass|less|html|sh)$/i; const GENERATED_SOURCE_PATHS = new Set(['src/features/localization/renderer/resources.d.ts']); +const FROZEN_EVIDENCE_SOURCE_PATHS = new Set([ + // Exact bytes are enforced by verify-hosted-phase0-evidence-integrity.mjs. + 'scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts', +]); const EXCLUDED_SEGMENT_PATTERN = /(?:^|\/)(?:test|tests|__tests__|fixture|fixtures|mock|mocks|__mocks__|e2e|smoke)(?:\/|$)/i; const EXCLUDED_ROOT_PATTERN = @@ -37,6 +41,7 @@ export function isProductionSourcePath(filePath) { return ( SOURCE_EXTENSION_PATTERN.test(normalizedPath) && !GENERATED_SOURCE_PATHS.has(normalizedPath) && + !FROZEN_EVIDENCE_SOURCE_PATHS.has(normalizedPath) && !isTestFilePath(normalizedPath) && !EXCLUDED_SEGMENT_PATTERN.test(normalizedPath) && !EXCLUDED_ROOT_PATTERN.test(normalizedPath) && @@ -143,27 +148,41 @@ export function evaluateSourceFileSizes(records, policy) { }; } +export function strictSourceFileSizeViolations(result) { + return [ + ...result.violations, + ...result.ratchetCandidates.map(({ path: filePath, lineCount, legacyCap }) => ({ + code: 'legacy-cap-not-tight', + path: filePath, + lineCount, + message: + `${filePath}: legacy file is now ${lineCount} lines; ` + + `lower its frozen cap from ${legacyCap}.`, + })), + ]; +} + function splitNullDelimited(output) { return output.split('\0').filter(Boolean); } -function gitOutput(args) { +function gitOutput(args, root = REPO_ROOT) { return execFileSync('git', args, { - cwd: REPO_ROOT, + cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }); } -function readWorkingTreeRecords() { +export function readWorkingTreeRecords(root = REPO_ROOT) { const fileNames = splitNullDelimited( - gitOutput(['ls-files', '--cached', '--others', '--exclude-standard', '-z']) + gitOutput(['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root) ); return fileNames .filter(isProductionSourcePath) - .filter((fileName) => existsSync(path.join(REPO_ROOT, fileName))) + .filter((fileName) => existsSync(path.join(root, fileName))) .map((fileName) => { - const contents = readFileSync(path.join(REPO_ROOT, fileName), 'utf8'); + const contents = readFileSync(path.join(root, fileName), 'utf8'); return { path: normalizeRepoPath(fileName), lineCount: countPhysicalLines(contents) }; }); } @@ -198,9 +217,10 @@ function printBaselineFromHead() { function runGuard() { const result = evaluateSourceFileSizes(readWorkingTreeRecords(), loadPolicy()); - if (result.violations.length > 0) { - console.error(`Source file size guard failed with ${result.violations.length} violation(s):\n`); - for (const violation of result.violations) console.error(`- ${violation.message}`); + const violations = strictSourceFileSizeViolations(result); + if (violations.length > 0) { + console.error(`Source file size guard failed with ${violations.length} violation(s):\n`); + for (const violation of violations) console.error(`- ${violation.message}`); console.error( `\nNew production files must stay at or below ${result.maxLines} physical lines. ` + 'Legacy caps in scripts/ci/source-file-size-baseline.json may only move downward.' @@ -213,12 +233,6 @@ function runGuard() { `Source file size guard passed: ${result.checkedFiles} production files checked, ` + `${result.legacyFiles} frozen legacy exceptions, ${result.maxLines}-line limit.` ); - if (result.ratchetCandidates.length > 0) { - console.log( - `${result.ratchetCandidates.length} legacy file(s) are below their frozen caps; ` + - 'lower those caps when committing the refactor.' - ); - } } const isEntrypoint = diff --git a/scripts/ci/feature-ambient-dependency-analysis.mjs b/scripts/ci/feature-ambient-dependency-analysis.mjs new file mode 100644 index 0000000000..94727a28d4 --- /dev/null +++ b/scripts/ci/feature-ambient-dependency-analysis.mjs @@ -0,0 +1,423 @@ +import ts from 'typescript'; + +import { + isIdentifierReference, + memberAccess, + unwrapExpression, +} from './feature-export-analysis.mjs'; +import { isShadowedTypeReference } from './feature-type-scope-analysis.mjs'; +import { + reachingLocalValueWrites, + resolvedLocalValueNodes, +} from './feature-constructor-local-value-analysis.mjs'; +import { executedInvocationParameterInitializer } from './feature-executed-iife-analysis.mjs'; +import { isUnshadowedGlobalValueReference } from './feature-lexical-binding-analysis.mjs'; +import { forEachChildIncludingJsDoc } from './feature-module-syntax-analysis.mjs'; +import { staticMemberAccess, staticStringValue } from './feature-static-value-analysis.mjs'; + +const SAFE_LANGUAGE_LIB_PATTERN = /^(?:es(?:5|6|20\d{2}|next)(?:\..+)?|decorators(?:\.legacy)?)$/i; +const AMBIENT_TYPE_NAMESPACE_SPECIFIERS = new Map([ + ['Electron', 'electron'], + ['NodeJS', 'node:types'], +]); +const AMBIENT_TYPE_NAME_SPECIFIERS = new Map( + [ + 'AllowSharedBuffer', + 'BufferEncoding', + 'NodeModule', + 'NodeRequire', + 'NonSharedBuffer', + 'RequireResolve', + ].map((name) => [name, 'node:types']) +); +const RUNTIME_GLOBAL_SPECIFIERS = new Map([ + ['Buffer', 'node:buffer'], + ['EventSource', 'browser:event-source'], + ['WebSocket', 'browser:websocket'], + ['Worker', 'browser:worker'], + ['__dirname', 'node:module'], + ['__filename', 'node:module'], + ['clearImmediate', 'node:timers'], + ['clearInterval', 'runtime:timers'], + ['clearTimeout', 'runtime:timers'], + ['crypto', 'runtime:crypto'], + ['document', 'browser:dom'], + ['exports', 'node:module'], + ['fetch', 'browser:fetch'], + ['global', 'node:global'], + ['localStorage', 'browser:storage'], + ['location', 'browser:dom'], + ['module', 'node:module'], + ['navigator', 'browser:dom'], + ['process', 'node:process'], + ['require', 'node:module'], + ['sessionStorage', 'browser:storage'], + ['setImmediate', 'node:timers'], + ['setInterval', 'runtime:timers'], + ['setTimeout', 'runtime:timers'], + ['window', 'browser:dom'], +]); +const REFLECTIVE_RUNTIME_READ_METHODS = new Map([ + ['Object', new Set(['getOwnPropertyDescriptor'])], + ['Reflect', new Set(['get', 'getOwnPropertyDescriptor'])], +]); + +function reflectiveRuntimeRead(expression, resolveIdentifier) { + const current = unwrapExpression(expression); + if (!ts.isCallExpression(current) || current.arguments.length < 2) return null; + + const access = staticMemberAccess(current.expression, resolveIdentifier); + const receiver = access && unwrapExpression(access.receiver); + if ( + !access || + !ts.isIdentifier(receiver) || + !isUnshadowedGlobalValueReference(receiver) || + !REFLECTIVE_RUNTIME_READ_METHODS.get(receiver.text)?.has(access.name) + ) { + return null; + } + + const name = staticStringValue(current.arguments[1], resolveIdentifier); + return name === null ? null : { name, receiver: current.arguments[0] }; +} + +function referenceDirectiveEdges(sourceFile, sourcePath) { + const runtimeLibReferences = sourceFile.libReferenceDirectives + .filter((reference) => !SAFE_LANGUAGE_LIB_PATTERN.test(reference.fileName)) + .map((reference) => ({ + ...reference, + fileName: `typescript:lib/${reference.fileName}`, + })); + + return [ + ...sourceFile.typeReferenceDirectives, + ...sourceFile.referencedFiles, + ...runtimeLibReferences, + ].map((reference) => ({ + isTypeOnly: true, + kind: 'reference', + line: sourceFile.getLineAndCharacterOfPosition(reference.pos).line + 1, + source: sourcePath, + specifier: reference.fileName, + })); +} + +function jsxRuntimeEdges(sourceFile, sourcePath) { + let firstJsxNode = null; + const visit = (node) => { + if (firstJsxNode) return; + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { + firstJsxNode = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (!firstJsxNode) return []; + + const pragmaImportSource = sourceFile.pragmas.get('jsximportsource')?.arguments?.factory; + const importSource = + typeof pragmaImportSource === 'string' && pragmaImportSource.length > 0 + ? pragmaImportSource.replace(/\/+$/, '') + : 'react'; + + return [ + { + isTypeOnly: false, + kind: 'import', + line: sourceFile.getLineAndCharacterOfPosition(firstJsxNode.getStart(sourceFile)).line + 1, + source: sourcePath, + specifier: `${importSource}/jsx-runtime`, + }, + ]; +} + +function importDeclaresLocalName(statement, name) { + if (ts.isImportEqualsDeclaration(statement)) return statement.name.text === name; + if (!ts.isImportDeclaration(statement)) return false; + const clause = statement.importClause; + if (!clause) return false; + if (clause.name?.text === name) return true; + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) return bindings.name.text === name; + return ( + bindings && + ts.isNamedImports(bindings) && + bindings.elements.some((element) => element.name.text === name) + ); +} + +function statementDeclaresTypeName(statement, name) { + if (importDeclaresLocalName(statement, name)) return true; + const isTypeDeclaration = + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement); + return isTypeDeclaration && ts.isIdentifier(statement.name) && statement.name.text === name; +} + +function scopeStatements(node) { + if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)) { + return node.statements; + } + return ts.isCaseBlock(node) ? node.clauses.flatMap((clause) => clause.statements) : []; +} + +function isShadowedAmbientTypeReference(reference, sourceFile) { + if (isShadowedTypeReference(reference, sourceFile)) return true; + let current = reference.parent; + while (current) { + if ( + scopeStatements(current).some((statement) => + statementDeclaresTypeName(statement, reference.text) + ) + ) { + return true; + } + current = current.parent; + } + return false; +} + +function outermostEntityAccess(reference) { + let current = reference; + while ( + (ts.isQualifiedName(current.parent) && current.parent.left === current) || + (ts.isPropertyAccessExpression(current.parent) && current.parent.expression === current) + ) { + current = current.parent; + } + return current; +} + +function typeEntityUsage(reference) { + const entity = outermostEntityAccess(reference); + const parent = entity.parent; + if (ts.isTypeReferenceNode(parent) && parent.typeName === entity) { + return { entity, space: 'type' }; + } + if (ts.isTypeQueryNode(parent) && parent.exprName === entity) { + return { entity, space: 'value' }; + } + if (!ts.isExpressionWithTypeArguments(parent) || parent.expression !== entity) { + return null; + } + + const heritage = parent.parent; + const extendsClass = + ts.isHeritageClause(heritage) && + heritage.token === ts.SyntaxKind.ExtendsKeyword && + ts.isClassLike(heritage.parent); + return { entity, space: extendsClass ? 'value' : 'type' }; +} + +function ambientTypeNamespaceEdges(sourceFile, sourcePath) { + const edges = []; + const ambientSpecifierForReference = (reference, specifiers, space) => { + if (!ts.isIdentifier(reference) || !specifiers.has(reference.text)) return null; + const isTypeShadowed = isShadowedAmbientTypeReference(reference, sourceFile); + const isValueShadowed = !isUnshadowedGlobalValueReference(reference); + const isShadowed = + space === 'type' + ? isTypeShadowed + : space === 'value' + ? isValueShadowed + : isTypeShadowed || isValueShadowed; + return isShadowed ? null : specifiers.get(reference.text); + }; + const rootEntityName = (entityName) => { + let current = entityName; + while (ts.isQualifiedName(current)) current = current.left; + return ts.isIdentifier(current) ? current : null; + }; + const visit = (node) => { + const directUsage = ts.isIdentifier(node) ? typeEntityUsage(node) : null; + const directSpecifiers = + directUsage?.entity === node && directUsage.space === 'type' + ? AMBIENT_TYPE_NAME_SPECIFIERS + : AMBIENT_TYPE_NAMESPACE_SPECIFIERS; + const aliasReference = + ts.isImportEqualsDeclaration(node) && !ts.isExternalModuleReference(node.moduleReference) + ? rootEntityName(node.moduleReference) + : null; + const reference = directUsage ? node : aliasReference; + const specifier = reference + ? ambientSpecifierForReference( + reference, + directUsage ? directSpecifiers : AMBIENT_TYPE_NAMESPACE_SPECIFIERS, + directUsage?.space ?? 'namespace' + ) + : null; + if (specifier) { + edges.push({ + isTypeOnly: true, + kind: 'reference', + line: sourceFile.getLineAndCharacterOfPosition(reference.getStart(sourceFile)).line + 1, + source: sourcePath, + specifier, + }); + } + forEachChildIncludingJsDoc(node, visit); + }; + visit(sourceFile); + return edges; +} + +function runtimeGlobalEdges(sourceFile, sourcePath) { + const edges = []; + const resolveStaticBinding = (identifier) => + resolvedLocalValueNodes(identifier, sourceFile, { captureOuter: true }); + let hasGlobalThisReference = false; + const findGlobalThis = (node) => { + if (hasGlobalThisReference) return; + if (ts.isIdentifier(node) && node.text === 'globalThis') { + hasGlobalThisReference = true; + return; + } + ts.forEachChild(node, findGlobalThis); + }; + findGlobalThis(sourceFile); + let invocationsByCallable = null; + const collectLocalInvocations = () => { + if (invocationsByCallable) return invocationsByCallable; + invocationsByCallable = new Map(); + const visitCall = (node) => { + if (ts.isCallExpression(node)) { + const access = memberAccess(node.expression); + const isCallMethod = access?.name === 'call'; + const target = isCallMethod ? access.receiver : node.expression; + if (!access || isCallMethod) { + for (const callable of resolvedLocalValueNodes(target, sourceFile, { + captureOuter: true, + })) { + if (!ts.isFunctionLike(callable)) continue; + const invocations = invocationsByCallable.get(callable) ?? []; + invocations.push({ + arguments: isCallMethod ? node.arguments.slice(1) : [...node.arguments], + call: node, + }); + invocationsByCallable.set(callable, invocations); + } + } + } + ts.forEachChild(node, visitCall); + }; + visitCall(sourceFile); + return invocationsByCallable; + }; + const parameterArgumentWrites = (write) => { + if (!ts.isParameter(write.node) || !ts.isFunctionLike(write.node.parent)) return []; + const callable = write.node.parent; + const parameterIndex = callable.parameters.indexOf(write.node); + if (parameterIndex < 0 || write.node.dotDotDotToken) return []; + + return (collectLocalInvocations().get(callable) ?? []).flatMap((invocation) => { + const value = executedInvocationParameterInitializer( + write.node, + invocation.arguments[parameterIndex] + ); + return value + ? [ + { + key: `${write.key}:call:${invocation.call.pos}`, + selected: write.selected, + value, + }, + ] + : []; + }); + }; + const globalThisSelections = (expression, selected = [], visited = new Set()) => { + const current = expression && unwrapExpression(expression); + if (!current) return []; + if (ts.isIdentifier(current)) { + if (current.text === 'globalThis' && isUnshadowedGlobalValueReference(current)) { + return [selected]; + } + return reachingLocalValueWrites(current, sourceFile, { captureOuter: true }).flatMap( + (write) => { + const candidates = [...(write.value ? [write] : []), ...parameterArgumentWrites(write)]; + return candidates.flatMap(({ key, selected: writeSelection, value }) => { + if (visited.has(key)) return []; + return globalThisSelections( + value, + [...writeSelection, ...selected], + new Set(visited).add(key) + ); + }); + } + ); + } + + const reflectiveRead = reflectiveRuntimeRead(current, resolveStaticBinding); + if (reflectiveRead) { + return globalThisSelections( + reflectiveRead.receiver, + [reflectiveRead.name, ...selected], + visited + ); + } + + const access = staticMemberAccess(current, resolveStaticBinding); + return access ? globalThisSelections(access.receiver, [access.name, ...selected], visited) : []; + }; + const globalThisSpecifier = (node) => { + if (!hasGlobalThisReference) return null; + const isAssignmentTarget = + ts.isIdentifier(node) && + ts.isBinaryExpression(node.parent) && + node.parent.left === node && + ts.isAssignmentOperator(node.parent.operatorToken.kind); + const isRuntimeExpression = + staticMemberAccess(node, resolveStaticBinding) || + reflectiveRuntimeRead(node, resolveStaticBinding) || + (ts.isIdentifier(node) && !isAssignmentTarget && isIdentifierReference(node)); + if (!isRuntimeExpression) return null; + + for (const selection of globalThisSelections(node)) { + if (selection.length !== 1 || typeof selection[0] !== 'string') continue; + const specifier = RUNTIME_GLOBAL_SPECIFIERS.get(selection[0]); + if (specifier) return specifier; + } + return null; + }; + const visit = (node) => { + const globalThisDependency = globalThisSpecifier(node); + const directUsage = ts.isIdentifier(node) ? typeEntityUsage(node) : null; + const isUnshadowedDirectGlobal = + directUsage?.space === 'type' + ? !isShadowedAmbientTypeReference(node, sourceFile) + : isUnshadowedGlobalValueReference(node); + const directGlobalDependency = + ts.isIdentifier(node) && + RUNTIME_GLOBAL_SPECIFIERS.has(node.text) && + isIdentifierReference(node) && + isUnshadowedDirectGlobal + ? RUNTIME_GLOBAL_SPECIFIERS.get(node.text) + : null; + const specifier = globalThisDependency ?? directGlobalDependency; + if (specifier) { + edges.push({ + isTypeOnly: false, + kind: 'global', + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + source: sourcePath, + specifier, + }); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return edges; +} + +export function collectAmbientDependencyEdges(sourceFile, sourcePath) { + return [ + ...referenceDirectiveEdges(sourceFile, sourcePath), + ...jsxRuntimeEdges(sourceFile, sourcePath), + ...ambientTypeNamespaceEdges(sourceFile, sourcePath), + ...runtimeGlobalEdges(sourceFile, sourcePath), + ]; +} diff --git a/scripts/ci/feature-architecture-baseline.json b/scripts/ci/feature-architecture-baseline.json new file mode 100644 index 0000000000..4c6d60c21a --- /dev/null +++ b/scripts/ci/feature-architecture-baseline.json @@ -0,0 +1,1324 @@ +{ + "version": 2, + "violations": [ + { + "rule": "core-application-dependencies", + "source": "src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts", + "specifier": "@shared/types" + }, + { + "rule": "core-application-dependencies", + "source": "src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts", + "specifier": "runtime:timers" + }, + { + "rule": "core-application-dependencies", + "source": "src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatchPolicy.ts", + "specifier": "runtime:timers" + }, + { + "rule": "core-application-dependencies", + "source": "src/features/task-board-commands/core/application/TaskBoardCommandFacade.ts", + "specifier": "@features/application-command-ledger" + }, + { + "rule": "core-application-dependencies", + "source": "src/features/task-board-commands/core/application/TaskBoardCommandFacade.ts", + "specifier": "@shared/types/team" + }, + { + "rule": "core-application-dependencies", + "source": "src/features/task-board-commands/core/application/TaskBoardCommandFacade.ts", + "specifier": "@shared/utils/taskIdentity" + }, + { + "rule": "cross-feature-public-entrypoint", + "source": "src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts", + "specifier": "@features/codex-account/main/infrastructure/CodexAccountEnvBuilder" + }, + { + "rule": "cross-feature-public-entrypoint", + "source": "src/features/member-work-sync/main/infrastructure/BackendSelectingMemberWorkSyncStore.ts", + "specifier": "@features/internal-storage/contracts/internalStorageContracts" + }, + { + "rule": "cross-feature-public-entrypoint", + "source": "src/features/member-work-sync/main/infrastructure/memberWorkSyncSnapshotMerge.ts", + "specifier": "@features/internal-storage/contracts/internalStorageContracts" + }, + { + "rule": "cross-feature-public-entrypoint", + "source": "src/features/member-work-sync/main/infrastructure/MemberWorkSyncSqliteImporter.ts", + "specifier": "@features/internal-storage/contracts/internalStorageContracts" + }, + { + "rule": "cross-feature-public-entrypoint", + "source": "src/features/member-work-sync/main/infrastructure/memberWorkSyncSqliteMappers.ts", + "specifier": "@features/internal-storage/contracts/internalStorageContracts" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/agent-attachments/main/index.ts", + "specifier": "./infrastructure/attachmentArtifactStore", + "publicEntrypoint": "src/features/agent-attachments/main/index.ts", + "exportedName": "AgentAttachmentArtifactFileName", + "importedName": "AgentAttachmentArtifactFileName" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/agent-attachments/main/index.ts", + "specifier": "./infrastructure/attachmentArtifactStore", + "publicEntrypoint": "src/features/agent-attachments/main/index.ts", + "exportedName": "resolveAgentAttachmentArtifactPath", + "importedName": "resolveAgentAttachmentArtifactPath" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/agent-attachments/main/index.ts", + "specifier": "./infrastructure/attachmentArtifactStore", + "publicEntrypoint": "src/features/agent-attachments/main/index.ts", + "exportedName": "ResolveAgentAttachmentArtifactPathInput", + "importedName": "ResolveAgentAttachmentArtifactPathInput" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/agent-attachments/main/index.ts", + "specifier": "./infrastructure/attachmentArtifactStore", + "publicEntrypoint": "src/features/agent-attachments/main/index.ts", + "exportedName": "writeFileAtomic", + "importedName": "writeFileAtomic" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts", + "specifier": "path", + "publicEntrypoint": "src/features/agent-attachments/main/index.ts", + "exportedName": "buildCodexNativeAttachmentDeliveryParts", + "importedName": "*" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts", + "specifier": "path", + "publicEntrypoint": "src/features/agent-attachments/main/index.ts", + "exportedName": "redactCodexNativeAttachmentPartsForDiagnostics", + "importedName": "*" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/agent-graph/renderer/index.ts", + "specifier": "./adapters/TeamGraphAdapter", + "publicEntrypoint": "src/features/agent-graph/renderer/index.ts", + "exportedName": "TeamGraphAdapter", + "importedName": "TeamGraphAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/app-close-coordination/main/RendererCloseReadinessCoordinator.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/app-close-coordination/main/index.ts", + "exportedName": "RendererCloseReadinessCoordinator", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/app-close-coordination/main/RendererCloseReadinessCoordinator.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/app-close-coordination/main/index.ts", + "exportedName": "RendererCloseReadinessCoordinator", + "importedName": "IpcMain" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/app-close-coordination/preload/createAppCloseCoordinationBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/app-close-coordination/preload/index.ts", + "exportedName": "createAppCloseCoordinationBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/change-review-history/main/index.ts", + "specifier": "./infrastructure/ReviewDraftHistoryStore", + "publicEntrypoint": "src/features/change-review-history/main/index.ts", + "exportedName": "ReviewDraftHistoryStore", + "importedName": "ReviewDraftHistoryStore" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/change-review-history/main/index.ts", + "specifier": "./infrastructure/ReviewDraftHistoryStore", + "publicEntrypoint": "src/features/change-review-history/main/index.ts", + "exportedName": "SaveReviewDraftHistoryEntryInput", + "importedName": "SaveReviewDraftHistoryEntryInput" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/change-review-history/renderer/index.ts", + "specifier": "./adapters/reviewDraftEditorState", + "publicEntrypoint": "src/features/change-review-history/renderer/index.ts", + "exportedName": "restoreReviewDraftEditorState", + "importedName": "restoreReviewDraftEditorState" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/change-review-history/renderer/index.ts", + "specifier": "./adapters/reviewDraftEditorState", + "publicEntrypoint": "src/features/change-review-history/renderer/index.ts", + "exportedName": "serializeReviewDraftEditorState", + "importedName": "serializeReviewDraftEditorState" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/main/adapters/output/presenters/CodexAccountSnapshotPresenter.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-account/main/index.ts", + "exportedName": "createCodexAccountFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/main/composition/createCodexAccountFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-account/main/index.ts", + "exportedName": "CodexAccountFeatureFacade", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/main/composition/createCodexAccountFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-account/main/index.ts", + "exportedName": "createCodexAccountFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/main/composition/createCodexAccountFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-model-catalog/index.ts", + "exportedName": "createCodexModelCatalogFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/main/composition/createCodexAccountFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-model-catalog/main/index.ts", + "exportedName": "createCodexModelCatalogFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/main/index.ts", + "specifier": "./adapters/input/ipc/registerCodexAccountIpc", + "publicEntrypoint": "src/features/codex-account/main/index.ts", + "exportedName": "registerCodexAccountIpc", + "importedName": "registerCodexAccountIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/main/index.ts", + "specifier": "./adapters/input/ipc/registerCodexAccountIpc", + "publicEntrypoint": "src/features/codex-account/main/index.ts", + "exportedName": "removeCodexAccountIpc", + "importedName": "removeCodexAccountIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/preload/createCodexAccountBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-account/preload/index.ts", + "exportedName": "createCodexAccountBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/renderer/index.ts", + "specifier": "./hooks/useCodexAccountSnapshot", + "publicEntrypoint": "src/features/codex-account/renderer/index.ts", + "exportedName": "CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS", + "importedName": "CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/renderer/index.ts", + "specifier": "./hooks/useCodexAccountSnapshot", + "publicEntrypoint": "src/features/codex-account/renderer/index.ts", + "exportedName": "CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS", + "importedName": "CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/renderer/index.ts", + "specifier": "./hooks/useCodexAccountSnapshot", + "publicEntrypoint": "src/features/codex-account/renderer/index.ts", + "exportedName": "CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS", + "importedName": "CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/renderer/index.ts", + "specifier": "./hooks/useCodexAccountSnapshot", + "publicEntrypoint": "src/features/codex-account/renderer/index.ts", + "exportedName": "isCodexAccountSnapshotPending", + "importedName": "isCodexAccountSnapshotPending" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-account/renderer/index.ts", + "specifier": "./hooks/useCodexAccountSnapshot", + "publicEntrypoint": "src/features/codex-account/renderer/index.ts", + "exportedName": "useCodexAccountSnapshot", + "importedName": "useCodexAccountSnapshot" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts", + "specifier": "node:crypto", + "publicEntrypoint": "src/features/codex-model-catalog/index.ts", + "exportedName": "createCodexModelCatalogFeature", + "importedName": "createHash" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts", + "specifier": "node:crypto", + "publicEntrypoint": "src/features/codex-model-catalog/main/index.ts", + "exportedName": "createCodexModelCatalogFeature", + "importedName": "createHash" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/composition/createCodexRuntimeInstallerFeature.ts", + "specifier": "../infrastructure/CodexRuntimeInstallerService", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "createCodexRuntimeInstallerFeature", + "importedName": "CodexRuntimeInstallerServiceDependencies" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/composition/createCodexRuntimeInstallerFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "CodexRuntimeInstallerFeatureFacade", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/composition/createCodexRuntimeInstallerFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "createCodexRuntimeInstallerFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./adapters/input/ipc/registerCodexRuntimeInstallerIpc", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "registerCodexRuntimeInstallerIpc", + "importedName": "registerCodexRuntimeInstallerIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./adapters/input/ipc/registerCodexRuntimeInstallerIpc", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "removeCodexRuntimeInstallerIpc", + "importedName": "removeCodexRuntimeInstallerIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./infrastructure/CodexRuntimeInstallerService", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "CodexRuntimeInstallerServiceDependencies", + "importedName": "CodexRuntimeInstallerServiceDependencies" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./infrastructure/CodexRuntimeInstallerService", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "extractCodexRuntimePackageFilesFromTarball", + "importedName": "extractCodexRuntimePackageFilesFromTarball" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./infrastructure/CodexRuntimeInstallerService", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "getCodexRuntimePlatformCandidates", + "importedName": "getCodexRuntimePlatformCandidates" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./infrastructure/CodexRuntimeInstallerService", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "resolveAppManagedCodexRuntimeBinaryPath", + "importedName": "resolveAppManagedCodexRuntimeBinaryPath" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./infrastructure/CodexRuntimeInstallerService", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "resolveVerifiedAppManagedCodexRuntimeBinaryPath", + "importedName": "resolveVerifiedAppManagedCodexRuntimeBinaryPath" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/main/index.ts", + "specifier": "./infrastructure/CodexRuntimeInstallerService", + "publicEntrypoint": "src/features/codex-runtime-installer/main/index.ts", + "exportedName": "verifyCodexRuntimePackageIntegrity", + "importedName": "verifyCodexRuntimePackageIntegrity" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/codex-runtime-installer/preload/createCodexRuntimeInstallerBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/codex-runtime-installer/preload/index.ts", + "exportedName": "createCodexRuntimeInstallerBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/internal-storage/main/composition/createInternalStorageFeature.ts", + "specifier": "node:path", + "publicEntrypoint": "src/features/internal-storage/main/index.ts", + "exportedName": "getInternalStorageDatabasePath", + "importedName": "*" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/internal-storage/main/index.ts", + "specifier": "./adapters/output/TeamScopedLegacyJsonSource", + "publicEntrypoint": "src/features/internal-storage/main/index.ts", + "exportedName": "archiveFileWithGenerations", + "importedName": "archiveFileWithGenerations" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/internal-storage/main/index.ts", + "specifier": "./adapters/output/TeamScopedLegacyJsonSource", + "publicEntrypoint": "src/features/internal-storage/main/index.ts", + "exportedName": "listPreSqliteArchiveGenerations", + "importedName": "listPreSqliteArchiveGenerations" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/internal-storage/main/index.ts", + "specifier": "./infrastructure/InternalStorageJsonReplica", + "publicEntrypoint": "src/features/internal-storage/main/index.ts", + "exportedName": "InternalStorageFallbackUnsafeError", + "importedName": "InternalStorageFallbackUnsafeError" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/internal-storage/main/index.ts", + "specifier": "./infrastructure/InternalStorageJsonReplica", + "publicEntrypoint": "src/features/internal-storage/main/index.ts", + "exportedName": "InternalStorageJsonReplica", + "importedName": "InternalStorageJsonReplica" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/internal-storage/main/infrastructure/InternalStorageJsonReplica.ts", + "specifier": "node:fs/promises", + "publicEntrypoint": "src/features/internal-storage/main/index.ts", + "exportedName": "BackendSelectingTaskCommentNotificationJournalStore", + "importedName": "mkdir" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/internal-storage/main/infrastructure/InternalStorageJsonReplica.ts", + "specifier": "node:fs/promises", + "publicEntrypoint": "src/features/internal-storage/main/index.ts", + "exportedName": "BackendSelectingTaskStallJournalStore", + "importedName": "mkdir" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-log-stream/main/application/MemberRuntimeLogTailReader.ts", + "specifier": "fs", + "publicEntrypoint": "src/features/member-log-stream/main/index.ts", + "exportedName": "createMemberLogStreamFeature", + "importedName": "promises" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-log-stream/main/index.ts", + "specifier": "./adapters/input/ipc/registerMemberLogStreamIpc", + "publicEntrypoint": "src/features/member-log-stream/main/index.ts", + "exportedName": "registerMemberLogStreamIpc", + "importedName": "registerMemberLogStreamIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-log-stream/main/index.ts", + "specifier": "./adapters/input/ipc/registerMemberLogStreamIpc", + "publicEntrypoint": "src/features/member-log-stream/main/index.ts", + "exportedName": "removeMemberLogStreamIpc", + "importedName": "removeMemberLogStreamIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-log-stream/preload/createMemberLogStreamBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/member-log-stream/preload/index.ts", + "exportedName": "createMemberLogStreamBridge", + "importedName": "ipcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-log-stream/renderer/index.ts", + "specifier": "./adapters/MemberLogStreamSection", + "publicEntrypoint": "src/features/member-log-stream/renderer/index.ts", + "exportedName": "MemberLogStreamSection", + "importedName": "MemberLogStreamSection" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts", + "specifier": "path", + "publicEntrypoint": "src/features/member-work-sync/main/index.ts", + "exportedName": "createMemberWorkSyncFeature", + "importedName": "default" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/main/index.ts", + "specifier": "./adapters/input/registerMemberWorkSyncIpc", + "publicEntrypoint": "src/features/member-work-sync/main/index.ts", + "exportedName": "registerMemberWorkSyncIpc", + "importedName": "registerMemberWorkSyncIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/main/index.ts", + "specifier": "./adapters/input/registerMemberWorkSyncIpc", + "publicEntrypoint": "src/features/member-work-sync/main/index.ts", + "exportedName": "removeMemberWorkSyncIpc", + "importedName": "removeMemberWorkSyncIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths.ts", + "specifier": "path", + "publicEntrypoint": "src/features/member-work-sync/main/index.ts", + "exportedName": "buildMemberWorkSyncRuntimeTurnSettledEnvironment", + "importedName": "default" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/preload/index.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/member-work-sync/preload/index.ts", + "exportedName": "createMemberWorkSyncBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/renderer/index.ts", + "specifier": "./hooks/useMemberWorkSyncStatus", + "publicEntrypoint": "src/features/member-work-sync/renderer/index.ts", + "exportedName": "useMemberWorkSyncStatus", + "importedName": "useMemberWorkSyncStatus" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/renderer/index.ts", + "specifier": "./hooks/useMemberWorkSyncStatus", + "publicEntrypoint": "src/features/member-work-sync/renderer/index.ts", + "exportedName": "UseMemberWorkSyncStatusOptions", + "importedName": "UseMemberWorkSyncStatusOptions" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/renderer/index.ts", + "specifier": "./hooks/useMemberWorkSyncStatus", + "publicEntrypoint": "src/features/member-work-sync/renderer/index.ts", + "exportedName": "UseMemberWorkSyncStatusResult", + "importedName": "UseMemberWorkSyncStatusResult" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/member-work-sync/renderer/index.ts", + "specifier": "./ui/MemberWorkSyncStatusPanel", + "publicEntrypoint": "src/features/member-work-sync/renderer/index.ts", + "exportedName": "MemberWorkSyncStatusPanel", + "importedName": "MemberWorkSyncStatusPanel" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/organizations/main/index.ts", + "specifier": "./adapters/input/http/registerOrganizationsHttp", + "publicEntrypoint": "src/features/organizations/main/index.ts", + "exportedName": "registerOrganizationsHttp", + "importedName": "registerOrganizationsHttp" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/organizations/main/index.ts", + "specifier": "./adapters/input/ipc/registerOrganizationsIpc", + "publicEntrypoint": "src/features/organizations/main/index.ts", + "exportedName": "registerOrganizationsIpc", + "importedName": "registerOrganizationsIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/organizations/main/index.ts", + "specifier": "./adapters/input/ipc/registerOrganizationsIpc", + "publicEntrypoint": "src/features/organizations/main/index.ts", + "exportedName": "removeOrganizationsIpc", + "importedName": "removeOrganizationsIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/organizations/preload/index.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/organizations/preload/index.ts", + "exportedName": "createOrganizationsBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/organizations/renderer/index.ts", + "specifier": "./ui/OrganizationMapTab", + "publicEntrypoint": "src/features/organizations/renderer/index.ts", + "exportedName": "OrganizationMapTab", + "importedName": "OrganizationMapTab" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts", + "specifier": "node:fs/promises", + "publicEntrypoint": "src/features/recent-projects/main/index.ts", + "exportedName": "createRecentProjectsFeature", + "importedName": "default" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/recent-projects/main/index.ts", + "specifier": "./adapters/input/http/registerRecentProjectsHttp", + "publicEntrypoint": "src/features/recent-projects/main/index.ts", + "exportedName": "registerRecentProjectsHttp", + "importedName": "registerRecentProjectsHttp" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/recent-projects/main/index.ts", + "specifier": "./adapters/input/ipc/registerRecentProjectsIpc", + "publicEntrypoint": "src/features/recent-projects/main/index.ts", + "exportedName": "registerRecentProjectsIpc", + "importedName": "registerRecentProjectsIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/recent-projects/main/index.ts", + "specifier": "./adapters/input/ipc/registerRecentProjectsIpc", + "publicEntrypoint": "src/features/recent-projects/main/index.ts", + "exportedName": "removeRecentProjectsIpc", + "importedName": "removeRecentProjectsIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/recent-projects/preload/createRecentProjectsBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/recent-projects/preload/index.ts", + "exportedName": "createRecentProjectsBridge", + "importedName": "ipcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/recent-projects/renderer/index.ts", + "specifier": "./ui/RecentProjectsSection", + "publicEntrypoint": "src/features/recent-projects/renderer/index.ts", + "exportedName": "RecentProjectsSection", + "importedName": "RecentProjectsSection" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts", + "specifier": "../infrastructure/AgentTeamsRuntimeProviderManagementCliClient", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "createRuntimeProviderManagementFeature", + "importedName": "RuntimeProviderOAuthClientDependencies" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts", + "specifier": "../infrastructure/cli-companion/types", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "createRuntimeProviderManagementFeature", + "importedName": "RuntimeProviderCompanionRegistry" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts", + "specifier": "../infrastructure/KiroCliCompanionService", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "createRuntimeProviderManagementFeature", + "importedName": "KiroCliCompanionService" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts", + "specifier": "../infrastructure/KiroCliCompanionService", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "createRuntimeProviderManagementFeature", + "importedName": "KiroCliCompanionServiceDependencies" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/index.ts", + "specifier": "./adapters/input/registerRuntimeProviderManagementIpc", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "registerRuntimeProviderManagementIpc", + "importedName": "registerRuntimeProviderManagementIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/index.ts", + "specifier": "./adapters/input/registerRuntimeProviderManagementIpc", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "removeRuntimeProviderManagementIpc", + "importedName": "removeRuntimeProviderManagementIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/index.ts", + "specifier": "./infrastructure/OpenCodeLocalModelRuntimeInspector", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "inspectOpenCodeLocalModelRuntimeReadiness", + "importedName": "inspectOpenCodeLocalModelRuntimeReadiness" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/index.ts", + "specifier": "./infrastructure/OpenCodeLocalModelRuntimeInspector", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "MIN_AGENT_TEAMS_LOCAL_CONTEXT_TOKENS", + "importedName": "MIN_AGENT_TEAMS_LOCAL_CONTEXT_TOKENS" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/index.ts", + "specifier": "./infrastructure/OpenCodeLocalModelRuntimeInspector", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "OpenCodeLocalModelRuntimeReadiness", + "importedName": "OpenCodeLocalModelRuntimeReadiness" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/index.ts", + "specifier": "./infrastructure/OpenCodeLocalModelRuntimeInspector", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "RECOMMENDED_AGENT_TEAMS_LOCAL_CONTEXT_TOKENS", + "importedName": "RECOMMENDED_AGENT_TEAMS_LOCAL_CONTEXT_TOKENS" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/main/index.ts", + "specifier": "./infrastructure/OpenCodeLocalProviderConnector", + "publicEntrypoint": "src/features/runtime-provider-management/main/index.ts", + "exportedName": "OpenCodeLocalProviderConnector", + "importedName": "OpenCodeLocalProviderConnector" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/runtime-provider-management/preload/index.ts", + "exportedName": "createRuntimeProviderManagementBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./hooks/useOpenCodeLocalProviders", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "mergeOpenCodeLocalProviders", + "importedName": "mergeOpenCodeLocalProviders" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./hooks/useOpenCodeLocalProviders", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "resolveOpenCodeLocalProviderLookup", + "importedName": "resolveOpenCodeLocalProviderLookup" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./hooks/useOpenCodeLocalProviders", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "useOpenCodeLocalProviders", + "importedName": "useOpenCodeLocalProviders" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./hooks/useRuntimeProviderOnboarding", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "RuntimeProviderOnboardingMode", + "importedName": "RuntimeProviderOnboardingMode" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./openCodeLocalModelLimits", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "OpenCodeLocalModelLimitSuggestion", + "importedName": "OpenCodeLocalModelLimitSuggestion" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./openCodeLocalModelLimits", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "resolveOpenCodeLocalModelLimitSuggestion", + "importedName": "resolveOpenCodeLocalModelLimitSuggestion" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./OpenCodeLocalModelLimitsCard", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "OpenCodeLocalModelLimitsCard", + "importedName": "OpenCodeLocalModelLimitsCard" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./RuntimeProviderManagementPanel", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "RuntimeProviderManagementPanel", + "importedName": "RuntimeProviderManagementPanel" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./RuntimeProviderOnboardingDialog", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "RuntimeProviderOnboardingDialog", + "importedName": "RuntimeProviderOnboardingDialog" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/runtime-provider-management/renderer/index.ts", + "specifier": "./RuntimeProviderQuickConnect", + "publicEntrypoint": "src/features/runtime-provider-management/renderer/index.ts", + "exportedName": "RuntimeProviderQuickConnect", + "importedName": "RuntimeProviderQuickConnect" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-import/main/index.ts", + "specifier": "./adapters/input/ipc/registerTeamImportIpc", + "publicEntrypoint": "src/features/team-import/main/index.ts", + "exportedName": "registerTeamImportIpc", + "importedName": "registerTeamImportIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-import/main/index.ts", + "specifier": "./adapters/input/ipc/registerTeamImportIpc", + "publicEntrypoint": "src/features/team-import/main/index.ts", + "exportedName": "removeTeamImportIpc", + "importedName": "removeTeamImportIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-import/main/infrastructure/ElectronTeamImportFolderPicker.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/team-import/main/index.ts", + "exportedName": "createTeamImportFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-import/preload/index.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/team-import/preload/index.ts", + "exportedName": "createTeamImportBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-import/renderer/index.ts", + "specifier": "./ui/ImportTeamDialog", + "publicEntrypoint": "src/features/team-import/renderer/index.ts", + "exportedName": "ImportTeamDialog", + "importedName": "ImportTeamDialog" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./adapters", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "RuntimeRecoveryNotificationAdapter", + "importedName": "RuntimeRecoveryNotificationAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./adapters", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "RuntimeRecoverySignalAdapter", + "importedName": "RuntimeRecoverySignalAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./adapters", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "RuntimeRecoveryTargetAdapter", + "importedName": "RuntimeRecoveryTargetAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./adapters", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "TeamInboxRuntimeRecoveryDeliveryAdapter", + "importedName": "TeamInboxRuntimeRecoveryDeliveryAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./infrastructure", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "JsonTeamRuntimeRecoveryRepository", + "importedName": "JsonTeamRuntimeRecoveryRepository" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./infrastructure", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "NodeRuntimeRecoveryHash", + "importedName": "NodeRuntimeRecoveryHash" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./infrastructure", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "SystemRuntimeRecoveryClock", + "importedName": "SystemRuntimeRecoveryClock" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./infrastructure", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "TeamRuntimeRecoveryScheduler", + "importedName": "TeamRuntimeRecoveryScheduler" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/index.ts", + "specifier": "./infrastructure", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "TeamRuntimeRecoveryStorePaths", + "importedName": "TeamRuntimeRecoveryStorePaths" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/team-runtime-recovery/main/infrastructure/JsonTeamRuntimeRecoveryRepository.ts", + "specifier": "fs/promises", + "publicEntrypoint": "src/features/team-runtime-recovery/main/index.ts", + "exportedName": "createTeamRuntimeRecoveryFeature", + "importedName": "mkdir" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/terminal-workspace/main/composition/createTerminalWorkspaceFeature.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/terminal-workspace/main/index.ts", + "exportedName": "createTerminalWorkspaceFeature", + "importedName": "spawn" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/terminal-workspace/main/index.ts", + "specifier": "./adapters/input/registerTerminalWorkspaceIpc", + "publicEntrypoint": "src/features/terminal-workspace/main/index.ts", + "exportedName": "registerTerminalWorkspaceIpc", + "importedName": "registerTerminalWorkspaceIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/terminal-workspace/main/index.ts", + "specifier": "./adapters/input/registerTerminalWorkspaceIpc", + "publicEntrypoint": "src/features/terminal-workspace/main/index.ts", + "exportedName": "removeTerminalWorkspaceIpc", + "importedName": "removeTerminalWorkspaceIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/terminal-workspace/preload/index.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/terminal-workspace/preload/index.ts", + "exportedName": "createTerminalWorkspaceBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/terminal-workspace/renderer/index.ts", + "specifier": "./adapters/TerminalWorkspaceBottomSheetAdapter", + "publicEntrypoint": "src/features/terminal-workspace/renderer/index.ts", + "exportedName": "TerminalWorkspaceBottomSheet", + "importedName": "TerminalWorkspaceBottomSheetAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/terminal-workspace/renderer/index.ts", + "specifier": "./adapters/TerminalWorkspaceFloatingLauncher", + "publicEntrypoint": "src/features/terminal-workspace/renderer/index.ts", + "exportedName": "TerminalWorkspaceFloatingLauncher", + "importedName": "TerminalWorkspaceFloatingLauncher" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/terminal-workspace/renderer/index.ts", + "specifier": "./adapters/TerminalWorkspacePanelAdapter", + "publicEntrypoint": "src/features/terminal-workspace/renderer/index.ts", + "exportedName": "TerminalWorkspacePanel", + "importedName": "TerminalWorkspacePanelAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "createTmuxInstallerFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "invalidateTmuxRuntimeStatusCache", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "isTmuxRuntimeReadyForCurrentPlatform", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "killTmuxPaneForCurrentPlatform", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "killTmuxPaneForCurrentPlatformSync", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "listRuntimeProcessTableForCurrentPlatform", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "listTmuxPanePidsForCurrentPlatform", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "listTmuxPaneRuntimeInfoForCurrentPlatform", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts", + "specifier": "node:child_process", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "sendKeysToTmuxPaneForCurrentPlatform", + "importedName": "execFile" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "createTmuxInstallerFeature", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "TmuxInstallerFeatureFacade", + "importedName": "BrowserWindow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/composition/runtimeSupport.ts", + "specifier": "../infrastructure/runtime/TmuxPlatformCommandExecutor", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "listRuntimeProcessTableForCurrentPlatform", + "importedName": "ListRuntimeProcessesOptions" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/composition/runtimeSupport.ts", + "specifier": "../infrastructure/runtime/TmuxPlatformCommandExecutor", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "listRuntimeProcessTableForCurrentPlatform", + "importedName": "RuntimeProcessTableRow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/composition/runtimeSupport.ts", + "specifier": "../infrastructure/runtime/TmuxPlatformCommandExecutor", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "listTmuxPaneRuntimeInfoForCurrentPlatform", + "importedName": "TmuxPaneRuntimeInfo" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/index.ts", + "specifier": "./adapters/input/ipc/registerTmuxInstallerIpc", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "registerTmuxInstallerIpc", + "importedName": "registerTmuxInstallerIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/index.ts", + "specifier": "./adapters/input/ipc/registerTmuxInstallerIpc", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "removeTmuxInstallerIpc", + "importedName": "removeTmuxInstallerIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/index.ts", + "specifier": "./infrastructure/runtime/TmuxPlatformCommandExecutor", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "parseRuntimeProcessTable", + "importedName": "parseRuntimeProcessTable" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/index.ts", + "specifier": "./infrastructure/runtime/TmuxPlatformCommandExecutor", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "RuntimeProcessTableRow", + "importedName": "RuntimeProcessTableRow" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/main/index.ts", + "specifier": "./infrastructure/runtime/TmuxPlatformCommandExecutor", + "publicEntrypoint": "src/features/tmux-installer/main/index.ts", + "exportedName": "TmuxPaneRuntimeInfo", + "importedName": "TmuxPaneRuntimeInfo" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/preload/createTmuxInstallerBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/tmux-installer/preload/index.ts", + "exportedName": "createTmuxInstallerBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/tmux-installer/renderer/index.ts", + "specifier": "./ui/TmuxInstallerBannerView", + "publicEntrypoint": "src/features/tmux-installer/renderer/index.ts", + "exportedName": "TmuxInstallerBannerView", + "importedName": "TmuxInstallerBannerView" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/main/index.ts", + "specifier": "./adapters/input/http/registerTokenUsageHttp", + "publicEntrypoint": "src/features/token-usage/main/index.ts", + "exportedName": "registerTokenUsageHttp", + "importedName": "registerTokenUsageHttp" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/main/index.ts", + "specifier": "./adapters/input/ipc/registerTokenUsageIpc", + "publicEntrypoint": "src/features/token-usage/main/index.ts", + "exportedName": "registerTokenUsageIpc", + "importedName": "registerTokenUsageIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/main/index.ts", + "specifier": "./adapters/input/ipc/registerTokenUsageIpc", + "publicEntrypoint": "src/features/token-usage/main/index.ts", + "exportedName": "removeTokenUsageIpc", + "importedName": "removeTokenUsageIpc" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/main/index.ts", + "specifier": "./infrastructure/OpenCodeSessionStoreRunSourceDiscovery", + "publicEntrypoint": "src/features/token-usage/main/index.ts", + "exportedName": "resolveClaudeMultimodelDataHomePath", + "importedName": "resolveClaudeMultimodelDataHomePath" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/main/index.ts", + "specifier": "./infrastructure/TeamTaskUsageAttributionSource", + "publicEntrypoint": "src/features/token-usage/main/index.ts", + "exportedName": "TeamTaskUsageAttributionSource", + "importedName": "TeamTaskUsageAttributionSource" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/main/infrastructure/ClaudeJsonlUsageImporter.ts", + "specifier": "crypto", + "publicEntrypoint": "src/features/token-usage/main/index.ts", + "exportedName": "createTokenUsageFeature", + "importedName": "createHash" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/preload/createTokenUsageBridge.ts", + "specifier": "electron", + "publicEntrypoint": "src/features/token-usage/preload/index.ts", + "exportedName": "createTokenUsageBridge", + "importedName": "IpcRenderer" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/renderer/index.ts", + "specifier": "./hooks/useTokenUsageSnapshot", + "publicEntrypoint": "src/features/token-usage/renderer/index.ts", + "exportedName": "useTokenUsageSnapshot", + "importedName": "useTokenUsageSnapshot" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/token-usage/renderer/index.ts", + "specifier": "./ui/TokenUsageDashboard", + "publicEntrypoint": "src/features/token-usage/renderer/index.ts", + "exportedName": "TokenUsageDashboard", + "importedName": "TokenUsageDashboard" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/adapters/output/ClaudeStateProbe.ts", + "specifier": "node:fs/promises", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "createWorkspaceTrustCoordinator", + "importedName": "default" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./adapters/output/ClaudeStateProbe", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "FileClaudeStateProbe", + "importedName": "FileClaudeStateProbe" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./adapters/output/ClaudeTrustPersister", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "FileClaudeTrustPersister", + "importedName": "FileClaudeTrustPersister" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./adapters/output/NodePtyProcessAdapter", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "NodePtyProcessAdapter", + "importedName": "NodePtyProcessAdapter" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./adapters/output/TempEmptyMcpConfigStore", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "FileTempEmptyMcpConfigStore", + "importedName": "FileTempEmptyMcpConfigStore" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./infrastructure/WorkspaceTrustCanonicalGitRoot", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "resolveWorkspaceTrustCanonicalGitRoot", + "importedName": "resolveWorkspaceTrustCanonicalGitRoot" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./infrastructure/WorkspaceTrustCanonicalGitRoot", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "resolveWorkspaceTrustFilesystemGitRoot", + "importedName": "resolveWorkspaceTrustFilesystemGitRoot" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./infrastructure/WorkspaceTrustFeatureFlags", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "resolveWorkspaceTrustFeatureFlags", + "importedName": "resolveWorkspaceTrustFeatureFlags" + }, + { + "rule": "public-api-implementation-export", + "source": "src/features/workspace-trust/main/index.ts", + "specifier": "./infrastructure/workspaceTrustPreflightEnv", + "publicEntrypoint": "src/features/workspace-trust/main/index.ts", + "exportedName": "buildWorkspaceTrustPreflightEnv", + "importedName": "buildWorkspaceTrustPreflightEnv" + }, + { + "rule": "public-api-implementation-export", + "source": "src/main/services/index.ts", + "specifier": "./infrastructure", + "publicEntrypoint": "src/features/recent-projects/main/index.ts", + "exportedName": "createRecentProjectsFeature", + "importedName": "ServiceContext" + }, + { + "rule": "public-api-implementation-export", + "source": "src/main/services/team/TeamMembersMetaStore.ts", + "specifier": "fs", + "publicEntrypoint": "src/features/member-log-stream/main/index.ts", + "exportedName": "createMemberLogStreamFeature", + "importedName": "*" + } + ] +} diff --git a/scripts/ci/feature-architecture-policy.mjs b/scripts/ci/feature-architecture-policy.mjs new file mode 100644 index 0000000000..a04f75a1de --- /dev/null +++ b/scripts/ci/feature-architecture-policy.mjs @@ -0,0 +1,761 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import ts from 'typescript'; + +import { + findPublicReferenceOwner, + hasModifier, + importTypeSelectedNames, + importedNameForCall, + importedNameForReference, + isIdentifierReference, + selectImportedName, + selectedMemberForReference, + statementBindingNames, +} from './feature-export-analysis.mjs'; +import { isShadowedTypeReference } from './feature-type-scope-analysis.mjs'; +import { + declarationNamesForNamespace, + directExportNamesForNamespace, + directReexportsForDeclaration, + importSelectionsForClause, +} from './feature-export-namespace-analysis.mjs'; +import { + collectConsumedDescriptorGetterProperties, + consumedDescriptorGetterMembersForReference, +} from './feature-public-descriptor-analysis.mjs'; +import { isForbiddenCoreDomainPackage, isProjectSpecifier } from './feature-core-domain-policy.mjs'; +import { isLexicallyShadowedValueReference } from './feature-lexical-binding-analysis.mjs'; +import { resolvedLocalValueNodes } from './feature-constructor-local-value-analysis.mjs'; +import { createCommonJsLoaderCallAnalysis } from './feature-commonjs-loader-analysis.mjs'; +import { isSourceCodeProjectTarget, resolveProjectTarget } from './feature-module-resolution.mjs'; +import { + forEachChildIncludingJsDoc, + importDeclarationIsTypeOnly, +} from './feature-module-syntax-analysis.mjs'; +import { analyzePublicClassSurfaces } from './feature-public-class-surface-analysis.mjs'; +import { collectPublicApiImplementationExports } from './feature-public-export-policy.mjs'; +import { + dependencyHasForbiddenReexportOrigin, + isContractProjectTarget, +} from './feature-reexport-origin-analysis.mjs'; +import { collectAmbientDependencyEdges } from './feature-ambient-dependency-analysis.mjs'; +import { snapshotExportSelection } from './feature-public-snapshot-analysis.mjs'; +import { analyzePublicTargets } from './feature-public-target-analysis.mjs'; +import { + collectProductionSourceFiles, + isFeaturePublicEntrypoint, +} from './feature-source-files.mjs'; +import { staticStringValue } from './feature-static-value-analysis.mjs'; + +export const FEATURE_ARCHITECTURE_RULES = Object.freeze({ + crossFeaturePublicEntrypoint: 'cross-feature-public-entrypoint', + coreDomainIsolation: 'core-domain-isolation', + coreApplicationDependencies: 'core-application-dependencies', + publicApiImplementationExport: 'public-api-implementation-export', +}); + +const PUBLIC_FEATURE_ENTRYPOINTS = new Set(['contracts', 'main', 'preload', 'renderer']); +const DIRECT_FEATURE_FACETS = new Set(['main/hosted', 'main/composition', 'renderer/hosted']); +const IMPLEMENTATION_DIRECTORIES = new Set(['adapters', 'infrastructure']); + +function isWithin(filePath, directoryPath) { + return filePath === directoryPath || filePath.startsWith(`${directoryPath}/`); +} + +function hasDirectorySegment(filePath, directoryNames) { + return filePath.split('/').some((segment) => directoryNames.has(segment)); +} + +function lineForNode(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function collectModuleAnalysisFromSource(source, sourcePath) { + const sourceFile = ts.createSourceFile( + sourcePath, + source, + ts.ScriptTarget.Latest, + true, + sourcePath.endsWith('.tsx') || sourcePath.endsWith('.jsx') ? ts.ScriptKind.TSX : undefined + ); + const edges = collectAmbientDependencyEdges(sourceFile, sourcePath); + const importedBindings = new Map(); + const localExports = []; + const localTypeExportNames = new Set(); + const localValueExportNames = new Set(); + const localDependencyReferences = new Map(); + const localReferenceNames = new Map(); + const reexports = []; + const resolveStaticBinding = (identifier) => + resolvedLocalValueNodes(identifier, sourceFile, { captureOuter: true }); + const describeCommonJsLoaderCall = createCommonJsLoaderCallAnalysis(sourceFile); + + const addEdge = (node, moduleSpecifier, kind, isTypeOnly = false) => { + const specifier = staticStringValue(moduleSpecifier, resolveStaticBinding); + if (specifier === null) return null; + const edge = { + isTypeOnly, + kind, + line: lineForNode(sourceFile, node), + source: sourcePath, + specifier, + }; + edges.push(edge); + return edge; + }; + + const addImportBindings = (importClause, edge) => { + if (!importClause || !edge) return; + if (importClause.name) { + importedBindings.set(importClause.name.text, { edge, importedName: 'default' }); + } + + const bindings = importClause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) { + importedBindings.set(bindings.name.text, { edge, importedName: '*' }); + } else if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + importedBindings.set(element.name.text, { + edge: element.isTypeOnly && !edge.isTypeOnly ? { ...edge, isTypeOnly: true } : edge, + importedName: element.propertyName?.text ?? element.name.text, + }); + } + } + }; + + const declaredLocalNames = new Set(); + const declaredTypeNames = new Set(); + const declaredValueNames = new Set(); + const directLocalExports = []; + const exportedLocalNames = new Set(); + const liveExportedLocalNames = new Set(); + const snapshotLocalExports = []; + for (const statement of sourceFile.statements) { + const localNames = statementBindingNames(statement); + for (const localName of localNames) declaredLocalNames.add(localName); + for (const localName of declarationNamesForNamespace(statement, 'type')) { + declaredTypeNames.add(localName); + } + for (const localName of declarationNamesForNamespace(statement, 'value')) { + declaredValueNames.add(localName); + } + if ( + ts.isExportDeclaration(statement) && + !statement.moduleSpecifier && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + const localName = element.propertyName?.text ?? element.name.text; + exportedLocalNames.add(localName); + liveExportedLocalNames.add(localName); + } + } else if (ts.isExportAssignment(statement)) { + const snapshot = snapshotExportSelection( + statement.expression, + statement.getStart(sourceFile) + ); + if (snapshot) { + snapshotLocalExports.push(snapshot); + if (snapshot.path.length === 0) exportedLocalNames.add(snapshot.name); + } + } + if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue; + + const exportedNames = hasModifier(statement, ts.SyntaxKind.DefaultKeyword) + ? ['default'] + : localNames; + for (const [index, localName] of localNames.entries()) { + exportedLocalNames.add(localName); + if (!hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) { + liveExportedLocalNames.add(localName); + } + directLocalExports.push({ + exportedName: exportedNames[index] ?? exportedNames[0], + line: lineForNode(sourceFile, statement), + localName, + }); + } + } + + const publicTargets = analyzePublicTargets( + sourceFile, + liveExportedLocalNames, + snapshotLocalExports + ); + for (const constructorExport of publicTargets.constructorExports) { + directLocalExports.push({ ...constructorExport, line: 1 }); + } + const publicClassSurfaces = analyzePublicClassSurfaces({ + constructorExports: publicTargets.constructorExports, + exportedLocalNames, + propertyWrites: publicTargets.propertyWrites, + prototypeRelations: publicTargets.prototypeRelations, + sourceFile, + }); + const publicReferenceOwner = (node) => + findPublicReferenceOwner( + node, + sourceFile, + publicTargets.localOwnersAt(node.getStart(sourceFile)), + publicTargets.commonJsTargetsAt(node.getStart(sourceFile)), + (reference) => + publicTargets.classifyProxyReference(reference) ?? + publicClassSurfaces.classifyReference(reference) + ); + + const addOwnerDependency = (owner, dependency) => { + const localDependency = + owner.localMember === undefined + ? { ...dependency, getterOnly: owner.getterOnly ?? false } + : { + ...dependency, + getterOnly: owner.getterOnly ?? false, + localMember: owner.localMember, + }; + const selectedDependencies = + localDependency.importedName === '*' && owner.bindingSelections + ? owner.bindingSelections.flatMap(({ importedName, localNames }) => + localNames.map((localName) => ({ + dependency: { ...localDependency, importedName }, + localName, + })) + ) + : owner.localNames.map((localName) => ({ dependency: localDependency, localName })); + for (const selected of selectedDependencies) { + const references = localDependencyReferences.get(selected.localName) ?? []; + references.push(selected.dependency); + localDependencyReferences.set(selected.localName, references); + } + if (owner.localNames.length === 0) { + const directDependency = { + ...localDependency, + importedName: selectImportedName(localDependency.importedName, owner.localMember), + localMember: undefined, + }; + for (const exportedName of owner.exportedNames) { + reexports.push({ + ...directDependency.edge, + exportedName, + ...directDependency, + isDependencyTrace: true, + kind: 'export', + }); + } + } + }; + + const addTypeReference = (node) => { + if (!ts.isLiteralTypeNode(node.argument)) return; + const edge = addEdge(node, node.argument.literal, 'import', true); + if (!edge) return; + edge.importedNames = importTypeSelectedNames(node); + + const owner = publicReferenceOwner(node); + if (!owner) return; + for (const importedName of edge.importedNames) { + addOwnerDependency(owner, { edge, importedName }); + } + }; + + const visit = (node) => { + if (ts.isImportDeclaration(node)) { + const edge = addEdge(node, node.moduleSpecifier, 'import', importDeclarationIsTypeOnly(node)); + if (edge) Object.assign(edge, importSelectionsForClause(node.importClause)); + addImportBindings(node.importClause, edge); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + const edge = addEdge(node, node.moduleSpecifier, 'export', node.isTypeOnly); + if (edge) { + edge.importedNames = + node.exportClause && ts.isNamedExports(node.exportClause) + ? node.exportClause.elements.map( + (element) => element.propertyName?.text ?? element.name.text + ) + : ['*']; + } + reexports.push(...directReexportsForDeclaration(node, edge)); + } else if ( + ts.isExportDeclaration(node) && + node.exportClause && + ts.isNamedExports(node.exportClause) + ) { + for (const element of node.exportClause.elements) { + localExports.push({ + exportedName: element.name.text, + isTypeOnly: node.isTypeOnly || element.isTypeOnly, + line: lineForNode(sourceFile, node), + localName: element.propertyName?.text ?? element.name.text, + }); + } + } else if (ts.isExportAssignment(node) && ts.isIdentifier(node.expression)) { + localExports.push({ + exportedName: 'default', + line: lineForNode(sourceFile, node), + localName: node.expression.text, + }); + } else if ( + ts.isExportAssignment(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) + ) { + localExports.push({ + exportedName: 'default', + importedName: node.expression.name.text, + line: lineForNode(sourceFile, node), + localName: node.expression.expression.text, + }); + } else if (ts.isExportAssignment(node)) { + localValueExportNames.add('default'); + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + node.moduleReference.expression + ) { + const edge = addEdge(node, node.moduleReference.expression, 'import', node.isTypeOnly); + if (edge) { + edge.importedNames = ['*']; + importedBindings.set(node.name.text, { edge, importedName: '*' }); + } + } else if (ts.isImportTypeNode(node)) { + addTypeReference(node); + } else if (ts.isJSDocImportTag(node)) { + const edge = addEdge(node, node.moduleSpecifier, 'import', true); + if (edge) Object.assign(edge, importSelectionsForClause(node.importClause)); + addImportBindings(node.importClause, edge); + } else if (ts.isCallExpression(node)) { + const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword; + const loaderCall = describeCommonJsLoaderCall(node); + const moduleSpecifier = isDynamicImport ? node.arguments[0] : loaderCall?.moduleSpecifier; + const selectionReference = loaderCall?.selectionReference ?? node; + if (moduleSpecifier) { + const edge = addEdge(node, moduleSpecifier, 'import'); + if (edge) edge.importedNames = [importedNameForCall(selectionReference, isDynamicImport)]; + const owner = publicReferenceOwner(node); + if (edge && owner) { + const importedName = importedNameForCall(selectionReference, isDynamicImport); + addOwnerDependency(owner, { edge, importedName }); + } + } + } + forEachChildIncludingJsDoc(node, visit); + }; + + visit(sourceFile); + + const consumedDescriptorGetterProperties = collectConsumedDescriptorGetterProperties( + sourceFile, + publicTargets + ); + + const visitBindingReference = (node) => { + if (ts.isImportEqualsDeclaration(node)) { + if (!ts.isExternalModuleReference(node.moduleReference)) { + ts.forEachChild(node.moduleReference, visitBindingReference); + } + return; + } + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + return; + } + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + !isShadowedTypeReference(node, sourceFile) + ) { + const owner = publicReferenceOwner(node); + if (owner) { + const importedBinding = importedBindings.get(node.text); + if (importedBinding && !isLexicallyShadowedValueReference(node, sourceFile)) { + addOwnerDependency(owner, { + edge: importedBinding.edge, + importedName: importedNameForReference(node, importedBinding), + }); + } else if (declaredLocalNames.has(node.text) && !owner.localNames.includes(node.text)) { + const selectedName = + selectedMemberForReference(node) ?? + (owner.localMember && owner.localMember !== '*' ? owner.localMember : null); + const consumedMembers = consumedDescriptorGetterMembersForReference( + node, + sourceFile, + consumedDescriptorGetterProperties + ); + const selectedNames = + selectedName || consumedMembers.length === 0 || consumedMembers.includes('*') + ? [selectedName] + : consumedMembers; + if (owner.localNames.length === 0) { + for (const exportedName of owner.exportedNames) { + for (const consumedName of selectedNames) { + localExports.push({ + exportedName, + importedName: consumedName, + line: lineForNode(sourceFile, node), + localName: node.text, + viaGetter: consumedMembers.length > 0, + }); + } + } + } else { + for (const localName of owner.localNames) { + const references = localReferenceNames.get(localName) ?? new Map(); + for (const consumedName of selectedNames) { + references.set(`${node.text}:${consumedName ?? ''}:${owner.localMember ?? ''}`, { + localMember: owner.localMember, + localName: node.text, + selectedName: consumedName, + viaGetter: consumedMembers.length > 0, + }); + } + localReferenceNames.set(localName, references); + } + } + } + } + } + forEachChildIncludingJsDoc(node, visitBindingReference); + }; + + visitBindingReference(sourceFile); + + const selectLocalDependencies = (dependencies, selectedName, allowGetterOnly) => { + return dependencies.flatMap((dependency) => { + if (dependency.getterOnly && !allowGetterOnly) return []; + if ( + selectedName && + dependency.localMember && + dependency.localMember !== '*' && + dependency.localMember !== selectedName + ) { + return []; + } + return [ + { + ...dependency, + importedName: dependency.localMember + ? dependency.importedName + : selectImportedName(dependency.importedName, selectedName ?? null), + getterOnly: false, + localMember: undefined, + }, + ]; + }); + }; + + const resolveLocalDependencies = ( + localName, + visited = new Set(), + selectedName, + allowGetterOnly = false + ) => { + const importedBinding = importedBindings.get(localName); + if (importedBinding) { + return selectLocalDependencies([importedBinding], selectedName, allowGetterOnly); + } + if (visited.has(localName)) return []; + const nextVisited = new Set(visited).add(localName); + const dependencies = [ + ...(localDependencyReferences.get(localName) ?? []), + ...[...(localReferenceNames.get(localName)?.values() ?? [])].flatMap((reference) => + resolveLocalDependencies( + reference.localName, + nextVisited, + reference.selectedName, + allowGetterOnly || reference.viaGetter + ).map((dependency) => ({ + ...dependency, + localMember: reference.localMember ?? dependency.localMember, + })) + ), + ]; + return selectLocalDependencies(dependencies, selectedName, allowGetterOnly); + }; + + const addResolvedReexports = ({ exportedName, importedName, line, localName, viaGetter }) => { + const dependencies = resolveLocalDependencies(localName, new Set(), importedName, viaGetter); + for (const dependency of dependencies) { + reexports.push({ + ...dependency.edge, + exportedName, + importedName: dependency.importedName, + isDependencyTrace: true, + kind: 'export', + line, + }); + } + return dependencies.length > 0; + }; + + for (const directExport of directLocalExports) addResolvedReexports(directExport); + for (const localExport of localExports) { + if (addResolvedReexports(localExport)) continue; + if (declaredTypeNames.has(localExport.localName)) { + localTypeExportNames.add(localExport.exportedName); + } + if (!localExport.isTypeOnly && declaredValueNames.has(localExport.localName)) { + localValueExportNames.add(localExport.exportedName); + } + } + + for (const statement of sourceFile.statements) { + for (const name of directExportNamesForNamespace(statement, 'type')) { + localTypeExportNames.add(name); + } + for (const name of directExportNamesForNamespace(statement, 'value')) { + localValueExportNames.add(name); + } + } + + return { + edges, + localTypeExportNames, + localValueExportNames, + reexports, + source: sourcePath, + }; +} + +export function collectModuleEdgesFromSource(source, sourcePath) { + return collectModuleAnalysisFromSource(source, sourcePath).edges; +} + +function parseFeaturePath(filePath) { + const match = /^src\/features\/([^/]+)(?:\/(.*))?$/.exec(filePath); + if (!match) return null; + return { feature: match[1], rest: match[2] ?? '' }; +} + +function parseFeatureAlias(specifier) { + const match = /^@features\/([^/]+)(?:\/(.*))?$/.exec(specifier); + if (!match) return null; + return { feature: match[1], rest: match[2] ?? '' }; +} + +function isPublicFeatureAlias(featureAlias, edge, sourceFilePaths) { + const targetPath = resolveProjectTarget(edge, sourceFilePaths); + if (DIRECT_FEATURE_FACETS.has(featureAlias.rest)) { + return targetPath === `src/features/${featureAlias.feature}/${featureAlias.rest}.ts`; + } + if (featureAlias.rest !== '' && !PUBLIC_FEATURE_ENTRYPOINTS.has(featureAlias.rest)) return false; + + return targetPath !== null && isFeaturePublicEntrypoint(targetPath); +} + +function createViolation(rule, edge, message, publicEntrypoint) { + return { + line: edge.line, + message, + publicEntrypoint, + rule, + source: edge.source, + specifier: edge.specifier, + }; +} + +function evaluateCrossFeatureEntrypoint(edge, sourceFilePaths) { + const sourceFeature = parseFeaturePath(edge.source)?.feature; + const featureAlias = parseFeatureAlias(edge.specifier); + const targetPath = resolveProjectTarget(edge, sourceFilePaths); + const targetFeature = targetPath ? parseFeaturePath(targetPath)?.feature : undefined; + + if (featureAlias) { + if ( + sourceFeature === targetFeature || + isPublicFeatureAlias(featureAlias, edge, sourceFilePaths) + ) { + return null; + } + const importedFeature = targetFeature ?? featureAlias.feature; + return createViolation( + FEATURE_ARCHITECTURE_RULES.crossFeaturePublicEntrypoint, + edge, + `feature ${importedFeature} must be imported through its root or layer entrypoint` + ); + } + + if (!targetFeature || sourceFeature === targetFeature) return null; + + return createViolation( + FEATURE_ARCHITECTURE_RULES.crossFeaturePublicEntrypoint, + edge, + `cross-feature relative imports are forbidden; use a public @features/${targetFeature} entrypoint` + ); +} + +function isForbiddenDomainProjectTarget(targetPath) { + if (hasDirectorySegment(targetPath, IMPLEMENTATION_DIRECTORIES)) return true; + if ( + isWithin(targetPath, 'src/main') || + isWithin(targetPath, 'src/preload') || + isWithin(targetPath, 'src/renderer') + ) { + return true; + } + + const targetFeature = parseFeaturePath(targetPath); + if (targetFeature) { + const [firstLayer, secondLayer] = targetFeature.rest.split('/'); + return firstLayer !== 'contracts' && !(firstLayer === 'core' && secondLayer === 'domain'); + } + + return ( + isWithin(targetPath, 'src/shared/api') || + isWithin(targetPath, 'src/shared/ipc') || + isWithin(targetPath, 'src/shared/transport') + ); +} + +function evaluateCoreDomainDependency(edge, reexportContext) { + const { sourceFilePaths } = reexportContext; + if (!/^src\/features\/[^/]+\/core\/domain\//.test(edge.source)) return null; + + const targetPath = resolveProjectTarget(edge, sourceFilePaths); + const forbidden = + (targetPath === null && isProjectSpecifier(edge.specifier)) || + isForbiddenCoreDomainPackage(edge) || + (targetPath !== null && !isSourceCodeProjectTarget(targetPath)) || + (targetPath !== null && isForbiddenDomainProjectTarget(targetPath)) || + (targetPath !== null && + isContractProjectTarget(targetPath) && + dependencyHasForbiddenReexportOrigin(edge, reexportContext, (reexport) => { + const origin = resolveProjectTarget(reexport, sourceFilePaths); + return ( + isForbiddenCoreDomainPackage(reexport) || + (origin !== null && isForbiddenDomainProjectTarget(origin)) + ); + })); + if (!forbidden) return null; + + return createViolation( + FEATURE_ARCHITECTURE_RULES.coreDomainIsolation, + edge, + 'core/domain may not depend on application, Node, Electron, frameworks, transport, adapters, or infrastructure' + ); +} + +function isAllowedCoreApplicationTarget(sourceFeature, targetPath) { + if (isWithin(targetPath, `src/features/${sourceFeature}/core/application`)) return true; + if (isWithin(targetPath, `src/features/${sourceFeature}/core/domain`)) return true; + if (isWithin(targetPath, `src/features/${sourceFeature}/contracts`)) return true; + if (isWithin(targetPath, 'src/shared/contracts')) return true; + + const targetFeature = parseFeaturePath(targetPath); + return targetFeature?.rest === 'contracts' || targetFeature?.rest.startsWith('contracts/'); +} + +function evaluateCoreApplicationDependency(edge, reexportContext) { + const { sourceFilePaths } = reexportContext; + const match = /^src\/features\/([^/]+)\/core\/application\//.exec(edge.source); + if (!match) return null; + + const targetPath = resolveProjectTarget(edge, sourceFilePaths); + if ( + targetPath && + isSourceCodeProjectTarget(targetPath) && + isAllowedCoreApplicationTarget(match[1], targetPath) && + (!isContractProjectTarget(targetPath) || + !dependencyHasForbiddenReexportOrigin(edge, reexportContext, (reexport) => { + const origin = resolveProjectTarget(reexport, sourceFilePaths); + return !origin || !isAllowedCoreApplicationTarget(match[1], origin); + })) + ) { + return null; + } + + return createViolation( + FEATURE_ARCHITECTURE_RULES.coreApplicationDependencies, + edge, + 'core/application may depend only on domain, contracts, and its own application models, use cases, and ports' + ); +} + +export function violationKey(violation) { + return JSON.stringify([ + violation.rule, + violation.source, + violation.specifier, + violation.publicEntrypoint ?? '', + violation.exportedName ?? '', + violation.importedName ?? '', + ]); +} + +export function compareViolations(left, right) { + return violationKey(left).localeCompare(violationKey(right)); +} + +export function toBaselineEntry(violation) { + const entry = { + rule: violation.rule, + source: violation.source, + specifier: violation.specifier, + }; + if (violation.publicEntrypoint) { + entry.publicEntrypoint = violation.publicEntrypoint; + entry.exportedName = violation.exportedName; + entry.importedName = violation.importedName; + } + return entry; +} + +export function collectFeatureArchitectureViolations(repoRoot) { + const sourceRoot = path.join(repoRoot, 'src'); + const sourceFiles = collectProductionSourceFiles(sourceRoot, repoRoot).sort(); + const sourceFilePaths = new Set(sourceFiles); + const moduleAnalyses = sourceFiles.map((sourcePath) => { + const source = readFileSync(path.join(repoRoot, sourcePath), 'utf8'); + return collectModuleAnalysisFromSource(source, sourcePath); + }); + const edges = moduleAnalyses.flatMap(({ edges: moduleEdges }) => moduleEdges); + const localTypeExportNamesBySource = new Map( + moduleAnalyses.map(({ localTypeExportNames, source }) => [source, localTypeExportNames]) + ); + const localValueExportNamesBySource = new Map( + moduleAnalyses.map(({ localValueExportNames, source }) => [source, localValueExportNames]) + ); + const reexports = moduleAnalyses.flatMap(({ reexports: moduleReexports }) => moduleReexports); + const reexportsBySource = new Map(); + for (const reexport of reexports) { + const sourceReexports = reexportsBySource.get(reexport.source) ?? []; + sourceReexports.push(reexport); + reexportsBySource.set(reexport.source, sourceReexports); + } + const reexportContext = { + localTypeExportNamesBySource, + localValueExportNamesBySource, + reexportsBySource, + sourceFilePaths, + }; + const violations = []; + + for (const edge of edges) { + const crossFeatureViolation = evaluateCrossFeatureEntrypoint(edge, sourceFilePaths); + if (crossFeatureViolation) violations.push(crossFeatureViolation); + + const domainViolation = evaluateCoreDomainDependency(edge, reexportContext); + if (domainViolation) violations.push(domainViolation); + + const applicationViolation = evaluateCoreApplicationDependency(edge, reexportContext); + if (applicationViolation) violations.push(applicationViolation); + } + + violations.push( + ...collectPublicApiImplementationExports({ + edges, + localTypeExportNamesBySource, + localValueExportNamesBySource, + reexports, + rule: FEATURE_ARCHITECTURE_RULES.publicApiImplementationExport, + sourceFilePaths, + }) + ); + + const uniqueViolations = new Map(); + for (const violation of violations) uniqueViolations.set(violationKey(violation), violation); + + return { + sourceFileCount: sourceFiles.length, + violations: [...uniqueViolations.values()].sort(compareViolations), + }; +} diff --git a/scripts/ci/feature-assignment-operators.mjs b/scripts/ci/feature-assignment-operators.mjs new file mode 100644 index 0000000000..1d498a7930 --- /dev/null +++ b/scripts/ci/feature-assignment-operators.mjs @@ -0,0 +1,7 @@ +import ts from 'typescript'; + +export const LOGICAL_ASSIGNMENT_KINDS = new Set([ + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, +]); diff --git a/scripts/ci/feature-binding-model-analysis.mjs b/scripts/ci/feature-binding-model-analysis.mjs new file mode 100644 index 0000000000..3338f911e9 --- /dev/null +++ b/scripts/ci/feature-binding-model-analysis.mjs @@ -0,0 +1,192 @@ +import ts from 'typescript'; + +import { propertyNameText, unwrapExpression } from './feature-export-ast.mjs'; +import { visitDefiniteTopLevelExpressions } from './feature-definite-execution.mjs'; +import { + executedInvocationForCall, + executedInvocationParameterInitializer, +} from './feature-executed-iife-analysis.mjs'; + +function bindingNames(bindingName) { + if (ts.isIdentifier(bindingName)) return [bindingName.text]; + return bindingName.elements.flatMap((element) => + ts.isBindingElement(element) ? bindingNames(element.name) : [] + ); +} + +export function collectBindingModel(sourceFile) { + const eventsByName = new Map(); + const versions = new Map(); + const topLevelNames = new Set(); + let sequence = 0; + + const addVersion = (name, initializer, position, forcedAlias, scope) => { + const key = `${name}:${position}:${sequence++}`; + const version = { forcedAlias, initializer, key, name, position, ...scope }; + versions.set(key, version); + const events = eventsByName.get(name) ?? []; + events.push(version); + eventsByName.set(name, events); + }; + const addBinding = (bindingName, initializer, position, forcedAlias, scope) => { + if (ts.isIdentifier(bindingName)) { + addVersion(bindingName.text, initializer, position, forcedAlias, scope); + return; + } + for (const [index, element] of bindingName.elements.entries()) { + if (!ts.isBindingElement(element)) continue; + const selectedName = ts.isObjectBindingPattern(bindingName) + ? propertyNameText(element.propertyName ?? element.name) + : String(index); + const skipIdentity = Boolean(element.dotDotDotToken || element.initializer); + addBinding( + element.name, + initializer, + position, + skipIdentity + ? { skipIdentity: true } + : { + expression: initializer, + path: [...(forcedAlias?.path ?? []), selectedName], + symmetric: false, + }, + scope + ); + } + }; + + for (const statement of sourceFile.statements) { + if ( + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && + statement.name + ) { + topLevelNames.add(statement.name.text); + addVersion( + statement.name.text, + statement, + ts.isFunctionDeclaration(statement) + ? sourceFile.pos + : statement.getStart(sourceFile), + undefined + ); + continue; + } + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + for (const name of bindingNames(declaration.name)) topLevelNames.add(name); + if (declaration.initializer) { + addBinding( + declaration.name, + declaration.initializer, + declaration.getStart(sourceFile), + undefined + ); + } + } + } + + const visitExpression = (node) => { + if (ts.isFunctionLike(node) || ts.isClassLike(node)) return; + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const target = unwrapExpression(node.left); + if (ts.isIdentifier(target) && topLevelNames.has(target.text)) { + addVersion(target.text, node.right, node.end, undefined); + } + } + ts.forEachChild(node, visitExpression); + }; + const visitStatement = (statement) => { + if (ts.isExpressionStatement(statement)) { + visitExpression(statement.expression); + } else if (ts.isBlock(statement)) { + for (const child of statement.statements) visitStatement(child); + } else if ( + ts.isIfStatement(statement) && + statement.expression.kind === ts.SyntaxKind.TrueKeyword + ) { + visitStatement(statement.thenStatement); + } + }; + for (const statement of sourceFile.statements) visitStatement(statement); + + visitDefiniteTopLevelExpressions(sourceFile, (node) => { + const invocation = executedInvocationForCall(node); + if (!invocation || !invocation.callable.body) return; + for (const [index, parameter] of invocation.callable.parameters.entries()) { + if (parameter.dotDotDotToken) continue; + const candidateInitializers = [ + ...new Set( + (invocation.invocations ?? [invocation]).flatMap((candidate) => { + const argumentsForParameter = + candidate.argumentCandidates?.[index] ?? [candidate.arguments[index]]; + return argumentsForParameter.flatMap((argument) => { + const initializer = executedInvocationParameterInitializer(parameter, argument); + return initializer ? [initializer] : []; + }); + }) + ), + ]; + const [initializer] = candidateInitializers; + if (!initializer) continue; + addBinding(parameter.name, initializer, parameter.getStart(sourceFile), undefined, { + candidateInitializers, + scopeEnd: invocation.callable.body.end, + scopeStart: invocation.callable.body.pos, + }); + } + }); + + for (const events of eventsByName.values()) { + events.sort((left, right) => left.position - right.position); + } + const bindingAt = (name, position) => { + const events = eventsByName.get(name) ?? []; + return ( + [...events] + .reverse() + .find( + (event) => + event.position <= position && + (event.scopeStart === undefined || + (event.scopeStart <= position && position <= event.scopeEnd)) + )?.key ?? null + ); + }; + return { bindingAt, eventsByName, versions }; +} + +export function collectContainedBindingEntries(expression, bindingModel) { + const entries = []; + const visit = (value, path = []) => { + const current = unwrapExpression(value); + if (ts.isIdentifier(current)) { + const key = bindingModel.bindingAt(current.text, current.getStart()); + if (key) entries.push({ key, path }); + return; + } + if (ts.isObjectLiteralExpression(current)) { + for (const property of current.properties) { + if (ts.isShorthandPropertyAssignment(property)) { + visit(property.name, [...path, property.name.text]); + } else if (ts.isPropertyAssignment(property)) { + visit(property.initializer, [...path, propertyNameText(property.name)]); + } + } + return; + } + if (ts.isArrayLiteralExpression(current)) { + for (const [index, element] of current.elements.entries()) { + if (!ts.isOmittedExpression(element)) { + visit(element, [...path, String(index)]); + } + } + return; + } + if (ts.isConditionalExpression(current)) { + visit(current.whenTrue, path); + visit(current.whenFalse, path); + } + }; + visit(expression); + return entries; +} diff --git a/scripts/ci/feature-callable-analysis.mjs b/scripts/ci/feature-callable-analysis.mjs new file mode 100644 index 0000000000..18c6a69b7c --- /dev/null +++ b/scripts/ci/feature-callable-analysis.mjs @@ -0,0 +1,36 @@ +import ts from 'typescript'; + +import { unwrapExpression } from './feature-export-ast.mjs'; + +export function callableTarget(expression) { + let current = unwrapExpression(expression); + while ( + ts.isBinaryExpression(current) && + current.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + current = unwrapExpression(current.right); + } + if (ts.isArrowFunction(current)) return current; + return ts.isFunctionExpression(current) && !current.asteriskToken ? current : null; +} + +export function callMethod(expression) { + const current = unwrapExpression(expression); + if (ts.isPropertyAccessExpression(current)) { + return { + name: current.name.text, + receiver: current.expression, + }; + } + if ( + ts.isElementAccessExpression(current) && + current.argumentExpression && + ts.isStringLiteralLike(unwrapExpression(current.argumentExpression)) + ) { + return { + name: unwrapExpression(current.argumentExpression).text, + receiver: current.expression, + }; + } + return null; +} diff --git a/scripts/ci/feature-commonjs-loader-analysis.mjs b/scripts/ci/feature-commonjs-loader-analysis.mjs new file mode 100644 index 0000000000..b5b6add689 --- /dev/null +++ b/scripts/ci/feature-commonjs-loader-analysis.mjs @@ -0,0 +1,202 @@ +import ts from 'typescript'; + +import { unwrapExpression } from './feature-export-analysis.mjs'; +import { + isCommonJsRequireCall, + isCommonJsRequireReference, + isLexicallyShadowedValueReference, +} from './feature-lexical-binding-analysis.mjs'; +import { + reachingLocalValueWrites, + resolvedLocalValueNodes, +} from './feature-constructor-local-value-analysis.mjs'; +import { staticMemberAccess, staticStringValue } from './feature-static-value-analysis.mjs'; + +const NODE_MODULE_SPECIFIERS = new Set(['module', 'node:module']); + +function createRequireImports(sourceFile) { + const factories = new Set(); + const namespaces = new Set(); + + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteralLike(statement.moduleSpecifier) && + NODE_MODULE_SPECIFIERS.has(statement.moduleSpecifier.text) && + !statement.importClause?.isTypeOnly + ) { + const clause = statement.importClause; + if (!clause) continue; + if (clause.name) namespaces.add(clause.name.text); + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) { + namespaces.add(bindings.name.text); + } else if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + if ( + !element.isTypeOnly && + (element.propertyName?.text ?? element.name.text) === 'createRequire' + ) { + factories.add(element.name.text); + } + } + } + } else if ( + ts.isImportEqualsDeclaration(statement) && + !statement.isTypeOnly && + ts.isExternalModuleReference(statement.moduleReference) && + statement.moduleReference.expression && + ts.isStringLiteralLike(statement.moduleReference.expression) && + NODE_MODULE_SPECIFIERS.has(statement.moduleReference.expression.text) + ) { + namespaces.add(statement.name.text); + } + } + + return { factories, namespaces }; +} + +export function createCommonJsLoaderCallAnalysis(sourceFile) { + const { factories, namespaces } = createRequireImports(sourceFile); + const resolveStaticBinding = (identifier) => + resolvedLocalValueNodes(identifier, sourceFile, { captureOuter: true }); + const isNodeModuleSpecifier = (expression) => { + const specifier = staticStringValue(expression, resolveStaticBinding); + return specifier !== null && NODE_MODULE_SPECIFIERS.has(specifier); + }; + const tracesLocalWrites = (reference, visited, resolve) => { + if (!ts.isIdentifier(reference)) return []; + return reachingLocalValueWrites(reference, sourceFile, { captureOuter: true }).flatMap( + (write) => { + if (!write.value || visited.has(write.key)) return []; + return resolve(write, new Set(visited).add(write.key)); + } + ); + }; + const isNodeModuleNamespaceReference = (expression, visited = new Set()) => { + const reference = expression && unwrapExpression(expression); + if (!reference) return false; + if ( + ts.isIdentifier(reference) && + namespaces.has(reference.text) && + !isLexicallyShadowedValueReference(reference, sourceFile) + ) { + return true; + } + if (ts.isAwaitExpression(reference)) { + return isNodeModuleNamespaceReference(reference.expression, visited); + } + if ( + ts.isCallExpression(reference) && + reference.arguments.length >= 1 && + isNodeModuleSpecifier(reference.arguments[0]) && + (reference.expression.kind === ts.SyntaxKind.ImportKeyword || + isCommonJsRequireCall(reference, sourceFile)) + ) { + return true; + } + return tracesLocalWrites(reference, visited, (write, nextVisited) => [ + (write.selected.length === 0 || (write.selected.length === 1 && write.selected[0] === '*')) && + isNodeModuleNamespaceReference(write.value, nextVisited), + ]).some(Boolean); + }; + const isCreateRequireFactoryReference = (expression, visited = new Set()) => { + const reference = expression && unwrapExpression(expression); + if (!reference) return false; + if ( + ts.isIdentifier(reference) && + factories.has(reference.text) && + !isLexicallyShadowedValueReference(reference, sourceFile) + ) { + return true; + } + const access = staticMemberAccess(reference, resolveStaticBinding); + if ( + access?.name === 'createRequire' && + isNodeModuleNamespaceReference(access.receiver, visited) + ) { + return true; + } + return tracesLocalWrites(reference, visited, (write, nextVisited) => [ + (write.selected.length === 1 && + write.selected[0] === 'createRequire' && + isNodeModuleNamespaceReference(write.value, nextVisited)) || + isCreateRequireFactoryReference(write.value, nextVisited), + ]).some(Boolean); + }; + const arrayElementExpressions = (expression, index, visited = new Set()) => { + const reference = expression && unwrapExpression(expression); + if (!reference) return []; + if (ts.isArrayLiteralExpression(reference)) { + const element = reference.elements[index]; + if (!element || ts.isOmittedExpression(element)) return []; + if (!ts.isSpreadElement(element)) return [element]; + return arrayElementExpressions(element.expression, index, visited); + } + return tracesLocalWrites(reference, visited, (write, nextVisited) => + write.selected.length === 0 ? arrayElementExpressions(write.value, index, nextVisited) : [] + ); + }; + const exactStaticExpression = (expressions) => { + if (expressions.length === 0) return null; + const values = expressions.map((expression) => + staticStringValue(expression, resolveStaticBinding) + ); + if (values.some((value) => value === null) || new Set(values).size !== 1) return null; + return expressions[0]; + }; + const loaderCallableDescriptors = (expression, visited = new Set()) => { + const reference = expression && unwrapExpression(expression); + if (!reference) return []; + if (isCommonJsRequireReference(reference, sourceFile)) { + return [{ boundArguments: [] }]; + } + if ( + ts.isCallExpression(reference) && + reference.arguments.length >= 1 && + isCreateRequireFactoryReference(reference.expression) + ) { + return [{ boundArguments: [] }]; + } + + if (ts.isCallExpression(reference)) { + const access = staticMemberAccess(reference.expression, resolveStaticBinding); + if (access?.name === 'bind') { + return loaderCallableDescriptors(access.receiver, visited).map((descriptor) => ({ + boundArguments: [...descriptor.boundArguments, ...reference.arguments.slice(1)], + })); + } + } + + return tracesLocalWrites(reference, visited, (write, nextVisited) => + write.selected.length === 0 ? loaderCallableDescriptors(write.value, nextVisited) : [] + ); + }; + const describeCommonJsLoaderCall = (node) => { + if (!ts.isCallExpression(node)) return null; + const access = staticMemberAccess(node.expression, resolveStaticBinding); + const isCallWrapper = access?.name === 'call'; + const isApplyWrapper = access?.name === 'apply'; + const callable = isCallWrapper || isApplyWrapper ? access.receiver : node.expression; + const descriptors = loaderCallableDescriptors(callable); + if (descriptors.length === 0) return null; + + const invocationArguments = isCallWrapper + ? node.arguments.slice(1, 2) + : isApplyWrapper + ? arrayElementExpressions(node.arguments[1], 0) + : node.arguments.slice(0, 1); + const moduleSpecifiers = descriptors.flatMap(({ boundArguments }) => + boundArguments.length > 0 ? [boundArguments[0]] : invocationArguments + ); + const moduleSpecifier = exactStaticExpression(moduleSpecifiers); + return moduleSpecifier + ? { + moduleSpecifier, + selectionReference: node, + } + : null; + }; + + return describeCommonJsLoaderCall; +} diff --git a/scripts/ci/feature-constructor-local-value-analysis.mjs b/scripts/ci/feature-constructor-local-value-analysis.mjs new file mode 100644 index 0000000000..df20e79d63 --- /dev/null +++ b/scripts/ci/feature-constructor-local-value-analysis.mjs @@ -0,0 +1,665 @@ +import ts from 'typescript'; + +import { memberAccess, unwrapExpression } from './feature-export-analysis.mjs'; +import { + resolveLiteralSelection, + selectedBindings, + selectionKey, +} from './feature-local-binding-selection.mjs'; +import { + immediateIifeInvocation, + staticNullishness, + staticTruthiness, +} from './feature-executed-iife-analysis.mjs'; + +const localBindingModels = new WeakMap(); +const logicalAssignmentKinds = new Set([ + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, +]); + +function immediateApplyInvocation(node) { + if (!ts.isArrowFunction(node) && !(ts.isFunctionExpression(node) && !node.asteriskToken)) { + return null; + } + + let current = node; + while (current.parent) { + const parent = current.parent; + if ( + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent)) && + parent.expression === current + ) { + current = parent; + continue; + } + if ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.CommaToken && + parent.right === current + ) { + current = parent; + continue; + } + break; + } + + const method = current.parent; + const methodCall = method?.parent; + const isApply = + (ts.isPropertyAccessExpression(method) && + !method.questionDotToken && + method.expression === current && + method.name.text === 'apply') || + (ts.isElementAccessExpression(method) && + !method.questionDotToken && + method.expression === current && + method.argumentExpression && + ts.isStringLiteralLike(unwrapExpression(method.argumentExpression)) && + unwrapExpression(method.argumentExpression).text === 'apply'); + return isApply && + methodCall && + ts.isCallExpression(methodCall) && + !methodCall.questionDotToken && + methodCall.expression === method + ? methodCall + : null; +} + +export function immediateConstructorIifeInvocation(node) { + return immediateIifeInvocation(node) ?? immediateApplyInvocation(node); +} + +function containsReference(node, reference) { + return reference.pos >= node.pos && reference.end <= node.end; +} + +function containingFunction(node, boundary) { + let current = node.parent; + while (current && current !== boundary) { + if (ts.isFunctionLike(current)) return current; + current = current.parent; + } + return ts.isFunctionLike(boundary) ? boundary : null; +} + +function nearestLexicalScope(node, boundary, functionOwner) { + let current = node.parent; + while (current && current !== boundary) { + if ( + ts.isBlock(current) || + ts.isCaseBlock(current) || + ts.isCatchClause(current) || + ts.isIterationStatement(current, false) + ) { + return current; + } + current = current.parent; + } + return functionOwner?.body ?? boundary; +} + +function constrainPath(path, control, selected, staticSelection = null) { + if (staticSelection !== null) { + if (staticSelection !== selected) path.reachable = false; + return; + } + const previous = path.constraints.get(control); + if (previous !== undefined && previous !== selected) { + path.reachable = false; + } else { + path.constraints.set(control, selected); + } +} + +function executionOwner(node, boundary) { + for (let current = node.parent; current && current !== boundary; current = current.parent) { + if (ts.isFunctionLike(current) && !immediateConstructorIifeInvocation(current)) return current; + } + return boundary; +} + +function executionPath(node, boundary) { + const path = { constraints: new Map(), reachable: true }; + let current = node; + while (path.reachable && current.parent && current.parent !== boundary) { + const parent = current.parent; + if (ts.isIfStatement(parent)) { + if (containsReference(parent.thenStatement, current)) { + constrainPath(path, parent, true, staticTruthiness(parent.expression)); + } else if (parent.elseStatement && containsReference(parent.elseStatement, current)) { + constrainPath(path, parent, false, staticTruthiness(parent.expression)); + } + } else if (ts.isConditionalExpression(parent)) { + if (containsReference(parent.whenTrue, current)) { + constrainPath(path, parent, true, staticTruthiness(parent.condition)); + } else if (containsReference(parent.whenFalse, current)) { + constrainPath(path, parent, false, staticTruthiness(parent.condition)); + } + } else if ( + ts.isBinaryExpression(parent) && + containsReference(parent.right, current) && + [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, + ].includes(parent.operatorToken.kind) + ) { + let executes = null; + if (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + executes = staticTruthiness(parent.left); + } else if (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken) { + const truthiness = staticTruthiness(parent.left); + executes = truthiness === null ? null : !truthiness; + } else { + executes = staticNullishness(parent.left); + } + constrainPath(path, parent, true, executes); + } else if ( + ts.isIterationStatement(parent, false) && + containsReference(parent.statement, current) + ) { + const execution = ts.isDoStatement(parent) + ? true + : ts.isForStatement(parent) + ? parent.condition + ? staticTruthiness(parent.condition) + : true + : ts.isWhileStatement(parent) + ? staticTruthiness(parent.expression) + : null; + constrainPath(path, parent, true, execution); + } else if (ts.isCaseClause(parent) || ts.isDefaultClause(parent) || ts.isCatchClause(parent)) { + constrainPath(path, parent, true); + } + current = parent; + } + return path; +} + +function constraintsMatch(left, right) { + return [...left].every( + ([control, selected]) => !right.has(control) || right.get(control) === selected + ); +} + +function uniqueWrites(writes) { + return [...new Map(writes.map((write) => [write.key, write])).values()]; +} + +function applyLogicalWrite(states, write, classify) { + const next = []; + for (const state of states) { + const executes = classify(state, write.operator); + if (executes !== true) next.push(state); + if (executes !== false) next.push(write); + } + return uniqueWrites(next); +} + +function reachingWrites(writes, useNode, boundary, classify, captureOuter, declaration) { + let useOwner = executionOwner(useNode, boundary); + let usePath = executionPath(useNode, boundary); + let usePosition = useNode.getStart(); + if (captureOuter && declaration.owner !== useOwner) { + useOwner = declaration.owner; + usePath = { + ...usePath, + constraints: new Map( + [...usePath.constraints].filter( + ([control]) => executionOwner(control, boundary) === declaration.owner + ) + ), + }; + usePosition = declaration.scope.end; + } + if (!usePath.reachable) return []; + const candidates = writes + .filter( + (write) => write.position <= usePosition && executionOwner(write.node, boundary) === useOwner + ) + .map((write) => ({ ...write, path: executionPath(write.node, boundary) })) + .filter( + (write) => + write.path.reachable && constraintsMatch(write.path.constraints, usePath.constraints) + ); + const controls = new Set(); + for (const write of candidates) { + for (const control of write.path.constraints.keys()) { + if (!usePath.constraints.has(control)) controls.add(control); + } + } + if (controls.size > 8) return candidates; + + const variableControls = [...controls]; + const outcomes = []; + for (let mask = 0; mask < 2 ** variableControls.length; mask += 1) { + const choices = new Map(usePath.constraints); + for (const [index, control] of variableControls.entries()) { + choices.set(control, Boolean(mask & (1 << index))); + } + const pathWrites = candidates + .filter((write) => constraintsMatch(write.path.constraints, choices)) + .sort((left, right) => left.position - right.position); + let states = []; + for (const write of pathWrites) { + states = logicalAssignmentKinds.has(write.operator) + ? applyLogicalWrite(states, write, classify) + : [write]; + } + outcomes.push(...states); + } + return uniqueWrites(outcomes); +} + +function collectLocalBindingModel(boundary) { + const cached = localBindingModels.get(boundary); + if (cached) return cached; + + const declarationsByName = new Map(); + const addDeclaration = ( + name, + node, + scope, + initializer, + selected = [], + fallback, + position = node.getStart() + ) => { + const declarationKey = `${name}:${node.pos}`; + const seed = { + fallback, + key: `${declarationKey}:${position}`, + node, + operator: ts.SyntaxKind.EqualsToken, + position, + selected, + }; + if (!initializer) seed.seed = ts.isParameter(node) ? 'unknown' : 'undefined'; + else seed.expression = initializer; + const declaration = { + key: declarationKey, + owner: executionOwner(node, boundary), + position, + scope, + writes: [seed], + }; + const declarations = declarationsByName.get(name) ?? []; + declarationsByName.set(name, [...declarations, declaration]); + }; + const visitDeclarations = (node) => { + if (ts.isFunctionDeclaration(node) && node.name && node.body) { + const owner = containingFunction(node, boundary); + const scope = nearestLexicalScope(node, boundary, owner); + addDeclaration(node.name.text, node, scope, node, [], undefined, scope.pos); + } + if ( + ts.isVariableDeclaration(node) || + (ts.isParameter(node) && ts.isFunctionLike(node.parent)) + ) { + const owner = containingFunction(node, boundary); + const list = + ts.isVariableDeclaration(node) && ts.isVariableDeclarationList(node.parent) + ? node.parent + : null; + const scope = + list && list.flags & ts.NodeFlags.BlockScoped + ? nearestLexicalScope(node, boundary, owner) + : (owner?.body ?? boundary); + for (const binding of selectedBindings(node.name)) { + addDeclaration( + binding.identifier.text, + node, + scope, + node.initializer, + binding.path, + binding.fallback + ); + } + } + ts.forEachChild(node, visitDeclarations); + }; + visitDeclarations(boundary); + + const declarationAt = (name, position) => + (declarationsByName.get(name) ?? []) + .filter( + (declaration) => + declaration.position <= position && + declaration.scope.pos <= position && + position <= declaration.scope.end + ) + .sort( + (left, right) => + left.scope.end - left.scope.pos - (right.scope.end - right.scope.pos) || + right.position - left.position + )[0] ?? null; + + const visitAssignments = (node) => { + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.EqualsToken || + logicalAssignmentKinds.has(node.operatorToken.kind)) + ) { + const target = unwrapExpression(node.left); + for (const binding of selectedBindings(target)) { + const declaration = declarationAt(binding.identifier.text, node.getStart()); + if (declaration) { + declaration.writes.push({ + expression: node.right, + fallback: binding.fallback, + key: `${declaration.key}:${node.end}`, + node, + operator: node.operatorToken.kind, + position: node.end, + selected: binding.path, + }); + } + } + } + ts.forEachChild(node, visitAssignments); + }; + visitAssignments(boundary); + + const model = { + resolveAll(name, useNode, classify, captureOuter) { + const declaration = declarationAt(name, useNode.getStart()); + return declaration + ? reachingWrites(declaration.writes, useNode, boundary, classify, captureOuter, declaration) + : []; + }, + }; + localBindingModels.set(boundary, model); + return model; +} + +function uniqueNodes(nodes) { + return [...new Map(nodes.map((node) => [`${node.pos}:${node.end}`, node])).values()]; +} + +function mergeResolution(...resolutions) { + return { + missing: resolutions.some(({ missing }) => missing), + nodes: uniqueNodes(resolutions.flatMap(({ nodes }) => nodes)), + unknown: resolutions.some(({ unknown }) => unknown), + }; +} + +function staticUndefinedness(expression) { + const current = unwrapExpression(expression); + if ((ts.isIdentifier(current) && current.text === 'undefined') || ts.isVoidExpression(current)) { + return true; + } + if ( + current.kind === ts.SyntaxKind.NullKeyword || + current.kind === ts.SyntaxKind.TrueKeyword || + current.kind === ts.SyntaxKind.FalseKeyword || + ts.isLiteralExpression(current) || + ts.isObjectLiteralExpression(current) || + ts.isArrayLiteralExpression(current) || + ts.isFunctionLike(current) || + ts.isClassLike(current) || + ts.isNewExpression(current) + ) { + return false; + } + return null; +} + +function staticValueTruthiness(expression) { + const known = staticTruthiness(expression); + if (known !== null) return known; + const current = unwrapExpression(expression); + if ( + ts.isObjectLiteralExpression(current) || + ts.isArrayLiteralExpression(current) || + ts.isFunctionLike(current) || + ts.isClassLike(current) || + ts.isNewExpression(current) + ) { + return true; + } + return staticUndefinedness(current) === true ? false : null; +} + +function staticValueNullishness(expression) { + const current = unwrapExpression(expression); + if (current.kind === ts.SyntaxKind.NullKeyword || staticUndefinedness(current) === true) { + return true; + } + const truthiness = staticValueTruthiness(current); + return truthiness === null ? null : false; +} + +function assignmentExecutesForNode(node, operator) { + if (operator === ts.SyntaxKind.QuestionQuestionEqualsToken) { + return staticValueNullishness(node); + } + const truthiness = staticValueTruthiness(node); + if (truthiness === null) return null; + return operator === ts.SyntaxKind.BarBarEqualsToken ? !truthiness : truthiness; +} + +function applyFallback(primary, fallback, selected, resolve) { + if (!fallback) return primary; + let needsFallback = primary.missing || primary.unknown; + const defined = []; + for (const node of primary.nodes) { + const undefinedness = staticUndefinedness(node); + if (undefinedness !== true) defined.push(node); + if (undefinedness !== false) needsFallback = true; + } + if (!needsFallback) return { missing: false, nodes: uniqueNodes(defined), unknown: false }; + const fallbackResolution = resolve(fallback.expression, [...fallback.selected, ...selected]); + return { + missing: fallbackResolution.missing, + nodes: uniqueNodes([...defined, ...fallbackResolution.nodes]), + unknown: primary.unknown || fallbackResolution.unknown, + }; +} + +function resolvedWrite(write, boundary, visited, memo, selected = [], captureOuter = false) { + if (write.seed === 'unknown') return { missing: false, nodes: [], unknown: true }; + if (write.seed === 'undefined') return { missing: true, nodes: [], unknown: false }; + const resolve = (expression, selection = []) => + resolvedLocalValues(expression, boundary, selection, visited, memo, captureOuter); + const primary = resolve(write.expression, [...write.selected, ...selected]); + return applyFallback(primary, write.fallback, selected, resolve); +} + +function logicalWriteDecision(state, operator, boundary, visited, memo, captureOuter) { + const resolution = resolvedWrite( + state, + boundary, + new Set(visited).add(state.key), + memo, + [], + captureOuter + ); + const outcomes = []; + if (resolution.missing) { + outcomes.push(operator !== ts.SyntaxKind.AmpersandAmpersandEqualsToken); + } + if (resolution.unknown) outcomes.push(true, false); + for (const node of resolution.nodes) { + const executes = assignmentExecutesForNode(node, operator); + if (executes === null) outcomes.push(true, false); + else outcomes.push(executes); + } + if (outcomes.length === 0) return null; + return outcomes.every(Boolean) ? true : outcomes.every((value) => !value) ? false : null; +} + +function staticPropertyName(node) { + const current = unwrapExpression(node); + return ts.isStringLiteralLike(current) || ts.isNumericLiteral(current) ? current.text : null; +} + +function resolvedLocalValues( + expression, + boundary, + selected = [], + visited = new Set(), + memo = new Map(), + captureOuter = false +) { + const current = expression && unwrapExpression(expression); + if (!current) return { missing: true, nodes: [], unknown: false }; + const memoKey = `${current.pos}:${current.end}:${selectionKey(selected)}:${captureOuter}`; + if (memo.has(memoKey)) return memo.get(memoKey); + memo.set(memoKey, { missing: false, nodes: [], unknown: true }); + + const bindings = collectLocalBindingModel(boundary); + const resolve = (value, remaining = []) => + resolvedLocalValues(value, boundary, remaining, visited, memo, captureOuter); + const resolveKey = (value) => + resolvedStaticPropertyNames(value, boundary, visited, memo, captureOuter); + const literalSelection = resolveLiteralSelection(current, selected, resolve, resolveKey); + let resolution; + if (literalSelection !== null) { + resolution = literalSelection; + } else if (ts.isConditionalExpression(current)) { + resolution = mergeResolution( + resolvedLocalValues( + current.whenTrue, + boundary, + selected, + new Set(visited), + memo, + captureOuter + ), + resolvedLocalValues( + current.whenFalse, + boundary, + selected, + new Set(visited), + memo, + captureOuter + ) + ); + } else if (ts.isIdentifier(current)) { + const writes = bindings.resolveAll( + current.text, + current, + (state, operator) => + logicalWriteDecision(state, operator, boundary, visited, memo, captureOuter), + captureOuter + ); + const values = []; + for (const write of writes) { + if (visited.has(write.key)) continue; + values.push( + resolvedWrite( + write, + boundary, + new Set(visited).add(write.key), + memo, + selected, + captureOuter + ) + ); + } + resolution = + values.length > 0 + ? mergeResolution(...values) + : selected.length > 0 + ? { missing: true, nodes: [], unknown: true } + : { missing: false, nodes: [current], unknown: false }; + } else { + const access = memberAccess(current); + if (access) { + resolution = resolvedLocalValues( + access.receiver, + boundary, + [access.name, ...selected], + visited, + memo, + captureOuter + ); + } else { + resolution = + selected.length > 0 + ? { missing: true, nodes: [], unknown: true } + : { missing: false, nodes: [current], unknown: false }; + } + } + const normalized = { ...resolution, nodes: uniqueNodes(resolution.nodes) }; + memo.set(memoKey, normalized); + return normalized; +} + +export function resolvedStaticPropertyNames( + expression, + boundary, + visited = new Set(), + memo = new Map(), + captureOuter = false +) { + return [ + ...new Set( + resolvedLocalValues(expression, boundary, [], visited, memo, captureOuter) + .nodes.map(staticPropertyName) + .filter((name) => name !== null) + ), + ]; +} + +export function resolvedLocalValueNodes(expression, boundary, options = {}) { + return resolvedLocalValues( + expression, + boundary, + [], + new Set(), + new Map(), + options.captureOuter ?? false + ).nodes; +} + +export function reachingLocalValueWrites(expression, boundary, options = {}) { + const current = expression && unwrapExpression(expression); + if (!current || !ts.isIdentifier(current)) return []; + + const captureOuter = options.captureOuter ?? false; + const visited = new Set(); + const memo = new Map(); + const bindings = collectLocalBindingModel(boundary); + return bindings + .resolveAll( + current.text, + current, + (state, operator) => + logicalWriteDecision(state, operator, boundary, visited, memo, captureOuter), + captureOuter + ) + .map(({ expression: value, key, node, seed, selected }) => ({ + key, + node, + seed, + selected, + value, + })); +} + +export function resolvedLocalValueContainsReference(expression, reference, boundary, options = {}) { + return resolvedLocalValueNodes(expression, boundary, options).some((node) => { + const value = unwrapExpression(node); + if (ts.isFunctionLike(value) || !containsReference(value, reference)) return false; + let current = reference; + while (current && current !== value) { + if (ts.isFunctionLike(current)) return false; + current = current.parent; + } + return current === value; + }); +} + +export function resolvedLocalObjects(expression, boundary) { + return resolvedLocalValueNodes(expression, boundary).filter((node) => + ts.isObjectLiteralExpression(node) + ); +} diff --git a/scripts/ci/feature-core-domain-policy.mjs b/scripts/ci/feature-core-domain-policy.mjs new file mode 100644 index 0000000000..83816f364f --- /dev/null +++ b/scripts/ci/feature-core-domain-policy.mjs @@ -0,0 +1,31 @@ +// External packages are denied for both value and type imports by default. +// This small set documents deliberate, side-effect-free domain dependencies +// instead of trying to enumerate every runtime, framework, and provider SDK. +const DOMAIN_SAFE_PACKAGES = new Set(['@claude-teams/agent-graph', 'yaml', 'zod']); + +function packageName(specifier) { + if (specifier.startsWith('node:')) return specifier; + if (specifier.startsWith('@')) return specifier.split('/').slice(0, 2).join('/'); + return specifier.split('/')[0]; +} + +export function isProjectSpecifier(specifier) { + return ( + specifier.startsWith('.') || + specifier === '@features' || + specifier.startsWith('@features/') || + specifier === '@main' || + specifier.startsWith('@main/') || + specifier === '@preload' || + specifier.startsWith('@preload/') || + specifier === '@renderer' || + specifier.startsWith('@renderer/') || + specifier === '@shared' || + specifier.startsWith('@shared/') + ); +} + +export function isForbiddenCoreDomainPackage(edge) { + if (isProjectSpecifier(edge.specifier)) return false; + return !DOMAIN_SAFE_PACKAGES.has(packageName(edge.specifier)); +} diff --git a/scripts/ci/feature-definite-execution.mjs b/scripts/ci/feature-definite-execution.mjs new file mode 100644 index 0000000000..4ac7183b0f --- /dev/null +++ b/scripts/ci/feature-definite-execution.mjs @@ -0,0 +1,346 @@ +import ts from 'typescript'; + +import { + executedInvocationForCall, + immediateExecutedInvocation, + staticNullishness, + staticStrictEquality, + staticTruthiness, +} from './feature-executed-iife-analysis.mjs'; + +function visitDefiniteExpression(node, visitor) { + if (ts.isFunctionLike(node) || ts.isClassLike(node)) return; + visitor(node); + if (ts.isConditionalExpression(node)) { + visitDefiniteExpression(node.condition, visitor); + const truthiness = staticTruthiness(node.condition); + if (truthiness === true) visitDefiniteExpression(node.whenTrue, visitor); + if (truthiness === false) visitDefiniteExpression(node.whenFalse, visitor); + return; + } + if (ts.isCallExpression(node) && (node.questionDotToken || node.expression.questionDotToken)) { + visitDefiniteExpression(node.expression, visitor); + return; + } + if (ts.isElementAccessExpression(node) && node.questionDotToken) { + visitDefiniteExpression(node.expression, visitor); + return; + } + if ( + ts.isBinaryExpression(node) && + [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.QuestionQuestionToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ].includes(node.operatorToken.kind) + ) { + visitDefiniteExpression(node.left, visitor); + const truthiness = staticTruthiness(node.left); + const rightIsDefinite = + (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && truthiness === true) || + (node.operatorToken.kind === ts.SyntaxKind.BarBarToken && truthiness === false) || + (node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && + staticNullishness(node.left) === true); + if (rightIsDefinite) visitDefiniteExpression(node.right, visitor); + return; + } + if (ts.isCallExpression(node)) { + const invocation = executedInvocationForCall(node); + if (invocation) { + visitDefiniteExpression(node.expression, visitor); + for (const argument of node.arguments) { + visitDefiniteExpression(argument, visitor); + } + if (ts.isBlock(invocation.callable.body)) { + visitDefiniteStatement(invocation.callable.body, visitor); + } else { + visitDefiniteExpression(invocation.callable.body, visitor); + } + return; + } + } + ts.forEachChild(node, (child) => visitDefiniteExpression(child, visitor)); +} + +const COMPLETION_NORMAL = 1 << 0; +const COMPLETION_RETURN = 1 << 1; +const COMPLETION_THROW = 1 << 2; +const COMPLETION_BREAK = 1 << 3; +const COMPLETION_CONTINUE = 1 << 4; +const COMPLETION_ABRUPT = + COMPLETION_RETURN | COMPLETION_THROW | COMPLETION_BREAK | COMPLETION_CONTINUE; + +function sequenceCompletion(statements) { + let completion = COMPLETION_NORMAL; + for (const statement of statements) { + if ((completion & COMPLETION_NORMAL) === 0) break; + completion = (completion & COMPLETION_ABRUPT) | statementCompletion(statement); + } + return completion; +} + +export function isReachableThroughContainingStatementLists(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + const statements = + ts.isBlock(parent) || + ts.isSourceFile(parent) || + ts.isCaseClause(parent) || + ts.isDefaultClause(parent) + ? parent.statements + : null; + const index = statements?.indexOf(current) ?? -1; + if ( + index > 0 && + (sequenceCompletion(statements.slice(0, index)) & COMPLETION_NORMAL) === 0 + ) { + return false; + } + current = parent; + } + return true; +} + +function possibleSwitchEntries(statement) { + const clauses = statement.caseBlock.clauses; + const entries = []; + for (const [index, clause] of clauses.entries()) { + if (!ts.isCaseClause(clause)) continue; + const matches = staticStrictEquality(statement.expression, clause.expression); + if (matches !== false) entries.push(index); + if (matches === true) return { entries, noMatch: false }; + } + + const defaultIndex = clauses.findIndex(ts.isDefaultClause); + if (defaultIndex >= 0) entries.push(defaultIndex); + return { entries, noMatch: defaultIndex < 0 }; +} + +function selectedSwitchClause(statement) { + const { entries, noMatch } = possibleSwitchEntries(statement); + return !noMatch && entries.length === 1 ? entries[0] : null; +} + +function switchCompletionFrom(statement, startIndex) { + if (startIndex < 0) return COMPLETION_NORMAL; + const statements = statement.caseBlock.clauses + .slice(startIndex) + .flatMap((clause) => [...clause.statements]); + const completion = sequenceCompletion(statements); + return ( + (completion & ~COMPLETION_BREAK) | + ((completion & COMPLETION_BREAK) !== 0 ? COMPLETION_NORMAL : 0) + ); +} + +function switchCompletion(statement) { + const { entries, noMatch } = possibleSwitchEntries(statement); + let completion = entries.reduce( + (combined, index) => combined | switchCompletionFrom(statement, index), + 0 + ); + if (noMatch) completion |= COMPLETION_NORMAL; + return completion; +} + +function loopCompletion(statement) { + const bodyCompletion = statementCompletion(statement.statement); + const abrupt = bodyCompletion & (COMPLETION_RETURN | COMPLETION_THROW); + const breaks = (bodyCompletion & COMPLETION_BREAK) !== 0 ? COMPLETION_NORMAL : 0; + const condition = ts.isForStatement(statement) ? statement.condition : statement.expression; + const truthiness = condition ? staticTruthiness(condition) : true; + + if (ts.isDoStatement(statement)) { + const reachesCondition = (bodyCompletion & (COMPLETION_NORMAL | COMPLETION_CONTINUE)) !== 0; + const exitsAfterCondition = reachesCondition && truthiness !== true ? COMPLETION_NORMAL : 0; + return abrupt | breaks | exitsAfterCondition; + } + if (truthiness === false) return COMPLETION_NORMAL; + if (truthiness === true) return abrupt | breaks; + return abrupt | COMPLETION_NORMAL; +} + +function statementCompletion(statement) { + if (ts.isReturnStatement(statement)) return COMPLETION_RETURN; + if (ts.isThrowStatement(statement)) return COMPLETION_THROW; + if (ts.isBreakStatement(statement)) return COMPLETION_BREAK; + if (ts.isContinueStatement(statement)) return COMPLETION_CONTINUE; + if (ts.isBlock(statement)) return sequenceCompletion(statement.statements); + if (ts.isIfStatement(statement)) { + const truthiness = staticTruthiness(statement.expression); + if (truthiness === true) return statementCompletion(statement.thenStatement); + if (truthiness === false) { + return statement.elseStatement + ? statementCompletion(statement.elseStatement) + : COMPLETION_NORMAL; + } + return ( + statementCompletion(statement.thenStatement) | + (statement.elseStatement ? statementCompletion(statement.elseStatement) : COMPLETION_NORMAL) + ); + } + if (ts.isSwitchStatement(statement)) return switchCompletion(statement); + if ( + ts.isWhileStatement(statement) || + ts.isForStatement(statement) || + ts.isDoStatement(statement) + ) { + return loopCompletion(statement); + } + if (ts.isTryStatement(statement)) { + let completion = statementCompletion(statement.tryBlock); + if (statement.catchClause && (completion & COMPLETION_THROW) !== 0) { + completion = + (completion & ~COMPLETION_THROW) | statementCompletion(statement.catchClause.block); + } + if (statement.finallyBlock) { + const finallyCompletion = statementCompletion(statement.finallyBlock); + completion = + (finallyCompletion & COMPLETION_NORMAL ? completion : 0) | + (finallyCompletion & COMPLETION_ABRUPT); + } + return completion; + } + return COMPLETION_NORMAL; +} + +function visitVariableDeclarationList(declarationList, visitor) { + for (const declaration of declarationList.declarations) { + if (declaration.initializer) visitDefiniteExpression(declaration.initializer, visitor); + } +} + +function visitSelectedSwitch(statement, visitor) { + visitDefiniteExpression(statement.expression, visitor); + for (const clause of statement.caseBlock.clauses) { + if (!ts.isCaseClause(clause)) continue; + visitDefiniteExpression(clause.expression, visitor); + if (staticStrictEquality(statement.expression, clause.expression) !== false) break; + } + const selected = selectedSwitchClause(statement); + if (selected === null || selected < 0) return; + + let completion = COMPLETION_NORMAL; + for (const clause of statement.caseBlock.clauses.slice(selected)) { + for (const child of clause.statements) { + if ((completion & COMPLETION_NORMAL) === 0) return; + completion = (completion & COMPLETION_ABRUPT) | visitDefiniteStatement(child, visitor); + } + } +} + +function visitLoop(statement, visitor) { + if (ts.isForStatement(statement)) { + if (statement.initializer) { + if (ts.isVariableDeclarationList(statement.initializer)) { + visitVariableDeclarationList(statement.initializer, visitor); + } else { + visitDefiniteExpression(statement.initializer, visitor); + } + } + if (statement.condition) visitDefiniteExpression(statement.condition, visitor); + } else if (!ts.isDoStatement(statement)) { + visitDefiniteExpression(statement.expression, visitor); + } + + const condition = ts.isForStatement(statement) ? statement.condition : statement.expression; + const truthiness = condition ? staticTruthiness(condition) : true; + if (ts.isDoStatement(statement) || truthiness === true) { + const bodyCompletion = visitDefiniteStatement(statement.statement, visitor); + if ( + ts.isForStatement(statement) && + statement.incrementor && + (bodyCompletion & (COMPLETION_NORMAL | COMPLETION_CONTINUE)) !== 0 + ) { + visitDefiniteExpression(statement.incrementor, visitor); + } + if ( + ts.isDoStatement(statement) && + (bodyCompletion & (COMPLETION_NORMAL | COMPLETION_CONTINUE)) !== 0 + ) { + visitDefiniteExpression(statement.expression, visitor); + } + } +} + +function visitDefiniteStatement(statement, visitor) { + if (ts.isExpressionStatement(statement)) { + visitDefiniteExpression(statement.expression, visitor); + } else if (ts.isVariableStatement(statement)) { + visitVariableDeclarationList(statement.declarationList, visitor); + } else if (ts.isReturnStatement(statement) || ts.isThrowStatement(statement)) { + if (statement.expression) visitDefiniteExpression(statement.expression, visitor); + } else if (ts.isBlock(statement)) { + for (const child of statement.statements) { + const completion = visitDefiniteStatement(child, visitor); + if ((completion & COMPLETION_NORMAL) === 0) break; + } + } else if (ts.isIfStatement(statement)) { + visitDefiniteExpression(statement.expression, visitor); + const truthiness = staticTruthiness(statement.expression); + if (truthiness === true) { + visitDefiniteStatement(statement.thenStatement, visitor); + } else if (truthiness === false && statement.elseStatement) { + visitDefiniteStatement(statement.elseStatement, visitor); + } + } else if (ts.isSwitchStatement(statement)) { + visitSelectedSwitch(statement, visitor); + } else if ( + ts.isWhileStatement(statement) || + ts.isForStatement(statement) || + ts.isDoStatement(statement) + ) { + visitLoop(statement, visitor); + } else if (ts.isTryStatement(statement)) { + const tryCompletion = visitDefiniteStatement(statement.tryBlock, visitor); + if (tryCompletion === COMPLETION_THROW && statement.catchClause) { + visitDefiniteStatement(statement.catchClause.block, visitor); + } + if (statement.finallyBlock) { + visitDefiniteStatement(statement.finallyBlock, visitor); + } + } + return statementCompletion(statement); +} + +export function visitDefiniteTopLevelExpressions(sourceFile, visitor) { + for (const statement of sourceFile.statements) { + visitDefiniteStatement(statement, visitor); + } +} + +export function visitDefiniteBlockExpressions(block, visitor) { + visitDefiniteStatement(block, visitor); +} + +const definiteExpressionsBySource = new WeakMap(); + +export function definiteTopLevelExpressionBoundary(node, sourceFile) { + let expressions = definiteExpressionsBySource.get(sourceFile); + if (!expressions) { + expressions = new Set(); + visitDefiniteTopLevelExpressions(sourceFile, (candidate) => { + if (ts.isExpression(candidate)) expressions.add(candidate); + }); + definiteExpressionsBySource.set(sourceFile, expressions); + } + + let boundary = null; + let current = node; + while (current && current !== sourceFile) { + if (ts.isFunctionLike(current)) { + const invocation = immediateExecutedInvocation(current); + if (!invocation) break; + current = invocation; + continue; + } + if (ts.isClassLike(current)) break; + if (ts.isExpression(current) && expressions.has(current)) boundary = current; + current = current.parent; + } + return boundary; +} diff --git a/scripts/ci/feature-executed-iife-analysis.mjs b/scripts/ci/feature-executed-iife-analysis.mjs new file mode 100644 index 0000000000..4eba643c6b --- /dev/null +++ b/scripts/ci/feature-executed-iife-analysis.mjs @@ -0,0 +1,745 @@ +import ts from 'typescript'; + +import { callableTarget, callMethod } from './feature-callable-analysis.mjs'; +import { containsReference, propertyNameText, unwrapExpression } from './feature-export-ast.mjs'; +import { + staticNullishness, + staticPropertyKey, + staticStrictEquality, + staticTruthiness, + staticUndefinedness, +} from './feature-static-value-analysis.mjs'; +import { executedSynchronousArrayCallbackForCall } from './feature-synchronous-array-callback-analysis.mjs'; + +export { staticNullishness, staticStrictEquality, staticTruthiness }; + +export function executedIifeForCall(node) { + if (!ts.isCallExpression(node) || node.questionDotToken) return null; + + const directCallable = callableTarget(node.expression); + if (directCallable) { + return { + arguments: [...node.arguments], + call: node, + callable: directCallable, + }; + } + + const method = callMethod(node.expression); + const calledCallable = method?.name === 'call' ? callableTarget(method.receiver) : null; + return calledCallable + ? { + arguments: [...node.arguments].slice(1), + call: node, + callable: calledCallable, + } + : null; +} + +export function executedInvocationForCall(node) { + return executedIifeForCall(node) ?? executedSynchronousArrayCallbackForCall(node); +} + +export function immediateIifeInvocation(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + if ( + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent)) && + parent.expression === current + ) { + current = parent; + continue; + } + if ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.CommaToken && + parent.right === current + ) { + current = parent; + continue; + } + break; + } + + const directCall = current.parent; + if ( + directCall && + ts.isCallExpression(directCall) && + directCall.expression === current && + executedIifeForCall(directCall)?.callable === node + ) { + return directCall; + } + + const method = current.parent; + const methodCall = method?.parent; + return method && + ((ts.isPropertyAccessExpression(method) && + method.expression === current && + method.name.text === 'call') || + (ts.isElementAccessExpression(method) && + method.expression === current && + method.argumentExpression && + ts.isStringLiteralLike(unwrapExpression(method.argumentExpression)) && + unwrapExpression(method.argumentExpression).text === 'call')) && + methodCall && + ts.isCallExpression(methodCall) && + methodCall.expression === method && + executedIifeForCall(methodCall)?.callable === node + ? methodCall + : null; +} + +export function immediateSynchronousArrayCallbackInvocation(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + if ( + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent)) && + parent.expression === current + ) { + current = parent; + continue; + } + if ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.CommaToken && + parent.right === current + ) { + current = parent; + continue; + } + break; + } + const call = current.parent; + return call && + ts.isCallExpression(call) && + call.arguments[0] === current && + executedSynchronousArrayCallbackForCall(call)?.callable === node + ? call + : null; +} + +export function immediateExecutedInvocation(node) { + return ( + immediateIifeInvocation(node) ?? + immediateSynchronousArrayCallbackInvocation(node) + ); +} + +const MISSING_ARGUMENT = Symbol('missing-argument'); +const UNKNOWN_SELECTION = Symbol('unknown-selection'); +const DEFINED_SELECTION = Symbol('defined-selection'); + +function selectionUndefinedness(expression) { + if (expression === MISSING_ARGUMENT) return true; + if (expression === UNKNOWN_SELECTION) return null; + if (expression === DEFINED_SELECTION) return false; + return staticUndefinedness(expression); +} + +export function isPotentiallyExecutedAtTopLevel(node, sourceFile) { + let current = node; + while (current && current !== sourceFile) { + const parent = current.parent; + if (!parent) return false; + if (ts.isFunctionLike(parent)) { + const invocation = immediateExecutedInvocation(parent); + if (!invocation) return false; + current = invocation; + continue; + } + if (ts.isClassLike(parent)) return false; + if (ts.isIfStatement(parent)) { + const condition = staticTruthiness(parent.expression); + if ( + (condition === true && current === parent.elseStatement) || + (condition === false && current === parent.thenStatement) + ) { + return false; + } + } else if (ts.isConditionalExpression(parent)) { + const condition = staticTruthiness(parent.condition); + if ( + (condition === true && current === parent.whenFalse) || + (condition === false && current === parent.whenTrue) + ) { + return false; + } + } else if (ts.isBinaryExpression(parent) && current === parent.right) { + const left = staticTruthiness(parent.left); + if ( + (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && left === false) || + (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken && left === true) || + (parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && + staticNullishness(parent.left) === false) + ) { + return false; + } + } else if ( + (ts.isWhileStatement(parent) || ts.isForStatement(parent)) && + current === parent.statement + ) { + const condition = ts.isForStatement(parent) ? parent.condition : parent.expression; + if (condition && staticTruthiness(condition) === false) return false; + } + current = parent; + } + return current === sourceFile; +} + +function isValueReference(node) { + const parent = node.parent; + if (!parent) return false; + if ( + ts.isBinaryExpression(parent) && + ts.isAssignmentOperator(parent.operatorToken.kind) && + unwrapExpression(parent.left) === node + ) { + return false; + } + if ( + (ts.isPropertyAccessExpression(parent) && parent.name === node) || + ('name' in parent && parent.name === node && !ts.isShorthandPropertyAssignment(parent)) + ) { + return false; + } + return !(ts.isBindingElement(parent) && parent.propertyName === node); +} + +function bindingEntries(bindingName, path = []) { + if (ts.isIdentifier(bindingName)) return [{ identifier: bindingName, path }]; + return bindingName.elements.flatMap((element, index) => { + if (!ts.isBindingElement(element)) return []; + const selected = element.dotDotDotToken + ? '*' + : ts.isObjectBindingPattern(bindingName) + ? propertyNameText(element.propertyName ?? element.name) + : String(index); + return bindingEntries(element.name, [...path, selected]); + }); +} + +function directLexicalBindings(node) { + const names = new Set(); + const add = (bindingName) => { + for (const { identifier } of bindingEntries(bindingName)) { + names.add(identifier.text); + } + }; + const statements = + ts.isBlock(node) || ts.isSourceFile(node) + ? node.statements + : ts.isCaseBlock(node) + ? node.clauses.flatMap((clause) => [...clause.statements]) + : []; + for (const statement of statements) { + if ( + ts.isVariableStatement(statement) && + (statement.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0 + ) { + for (const declaration of statement.declarationList.declarations) { + add(declaration.name); + } + } else if ( + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && + statement.name + ) { + names.add(statement.name.text); + } + } + if (ts.isCatchClause(node) && node.variableDeclaration) { + add(node.variableDeclaration.name); + } + if ( + (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) && + node.initializer && + ts.isVariableDeclarationList(node.initializer) && + (node.initializer.flags & ts.NodeFlags.BlockScoped) !== 0 + ) { + for (const declaration of node.initializer.declarations) add(declaration.name); + } + return names; +} + +function callableBindsName(callable, name) { + return ( + callable.parameters.some((parameter) => + bindingEntries(parameter.name).some(({ identifier }) => identifier.text === name) + ) || + (callable.name && ts.isIdentifier(callable.name) && callable.name.text === name) + ); +} + +function expressionReadsBinding(expression, name) { + let found = false; + const visit = (node) => { + if (found || ts.isFunctionLike(node) || ts.isClassLike(node)) return; + if (ts.isIdentifier(node) && node.text === name && isValueReference(node)) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(expression); + return found; +} + +function bindingLiveAfterExpression(expression, name, live) { + const current = unwrapExpression(expression); + if (ts.isBinaryExpression(current)) { + const assignmentTarget = unwrapExpression(current.left); + if ( + ts.isIdentifier(assignmentTarget) && + assignmentTarget.text === name && + [ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ].includes(current.operatorToken.kind) + ) { + // A live implementation value is truthy and non-nullish on the path carrying this taint. + if ( + current.operatorToken.kind === ts.SyntaxKind.EqualsToken || + current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken + ) { + return live && expressionReadsBinding(current.right, name); + } + return live; + } + if (current.operatorToken.kind === ts.SyntaxKind.CommaToken) { + const afterLeft = bindingLiveAfterExpression(current.left, name, live); + return bindingLiveAfterExpression(current.right, name, afterLeft); + } + if ( + [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, + ].includes(current.operatorToken.kind) + ) { + const afterLeft = bindingLiveAfterExpression(current.left, name, live); + const truthiness = staticTruthiness(current.left); + const rightIsDefinite = + (current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + truthiness === true) || + (current.operatorToken.kind === ts.SyntaxKind.BarBarToken && truthiness === false) || + (current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && + staticNullishness(current.left) === true); + if (rightIsDefinite) { + return bindingLiveAfterExpression(current.right, name, afterLeft); + } + return afterLeft || bindingLiveAfterExpression(current.right, name, afterLeft); + } + } + if (ts.isConditionalExpression(current)) { + const afterCondition = bindingLiveAfterExpression(current.condition, name, live); + const truthiness = staticTruthiness(current.condition); + if (truthiness === true) { + return bindingLiveAfterExpression(current.whenTrue, name, afterCondition); + } + if (truthiness === false) { + return bindingLiveAfterExpression(current.whenFalse, name, afterCondition); + } + return ( + bindingLiveAfterExpression(current.whenTrue, name, afterCondition) || + bindingLiveAfterExpression(current.whenFalse, name, afterCondition) + ); + } + if (ts.isCallExpression(current)) { + const invocation = executedInvocationForCall(current); + if ( + invocation?.callable.body && + !callableBindsName(invocation.callable, name) && + ts.isBlock(invocation.callable.body) + ) { + let afterArguments = live; + for (const argument of invocation.arguments) { + afterArguments = bindingLiveAfterExpression(argument, name, afterArguments); + } + return bindingLiveAfterStatement(invocation.callable.body, name, afterArguments); + } + } + return live; +} + +function bindingLiveAfterStatement(statement, name, live) { + if (ts.isExpressionStatement(statement)) { + return bindingLiveAfterExpression(statement.expression, name, live); + } + if (ts.isBlock(statement)) { + if (directLexicalBindings(statement).has(name)) return live; + return statement.statements.reduce( + (current, child) => bindingLiveAfterStatement(child, name, current), + live + ); + } + if (ts.isIfStatement(statement)) { + const afterCondition = bindingLiveAfterExpression(statement.expression, name, live); + const truthiness = staticTruthiness(statement.expression); + if (truthiness === true) { + return bindingLiveAfterStatement(statement.thenStatement, name, afterCondition); + } + if (truthiness === false) { + return statement.elseStatement + ? bindingLiveAfterStatement(statement.elseStatement, name, afterCondition) + : afterCondition; + } + return ( + bindingLiveAfterStatement(statement.thenStatement, name, afterCondition) || + (statement.elseStatement + ? bindingLiveAfterStatement(statement.elseStatement, name, afterCondition) + : afterCondition) + ); + } + return live; +} + +function bindingIsLiveAtReference(callable, name, reference) { + const preceding = []; + let current = reference; + while (current && current !== callable.body) { + const parent = current.parent; + if (!parent) break; + if (ts.isBlock(parent) && parent.statements.includes(current)) { + preceding.unshift(...parent.statements.slice(0, parent.statements.indexOf(current))); + } else if ( + (ts.isIfStatement(parent) && + (current === parent.thenStatement || current === parent.elseStatement)) || + (ts.isConditionalExpression(parent) && + (current === parent.whenTrue || current === parent.whenFalse)) + ) { + preceding.unshift(parent.expression ?? parent.condition); + } + current = parent; + } + return preceding.reduce( + (live, node) => + ts.isStatement(node) + ? bindingLiveAfterStatement(node, name, live) + : bindingLiveAfterExpression(node, name, live), + true + ); +} + +function parameterReferences(callable, identifier) { + if (!callable.body) return []; + const references = []; + const visit = (node, shadowed = false) => { + if (node !== callable.body && (ts.isFunctionLike(node) || ts.isClassLike(node))) { + if ( + ts.isFunctionLike(node) && + immediateExecutedInvocation(node) && + node.body && + !callableBindsName(node, identifier.text) + ) { + visit(node.body, shadowed); + } + return; + } + const nestedShadow = + shadowed || + (node !== callable.body && + (ts.isBlock(node) || + ts.isCaseBlock(node) || + ts.isCatchClause(node) || + ts.isForStatement(node) || + ts.isForInStatement(node) || + ts.isForOfStatement(node)) && + directLexicalBindings(node).has(identifier.text)); + if ( + !nestedShadow && + ts.isIdentifier(node) && + node.text === identifier.text && + isValueReference(node) + ) { + if (bindingIsLiveAtReference(callable, identifier.text, node)) references.push(node); + return; + } + ts.forEachChild(node, (child) => visit(child, nestedShadow)); + }; + visit(callable.body); + return references; +} + +function referencePath(expression, reference) { + if (!containsReference(expression, reference)) return null; + const path = []; + let current = reference; + while (current !== expression) { + const parent = current.parent; + if (!parent || !containsReference(parent, reference)) return null; + if (ts.isPropertyAssignment(parent) && containsReference(parent.initializer, reference)) { + path.unshift(propertyNameText(parent.name)); + } else if ( + ts.isShorthandPropertyAssignment(parent) && + containsReference(parent.name, reference) + ) { + path.unshift(propertyNameText(parent.name)); + } else if (ts.isArrayLiteralExpression(parent)) { + path.unshift(String(parent.elements.indexOf(current))); + } else if (ts.isSpreadAssignment(parent) || ts.isSpreadElement(parent)) { + path.unshift('*'); + } + current = parent; + } + return path; +} + +function pathsOverlap(bindingPath, selectedPath) { + const length = Math.min(bindingPath.length, selectedPath.length); + return Array.from({ length }, (_, index) => index).every( + (index) => + bindingPath[index] === '*' || + selectedPath[index] === '*' || + bindingPath[index] === selectedPath[index] + ); +} + +function referencesForBindingSelection(callable, bindingName, selectedPath) { + return bindingEntries(bindingName) + .filter(({ path }) => pathsOverlap(path, selectedPath)) + .flatMap(({ identifier }) => parameterReferences(callable, identifier)); +} + +function referencesForSelection(callable, parameter, selectedPath) { + return referencesForBindingSelection(callable, parameter.name, selectedPath); +} + +function referenceIsMutationTarget(reference) { + let current = reference; + while (current.parent) { + const parent = current.parent; + if ( + ((ts.isPropertyAccessExpression(parent) || + ts.isElementAccessExpression(parent)) && + parent.expression === current) || + ((ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent)) && + parent.expression === current) + ) { + current = parent; + continue; + } + return ( + ts.isBinaryExpression(parent) && + ts.isAssignmentOperator(parent.operatorToken.kind) && + unwrapExpression(parent.left) === current + ); + } + return false; +} + +function staticPropertyName(name) { + return staticPropertyKey(name); +} + +function objectLiteralSelection(object, key) { + for (const property of [...object.properties].reverse()) { + if (ts.isSpreadAssignment(property)) { + const selected = literalSelection(property.expression, key, false); + if (selected !== MISSING_ARGUMENT) return selected; + continue; + } + const propertyKey = staticPropertyName(property.name); + if (propertyKey === null) return UNKNOWN_SELECTION; + if (propertyKey !== key) continue; + if (ts.isPropertyAssignment(property)) return property.initializer; + if (ts.isShorthandPropertyAssignment(property)) return property.name; + return DEFINED_SELECTION; + } + return MISSING_ARGUMENT; +} + +function arrayLiteralSelection(array, index) { + if (array.elements.some(ts.isSpreadElement)) return UNKNOWN_SELECTION; + const element = array.elements[index]; + return !element || ts.isOmittedExpression(element) ? MISSING_ARGUMENT : element; +} + +function literalSelection(source, key, arrayPattern) { + if (source === MISSING_ARGUMENT || source === UNKNOWN_SELECTION) return UNKNOWN_SELECTION; + const current = unwrapExpression(source); + if (arrayPattern && ts.isArrayLiteralExpression(current)) { + return arrayLiteralSelection(current, Number(key)); + } + if (!arrayPattern && ts.isObjectLiteralExpression(current)) { + return objectLiteralSelection(current, key); + } + if (!arrayPattern && staticNullishness(current) === false && staticTruthiness(current) !== null) { + return MISSING_ARGUMENT; + } + return UNKNOWN_SELECTION; +} + +function bindingPatternContains(pattern, target) { + if (ts.isIdentifier(pattern)) return false; + return pattern.elements.some( + (element) => + ts.isBindingElement(element) && + (element === target || bindingPatternContains(element.name, target)) + ); +} + +function bindingElementKey(pattern, element, index) { + if (ts.isArrayBindingPattern(pattern)) return String(index); + const name = element.propertyName ?? element.name; + return staticPropertyName(name); +} + +function bindingElementDefaultExecutes(pattern, source, target) { + if (ts.isIdentifier(pattern)) return false; + for (const [index, element] of pattern.elements.entries()) { + if (!ts.isBindingElement(element)) continue; + if (element !== target && !bindingPatternContains(element.name, target)) continue; + const key = bindingElementKey(pattern, element, index); + if (key === null || element.dotDotDotToken) return false; + let selected = literalSelection(source, key, ts.isArrayBindingPattern(pattern)); + const undefinedness = selectionUndefinedness(selected); + if (element === target) return undefinedness === true; + if (undefinedness === true) { + if (!element.initializer) return false; + selected = element.initializer; + } else if (undefinedness === null) { + return false; + } + return bindingElementDefaultExecutes(element.name, selected, target); + } + return false; +} + +function parameterDefaultExecutes(argument) { + return argument === undefined || staticUndefinedness(argument) === true; +} + +function effectiveParameterValue(parameter, argument) { + if (!parameterDefaultExecutes(argument)) { + return argument === undefined ? UNKNOWN_SELECTION : argument; + } + return parameter.initializer ?? UNKNOWN_SELECTION; +} + +export function executedInvocationParameterInitializer(parameter, argument) { + const value = effectiveParameterValue(parameter, argument); + return value === UNKNOWN_SELECTION || value === MISSING_ARGUMENT ? null : value; +} + +function defaultParameterReferences(callable, parameter, reference, argument) { + let current = reference; + while (current && current !== parameter) { + const parent = current.parent; + if (!parent) return []; + if ( + ts.isBindingElement(parent) && + parent.initializer && + containsReference(parent.initializer, reference) + ) { + if ( + !bindingElementDefaultExecutes( + parameter.name, + effectiveParameterValue(parameter, argument), + parent + ) + ) { + return []; + } + const selectedPath = referencePath(parent.initializer, reference) ?? []; + return referencesForBindingSelection(callable, parent.name, selectedPath); + } + current = parent; + } + if ( + !parameter.initializer || + !containsReference(parameter.initializer, reference) || + !parameterDefaultExecutes(argument) + ) { + return []; + } + const selectedPath = referencePath(parameter.initializer, reference) ?? []; + return referencesForSelection(callable, parameter, selectedPath); +} + +function synchronousCallbackParameterReferences(invocation, reference) { + const binding = invocation.elementBindings.find(({ element }) => + containsReference(element, reference) + ); + if (!binding) return []; + const { element, parameterIndex } = binding; + const isReducer = invocation.method === 'reduce' || invocation.method === 'reduceRight'; + const selections = [ + { + parameter: invocation.callable.parameters[parameterIndex], + path: referencePath(element, reference), + }, + { + parameter: invocation.callable.parameters[isReducer ? 3 : 2], + path: referencePath(invocation.receiver, reference), + }, + ]; + return [ + ...new Set( + selections.flatMap(({ parameter, path }) => + parameter && path + ? referencesForSelection(invocation.callable, parameter, path).filter( + (reference) => !referenceIsMutationTarget(reference) + ) + : [] + ) + ), + ]; +} + +export function executedInvocationParameterReferences(reference) { + let current = reference; + while (current.parent) { + const parent = current.parent; + if (ts.isCallExpression(parent)) { + const callbackInvocation = executedSynchronousArrayCallbackForCall(parent); + const callbackReferences = + callbackInvocation && + synchronousCallbackParameterReferences(callbackInvocation, reference); + if (callbackReferences && callbackReferences.length > 0) return callbackReferences; + const invocation = callbackInvocation ? null : executedIifeForCall(parent); + const argumentIndex = invocation?.arguments.findIndex((argument) => + argument ? containsReference(argument, reference) : false + ); + if (invocation && argumentIndex !== undefined && argumentIndex >= 0) { + const parameter = + invocation.callable.parameters[argumentIndex] ?? + (invocation.callable.parameters.at(-1)?.dotDotDotToken + ? invocation.callable.parameters.at(-1) + : null); + const selectedPath = referencePath(invocation.arguments[argumentIndex], reference); + return parameter && selectedPath + ? referencesForSelection(invocation.callable, parameter, selectedPath) + : []; + } + if (invocation) { + for (const [index, parameter] of invocation.callable.parameters.entries()) { + const references = defaultParameterReferences( + invocation.callable, + parameter, + reference, + invocation.arguments[index] + ); + if (references.length > 0) return references; + } + } + } + current = parent; + } + return []; +} diff --git a/scripts/ci/feature-export-analysis.mjs b/scripts/ci/feature-export-analysis.mjs new file mode 100644 index 0000000000..c69ea5eb72 --- /dev/null +++ b/scripts/ci/feature-export-analysis.mjs @@ -0,0 +1,769 @@ +import ts from 'typescript'; + +import { + definiteTopLevelExpressionBoundary, + isReachableThroughContainingStatementLists, +} from './feature-definite-execution.mjs'; +import { + bindingNames, + containsReference, + memberAccess, + propertyNameText, + statementBindingNames, + unwrapExpression, +} from './feature-export-ast.mjs'; +import { + immediateInvocation, + topLevelExpressionBoundary, +} from './feature-export-flow-analysis.mjs'; +import { + executedInvocationParameterReferences, + isPotentiallyExecutedAtTopLevel, +} from './feature-executed-iife-analysis.mjs'; +import { + dynamicThenCallbackMember, + exportAssignmentValueSelection, + expressionGetterSelection, + variableValueSelection, +} from './feature-export-value-analysis.mjs'; +import { publishedValueReferenceState } from './feature-public-value-flow-analysis.mjs'; + +export { bindingNames, memberAccess, propertyNameText, statementBindingNames, unwrapExpression }; + +const MUTATING_OBJECT_METHODS = new Set([ + 'assign', + 'defineProperties', + 'defineProperty', + 'set', + 'setPrototypeOf', +]); + +export function rootBindingName(expression) { + let current = expression; + while (true) { + if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) { + current = current.expression; + continue; + } + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + continue; + } + break; + } + return ts.isIdentifier(current) ? current.text : null; +} + +export function hasModifier(node, kind) { + return ( + ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) + ); +} + +function isExportedNamespaceStatement(statement) { + return ( + ts.isExportDeclaration(statement) || + ts.isExportAssignment(statement) || + hasModifier(statement, ts.SyntaxKind.ExportKeyword) + ); +} + +function isReferenceInExportedNamespaceMember(node, namespaceDeclaration) { + let current = node; + while (current && current !== namespaceDeclaration) { + if (current.parent && ts.isModuleBlock(current.parent)) { + if (!isExportedNamespaceStatement(current)) return false; + current = current.parent.parent; + continue; + } + current = current.parent; + } + return current === namespaceDeclaration; +} + +function unwrapParenthesizedType(node) { + let current = node; + while (ts.isParenthesizedTypeNode(current)) current = current.type; + return current; +} + +export function importTypeSelectedNames(node) { + let current = node.qualifier; + while (current && ts.isQualifiedName(current)) current = current.left; + if (current && ts.isIdentifier(current)) return [current.text]; + + let selectedType = node; + while ( + selectedType.parent && + ts.isParenthesizedTypeNode(selectedType.parent) && + selectedType.parent.type === selectedType + ) { + selectedType = selectedType.parent; + } + const parent = selectedType.parent; + if (!parent || !ts.isIndexedAccessTypeNode(parent) || parent.objectType !== selectedType) { + return ['*']; + } + const selected = unwrapParenthesizedType(parent.indexType); + const selectedTypes = ts.isUnionTypeNode(selected) ? selected.types : [selected]; + const names = selectedTypes.map((selectedTypeNode) => { + const unwrapped = unwrapParenthesizedType(selectedTypeNode); + return ts.isLiteralTypeNode(unwrapped) && ts.isStringLiteralLike(unwrapped.literal) + ? unwrapped.literal.text + : null; + }); + return names.every((name) => name !== null) ? [...new Set(names)] : ['*']; +} + +export function isIdentifierReference(node) { + const parent = node.parent; + if (!parent) return false; + if ( + (ts.isPropertyAccessExpression(parent) && parent.name === node) || + (ts.isQualifiedName(parent) && parent.right === node) + ) { + return false; + } + if ( + 'name' in parent && + parent.name === node && + !ts.isShorthandPropertyAssignment(parent) && + !ts.isExportSpecifier(parent) + ) { + return false; + } + return !( + (ts.isBindingElement(parent) && parent.propertyName === node) || + ts.isImportClause(parent) || + ts.isImportSpecifier(parent) || + ts.isNamespaceImport(parent) + ); +} + +function assignmentLocalNames(target) { + const current = unwrapExpression(target); + if (ts.isIdentifier(current)) return [current.text]; + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + return assignmentLocalNames(current.left); + } + if (ts.isObjectLiteralExpression(current)) { + return current.properties.flatMap((property) => { + if (ts.isShorthandPropertyAssignment(property)) return [property.name.text]; + if (ts.isPropertyAssignment(property)) return assignmentLocalNames(property.initializer); + if (ts.isSpreadAssignment(property)) return assignmentLocalNames(property.expression); + return []; + }); + } + if (ts.isArrayLiteralExpression(current)) { + return current.elements.flatMap((element) => + ts.isOmittedExpression(element) ? [] : assignmentLocalNames(element) + ); + } + return []; +} + +function assignmentTargetSelections(expression, exportedLocalNames) { + const current = unwrapExpression(expression); + if (!ts.isBinaryExpression(current) || current.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return []; + } + + const target = unwrapExpression(current.left); + if (ts.isObjectLiteralExpression(target)) { + return target.properties.flatMap((property) => { + let importedName = '*'; + let localNames = []; + if (ts.isShorthandPropertyAssignment(property)) { + importedName = property.name.text; + localNames = [property.name.text]; + } else if (ts.isPropertyAssignment(property)) { + const name = property.name; + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) importedName = name.text; + localNames = assignmentLocalNames(property.initializer); + } else if (ts.isSpreadAssignment(property)) { + localNames = assignmentLocalNames(property.expression); + } + localNames = localNames.filter((name) => exportedLocalNames.has(name)); + return localNames.length > 0 ? [{ importedName, localNames }] : []; + }); + } + if (ts.isArrayLiteralExpression(target)) { + const localNames = assignmentLocalNames(target).filter((name) => exportedLocalNames.has(name)); + return localNames.length > 0 ? [{ importedName: '*', localNames }] : []; + } + return []; +} + +function isModuleExports(expression) { + const access = memberAccess(expression); + return ( + access?.name === 'exports' && + ts.isIdentifier(access.receiver) && + access.receiver.text === 'module' + ); +} + +export function commonJsExportPath(expression) { + let current = unwrapExpression(expression); + if ((ts.isIdentifier(current) && current.text === 'exports') || isModuleExports(current)) { + return []; + } + + const path = []; + while (true) { + const access = + memberAccess(current) ?? + (ts.isElementAccessExpression(current) + ? { name: '*', receiver: unwrapExpression(current.expression) } + : null); + if (!access) return null; + path.unshift(access.name); + current = access.receiver; + if (ts.isIdentifier(current) && current.text === 'exports') return path; + if (isModuleExports(current)) return path; + } +} + +function commonJsTargetPath(expression, commonJsTargetAliases = new Set()) { + const directPath = commonJsExportPath(expression); + if (directPath !== null) { + const root = rootBindingName(expression); + if (root === 'exports' && commonJsTargetAliases.directExportsActive === false) return null; + if (root === 'module' && commonJsTargetAliases.directModuleExportsActive === false) { + return null; + } + return directPath; + } + + let current = unwrapExpression(expression); + const path = []; + while (true) { + const access = memberAccess(current); + if (!access) break; + path.unshift(access.name); + current = access.receiver; + } + if (!ts.isIdentifier(current)) return null; + const isPublicPath = + commonJsTargetAliases.hasPath?.(current.text, path) || commonJsTargetAliases.has(current.text); + return isPublicPath ? path : null; +} + +export function isCommonJsExportsObject(expression) { + return commonJsExportPath(expression) !== null; +} + +function commonJsAssignmentExportName(expression, commonJsTargetAliases) { + if ( + !ts.isBinaryExpression(expression) || + ![ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ].includes(expression.operatorToken.kind) + ) { + return null; + } + + const target = unwrapExpression(expression.left); + const exportPath = commonJsTargetPath(target, commonJsTargetAliases); + return exportPath === null ? null : (exportPath[0] ?? '*'); +} + +function commonJsCreateBindingSelection(expression, reference, commonJsTargetAliases = new Set()) { + const current = unwrapExpression(expression); + if (!ts.isCallExpression(current)) return null; + + const method = memberAccess(current.expression); + const callee = unwrapExpression(current.expression); + const helperName = ts.isIdentifier(callee) ? callee.text : method?.name; + if ( + helperName !== '__createBinding' || + !current.arguments[0] || + commonJsTargetPath(current.arguments[0], commonJsTargetAliases) === null || + !current.arguments[1] || + (reference && !containsReference(current.arguments[1], reference)) + ) { + return null; + } + + const importedName = current.arguments[2]; + const exportedName = current.arguments[3] ?? importedName; + const targetPath = commonJsTargetPath(current.arguments[0], commonJsTargetAliases); + return { + exportedName: + targetPath && targetPath.length > 0 + ? targetPath[0] + : exportedName && ts.isStringLiteralLike(exportedName) + ? exportedName.text + : '*', + importedName: importedName && ts.isStringLiteralLike(importedName) ? importedName.text : '*', + }; +} + +export function commonJsExportNamesForExpression(expression, commonJsTargetAliases = new Set()) { + const assignmentName = commonJsAssignmentExportName(expression, commonJsTargetAliases); + if (assignmentName) return [assignmentName]; + + const current = unwrapExpression(expression); + if (!ts.isCallExpression(current)) return []; + + const method = memberAccess(current.expression); + const callee = unwrapExpression(current.expression); + const helperName = ts.isIdentifier(callee) ? callee.text : method?.name; + if ( + (helperName === '__exportStar' || helperName === '_exportStar') && + current.arguments[1] && + commonJsTargetPath(current.arguments[1], commonJsTargetAliases) !== null + ) { + return [commonJsTargetPath(current.arguments[1], commonJsTargetAliases)?.[0] ?? '*']; + } + const createBinding = commonJsCreateBindingSelection(current, undefined, commonJsTargetAliases); + if (createBinding) return [createBinding.exportedName]; + const target = current.arguments[0]; + const targetPath = target ? commonJsTargetPath(target, commonJsTargetAliases) : null; + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) || + !MUTATING_OBJECT_METHODS.has(method.name) || + targetPath === null + ) { + return []; + } + + if (targetPath.length > 0) return [targetPath[0]]; + if (method.name !== 'defineProperty' && method.name !== 'set') return ['*']; + const exportName = current.arguments[1]; + return exportName && ts.isStringLiteralLike(exportName) ? [exportName.text] : ['*']; +} + +export function getterSelectionForReference(reference, boundary) { + if (ts.isVariableStatement(boundary)) { + for (const declaration of boundary.declarationList.declarations) { + const selection = variableValueSelection( + declaration, + reference, + hasModifier(boundary, ts.SyntaxKind.ExportKeyword) + ); + if (selection) return selection; + } + return null; + } + if (ts.isFunctionDeclaration(boundary) && !hasModifier(boundary, ts.SyntaxKind.ExportKeyword)) { + return { getterOnly: true, localMember: undefined }; + } + if (ts.isExpressionStatement(boundary)) { + return expressionGetterSelection(boundary.expression, reference); + } + if (ts.isExportAssignment(boundary)) { + return exportAssignmentValueSelection(boundary.expression, reference); + } + return null; +} + +export function commonJsExportNamesForReference( + expression, + reference, + insideFunctionBody, + commonJsTargetAliases = new Set(), + publicSelection +) { + const createBinding = commonJsCreateBindingSelection( + expression, + undefined, + commonJsTargetAliases + ); + if ( + createBinding && + !commonJsCreateBindingSelection(expression, reference, commonJsTargetAliases) + ) { + return []; + } + + const exportNames = commonJsExportNamesForExpression(expression, commonJsTargetAliases); + if ( + exportNames.length > 0 && + commonJsTargetAliases.isReferencePublic?.(expression, reference) === false + ) { + return []; + } + if (!insideFunctionBody || exportNames.length === 0) return exportNames; + + const selection = publicSelection ?? expressionGetterSelection(expression, reference); + if (!selection) return []; + return exportNames.includes('*') && selection.localMember ? [selection.localMember] : exportNames; +} + +function potentialTopLevelExpressionBoundary(node, sourceFile) { + if (!isPotentiallyExecutedAtTopLevel(node, sourceFile)) return null; + let current = node; + while (current && current !== sourceFile) { + if (ts.isFunctionLike(current) || ts.isClassLike(current)) return null; + if (ts.isExpressionStatement(current)) return current; + current = current.parent; + } + return null; +} + +function nestedPublicMutationExpression( + expression, + reference, + publicTargetOwners, + commonJsTargetAliases +) { + let current = reference; + while (current && current !== expression) { + if ( + ts.isExpression(current) && + (commonJsExportNamesForExpression(current, commonJsTargetAliases).length > 0 || + findPublicMutationOwner(current, publicTargetOwners)) + ) { + return current; + } + current = current.parent; + } + return expression; +} + +function definitePublicMutationExpression( + node, + sourceFile, + publicTargetOwners, + commonJsTargetAliases +) { + const boundary = definiteTopLevelExpressionBoundary(node, sourceFile); + if (!boundary) return null; + const mutation = nestedPublicMutationExpression( + boundary, + node, + publicTargetOwners, + commonJsTargetAliases + ); + return commonJsExportNamesForExpression(mutation, commonJsTargetAliases).length > 0 || + findPublicMutationOwner(mutation, publicTargetOwners) + ? mutation + : null; +} + +export function findPublicReferenceOwner( + node, + sourceFile, + publicTargetOwners, + commonJsTargetAliases = new Set(), + classifyPublicClassReference = () => undefined +) { + for (const parameterReference of executedInvocationParameterReferences(node)) { + const parameterOwners = + publicTargetOwners.atPosition?.(parameterReference.getStart(sourceFile)) ?? + publicTargetOwners; + const parameterCommonJsTargets = + commonJsTargetAliases.atPosition?.(parameterReference.getStart(sourceFile)) ?? + commonJsTargetAliases; + const parameterOwner = findPublicReferenceOwner( + parameterReference, + sourceFile, + parameterOwners, + parameterCommonJsTargets, + classifyPublicClassReference + ); + if ( + parameterOwner && + (parameterOwner.localNames.length > 0 || parameterOwner.exportedNames.length > 0) + ) { + return parameterOwner; + } + } + + let current = node; + let insideFunctionBody = false; + while (current && current !== sourceFile) { + if ( + ts.isFunctionLike(current) && + !immediateInvocation(current) && + current.body && + node.getStart(sourceFile) >= current.body.getStart(sourceFile) + ) { + insideFunctionBody = true; + } + if (current.parent === sourceFile) break; + current = current.parent; + } + if (!current || current.parent !== sourceFile) return null; + const potentiallyExecutedAtTopLevel = + isPotentiallyExecutedAtTopLevel(node, sourceFile) && + isReachableThroughContainingStatementLists(node); + const definiteMutation = potentiallyExecutedAtTopLevel + ? definitePublicMutationExpression(node, sourceFile, publicTargetOwners, commonJsTargetAliases) + : null; + const publicExpressionBoundary = potentiallyExecutedAtTopLevel + ? (topLevelExpressionBoundary(node, sourceFile) ?? + potentialTopLevelExpressionBoundary(node, sourceFile) ?? + definiteMutation) + : null; + current = publicExpressionBoundary ?? current; + const classReference = classifyPublicClassReference(node); + const getterSelection = classReference + ? classReference.selection + : insideFunctionBody + ? getterSelectionForReference(node, current) + : null; + if ((insideFunctionBody || classReference) && !getterSelection && !definiteMutation) return null; + + let bindingSelections = null; + let descriptorGetterIsPublic = false; + let localNames = []; + if (ts.isVariableStatement(current)) { + const declaration = current.declarationList.declarations.find((candidate) => + containsReference(candidate, node) + ); + if (declaration) { + if ( + publicTargetOwners.isBindingVersionPublic?.(declaration) === false || + publicTargetOwners.isReferencePublic?.(node, declaration) === false + ) { + return null; + } + bindingSelections = objectBindingSelections(declaration.name); + localNames = bindingNames(declaration.name); + const initializer = declaration.initializer && unwrapExpression(declaration.initializer); + const referenceOwner = getterSelection?.descriptorGetter + ? publicTargetOwners.ownerForReference?.(node, { + localMember: getterSelection.localMember, + localNames, + }) + : initializer && ts.isConditionalExpression(initializer) + ? publicTargetOwners.ownerForReference?.(node) + : null; + descriptorGetterIsPublic = Boolean(getterSelection?.descriptorGetter && referenceOwner); + localNames = referenceOwner + ? [referenceOwner] + : localNames.map((localName) => publicTargetOwners.get(localName) ?? localName); + } + } else if (ts.isModuleDeclaration(current)) { + if (!isReferenceInExportedNamespaceMember(node, current)) return null; + if (current.name && ts.isIdentifier(current.name)) { + localNames = [current.name.text]; + } + } else if ('name' in current && current.name && ts.isIdentifier(current.name)) { + localNames = [current.name.text]; + } else if (ts.isExpressionStatement(current) || ts.isExpression(current)) { + const boundaryExpression = ts.isExpressionStatement(current) ? current.expression : current; + const expression = publicExpressionBoundary + ? nestedPublicMutationExpression( + boundaryExpression, + node, + publicTargetOwners, + commonJsTargetAliases + ) + : boundaryExpression; + if (publicTargetOwners.isMutationReferencePublic?.(node, expression) === false) { + return null; + } + const commonJsExportNames = commonJsExportNamesForReference( + expression, + node, + insideFunctionBody, + commonJsTargetAliases, + classReference?.selection + ); + if (commonJsExportNames.length > 0) { + return { + bindingSelections: null, + exportedNames: commonJsExportNames, + localMember: commonJsCreateBindingSelection(expression, node, commonJsTargetAliases) + ?.importedName, + localNames: [], + }; + } + ({ bindingSelections, localNames } = publicMutationBinding(expression, publicTargetOwners)); + if (localNames.length === 0 && classReference?.localName) { + localNames = [classReference.localName]; + } + if (getterSelection?.descriptorGetter) { + const selectionLocalNames = mutationTargetLocalNames(expression); + const referenceOwner = publicTargetOwners.ownerForReference?.(node, { + localMember: getterSelection.localMember, + localNames: selectionLocalNames.length > 0 ? selectionLocalNames : localNames, + }); + if (referenceOwner) localNames = [referenceOwner]; + descriptorGetterIsPublic = Boolean(referenceOwner); + } + if ( + localNames.length === 0 && + getterSelection?.getterOnly && + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + localNames = assignmentLocalNames(expression.left); + } + } + + const getterOnly = + getterSelection?.descriptorGetter && descriptorGetterIsPublic + ? false + : getterSelection?.getterOnly; + if (ts.isExportAssignment(current)) { + const valueState = publishedValueReferenceState(current.expression, node); + if (valueState === 'scalarized') return null; + return { + bindingSelections, + exportedNames: ['default'], + getterOnly, + localMember: getterSelection?.localMember, + localNames: [], + }; + } + if (!hasModifier(current, ts.SyntaxKind.ExportKeyword)) { + return { + bindingSelections, + exportedNames: [], + getterOnly, + localMember: getterSelection?.localMember, + localNames, + }; + } + return { + bindingSelections, + exportedNames: hasModifier(current, ts.SyntaxKind.DefaultKeyword) ? ['default'] : localNames, + getterOnly, + localMember: getterSelection?.localMember, + localNames, + }; +} + +export function findPublicMutationOwner(expression, publicTargetOwners) { + const current = unwrapExpression(expression); + let target = ts.isAssignmentExpression(current) ? current.left : null; + const unwrappedTarget = target && unwrapExpression(target); + if (unwrappedTarget && ts.isIdentifier(unwrappedTarget)) { + const ownersAfterAssignment = + publicTargetOwners.atPosition?.(current.end) ?? publicTargetOwners; + return ownersAfterAssignment.get(unwrappedTarget.text) ?? null; + } + if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { + const receiver = rootBindingName(current.expression.expression); + if (receiver && publicTargetOwners.has(receiver)) { + return publicTargetOwners.get(receiver); + } + if ( + (receiver === 'Object' || receiver === 'Reflect') && + MUTATING_OBJECT_METHODS.has(current.expression.name.text) + ) { + [target] = current.arguments; + } + } + const targetName = target && rootBindingName(target); + return targetName ? (publicTargetOwners.get(targetName) ?? null) : null; +} + +export function publicMutationBinding(expression, publicTargetOwners) { + const bindingSelections = assignmentTargetSelections(expression, publicTargetOwners); + if (bindingSelections.length > 0) { + return { + bindingSelections, + localNames: bindingSelections.flatMap(({ localNames }) => localNames), + }; + } + + const mutationOwner = findPublicMutationOwner(expression, publicTargetOwners); + return { bindingSelections: null, localNames: mutationOwner ? [mutationOwner] : [] }; +} + +function mutationTargetLocalNames(expression) { + const current = unwrapExpression(expression); + if (ts.isAssignmentExpression(current)) return assignmentLocalNames(current.left); + if (!ts.isCallExpression(current)) return []; + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) || + !MUTATING_OBJECT_METHODS.has(method.name) + ) { + return []; + } + const targetName = current.arguments[0] && rootBindingName(current.arguments[0]); + return targetName ? [targetName] : []; +} + +export function objectBindingSelections(bindingName) { + if (!ts.isObjectBindingPattern(bindingName)) return null; + return bindingName.elements.map((element) => { + const selectedName = element.propertyName ?? element.name; + const importedName = + !element.dotDotDotToken && + (ts.isIdentifier(selectedName) || ts.isStringLiteralLike(selectedName)) + ? selectedName.text + : '*'; + return { importedName, localNames: bindingNames(element.name) }; + }); +} + +export function selectedMemberForReference(reference) { + const parent = reference.parent; + if (ts.isQualifiedName(parent) && parent.left === reference) return parent.right.text; + return selectedMemberAfterTransparentWrappers(reference); +} + +function transparentReferenceNode(reference) { + let current = reference; + while (current.parent) { + const parent = current.parent; + if ( + (ts.isAwaitExpression(parent) || + ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent)) && + parent.expression === current + ) { + current = parent; + continue; + } + break; + } + return current; +} + +export function selectedMemberAfterTransparentWrappers(reference) { + const current = transparentReferenceNode(reference); + const access = memberAccess(current.parent ?? current); + return access?.receiver === unwrapExpression(current) ? access.name : null; +} + +export function importedNameForCall(reference, isDynamicImport) { + const selectedName = selectedMemberAfterTransparentWrappers(reference); + if (!isDynamicImport || selectedName !== 'then') return selectedName ?? '*'; + + const current = transparentReferenceNode(reference); + const thenAccess = current.parent; + const thenCall = thenAccess?.parent; + if ( + !thenAccess || + !ts.isPropertyAccessExpression(thenAccess) || + !thenCall || + !ts.isCallExpression(thenCall) || + thenCall.expression !== thenAccess + ) { + return '*'; + } + return dynamicThenCallbackMember(thenCall.arguments[0]) ?? '*'; +} + +export function importedNameForReference(reference, importedBinding) { + if (importedBinding.importedName !== '*') return importedBinding.importedName; + return selectedMemberForReference(reference) ?? importedBinding.importedName; +} + +export function selectImportedName(importedName, selectedName) { + return importedName === '*' && selectedName ? selectedName : importedName; +} diff --git a/scripts/ci/feature-export-ast.mjs b/scripts/ci/feature-export-ast.mjs new file mode 100644 index 0000000000..2011893506 --- /dev/null +++ b/scripts/ci/feature-export-ast.mjs @@ -0,0 +1,66 @@ +import ts from 'typescript'; + +export function bindingNames(bindingName) { + if (ts.isIdentifier(bindingName)) return [bindingName.text]; + return bindingName.elements.flatMap((element) => + ts.isBindingElement(element) ? bindingNames(element.name) : [] + ); +} + +export function statementBindingNames(statement) { + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.flatMap((declaration) => + bindingNames(declaration.name) + ); + } + return 'name' in statement && statement.name && ts.isIdentifier(statement.name) + ? [statement.name.text] + : []; +} + +export function containsReference(node, reference) { + return reference.pos >= node.pos && reference.end <= node.end; +} + +export function unwrapExpression(expression) { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; +} + +export function memberAccess(expression) { + const current = unwrapExpression(expression); + if (ts.isPropertyAccessExpression(current)) { + return { name: current.name.text, receiver: unwrapExpression(current.expression) }; + } + if ( + ts.isElementAccessExpression(current) && + current.argumentExpression && + (ts.isStringLiteralLike(current.argumentExpression) || + ts.isNumericLiteral(current.argumentExpression)) + ) { + return { + name: current.argumentExpression.text, + receiver: unwrapExpression(current.expression), + }; + } + return null; +} + +export function propertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) { + return name.text; + } + if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) { + return name.expression.text; + } + return '*'; +} diff --git a/scripts/ci/feature-export-flow-analysis.mjs b/scripts/ci/feature-export-flow-analysis.mjs new file mode 100644 index 0000000000..c1d963ab8d --- /dev/null +++ b/scripts/ci/feature-export-flow-analysis.mjs @@ -0,0 +1,94 @@ +import ts from 'typescript'; + +import { + immediateIifeInvocation, + staticNullishness, + staticTruthiness, +} from './feature-executed-iife-analysis.mjs'; + +function isDefinitelyExecutedChild(statement, parent) { + if (ts.isBlock(parent)) { + if (ts.isTryStatement(parent.parent)) { + return parent === parent.parent.tryBlock || parent === parent.parent.finallyBlock; + } + return true; + } + if (ts.isIfStatement(parent)) { + const condition = staticTruthiness(parent.expression); + return condition === true + ? statement === parent.thenStatement + : condition === false && statement === parent.elseStatement; + } + if (ts.isDoStatement(parent)) return statement === parent.statement; + return false; +} + +export function immediateInvocation(node) { + return immediateIifeInvocation(node); +} + +function isStaticallyDeadExpression(node) { + let current = node; + while (current.parent && !ts.isExpressionStatement(current.parent)) { + const parent = current.parent; + if (ts.isConditionalExpression(parent)) { + const condition = staticTruthiness(parent.condition); + if ( + (condition === true && current === parent.whenFalse) || + (condition === false && current === parent.whenTrue) + ) { + return true; + } + } else if (ts.isBinaryExpression(parent) && current === parent.right) { + const left = staticTruthiness(parent.left); + if ( + (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && left === false) || + (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken && left === true) || + (parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && + staticNullishness(parent.left) === false) + ) { + return true; + } + } + current = parent; + } + return false; +} + +export function topLevelExpressionBoundary(node, sourceFile) { + let expressionStatement = node; + while (expressionStatement && !ts.isExpressionStatement(expressionStatement)) { + if (ts.isFunctionLike(expressionStatement)) { + const invocation = immediateInvocation(expressionStatement); + const body = expressionStatement.body; + return invocation && + !isStaticallyDeadExpression(invocation) && + body && + !ts.isBlock(body) && + topLevelExpressionBoundary(invocation, sourceFile) + ? body + : null; + } + if (ts.isClassLike(expressionStatement)) { + return null; + } + expressionStatement = expressionStatement.parent; + } + if (!expressionStatement) return null; + + let statement = expressionStatement; + while (statement.parent !== sourceFile) { + const parent = statement.parent; + if (parent && ts.isFunctionLike(parent)) { + const invocation = immediateInvocation(parent); + return invocation && + !isStaticallyDeadExpression(invocation) && + topLevelExpressionBoundary(invocation, sourceFile) + ? expressionStatement + : null; + } + if (!parent || !isDefinitelyExecutedChild(statement, parent)) return null; + statement = ts.isBlock(parent) && ts.isTryStatement(parent.parent) ? parent.parent : parent; + } + return expressionStatement; +} diff --git a/scripts/ci/feature-export-namespace-analysis.mjs b/scripts/ci/feature-export-namespace-analysis.mjs new file mode 100644 index 0000000000..ae45b44cc2 --- /dev/null +++ b/scripts/ci/feature-export-namespace-analysis.mjs @@ -0,0 +1,94 @@ +import ts from 'typescript'; + +import { statementBindingNames } from './feature-export-analysis.mjs'; + +function hasModifier(node, kind) { + return ( + ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) + ); +} + +function supportsNamespace(statement, namespace) { + if (namespace === 'value') { + if (hasModifier(statement, ts.SyntaxKind.DeclareKeyword)) return false; + return ( + ts.isVariableStatement(statement) || + ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement) + ); + } + return ( + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement) + ); +} + +export function declarationNamesForNamespace(statement, namespace) { + return supportsNamespace(statement, namespace) ? statementBindingNames(statement) : []; +} + +export function directExportNamesForNamespace(statement, namespace) { + if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) return []; + if (!supportsNamespace(statement, namespace)) return []; + return hasModifier(statement, ts.SyntaxKind.DefaultKeyword) + ? ['default'] + : statementBindingNames(statement); +} + +export function directReexportsForDeclaration(node, edge) { + if (!edge) return []; + if (!node.exportClause) { + return [ + { + ...edge, + exportedName: '*', + importedName: '*', + isExportStar: true, + }, + ]; + } + if (ts.isNamespaceExport(node.exportClause)) { + return [ + { + ...edge, + exportedName: node.exportClause.name.text, + importedName: '*', + }, + ]; + } + return node.exportClause.elements.map((element) => ({ + ...edge, + exportedName: element.name.text, + importedName: element.propertyName?.text ?? element.name.text, + isTypeOnly: edge.isTypeOnly || element.isTypeOnly, + })); +} + +export function importSelectionsForClause(importClause) { + if (!importClause) return { importedNames: ['*'], typeOnlyImportedNames: [] }; + const importedNames = []; + const typeOnlyImportedNames = []; + const add = (name, isTypeOnly) => { + importedNames.push(name); + if (isTypeOnly) typeOnlyImportedNames.push(name); + }; + if (importClause.name) add('default', importClause.isTypeOnly); + const bindings = importClause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) { + add('*', importClause.isTypeOnly); + } else if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + add( + element.propertyName?.text ?? element.name.text, + importClause.isTypeOnly || element.isTypeOnly + ); + } + } + return { importedNames, typeOnlyImportedNames }; +} diff --git a/scripts/ci/feature-export-value-analysis.mjs b/scripts/ci/feature-export-value-analysis.mjs new file mode 100644 index 0000000000..2d2e39435c --- /dev/null +++ b/scripts/ci/feature-export-value-analysis.mjs @@ -0,0 +1,454 @@ +import ts from 'typescript'; + +import { + containsReference, + memberAccess, + propertyNameText, + unwrapExpression, +} from './feature-export-ast.mjs'; +import { IDENTITY_WRAPPERS } from './feature-identity-wrappers.mjs'; +import { executedSynchronousArrayCallbackForCall } from './feature-synchronous-array-callback-analysis.mjs'; + +const ARRAY_CALLBACK_RESULT_METHODS = new Set(['flatMap', 'map', 'reduce', 'reduceRight']); + +function callable(node) { + const current = node && unwrapExpression(node); + return current && (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) + ? current + : null; +} + +function returnedExpressions(callback) { + if (!callback) return []; + if (!ts.isBlock(callback.body)) return [callback.body]; + + const returned = []; + const visit = (node) => { + if (node !== callback && (ts.isFunctionLike(node) || ts.isClassLike(node))) return; + if (ts.isReturnStatement(node)) { + if (node.expression) returned.push(node.expression); + return; + } + ts.forEachChild(node, visit); + }; + visit(callback.body); + return returned; +} + +function bindingSelections(bindingName, path = [], selections = new Map()) { + if (ts.isIdentifier(bindingName)) { + selections.set(bindingName.text, path[0] ?? '*'); + return selections; + } + for (const [index, element] of bindingName.elements.entries()) { + if (!ts.isBindingElement(element)) continue; + const selected = + ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken + ? propertyNameText(element.propertyName ?? element.name) + : ts.isArrayBindingPattern(bindingName) && !element.dotDotDotToken + ? String(index) + : '*'; + bindingSelections(element.name, [...path, selected], selections); + } + return selections; +} + +function selectedNamesFromReturn(expression, parameter) { + const selections = bindingSelections(parameter.name); + const names = new Set(); + const visit = (node) => { + if (!ts.isIdentifier(node) || !selections.has(node.text)) { + ts.forEachChild(node, visit); + return; + } + + const selected = selections.get(node.text); + if (selected !== '*') { + names.add(selected); + return; + } + const parent = node.parent; + if (ts.isPropertyAccessExpression(parent) && parent.expression === node) { + names.add(parent.name.text); + } else if (ts.isElementAccessExpression(parent) && parent.expression === node) { + const argument = parent.argumentExpression && unwrapExpression(parent.argumentExpression); + names.add( + argument && (ts.isStringLiteralLike(argument) || ts.isNumericLiteral(argument)) + ? argument.text + : '*' + ); + } else { + names.add('*'); + } + }; + visit(expression); + return names; +} + +export function dynamicThenCallbackMember(callbackExpression) { + const callback = callable(callbackExpression); + const [parameter] = callback?.parameters ?? []; + if (!callback || !parameter) return null; + + const names = new Set(); + for (const expression of returnedExpressions(callback)) { + for (const name of selectedNamesFromReturn(expression, parameter)) names.add(name); + } + return names.size === 1 ? [...names][0] : names.size > 1 ? '*' : null; +} + +function returnedMemberForReference(callback, reference) { + const returned = returnedExpressions(callback).find((expression) => + containsReference(expression, reference) + ); + if (!returned) return null; + + let current = reference; + while (current && current !== returned) { + const parent = current.parent; + if (!parent || !containsReference(parent, reference)) break; + if ( + (ts.isPropertyAssignment(parent) || + ts.isShorthandPropertyAssignment(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isMethodDeclaration(parent)) && + parent.name + ) { + return { localMember: propertyNameText(parent.name) }; + } + current = parent; + } + return { localMember: undefined }; +} + +function unwrapCallback(expression) { + let current = expression; + while ( + current.parent && + (ts.isParenthesizedExpression(current.parent) || + ts.isAsExpression(current.parent) || + ts.isTypeAssertionExpression(current.parent) || + ts.isNonNullExpression(current.parent) || + ts.isSatisfiesExpression(current.parent)) && + current.parent.expression === current + ) { + current = current.parent; + } + return current; +} + +function definiteArrayCallbackResultCall(callback) { + const wrappedCallback = unwrapCallback(callback); + const call = wrappedCallback.parent; + const invocation = + call && ts.isCallExpression(call) + ? executedSynchronousArrayCallbackForCall(call) + : null; + return invocation && + invocation.callable === callback && + ARRAY_CALLBACK_RESULT_METHODS.has(invocation.method) + ? call + : null; +} + +function transparentPublishedValueParent(current, parent) { + if ( + ((ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent) || + ts.isAwaitExpression(parent)) && + parent.expression === current) || + ((ts.isArrayLiteralExpression(parent) || ts.isObjectLiteralExpression(parent)) && + containsReference(parent, current)) || + ((ts.isPropertyAssignment(parent) || ts.isSpreadAssignment(parent)) && + containsReference(parent, current)) || + (ts.isSpreadElement(parent) && parent.expression === current) || + (ts.isReturnStatement(parent) && parent.expression === current) || + (ts.isConditionalExpression(parent) && + (parent.whenTrue === current || parent.whenFalse === current)) + ) { + return parent; + } + if (!ts.isCallExpression(parent) || !parent.arguments.includes(current)) return null; + const method = memberAccess(parent.expression); + return method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + IDENTITY_WRAPPERS.has(method.name) && + (method.name === 'assign' || parent.arguments[0] === current) + ? parent + : null; +} + +function callbackResultSelection(expression, reference) { + const publishedExpression = expression && unwrapExpression(expression); + if (!publishedExpression || !containsReference(publishedExpression, reference)) return null; + + let current = reference; + let selection = null; + while (current && current !== publishedExpression) { + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + const returnedSelection = returnedMemberForReference(current, reference); + const resultCall = returnedSelection && definiteArrayCallbackResultCall(current); + if (!resultCall) return null; + selection ??= returnedSelection; + current = resultCall; + continue; + } + const parent = current.parent; + if (!parent) return null; + if (ts.isFunctionLike(parent) && parent.body === current) { + current = parent; + continue; + } + if (current !== reference && ts.isExpression(current)) { + const publishedParent = transparentPublishedValueParent(current, parent); + if (!publishedParent) return null; + current = publishedParent; + continue; + } + current = parent; + } + return current === publishedExpression ? selection : null; +} + +export function iifeSelectionForReference(initializer, reference) { + const current = initializer && unwrapExpression(initializer); + if (!current || !ts.isCallExpression(current)) return null; + const callback = callable(current.expression); + return callback && containsReference(callback, reference) + ? returnedMemberForReference(callback, reference) + : null; +} + +function descriptorContainsReference(descriptorExpression, reference, includeValue = false) { + const descriptor = descriptorExpression && unwrapExpression(descriptorExpression); + if (!descriptor || !ts.isObjectLiteralExpression(descriptor)) return false; + return descriptor.properties.some((property) => { + if (!property.name) return false; + const name = propertyNameText(property.name); + if (name !== 'get' && (!includeValue || name !== 'value')) return false; + return containsReference(property, reference); + }); +} + +export function descriptorMapGetterMember(descriptorsExpression, reference) { + const descriptors = descriptorsExpression && unwrapExpression(descriptorsExpression); + if (!descriptors) return null; + if (ts.isConditionalExpression(descriptors)) { + return ( + descriptorMapGetterMember(descriptors.whenTrue, reference) ?? + descriptorMapGetterMember(descriptors.whenFalse, reference) + ); + } + if (!ts.isObjectLiteralExpression(descriptors)) return null; + const property = descriptors.properties.find( + (candidate) => + ts.isPropertyAssignment(candidate) && + descriptorContainsReference(candidate.initializer, reference) + ); + return property && ts.isPropertyAssignment(property) ? propertyNameText(property.name) : null; +} + +function isObjectCreateDescriptorMap(declaration) { + if (!ts.isIdentifier(declaration.name)) return false; + const sourceFile = declaration.getSourceFile(); + let found = false; + const visit = (node) => { + if (found || (node !== sourceFile && (ts.isFunctionLike(node) || ts.isClassLike(node)))) { + return; + } + if (ts.isCallExpression(node)) { + const method = memberAccess(node.expression); + const descriptors = node.arguments[1] && unwrapExpression(node.arguments[1]); + if ( + method?.name === 'create' && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + ts.isIdentifier(descriptors) && + descriptors.text === declaration.name.text + ) { + found = true; + return; + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +} + +function publicSurfaceExpressions(expression) { + const current = expression && unwrapExpression(expression); + if (!current) return []; + if (!ts.isCallExpression(current)) return [current]; + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + method.receiver.text !== 'Object' || + !IDENTITY_WRAPPERS.has(method.name) + ) { + return [current]; + } + const argumentsToInspect = + method.name === 'assign' ? current.arguments : current.arguments.slice(0, 1); + return argumentsToInspect.flatMap(publicSurfaceExpressions); +} + +function objectGetterMember(objectExpression, reference) { + for (const object of publicSurfaceExpressions(objectExpression)) { + if (!ts.isObjectLiteralExpression(object)) continue; + const getter = object.properties.find( + (property) => ts.isGetAccessorDeclaration(property) && containsReference(property, reference) + ); + if (getter && ts.isGetAccessorDeclaration(getter)) return propertyNameText(getter.name); + } + return null; +} + +function objectCallableMember(objectExpression, reference) { + for (const object of publicSurfaceExpressions(objectExpression)) { + if (!ts.isObjectLiteralExpression(object)) continue; + const member = object.properties.find((property) => { + if (ts.isMethodDeclaration(property)) return containsReference(property, reference); + if (!ts.isPropertyAssignment(property)) return false; + const initializer = unwrapExpression(property.initializer); + return ts.isFunctionLike(initializer) && containsReference(initializer, reference); + }); + if (member?.name) return propertyNameText(member.name); + } + return null; +} + +function objectCreateGetterMember(expression, reference) { + const current = unwrapExpression(expression); + if (!ts.isCallExpression(current)) return null; + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + method.receiver.text !== 'Object' || + method.name !== 'create' + ) { + return null; + } + return descriptorMapGetterMember(current.arguments[1], reference); +} + +function descriptorInitializerSelection(expression, reference) { + const current = expression && unwrapExpression(expression); + if (!current || !ts.isCallExpression(current)) return null; + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) + ) { + return null; + } + if (method.name === 'defineProperty') { + if (!descriptorContainsReference(current.arguments[2], reference, true)) return null; + const exportName = current.arguments[1] && unwrapExpression(current.arguments[1]); + return { + localMember: + exportName && (ts.isStringLiteralLike(exportName) || ts.isNumericLiteral(exportName)) + ? exportName.text + : '*', + }; + } + if (method.name !== 'defineProperties') return null; + const descriptors = current.arguments[1] && unwrapExpression(current.arguments[1]); + if (!descriptors || !ts.isObjectLiteralExpression(descriptors)) return null; + const property = descriptors.properties.find( + (candidate) => + ts.isPropertyAssignment(candidate) && + descriptorContainsReference(candidate.initializer, reference, true) + ); + return property?.name ? { localMember: propertyNameText(property.name) } : null; +} + +export function variableValueSelection(declaration, reference, exported) { + if (!declaration.initializer || !containsReference(declaration.initializer, reference)) { + return null; + } + const objectMember = objectGetterMember(declaration.initializer, reference); + if (objectMember !== null) return { localMember: objectMember }; + const callableMember = objectCallableMember(declaration.initializer, reference); + if (callableMember !== null) return { getterOnly: true, localMember: callableMember }; + const createdMember = objectCreateGetterMember(declaration.initializer, reference); + if (createdMember !== null) return { localMember: createdMember }; + const descriptorInitializer = descriptorInitializerSelection(declaration.initializer, reference); + if (descriptorInitializer) return descriptorInitializer; + if (descriptorContainsReference(declaration.initializer, reference)) { + return { localMember: null }; + } + const descriptorMember = descriptorMapGetterMember(declaration.initializer, reference); + if (descriptorMember !== null) { + return { + descriptorGetter: true, + getterOnly: !isObjectCreateDescriptorMap(declaration), + localMember: descriptorMember, + }; + } + const iifeSelection = iifeSelectionForReference(declaration.initializer, reference); + if (iifeSelection) return iifeSelection; + const resultSelection = callbackResultSelection(declaration.initializer, reference); + if (resultSelection) return resultSelection; + const initializer = unwrapExpression(declaration.initializer); + return !exported && ts.isFunctionLike(initializer) + ? { getterOnly: true, localMember: undefined } + : null; +} + +export function expressionGetterSelection(expression, reference) { + const current = unwrapExpression(expression); + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const localMember = objectGetterMember(current.right, reference); + if (localMember !== null) return { localMember }; + const resultSelection = callbackResultSelection(current.right, reference); + if (resultSelection) return resultSelection; + const initializer = unwrapExpression(current.right); + return ts.isFunctionLike(initializer) && containsReference(initializer, reference) + ? { getterOnly: true, localMember: undefined } + : null; + } + if (!ts.isCallExpression(current)) return null; + + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) + ) { + return null; + } + if (method.name === 'defineProperty') { + if (!descriptorContainsReference(current.arguments[2], reference)) return null; + const exportName = current.arguments[1]; + return { + localMember: exportName && ts.isStringLiteralLike(exportName) ? exportName.text : '*', + }; + } + if (method.name === 'defineProperties') { + const localMember = descriptorMapGetterMember(current.arguments[1], reference); + return localMember === null ? null : { descriptorGetter: true, getterOnly: true, localMember }; + } + if (method.name !== 'assign') return null; + for (const source of current.arguments.slice(1)) { + const localMember = objectGetterMember(source, reference); + if (localMember !== null) return { localMember }; + } + return null; +} + +export function exportAssignmentValueSelection(expression, reference) { + const localMember = objectGetterMember(expression, reference); + if (localMember !== null) return { localMember }; + return ( + iifeSelectionForReference(expression, reference) ?? + callbackResultSelection(expression, reference) + ); +} diff --git a/scripts/ci/feature-identity-wrappers.mjs b/scripts/ci/feature-identity-wrappers.mjs new file mode 100644 index 0000000000..34e2985770 --- /dev/null +++ b/scripts/ci/feature-identity-wrappers.mjs @@ -0,0 +1,7 @@ +export const IDENTITY_WRAPPERS = new Set([ + 'assign', + 'freeze', + 'preventExtensions', + 'seal', + 'setPrototypeOf', +]); diff --git a/scripts/ci/feature-lexical-binding-analysis.mjs b/scripts/ci/feature-lexical-binding-analysis.mjs new file mode 100644 index 0000000000..435784fe1d --- /dev/null +++ b/scripts/ci/feature-lexical-binding-analysis.mjs @@ -0,0 +1,142 @@ +import ts from 'typescript'; + +import { bindingNames, statementBindingNames } from './feature-export-ast.mjs'; +import { staticMemberAccess } from './feature-static-value-analysis.mjs'; + +function statementDeclaresValue(statement, name) { + if (ts.isVariableStatement(statement)) { + return statementBindingNames(statement).includes(name); + } + const runtimeDeclaration = + ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement); + return ( + runtimeDeclaration && + statement.name && + ts.isIdentifier(statement.name) && + statement.name.text === name + ); +} + +function blockDeclaresValue(block, name) { + const statements = ts.isCaseBlock(block) + ? block.clauses.flatMap((clause) => clause.statements) + : block.statements; + return statements.some((statement) => statementDeclaresValue(statement, name)); +} + +function functionDeclaresValue(functionLike, name) { + if ( + (ts.isFunctionDeclaration(functionLike) || ts.isFunctionExpression(functionLike)) && + functionLike.name?.text === name + ) { + return true; + } + if (functionLike.parameters.some((parameter) => bindingNames(parameter.name).includes(name))) { + return true; + } + + let found = false; + const visit = (node) => { + if (found || (node !== functionLike && ts.isFunctionLike(node))) return; + if ( + ts.isVariableDeclarationList(node) && + (node.flags & ts.NodeFlags.BlockScoped) === 0 && + node.declarations.some((declaration) => bindingNames(declaration.name).includes(name)) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + if (functionLike.body) visit(functionLike.body); + return found; +} + +function loopInitializerDeclaresValue(node, name) { + if ( + (ts.isForStatement(node) && + node.initializer && + ts.isVariableDeclarationList(node.initializer)) || + ((ts.isForInStatement(node) || ts.isForOfStatement(node)) && + ts.isVariableDeclarationList(node.initializer)) + ) { + return node.initializer.declarations.some((declaration) => + bindingNames(declaration.name).includes(name) + ); + } + return false; +} + +function sourceFileImportsValue(sourceFile, name) { + return sourceFile.statements.some((statement) => { + if (ts.isImportEqualsDeclaration(statement)) { + return !statement.isTypeOnly && statement.name.text === name; + } + if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) return false; + const clause = statement.importClause; + if (!clause) return false; + if (clause.name?.text === name) return true; + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) return bindings.name.text === name; + return ( + bindings && + ts.isNamedImports(bindings) && + bindings.elements.some((element) => !element.isTypeOnly && element.name.text === name) + ); + }); +} + +export function isLexicallyShadowedValueReference(reference, sourceFile) { + const name = reference.text; + let current = reference.parent; + while (current && current !== sourceFile) { + if ((ts.isBlock(current) || ts.isCaseBlock(current)) && blockDeclaresValue(current, name)) { + return true; + } + if (ts.isCatchClause(current) && current.variableDeclaration) { + if (bindingNames(current.variableDeclaration.name).includes(name)) return true; + } + if (ts.isFunctionLike(current) && functionDeclaresValue(current, name)) return true; + if (ts.isClassExpression(current) && current.name?.text === name) return true; + if (loopInitializerDeclaresValue(current, name)) return true; + current = current.parent; + } + + return blockDeclaresValue(sourceFile, name); +} + +export function isUnshadowedGlobalValueReference(reference) { + const sourceFile = reference.getSourceFile(); + return ( + !sourceFileImportsValue(sourceFile, reference.text) && + !isLexicallyShadowedValueReference(reference, sourceFile) + ); +} + +export function isCommonJsRequireReference(reference, sourceFile) { + if (ts.isIdentifier(reference) && reference.text === 'require') { + return ( + !sourceFileImportsValue(sourceFile, reference.text) && + !isLexicallyShadowedValueReference(reference, sourceFile) + ); + } + const access = staticMemberAccess(reference); + return ( + access?.name === 'require' && + ts.isIdentifier(access.receiver) && + access.receiver.text === 'module' && + !sourceFileImportsValue(sourceFile, access.receiver.text) && + !isLexicallyShadowedValueReference(access.receiver, sourceFile) + ); +} + +export function isCommonJsRequireCall(node, sourceFile) { + return ( + ts.isCallExpression(node) && + node.arguments.length >= 1 && + isCommonJsRequireReference(node.expression, sourceFile) + ); +} diff --git a/scripts/ci/feature-local-binding-selection.mjs b/scripts/ci/feature-local-binding-selection.mjs new file mode 100644 index 0000000000..ff53086e64 --- /dev/null +++ b/scripts/ci/feature-local-binding-selection.mjs @@ -0,0 +1,258 @@ +import ts from 'typescript'; + +import { propertyNameText, unwrapExpression } from './feature-export-analysis.mjs'; + +function fallbackBindings(bindings, expression) { + return expression + ? bindings.map((binding) => ({ + ...binding, + fallback: { expression, selected: [] }, + })) + : bindings; +} + +function objectElements(current) { + return ts.isObjectLiteralExpression(current) ? current.properties : current.elements; +} + +function objectKey(element) { + const name = element.propertyName ?? element.name; + return name && ts.isComputedPropertyName(name) + ? { expression: name.expression, kind: 'computed-key' } + : propertyNameText(name); +} + +export function selectedBindings(pattern, path = []) { + const current = unwrapExpression(pattern); + if (ts.isIdentifier(current)) return [{ identifier: current, path }]; + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + return fallbackBindings(selectedBindings(current.left, path), current.right); + } + if (ts.isObjectBindingPattern(current) || ts.isObjectLiteralExpression(current)) { + const elements = objectElements(current); + const excluded = elements + .filter((element) => !(element.dotDotDotToken || ts.isSpreadAssignment(element))) + .map(objectKey) + .filter((name) => name !== null); + return elements.flatMap((element) => { + const rest = element.dotDotDotToken || ts.isSpreadAssignment(element); + const target = ts.isPropertyAssignment(element) + ? element.initializer + : (element.name ?? element.expression); + if (!target) return []; + const segment = rest ? { excluded, kind: 'object-rest' } : objectKey(element); + const bindings = selectedBindings(target, [...path, segment]); + const initializer = + (ts.isBindingElement(element) && element.initializer) || + (ts.isShorthandPropertyAssignment(element) && element.objectAssignmentInitializer); + return fallbackBindings(bindings, initializer); + }); + } + if (ts.isArrayBindingPattern(current) || ts.isArrayLiteralExpression(current)) { + return current.elements.flatMap((element, index) => { + if (ts.isOmittedExpression(element)) return []; + const rest = element.dotDotDotToken || ts.isSpreadElement(element); + const target = ts.isBindingElement(element) + ? element.name + : ts.isSpreadElement(element) + ? element.expression + : element; + const segment = rest ? { from: index, kind: 'array-rest' } : String(index); + const bindings = selectedBindings(target, [...path, segment]); + return fallbackBindings( + bindings, + ts.isBindingElement(element) ? element.initializer : undefined + ); + }); + } + return []; +} + +export function selectionKey(selected) { + return JSON.stringify(selected, (_key, value) => + value?.kind === 'computed-key' + ? { end: value.expression.end, kind: value.kind, pos: value.expression.pos } + : value + ); +} + +function emptyResolution() { + return { missing: true, nodes: [], unknown: false }; +} + +function mergeResolutions(...resolutions) { + return { + missing: resolutions.some(({ missing }) => missing), + nodes: resolutions.flatMap(({ nodes }) => nodes), + unknown: resolutions.some(({ unknown }) => unknown), + }; +} + +function overlayResolution(previous, incoming) { + return { + missing: previous.missing && incoming.missing, + nodes: incoming.missing ? [...previous.nodes, ...incoming.nodes] : incoming.nodes, + unknown: previous.unknown || incoming.unknown, + }; +} + +function resolvedKeyNames(key, resolveKey) { + if (typeof key === 'string') return [key]; + if (key?.kind === 'computed-key') return resolveKey(key.expression); + return []; +} + +function propertyKeyNames(property, resolveKey) { + if (!property.name) return []; + return ts.isComputedPropertyName(property.name) + ? resolveKey(property.name.expression) + : [propertyNameText(property.name)].filter((name) => name !== null); +} + +function propertyValue(property) { + if (ts.isPropertyAssignment(property)) return property.initializer; + if (ts.isShorthandPropertyAssignment(property)) return property.name; + return null; +} + +function objectPropertySelection(current, segment, remaining, resolve, resolveKey) { + const selectedNames = resolvedKeyNames(segment, resolveKey); + if (selectedNames.length === 0) return { ...emptyResolution(), unknown: true }; + + let resolution = emptyResolution(); + for (const property of current.properties) { + if (ts.isSpreadAssignment(property)) { + const spread = mergeResolutions( + ...selectedNames.map((name) => resolve(property.expression, [name, ...remaining])) + ); + resolution = overlayResolution(resolution, spread); + continue; + } + const value = propertyValue(property); + if (!value) continue; + const names = propertyKeyNames(property, resolveKey); + if (names.some((name) => selectedNames.includes(name))) { + const incoming = resolve(value, remaining); + resolution = names.length === 1 ? incoming : mergeResolutions(resolution, incoming); + } else if (names.length === 0 && property.name && ts.isComputedPropertyName(property.name)) { + resolution = mergeResolutions(resolution, resolve(value, remaining), { + missing: false, + nodes: [], + unknown: true, + }); + } + } + return resolution; +} + +function objectRest(current, excluded, resolveKey) { + return { + end: current.end, + kind: ts.SyntaxKind.ObjectLiteralExpression, + pos: current.pos, + properties: current.properties, + restExclusions: excluded.flatMap((key) => resolvedKeyNames(key, resolveKey)), + }; +} + +function arrayElementVariants(current, resolve, visited = new Set()) { + const key = `${current.pos}:${current.end}`; + if (visited.has(key)) return [[...current.elements]]; + const nextVisited = new Set(visited).add(key); + let variants = [[]]; + for (const element of current.elements) { + if (!ts.isSpreadElement(element)) { + variants = variants.map((items) => [...items, element]); + continue; + } + const spread = unwrapExpression(element.expression); + const arrays = ts.isArrayLiteralExpression(spread) + ? [spread] + : resolve(spread, []).nodes.filter((node) => ts.isArrayLiteralExpression(node)); + const spreadVariants = arrays.flatMap((array) => + arrayElementVariants(array, resolve, nextVisited) + ); + const expansions = spreadVariants.length > 0 ? spreadVariants : [[element]]; + variants = variants.flatMap((items) => expansions.map((expanded) => [...items, ...expanded])); + } + return variants; +} + +function arrayRest(elements, current, from) { + return { + end: current.end, + kind: ts.SyntaxKind.ObjectLiteralExpression, + pos: current.pos, + properties: elements + .slice(from) + .flatMap((element, index) => + ts.isOmittedExpression(element) + ? [] + : [ + ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(String(index)), + ts.isSpreadElement(element) ? element.expression : element + ), + ] + ), + }; +} + +function arrayVariantSelection(elements, index, remaining, resolve) { + const value = elements[index]; + if (value && !ts.isOmittedExpression(value) && !ts.isSpreadElement(value)) { + return resolve(value, remaining); + } + let offset = 0; + const candidates = []; + for (const element of elements) { + if (ts.isSpreadElement(element)) { + if (index >= offset) { + candidates.push(resolve(element.expression, [String(index - offset), ...remaining])); + } + continue; + } + if (offset === index && !ts.isOmittedExpression(element)) { + candidates.push(resolve(element, remaining)); + } + offset += 1; + } + return candidates.length > 0 + ? mergeResolutions(...candidates, { missing: true, nodes: [], unknown: true }) + : emptyResolution(); +} + +function arrayElementSelection(current, index, remaining, resolve) { + return mergeResolutions( + ...arrayElementVariants(current, resolve).map((elements) => + arrayVariantSelection(elements, index, remaining, resolve) + ) + ); +} + +export function resolveLiteralSelection(current, selected, resolve, resolveKey = () => []) { + if (selected.length === 0) return null; + const [segment, ...remaining] = selected; + if (ts.isObjectLiteralExpression(current)) { + if (typeof segment === 'object' && segment.kind === 'object-rest') { + const rest = objectRest(current, segment.excluded, resolveKey); + return remaining.length === 0 + ? { missing: false, nodes: [rest], unknown: false } + : resolve(rest, remaining); + } + if (current.restExclusions?.includes(segment)) return emptyResolution(); + return objectPropertySelection(current, segment, remaining, resolve, resolveKey); + } + if (ts.isArrayLiteralExpression(current)) { + if (typeof segment === 'object' && segment.kind === 'array-rest') { + const rests = arrayElementVariants(current, resolve).map((elements) => + arrayRest(elements, current, segment.from) + ); + return remaining.length === 0 + ? { missing: false, nodes: rests, unknown: false } + : mergeResolutions(...rests.map((rest) => resolve(rest, remaining))); + } + return arrayElementSelection(current, Number(segment), remaining, resolve); + } + return null; +} diff --git a/scripts/ci/feature-module-resolution.mjs b/scripts/ci/feature-module-resolution.mjs new file mode 100644 index 0000000000..59b91d5ddd --- /dev/null +++ b/scripts/ci/feature-module-resolution.mjs @@ -0,0 +1,84 @@ +import path from 'node:path'; + +const RESOLUTION_EXTENSIONS = [ + '.ts', + '.tsx', + '.d.ts', + '.mts', + '.d.mts', + '.cts', + '.d.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', +]; +const RUNTIME_EXTENSION_SUBSTITUTIONS = new Map([ + ['.js', ['.ts', '.tsx', '.d.ts', '.js', '.jsx']], + ['.jsx', ['.tsx', '.jsx']], + ['.mjs', ['.mts', '.d.mts', '.mjs']], + ['.cjs', ['.cts', '.d.cts', '.cjs']], +]); + +const PROJECT_ALIASES = new Map([ + ['@features', 'src/features'], + ['@main', 'src/main'], + ['@preload', 'src/preload'], + ['@renderer', 'src/renderer'], + ['@shared', 'src/shared'], +]); + +export function normalizeSourcePath(filePath) { + return filePath.split(path.sep).join('/'); +} + +export function isSourceCodeProjectTarget(targetPath) { + const extension = path.posix.extname(targetPath); + return ( + extension === '' || + RESOLUTION_EXTENSIONS.includes(extension) || + RUNTIME_EXTENSION_SUBSTITUTIONS.has(extension) + ); +} + +function resolveAliasPath(specifier) { + for (const [alias, target] of PROJECT_ALIASES) { + if (specifier === alias) return target; + if (specifier.startsWith(`${alias}/`)) { + return `${target}/${specifier.slice(alias.length + 1)}`; + } + } + return null; +} + +export function resolveSourceFileCandidate(targetPath, sourceFilePaths) { + const normalizedTarget = normalizeSourcePath(path.posix.normalize(targetPath)); + const runtimeExtension = path.posix.extname(normalizedTarget); + if (runtimeExtension && !isSourceCodeProjectTarget(normalizedTarget)) return normalizedTarget; + const substitutions = RUNTIME_EXTENSION_SUBSTITUTIONS.get(runtimeExtension); + const candidates = substitutions + ? substitutions.map( + (extension) => `${normalizedTarget.slice(0, -runtimeExtension.length)}${extension}` + ) + : runtimeExtension + ? [normalizedTarget] + : [ + normalizedTarget, + ...RESOLUTION_EXTENSIONS.map((extension) => `${normalizedTarget}${extension}`), + ...RESOLUTION_EXTENSIONS.map((extension) => `${normalizedTarget}/index${extension}`), + ]; + + return candidates.find((candidate) => sourceFilePaths.has(candidate)) ?? normalizedTarget; +} + +export function resolveProjectTarget(edge, sourceFilePaths) { + const aliasPath = resolveAliasPath(edge.specifier); + if (aliasPath) return resolveSourceFileCandidate(aliasPath, sourceFilePaths); + if (edge.specifier.startsWith('src/')) { + return resolveSourceFileCandidate(edge.specifier, sourceFilePaths); + } + if (!edge.specifier.startsWith('.')) return null; + + const relativeTarget = path.posix.join(path.posix.dirname(edge.source), edge.specifier); + return resolveSourceFileCandidate(relativeTarget, sourceFilePaths); +} diff --git a/scripts/ci/feature-module-syntax-analysis.mjs b/scripts/ci/feature-module-syntax-analysis.mjs new file mode 100644 index 0000000000..893afcfb13 --- /dev/null +++ b/scripts/ci/feature-module-syntax-analysis.mjs @@ -0,0 +1,20 @@ +import ts from 'typescript'; + +export function forEachChildIncludingJsDoc(node, visit) { + for (const jsDoc of node.jsDoc ?? []) visit(jsDoc); + ts.forEachChild(node, visit); +} + +export function importDeclarationIsTypeOnly(node) { + const clause = node.importClause; + if (!clause) return false; + if (clause.isTypeOnly) return true; + const bindings = clause.namedBindings; + return ( + !clause.name && + bindings && + ts.isNamedImports(bindings) && + bindings.elements.length > 0 && + bindings.elements.every(({ isTypeOnly }) => isTypeOnly) + ); +} diff --git a/scripts/ci/feature-object-resolution.mjs b/scripts/ci/feature-object-resolution.mjs new file mode 100644 index 0000000000..f6980cfcc6 --- /dev/null +++ b/scripts/ci/feature-object-resolution.mjs @@ -0,0 +1,86 @@ +import ts from 'typescript'; + +import { memberAccess, propertyNameText, unwrapExpression } from './feature-export-analysis.mjs'; + +function uniqueNodes(nodes) { + return [...new Map(nodes.map((node) => [`${node.pos}:${node.end}`, node])).values()]; +} + +export function resolveObjectLiterals( + expression, + beforePosition, + resolveBinding, + visited = new Set(), + memo = new Map() +) { + const current = expression && unwrapExpression(expression); + if (!current) return []; + const memoKey = `${current.pos}:${current.end}:${beforePosition}`; + if (memo.has(memoKey)) return memo.get(memoKey); + memo.set(memoKey, []); + + let resolved = []; + if (ts.isObjectLiteralExpression(current)) { + resolved = [current]; + } else if (ts.isConditionalExpression(current)) { + resolved = [ + ...resolveObjectLiterals( + current.whenTrue, + beforePosition, + resolveBinding, + new Set(visited), + memo + ), + ...resolveObjectLiterals( + current.whenFalse, + beforePosition, + resolveBinding, + new Set(visited), + memo + ), + ]; + } else if (ts.isIdentifier(current)) { + const binding = resolveBinding(current.text, beforePosition); + if (binding && !visited.has(binding.key)) { + resolved = resolveObjectLiterals( + binding.expression, + binding.beforePosition, + resolveBinding, + new Set(visited).add(binding.key), + memo + ); + } + } else { + const access = memberAccess(current); + if (access) { + resolved = resolveObjectLiterals( + access.receiver, + beforePosition, + resolveBinding, + visited, + memo + ).flatMap((object) => { + const property = object.properties.find( + (candidate) => + (ts.isPropertyAssignment(candidate) || ts.isShorthandPropertyAssignment(candidate)) && + propertyNameText(candidate.name) === access.name + ); + if (property && ts.isPropertyAssignment(property)) { + return resolveObjectLiterals( + property.initializer, + beforePosition, + resolveBinding, + visited, + memo + ); + } + return property && ts.isShorthandPropertyAssignment(property) + ? resolveObjectLiterals(property.name, beforePosition, resolveBinding, visited, memo) + : []; + }); + } + } + const unique = uniqueNodes(resolved); + memo.set(memoKey, unique); + return unique; +} diff --git a/scripts/ci/feature-public-access-path.mjs b/scripts/ci/feature-public-access-path.mjs new file mode 100644 index 0000000000..e283385e5e --- /dev/null +++ b/scripts/ci/feature-public-access-path.mjs @@ -0,0 +1,63 @@ +import ts from 'typescript'; + +import { memberAccess, unwrapExpression } from './feature-export-analysis.mjs'; +import { IDENTITY_WRAPPERS } from './feature-identity-wrappers.mjs'; + +export function accessPath(expression) { + let current = unwrapExpression(expression); + const path = []; + while (true) { + const access = memberAccess(current); + if (!access) break; + path.unshift(access.name); + current = access.receiver; + } + return ts.isIdentifier(current) ? { path, root: current.text } : null; +} + +function directAliasPath(expression) { + const current = unwrapExpression(expression); + if (ts.isConditionalExpression(current)) { + const whenTrue = directAliasPath(current.whenTrue); + const whenFalse = directAliasPath(current.whenFalse); + return whenTrue && + whenFalse && + whenTrue.root === whenFalse.root && + whenTrue.path.length === whenFalse.path.length && + whenTrue.path.every((segment, index) => segment === whenFalse.path[index]) + ? whenTrue + : null; + } + if (ts.isCallExpression(current)) { + const method = memberAccess(current.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + IDENTITY_WRAPPERS.has(method.name) && + current.arguments[0] + ) { + return accessPath(current.arguments[0]); + } + } + return accessPath(current); +} + +export function bindingAliasTargets(expression, position, bindingModel) { + const target = accessPath(expression); + if (!target) return []; + const targets = []; + const visited = new Set(); + let path = target.path; + let sourceKey = bindingModel.bindingAt(target.root, position); + while (sourceKey && !visited.has(sourceKey)) { + visited.add(sourceKey); + targets.push({ path, sourceKey }); + const binding = bindingModel.versions.get(sourceKey); + const alias = binding && directAliasPath(binding.initializer); + if (!binding || !alias) break; + sourceKey = bindingModel.bindingAt(alias.root, binding.position); + path = [...alias.path, ...path]; + } + return targets; +} diff --git a/scripts/ci/feature-public-class-analysis.mjs b/scripts/ci/feature-public-class-analysis.mjs new file mode 100644 index 0000000000..3984685ce9 --- /dev/null +++ b/scripts/ci/feature-public-class-analysis.mjs @@ -0,0 +1,126 @@ +import ts from 'typescript'; + +import { + containsReference, + memberAccess, + propertyNameText, + unwrapExpression, +} from './feature-export-ast.mjs'; +import { visitDefiniteBlockExpressions } from './feature-definite-execution.mjs'; + +function hasModifier(node, kind) { + return ( + ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) + ); +} + +function isPublicStaticMember(member) { + return ( + member.name && + !ts.isPrivateIdentifier(member.name) && + hasModifier(member, ts.SyntaxKind.StaticKeyword) && + !hasModifier(member, ts.SyntaxKind.PrivateKeyword) && + !hasModifier(member, ts.SyntaxKind.ProtectedKeyword) + ); +} + +function declaredStaticMember(boundary, name) { + return boundary.members.find( + (member) => + member.name && + hasModifier(member, ts.SyntaxKind.StaticKeyword) && + propertyNameText(member.name) === name + ); +} + +function containingStaticBlock(reference, boundary) { + let current = reference.parent; + while (current && current !== boundary) { + if (ts.isClassStaticBlockDeclaration(current) && current.parent === boundary) return current; + current = current.parent; + } + return null; +} + +function isLexicalStaticThis(expression, staticBlock) { + const current = unwrapExpression(expression); + if (current.kind !== ts.SyntaxKind.ThisKeyword) return false; + let parent = current.parent; + while (parent && parent !== staticBlock) { + if (ts.isClassLike(parent)) return false; + if (ts.isFunctionLike(parent) && !ts.isArrowFunction(parent)) return false; + parent = parent.parent; + } + return parent === staticBlock; +} + +function publicStaticAssignmentName(targetExpression, boundary, staticBlock) { + const target = unwrapExpression(targetExpression); + if (ts.isPropertyAccessExpression(target) && ts.isPrivateIdentifier(target.name)) { + return null; + } + + const access = + memberAccess(target) ?? + (ts.isElementAccessExpression(target) + ? { name: '*', receiver: unwrapExpression(target.expression) } + : null); + if (!access || !isLexicalStaticThis(access.receiver, staticBlock)) return null; + if (access.name === '*') return '*'; + + const declared = declaredStaticMember(boundary, access.name); + return !declared || isPublicStaticMember(declared) ? access.name : null; +} + +const definiteStaticAssignments = new WeakMap(); + +function staticBlockAssignmentSelection(reference, boundary) { + const staticBlock = containingStaticBlock(reference, boundary); + if (!staticBlock) return null; + + let assignments = definiteStaticAssignments.get(staticBlock); + if (!assignments) { + assignments = []; + visitDefiniteBlockExpressions(staticBlock.body, (node) => { + if (ts.isBinaryExpression(node) && ts.isAssignmentOperator(node.operatorToken.kind)) { + assignments.push(node); + } + }); + definiteStaticAssignments.set(staticBlock, assignments); + } + + for (const assignment of assignments) { + if (!containsReference(assignment.right, reference)) continue; + const localMember = publicStaticAssignmentName(assignment.left, boundary, staticBlock); + if (localMember !== null) return { getterOnly: false, localMember }; + } + return null; +} + +export function publicStaticClassSelection(reference, boundary) { + const assignmentSelection = staticBlockAssignmentSelection(reference, boundary); + if (assignmentSelection) return assignmentSelection; + + let current = reference; + let returned = false; + while (current.parent && current.parent !== boundary) { + const parent = current.parent; + if (ts.isReturnStatement(parent)) returned = true; + if ( + (ts.isPropertyDeclaration(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isMethodDeclaration(parent)) && + parent.parent === boundary && + isPublicStaticMember(parent) + ) { + const outsideBody = + !parent.body || reference.pos < parent.body.pos || reference.end > parent.body.end; + if (ts.isPropertyDeclaration(parent) || returned || outsideBody) { + return { getterOnly: false, localMember: propertyNameText(parent.name) }; + } + } + if (ts.isFunctionLike(parent)) returned = false; + current = parent; + } + return null; +} diff --git a/scripts/ci/feature-public-class-surface-analysis.mjs b/scripts/ci/feature-public-class-surface-analysis.mjs new file mode 100644 index 0000000000..6010364a70 --- /dev/null +++ b/scripts/ci/feature-public-class-surface-analysis.mjs @@ -0,0 +1,519 @@ +import ts from 'typescript'; + +import { hasModifier, propertyNameText, unwrapExpression } from './feature-export-analysis.mjs'; +import { visitDefiniteTopLevelExpressions } from './feature-definite-execution.mjs'; +import { isPotentiallyExecutedAtTopLevel } from './feature-executed-iife-analysis.mjs'; +import { publicStaticClassSelection } from './feature-public-class-analysis.mjs'; +import { publicConstructorSelection } from './feature-public-constructor-analysis.mjs'; +import { + propertyWriteAvailableAt, + propertyWriteWasOverwrittenBefore, +} from './feature-public-object-analysis.mjs'; + +const DIRECT_CLASS_SURFACE = Object.freeze({ + constructorSignature: true, + heritage: true, + instance: true, + static: true, + typeParameters: true, +}); +const INSTANCE_CLASS_SURFACE = Object.freeze({ + constructorSignature: false, + heritage: true, + instance: true, + static: false, + typeParameters: false, +}); + +function classBoundaries(expression) { + const current = unwrapExpression(expression); + if (ts.isClassExpression(current)) return [current]; + if (ts.isConditionalExpression(current)) { + return [...classBoundaries(current.whenTrue), ...classBoundaries(current.whenFalse)]; + } + return []; +} + +function assignmentToIdentifier(node) { + if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return null; + } + const target = unwrapExpression(node.left); + return ts.isIdentifier(target) ? target.text : null; +} + +function collectClassBindingEvents(sourceFile) { + const eventsByName = new Map(); + const definiteAssignments = new Set(); + let sequence = 0; + const addEvent = (name, boundaries, position, definite = true) => { + const events = eventsByName.get(name) ?? []; + events.push({ boundaries, definite, position, sequence: sequence++ }); + eventsByName.set(name, events); + }; + + visitDefiniteTopLevelExpressions(sourceFile, (node) => { + if (assignmentToIdentifier(node)) definiteAssignments.add(node); + }); + + for (const statement of sourceFile.statements) { + if (ts.isClassDeclaration(statement) && statement.name) { + addEvent(statement.name.text, [statement], statement.getStart(sourceFile)); + continue; + } + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + addEvent( + declaration.name.text, + classBoundaries(declaration.initializer), + declaration.getStart(sourceFile) + ); + } + } + + const visitAssignment = (node) => { + if (ts.isFunctionLike(node) || ts.isClassLike(node)) return; + const name = assignmentToIdentifier(node); + if (name) { + addEvent(name, classBoundaries(node.right), node.end, definiteAssignments.has(node)); + } + ts.forEachChild(node, visitAssignment); + }; + for (const statement of sourceFile.statements) { + if (!ts.isClassDeclaration(statement) && !ts.isVariableStatement(statement)) { + visitAssignment(statement); + } + } + + for (const events of eventsByName.values()) { + events.sort((left, right) => left.position - right.position || left.sequence - right.sequence); + } + return eventsByName; +} + +function classBindingsAt(eventsByName, name, position) { + let boundaries = new Set(); + for (const event of eventsByName.get(name) ?? []) { + if (event.position > position) break; + if (event.definite) { + boundaries = new Set(event.boundaries); + } else { + for (const boundary of event.boundaries) boundaries.add(boundary); + } + } + return boundaries; +} + +function mergeSurface(existing, next) { + if (!existing) return { ...next }; + return { + constructorSignature: existing.constructorSignature || next.constructorSignature, + heritage: existing.heritage || next.heritage, + instance: existing.instance || next.instance, + static: existing.static || next.static, + typeParameters: existing.typeParameters || next.typeParameters, + }; +} + +function localBaseBoundaries(boundary, eventsByName) { + const heritage = boundary.heritageClauses?.find( + ({ token }) => token === ts.SyntaxKind.ExtendsKeyword + ); + const expression = heritage?.types[0]?.expression; + const base = expression && unwrapExpression(expression); + return base && ts.isIdentifier(base) + ? classBindingsAt(eventsByName, base.text, boundary.getStart()) + : new Set(); +} + +function nearestClassBoundary(reference) { + let current = reference.parent; + while (current) { + if (ts.isClassLike(current)) return current; + current = current.parent; + } + return null; +} + +function nearestFunctionDeclaration(reference) { + let current = reference.parent; + while (current) { + if (ts.isFunctionDeclaration(current)) return current; + if (ts.isClassLike(current)) return null; + current = current.parent; + } + return null; +} + +function isPublicMember(member) { + return ( + (!('name' in member) || !member.name || !ts.isPrivateIdentifier(member.name)) && + !hasModifier(member, ts.SyntaxKind.PrivateKeyword) && + !hasModifier(member, ts.SyntaxKind.ProtectedKeyword) + ); +} + +function isStaticMember(member) { + return hasModifier(member, ts.SyntaxKind.StaticKeyword); +} + +function containsReference(node, reference) { + return reference.pos >= node.pos && reference.end <= node.end; +} + +function containingClassMember(reference, boundary) { + let current = reference; + while (current.parent && current.parent !== boundary) current = current.parent; + return current.parent === boundary ? current : null; +} + +function referenceIsInBody(reference, member) { + return Boolean(member.body && containsReference(member.body, reference)); +} + +function publicSignatureSelection(reference, boundary, surface) { + const heritage = boundary.heritageClauses?.find((clause) => containsReference(clause, reference)); + if (heritage && surface.heritage) { + return { getterOnly: false, localMember: '*' }; + } + const typeParameter = boundary.typeParameters?.find((parameter) => + containsReference(parameter, reference) + ); + if (typeParameter && surface.typeParameters) { + return { getterOnly: false, localMember: '*' }; + } + const member = containingClassMember(reference, boundary); + if (!member) return null; + if (ts.isIndexSignatureDeclaration(member)) { + return surface.instance && isPublicMember(member) + ? { getterOnly: false, localMember: '*' } + : null; + } + if (ts.isConstructorDeclaration(member)) { + if ( + surface.constructorSignature && + isPublicMember(member) && + !referenceIsInBody(reference, member) + ) { + return { getterOnly: false, localMember: '*' }; + } + return null; + } + if (!('name' in member) || !member.name || !isPublicMember(member)) return null; + const selectedSurface = isStaticMember(member) ? surface.static : surface.instance; + if (!selectedSurface || referenceIsInBody(reference, member)) return null; + return { + getterOnly: false, + localMember: propertyNameText(member.name), + }; +} + +function publicClassSelection(reference, boundary, surface) { + if (surface.instance) { + const instanceSelection = publicConstructorSelection(reference, boundary); + if (instanceSelection) return instanceSelection; + } + if (surface.static) { + const staticSelection = publicStaticClassSelection(reference, boundary); + if (staticSelection) return staticSelection; + } + return publicSignatureSelection(reference, boundary, surface); +} + +function directAnonymousClasses(sourceFile) { + const boundaries = []; + for (const statement of sourceFile.statements) { + if ( + ts.isClassDeclaration(statement) && + !statement.name && + hasModifier(statement, ts.SyntaxKind.ExportKeyword) + ) { + boundaries.push(statement); + } else if (ts.isExportAssignment(statement)) { + boundaries.push(...classBoundaries(statement.expression)); + } + } + return boundaries; +} + +function bindingNames(name) { + if (ts.isIdentifier(name)) return [name.text]; + return name.elements.flatMap((element) => + ts.isBindingElement(element) ? bindingNames(element.name) : [] + ); +} + +function liveExportedLocalNames(sourceFile) { + const names = new Set(); + for (const statement of sourceFile.statements) { + if ( + ts.isExportDeclaration(statement) && + !statement.moduleSpecifier && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + names.add(element.propertyName?.text ?? element.name.text); + } + continue; + } + if ( + !hasModifier(statement, ts.SyntaxKind.ExportKeyword) || + hasModifier(statement, ts.SyntaxKind.DefaultKeyword) + ) { + continue; + } + if (ts.isClassDeclaration(statement) && statement.name) { + names.add(statement.name.text); + } else if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + for (const name of bindingNames(declaration.name)) names.add(name); + } + } + } + return names; +} + +function publicPrototypeWriteSelection( + reference, + propertyWrites, + publicConstructorBindingNames, + prototypeRelations, + sourceFile +) { + const referenceIsInGetter = (() => { + let current = reference.parent; + while (current && current !== sourceFile) { + if (ts.isGetAccessorDeclaration(current)) return true; + if (ts.isFunctionLike(current)) { + let callable = current; + while ( + callable.parent && + (ts.isParenthesizedExpression(callable.parent) || + ts.isAsExpression(callable.parent) || + ts.isTypeAssertionExpression(callable.parent) || + ts.isNonNullExpression(callable.parent) || + ts.isSatisfiesExpression(callable.parent)) + ) { + callable = callable.parent; + } + return ( + ts.isPropertyAssignment(callable.parent) && + propertyNameText(callable.parent.name) === 'get' + ); + } + current = current.parent; + } + return false; + })(); + if (!isPotentiallyExecutedAtTopLevel(reference, sourceFile) && !referenceIsInGetter) { + return null; + } + const position = reference.getStart(); + const pathEquals = (left, right) => + left.length === right.length && left.every((segment, index) => segment === right[index]); + const pathStartsWith = (path, prefix) => + prefix.every((segment, index) => path[index] === segment); + const writeContainsReference = (write) => + write.referenceRanges?.some((range) => range.start <= position && position <= range.end); + const liveWrites = (writes, prefix) => + writes.filter( + (write) => + pathStartsWith(write.path, prefix) && + !propertyWriteWasOverwrittenBefore(writes, write, Number.POSITIVE_INFINITY) + ); + const targetWasReplacedAfter = (relation) => + (propertyWrites.get(relation.ownerKey) ?? []).some( + (write) => + propertyWriteAvailableAt(write) > relation.position && + write.path.length <= relation.targetPath.length && + pathStartsWith(relation.targetPath, write.path) + ); + const latestRelation = (ownerKey, targetPath) => + prototypeRelations + .filter( + (relation) => + relation.ownerKey === ownerKey && pathEquals(relation.targetPath, targetPath) + ) + .sort( + (left, right) => + right.position - left.position || right.sequence - left.sequence + ) + .find((relation) => !targetWasReplacedAfter(relation)); + + for (const [bindingKey, localName] of publicConstructorBindingNames) { + const blockedMembers = new Set(); + const visited = new Set(); + let surface = { + inlineWrites: null, + path: ['prototype'], + sourceKey: bindingKey, + }; + while (surface) { + const surfaceKey = surface.inlineWrites + ? `inline:${surface.inlineWrites[0]?.position ?? 'empty'}` + : `${surface.sourceKey}:${JSON.stringify(surface.path)}`; + if (visited.has(surfaceKey)) break; + visited.add(surfaceKey); + + const allWrites = surface.inlineWrites ?? propertyWrites.get(surface.sourceKey) ?? []; + const currentWrites = liveWrites(allWrites, surface.path); + for (const write of currentWrites) { + const relativePath = write.path.slice(surface.path.length); + const member = relativePath[0] ?? '*'; + if ( + !write.removed && + !blockedMembers.has(member) && + writeContainsReference(write) + ) { + return { + localName, + selection: { + getterOnly: false, + localMember: member, + }, + }; + } + } + + for (const write of currentWrites) { + const relativePath = write.path.slice(surface.path.length); + if ( + !write.removed && + relativePath.length === 1 && + relativePath[0] !== '*' + ) { + blockedMembers.add(relativePath[0]); + } + } + + if (surface.inlineWrites) break; + const relation = latestRelation(surface.sourceKey, surface.path); + if (!relation) break; + surface = relation.sourceKey + ? { + inlineWrites: null, + path: relation.path, + sourceKey: relation.sourceKey, + } + : relation.inlineWrites?.length + ? { + inlineWrites: relation.inlineWrites, + path: [], + sourceKey: null, + } + : null; + } + } + return null; +} + +export function analyzePublicClassSurfaces({ + constructorExports, + exportedLocalNames, + propertyWrites, + prototypeRelations = [], + sourceFile, +}) { + const eventsByName = collectClassBindingEvents(sourceFile); + const publicFunctionConstructorNames = new Set( + constructorExports.map(({ localName }) => localName) + ); + const publicConstructorBindingNames = new Map( + constructorExports + .filter(({ bindingKey }) => bindingKey) + .map(({ bindingKey, localName }) => [bindingKey, localName]) + ); + const liveExportNames = liveExportedLocalNames(sourceFile); + const snapshotExports = sourceFile.statements.flatMap((statement) => { + if (!ts.isExportAssignment(statement)) return []; + const expression = unwrapExpression(statement.expression); + return ts.isIdentifier(expression) + ? [{ name: expression.text, position: statement.getStart(sourceFile) }] + : []; + }); + const snapshotExportNames = new Set(snapshotExports.map(({ name }) => name)); + const surfaces = new Map(); + const candidates = new Set(); + const addSurface = (boundary, surface) => { + candidates.add(boundary); + const merged = mergeSurface(surfaces.get(boundary), surface); + const changed = JSON.stringify(merged) !== JSON.stringify(surfaces.get(boundary)); + surfaces.set(boundary, merged); + return changed; + }; + + for (const name of exportedLocalNames) { + for (const event of eventsByName.get(name) ?? []) { + for (const boundary of event.boundaries) candidates.add(boundary); + } + if (!snapshotExportNames.has(name) || liveExportNames.has(name)) { + for (const boundary of classBindingsAt(eventsByName, name, Infinity)) { + addSurface(boundary, DIRECT_CLASS_SURFACE); + } + } + } + for (const snapshot of snapshotExports) { + for (const boundary of classBindingsAt(eventsByName, snapshot.name, snapshot.position)) { + addSurface(boundary, DIRECT_CLASS_SURFACE); + } + } + for (const boundary of directAnonymousClasses(sourceFile)) { + addSurface(boundary, DIRECT_CLASS_SURFACE); + } + for (const { localName, position = Infinity } of constructorExports) { + for (const boundary of classBindingsAt(eventsByName, localName, position)) { + addSurface(boundary, INSTANCE_CLASS_SURFACE); + } + } + + const queue = [...surfaces.keys()]; + while (queue.length > 0) { + const boundary = queue.shift(); + const surface = surfaces.get(boundary); + const inheritedSurface = { + ...INSTANCE_CLASS_SURFACE, + constructorSignature: + surface.constructorSignature && + !boundary.members.some((member) => ts.isConstructorDeclaration(member)), + static: surface.static, + }; + for (const base of localBaseBoundaries(boundary, eventsByName)) { + if (addSurface(base, inheritedSurface)) queue.push(base); + } + } + + const staleBoundaries = new Set([...candidates].filter((boundary) => !surfaces.has(boundary))); + return { + classifyReference: (reference) => { + const boundary = nearestClassBoundary(reference); + if (boundary) { + const surface = surfaces.get(boundary); + if (surface) { + return { + selection: publicClassSelection(reference, boundary, surface), + }; + } + return staleBoundaries.has(boundary) ? { selection: null } : undefined; + } + const prototypeSelection = publicPrototypeWriteSelection( + reference, + propertyWrites, + publicConstructorBindingNames, + prototypeRelations, + sourceFile + ); + if (prototypeSelection) { + return prototypeSelection; + } + const functionBoundary = nearestFunctionDeclaration(reference); + return functionBoundary?.name && + publicFunctionConstructorNames.has(functionBoundary.name.text) + ? { + selection: publicConstructorSelection(reference, functionBoundary), + } + : undefined; + }, + }; +} diff --git a/scripts/ci/feature-public-commonjs-analysis.mjs b/scripts/ci/feature-public-commonjs-analysis.mjs new file mode 100644 index 0000000000..6f1c3fe740 --- /dev/null +++ b/scripts/ci/feature-public-commonjs-analysis.mjs @@ -0,0 +1,574 @@ +import ts from 'typescript'; + +import { + commonJsExportPath, + isCommonJsExportsObject, + memberAccess, + propertyNameText, + rootBindingName, + unwrapExpression, +} from './feature-export-analysis.mjs'; +import { visitDefiniteTopLevelExpressions } from './feature-definite-execution.mjs'; +import { + accessPath, + propertyWriteAvailableAt, + staticDescriptorIsConfigurable, + staticDescriptorIsWritable, + staticDescriptorMapProperties, + staticOverwrittenPaths, +} from './feature-public-object-analysis.mjs'; + +function containsReference(node, reference) { + return node.pos <= reference.pos && reference.end <= node.end; +} + +export function commonJsRootKind(expression) { + const current = unwrapExpression(expression); + if (ts.isIdentifier(current) && current.text === 'exports') return 'exports'; + const access = memberAccess(current); + return access?.name === 'exports' && + ts.isIdentifier(access.receiver) && + access.receiver.text === 'module' + ? 'module' + : null; +} + +export function collectCommonJsRootAssignments(sourceFile) { + const assignments = []; + visitDefiniteTopLevelExpressions(sourceFile, (node) => { + if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return; + } + const kind = commonJsRootKind(node.left); + if (kind) { + assignments.push({ + expression: node.right, + kind, + node, + position: node.getStart(sourceFile), + }); + } + }); + return assignments + .sort((left, right) => { + if (left.node.pos <= right.node.pos && right.node.end <= left.node.end) return 1; + if (right.node.pos <= left.node.pos && left.node.end <= right.node.end) return -1; + return left.position - right.position; + }) + .map(({ expression, kind, position }, order) => ({ + expression, + kind, + order, + position, + })); +} + +function assignmentLinksExports(kind, expression) { + const current = unwrapExpression(expression); + const opposite = kind === 'module' ? 'exports' : 'module'; + if (commonJsRootKind(current) === opposite) return true; + if ( + kind === 'exports' && + isCommonJsExportsObject(current) && + rootBindingName(current) === 'module' + ) { + return true; + } + return ( + ts.isBinaryExpression(current) && + current.operatorToken.kind === ts.SyntaxKind.EqualsToken && + commonJsRootKind(current.left) === opposite + ); +} + +export function createExportsState(rootAssignments) { + return (position) => { + let active = true; + for (const assignment of rootAssignments) { + if (assignment.position >= position) break; + active = assignmentLinksExports(assignment.kind, assignment.expression); + } + return active; + }; +} + +export function lastCommonJsRootReplacement(rootAssignments, exportsActiveAt) { + return ( + [...rootAssignments].reverse().find((assignment) => { + if (assignment.kind !== 'module') return false; + const value = unwrapExpression(assignment.expression); + if (commonJsRootKind(value) === 'module') return false; + return !(commonJsRootKind(value) === 'exports' && exportsActiveAt(assignment.position)); + }) ?? null + ); +} + +function literalPropertyPath(expression, reference) { + if (!containsReference(expression, reference)) return null; + const path = []; + let current = reference; + while (current !== expression) { + const parent = current.parent; + if (!parent || !containsReference(parent, reference)) return null; + if (ts.isPropertyAssignment(parent) && containsReference(parent.initializer, reference)) { + path.unshift(propertyNameText(parent.name)); + } else if ( + ts.isShorthandPropertyAssignment(parent) && + containsReference(parent.name, reference) + ) { + path.unshift(propertyNameText(parent.name)); + } else if (ts.isArrayLiteralExpression(parent)) { + path.unshift(String(parent.elements.indexOf(current))); + } else if (ts.isSpreadAssignment(parent)) { + return null; + } + current = parent; + } + return path; +} + +function pathIsOverwritten(path, overwrittenPaths) { + return overwrittenPaths.some( + (overwritten) => + overwritten.length <= path.length && + overwritten.every((segment, index) => segment === path[index]) + ); +} + +export function pathWasOverwrittenAfter(writes, path, position) { + return writes.some( + (write) => + write.position > position && + write.path.length <= path.length && + write.path.every((segment, index) => segment === path[index]) + ); +} + +export function memberRelationIsAttachedAt(writes, relation, capturedAt, position) { + return !(writes.get(relation.sourceKey) ?? []).some( + (write) => + propertyWriteAvailableAt(write) > capturedAt && + propertyWriteAvailableAt(write) < position && + write.path.length <= relation.path.length && + write.path.every((segment, index) => segment === relation.path[index]) + ); +} + +export function commonJsRootWrapperSources(expression, bindingModel, beforePosition) { + const collect = (value) => { + const current = unwrapExpression(value); + const source = accessPath(current); + if (source) { + const sourceKey = bindingModel.bindingAt(source.root, beforePosition); + return sourceKey ? [{ path: source.path, sourceKey }] : []; + } + if (!ts.isCallExpression(current)) return []; + const method = memberAccess(current.expression); + if (!method || !ts.isIdentifier(method.receiver) || method.receiver.text !== 'Object') { + return []; + } + if (['create', 'freeze', 'preventExtensions', 'seal'].includes(method.name)) { + return current.arguments[0] ? collect(current.arguments[0]) : []; + } + return method.name === 'setPrototypeOf' + ? [...current.arguments].slice(0, 2).flatMap(collect) + : []; + }; + return [ + ...new Map( + collect(expression).map((source) => [`${source.sourceKey}:${source.path.join('.')}`, source]) + ).values(), + ]; +} + +function literalAssignedProperties(expression, prefix = []) { + const current = unwrapExpression(expression); + if (ts.isObjectLiteralExpression(current)) { + return current.properties.flatMap((property) => { + if ( + !property.name || + (!ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) && + !ts.isMethodDeclaration(property) && + !ts.isGetAccessorDeclaration(property) && + !ts.isSetAccessorDeclaration(property)) + ) { + return []; + } + const path = [...prefix, propertyNameText(property.name)]; + const initializer = ts.isPropertyAssignment(property) ? property.initializer : null; + return [ + { + configurable: true, + path, + valueState: initializer ? staticValueState(initializer) : 'unknown', + writable: true, + }, + ...(initializer ? literalAssignedProperties(initializer, path) : []), + ]; + }); + } + if (ts.isArrayLiteralExpression(current)) { + return current.elements.flatMap((element, index) => { + if (ts.isOmittedExpression(element) || ts.isSpreadElement(element)) return []; + const path = [...prefix, String(index)]; + return [ + { + configurable: true, + path, + valueState: staticValueState(element), + writable: true, + }, + ...literalAssignedProperties(element, path), + ]; + }); + } + if (!ts.isCallExpression(current)) return []; + const method = memberAccess(current.expression); + if (!method || !ts.isIdentifier(method.receiver) || method.receiver.text !== 'Object') { + return []; + } + if (method.name === 'assign') { + return [...current.arguments] + .slice(1) + .flatMap((source) => literalAssignedProperties(source, prefix)); + } + if (['freeze', 'preventExtensions', 'seal'].includes(method.name) && current.arguments[0]) { + return literalAssignedProperties(current.arguments[0], prefix).map((property) => ({ + ...property, + configurable: method.name === 'preventExtensions' ? property.configurable : false, + writable: method.name === 'freeze' ? false : property.writable, + })); + } + return []; +} + +function staticValueState(expression) { + const current = unwrapExpression(expression); + if (current.kind === ts.SyntaxKind.NullKeyword || ts.isVoidExpression(current)) { + return 'nullish'; + } + if (current.kind === ts.SyntaxKind.FalseKeyword) return 'falsy'; + if (current.kind === ts.SyntaxKind.TrueKeyword) return 'truthy'; + if (ts.isNumericLiteral(current)) { + return Number(current.text) === 0 ? 'falsy' : 'truthy'; + } + if (ts.isStringLiteralLike(current)) { + return current.text.length === 0 ? 'falsy' : 'truthy'; + } + return ts.isObjectLiteralExpression(current) || + ts.isArrayLiteralExpression(current) || + ts.isFunctionExpression(current) || + ts.isArrowFunction(current) || + ts.isClassExpression(current) || + ts.isNewExpression(current) + ? 'truthy' + : 'unknown'; +} + +function resolveTargetPaths(target, position, targetPathsAt) { + const paths = targetPathsAt(target, position); + if (paths === null || paths === undefined) return []; + if (paths.length === 0) return [paths]; + return Array.isArray(paths[0]) ? paths : [paths]; +} + +export function collectFinalCommonJsPropertyWrites(sourceFile, targetPathsAt, bindingModel) { + const writes = []; + const propertyStates = new Map(); + const pathKey = (path) => JSON.stringify(path); + const rootState = { + configurable: true, + path: [], + writable: true, + }; + propertyStates.set(pathKey([]), rootState); + const replacePropertyState = (path, descriptor) => { + const key = pathKey(path); + for (const [candidateKey, state] of propertyStates) { + if ( + state.path.length > path.length && + path.every((segment, index) => state.path[index] === segment) + ) { + propertyStates.delete(candidateKey); + } + } + propertyStates.set(key, { ...descriptor, path }); + }; + const recordAssignment = (path, position, value) => { + const current = propertyStates.get(pathKey(path)); + if (current?.writable === false) return; + replacePropertyState(path, { + configurable: current?.configurable ?? true, + valueState: value ? staticValueState(value) : 'unknown', + writable: current?.writable ?? true, + }); + writes.push({ path, position }); + }; + const recordConditionalAssignment = (path, operator, position, value) => { + const current = propertyStates.get(pathKey(path)); + const valueState = current?.valueState ?? 'nullish'; + const taken = + (operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken && valueState === 'truthy') || + (operator === ts.SyntaxKind.BarBarEqualsToken && ['falsy', 'nullish'].includes(valueState)) || + (operator === ts.SyntaxKind.QuestionQuestionEqualsToken && valueState === 'nullish'); + if (taken) recordAssignment(path, position, value); + }; + const recordDefinition = (path, position, descriptor) => { + const current = propertyStates.get(pathKey(path)); + const requestedConfigurable = descriptor.configurable ?? current?.configurable ?? false; + const requestedWritable = descriptor.writable ?? current?.writable ?? false; + replacePropertyState(path, { + configurable: current?.configurable === false ? false : requestedConfigurable, + writable: current?.writable === false ? false : requestedWritable, + }); + writes.push({ path, position }); + }; + const addPaths = (target, properties, position, definitions = false) => { + for (const targetPath of resolveTargetPaths(target, position, targetPathsAt)) { + for (const property of properties) { + const path = Array.isArray(property) ? property : property.path; + const publicPath = [...targetPath, ...path]; + if (definitions && !Array.isArray(property)) { + recordDefinition(publicPath, position, property); + } else { + recordAssignment(publicPath, position); + } + } + } + }; + visitDefiniteTopLevelExpressions(sourceFile, (node) => { + const position = node.getStart(sourceFile); + if (ts.isDeleteExpression(node)) { + for (const targetPath of resolveTargetPaths(node.expression, position, targetPathsAt)) { + const key = pathKey(targetPath); + if (propertyStates.get(key)?.configurable === true) { + writes.push({ path: targetPath, position: node.end }); + propertyStates.delete(key); + } + } + return; + } + if (ts.isBinaryExpression(node) && ts.isAssignmentOperator(node.operatorToken.kind)) { + for (const targetPath of resolveTargetPaths(node.left, position, targetPathsAt)) { + if (targetPath.length > 0) { + if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + recordAssignment(targetPath, node.end, node.right); + } else if ( + [ + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ].includes(node.operatorToken.kind) + ) { + recordConditionalAssignment(targetPath, node.operatorToken.kind, node.end, node.right); + } else { + recordAssignment(targetPath, node.end); + } + } else { + if (node.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return; + const rootKind = commonJsRootKind(node.left); + if (rootKind && assignmentLinksExports(rootKind, node.right)) { + return; + } + propertyStates.clear(); + propertyStates.set(pathKey([]), rootState); + const value = unwrapExpression(node.right); + const method = ts.isCallExpression(value) && memberAccess(value.expression); + const descriptorProperties = + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + method.name === 'create' && + value.arguments[1] + ? staticDescriptorMapProperties(value.arguments[1], bindingModel, position) + : []; + for (const property of descriptorProperties) { + replacePropertyState(property.path, property); + } + const assignedProperties = [ + ...staticOverwrittenPaths([node.right], bindingModel, position).map((path) => ({ + configurable: true, + path, + valueState: 'unknown', + writable: true, + })), + ...literalAssignedProperties(node.right), + ]; + for (const property of new Map( + assignedProperties.map((assignedProperty) => [ + pathKey(assignedProperty.path), + assignedProperty, + ]) + ).values()) { + replacePropertyState(property.path, property); + } + } + } + return; + } + if (!ts.isCallExpression(node) || !node.arguments[0]) return; + const method = memberAccess(node.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) + ) { + return; + } + if (['freeze', 'seal'].includes(method.name) && node.arguments[0]) { + for (const targetPath of resolveTargetPaths(node.arguments[0], position, targetPathsAt)) { + for (const state of [...propertyStates.values()]) { + if ( + state.path.length === targetPath.length + 1 && + targetPath.every((segment, index) => state.path[index] === segment) + ) { + propertyStates.set(pathKey(state.path), { + ...state, + configurable: false, + writable: method.name === 'freeze' ? false : state.writable, + }); + } + } + } + } else if (method.name === 'assign') { + addPaths( + node.arguments[0], + staticOverwrittenPaths([...node.arguments].slice(1), bindingModel, position), + node.end + ); + } else if ( + method.name === 'set' && + node.arguments[1] && + ts.isStringLiteralLike(unwrapExpression(node.arguments[1])) + ) { + addPaths(node.arguments[0], [[unwrapExpression(node.arguments[1]).text]], node.end); + } else if ( + method.name === 'defineProperty' && + node.arguments[1] && + ts.isStringLiteralLike(unwrapExpression(node.arguments[1])) + ) { + addPaths( + node.arguments[0], + [ + { + configurable: staticDescriptorIsConfigurable(node.arguments[2], bindingModel, position), + path: [unwrapExpression(node.arguments[1]).text], + writable: staticDescriptorIsWritable(node.arguments[2], bindingModel, position), + }, + ], + node.end, + true + ); + } else if (method.name === 'defineProperties' && node.arguments[1]) { + addPaths( + node.arguments[0], + staticDescriptorMapProperties(node.arguments[1], bindingModel, position), + node.end, + true + ); + } + }); + return writes; +} + +function objectAssignReferencePath(sources, reference, targetPaths, bindingModel, beforePosition) { + const sourceIndex = sources.findIndex((source) => containsReference(source, reference)); + if (sourceIndex < 0) return null; + const valuePath = literalPropertyPath(sources[sourceIndex], reference); + if ( + valuePath === null || + pathIsOverwritten( + valuePath, + staticOverwrittenPaths(sources.slice(sourceIndex + 1), bindingModel, beforePosition) + ) + ) { + return valuePath === null ? null : false; + } + return targetPaths.map((targetPath) => [...targetPath, ...valuePath]); +} + +function assignedReferencePath(expression, reference, bindingModel, targetPathAt) { + const current = unwrapExpression(expression); + if (ts.isBinaryExpression(current) && ts.isAssignmentOperator(current.operatorToken.kind)) { + const targetPaths = resolveTargetPaths(current.left, current.getStart(), targetPathAt); + if (targetPaths.length === 0) return null; + const value = unwrapExpression(current.right); + if (ts.isCallExpression(value)) { + const method = memberAccess(value.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + method.name === 'assign' + ) { + return objectAssignReferencePath( + [...value.arguments], + reference, + targetPaths, + bindingModel, + current.getStart() + ); + } + } + const valuePath = literalPropertyPath(current.right, reference); + return valuePath === null + ? null + : targetPaths.map((targetPath) => [...targetPath, ...valuePath]); + } + if (!ts.isCallExpression(current) || !current.arguments[0]) return null; + const method = memberAccess(current.expression); + const targetPaths = resolveTargetPaths(current.arguments[0], current.getStart(), targetPathAt); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) || + targetPaths.length === 0 + ) { + return null; + } + if (method.name === 'assign') { + const sources = [...current.arguments].slice(1); + return objectAssignReferencePath( + sources, + reference, + targetPaths, + bindingModel, + current.getStart() + ); + } + if ( + ['defineProperty', 'set'].includes(method.name) && + current.arguments[1] && + ts.isStringLiteralLike(unwrapExpression(current.arguments[1])) + ) { + return targetPaths.map((targetPath) => [ + ...targetPath, + unwrapExpression(current.arguments[1]).text, + ]); + } + if (method.name === 'defineProperties' && current.arguments[1]) { + const valuePath = literalPropertyPath(current.arguments[1], reference); + return valuePath?.length + ? targetPaths.map((targetPath) => [...targetPath, valuePath[0]]) + : null; + } + return null; +} + +export function commonJsReferenceIsPublic( + expression, + reference, + finalPropertyWrites, + bindingModel, + targetPathAt = (target) => commonJsExportPath(target) +) { + const publicPaths = assignedReferencePath(expression, reference, bindingModel, targetPathAt); + if (publicPaths === false) return false; + return ( + publicPaths === null || + publicPaths.some( + (publicPath) => !pathWasOverwrittenAfter(finalPropertyWrites, publicPath, expression.end) + ) + ); +} diff --git a/scripts/ci/feature-public-constructor-analysis.mjs b/scripts/ci/feature-public-constructor-analysis.mjs new file mode 100644 index 0000000000..4a47d85099 --- /dev/null +++ b/scripts/ci/feature-public-constructor-analysis.mjs @@ -0,0 +1,542 @@ +import ts from 'typescript'; + +import { + bindingNames, + memberAccess, + propertyNameText, + unwrapExpression, +} from './feature-export-analysis.mjs'; +import { + immediateConstructorIifeInvocation, + resolvedLocalObjects, + resolvedLocalValueContainsReference, + resolvedLocalValueNodes, + resolvedStaticPropertyNames, +} from './feature-constructor-local-value-analysis.mjs'; + +function isPublicInstanceMember(node) { + if (!node.name || ts.isPrivateIdentifier(node.name)) return false; + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; + return !modifiers?.some( + ({ kind }) => + kind === ts.SyntaxKind.PrivateKeyword || + kind === ts.SyntaxKind.ProtectedKeyword || + kind === ts.SyntaxKind.StaticKeyword + ); +} + +function isStaticMember(node) { + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; + return modifiers?.some(({ kind }) => kind === ts.SyntaxKind.StaticKeyword) ?? false; +} + +function declaredInstanceMember(boundary, name) { + for (const member of boundary.members) { + if (ts.isConstructorDeclaration(member)) { + const parameter = member.parameters.find( + (candidate) => + ts.isParameterPropertyDeclaration(candidate, member) && + propertyNameText(candidate.name) === name + ); + if (parameter) return parameter; + } else if (member.name && !isStaticMember(member) && propertyNameText(member.name) === name) { + return member; + } + } + return null; +} + +function statementContainer(node) { + let current = node.parent; + while (current && !Array.isArray(current.statements)) current = current.parent; + return current; +} + +function localClassBinding(boundary, name) { + const container = statementContainer(boundary); + if (!container) return null; + for (const statement of container.statements) { + if (ts.isClassDeclaration(statement) && statement.name?.text === name) { + return statement; + } + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + const initializer = declaration.initializer && unwrapExpression(declaration.initializer); + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === name && + initializer && + ts.isClassExpression(initializer) + ) { + return initializer; + } + } + } + return null; +} + +function publicInstanceMemberName(boundary, name, visited = new Set()) { + if (!ts.isClassLike(boundary) || name === '*') return name; + const declared = declaredInstanceMember(boundary, name); + if (declared) return isPublicInstanceMember(declared) ? name : null; + const heritage = boundary.heritageClauses?.find( + ({ token }) => token === ts.SyntaxKind.ExtendsKeyword + ); + if (!heritage?.types[0]) return name; + const baseExpression = unwrapExpression(heritage.types[0].expression); + if (!ts.isIdentifier(baseExpression)) return null; + const base = localClassBinding(boundary, baseExpression.text); + if (!base || visited.has(base)) return null; + return publicInstanceMemberName(base, name, new Set(visited).add(base)); +} + +function immediateInvocationMethod(call) { + const method = memberAccess(call.expression); + return method && ['call', 'apply'].includes(method.name) ? method.name : null; +} + +function isBoundaryInstanceExpression(expression, boundary, visited = new Set()) { + return resolvedLocalValueNodes(expression, boundary).some((node) => { + const value = unwrapExpression(node); + if (value.kind !== ts.SyntaxKind.ThisKeyword || visited.has(value)) return false; + + const nextVisited = new Set(visited).add(value); + let current = value.parent; + while (current && current !== boundary) { + if (ts.isClassLike(current)) return false; + if (ts.isArrowFunction(current)) { + current = current.parent; + continue; + } + if (ts.isFunctionLike(current)) { + if (current.parent === boundary && !isStaticMember(current)) return true; + const invocation = immediateConstructorIifeInvocation(current); + if (!invocation || !immediateInvocationMethod(invocation) || !invocation.arguments[0]) { + return false; + } + return isBoundaryInstanceExpression(invocation.arguments[0], boundary, nextVisited); + } + current = current.parent; + } + return current === boundary; + }); +} + +function isPublicInstanceAssignment(targetExpression, boundary) { + if ( + ts.isPropertyAccessExpression(targetExpression) && + ts.isPrivateIdentifier(targetExpression.name) + ) { + return null; + } + const target = memberAccess(targetExpression); + if (!target || !isBoundaryInstanceExpression(target.receiver, boundary)) return null; + return publicInstanceMemberName(boundary, target.name); +} + +function functionReturnsResolvedReference(functionNode, reference, boundary) { + if (!functionNode.body) return false; + if (ts.isArrowFunction(functionNode) && !ts.isBlock(functionNode.body)) { + return resolvedLocalValueContainsReference(functionNode.body, reference, boundary, { + captureOuter: true, + }); + } + let returned = false; + const visit = (node) => { + if (returned || (node !== functionNode && ts.isFunctionLike(node))) return; + if ( + ts.isReturnStatement(node) && + node.expression && + resolvedLocalValueContainsReference(node.expression, reference, boundary, { + captureOuter: true, + }) + ) { + returned = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(functionNode.body); + return returned; +} + +function assignedPropertyContainsReference(property, reference, boundary) { + if (ts.isShorthandPropertyAssignment(property)) { + return resolvedLocalValueContainsReference(property.name, reference, boundary); + } + if (ts.isPropertyAssignment(property)) { + return resolvedLocalValueContainsReference(property.initializer, reference, boundary); + } + return ts.isGetAccessorDeclaration(property) + ? functionReturnsResolvedReference(property, reference, boundary) + : false; +} + +function assignedPropertyNames(property, boundary) { + if (!property.name) return []; + if (ts.isComputedPropertyName(property.name)) { + return resolvedStaticPropertyNames(property.name.expression, boundary); + } + const name = propertyNameText(property.name); + return name === null ? [] : [name]; +} + +function overlayObjectState(base, incoming) { + const result = new Map(base); + for (const [name, contains] of incoming) result.set(name, contains); + return result; +} + +function objectReferenceStates(object, reference, boundary, visited = new Set()) { + const key = `${object.pos}:${object.end}`; + if (visited.has(key)) return []; + const nextVisited = new Set(visited).add(key); + let states = [new Map()]; + for (const property of object.properties) { + if (ts.isSpreadAssignment(property)) { + const spreadStates = resolvedLocalObjects(property.expression, boundary).flatMap((spread) => + objectReferenceStates(spread, reference, boundary, nextVisited) + ); + if (spreadStates.length > 0) { + states = states.flatMap((state) => + spreadStates.map((spreadState) => overlayObjectState(state, spreadState)) + ); + } + continue; + } + const contains = assignedPropertyContainsReference(property, reference, boundary); + const names = assignedPropertyNames(property, boundary); + if (names.length === 0 && contains && property.name) { + for (const state of states) state.set('*', true); + continue; + } + for (const state of states) { + for (const name of names) state.set(name, contains); + } + } + for (const state of states) { + for (const excluded of object.restExclusions ?? []) state.delete(excluded); + } + return states; +} + +function objectAssignReferenceMember(expressions, reference, boundary) { + let states = [new Map()]; + for (const expression of expressions) { + const sourceStates = resolvedLocalObjects(expression, boundary).flatMap((object) => + objectReferenceStates(object, reference, boundary) + ); + if (sourceStates.length === 0) continue; + states = states.flatMap((state) => + sourceStates.map((sourceState) => overlayObjectState(state, sourceState)) + ); + } + const names = new Set(); + for (const state of states) { + for (const [name, contains] of state) { + if (contains) names.add(name); + } + } + for (const name of names) { + const publicName = publicInstanceMemberName(boundary, name); + if (publicName !== null) return publicName; + } + return null; +} + +function descriptorGetterContainsReference(property, reference, boundary) { + if (ts.isMethodDeclaration(property)) { + return functionReturnsResolvedReference(property, reference, boundary); + } + if (!ts.isPropertyAssignment(property)) return false; + return resolvedLocalValueNodes(property.initializer, boundary).some((node) => { + const getter = unwrapExpression(node); + return ts.isFunctionLike(getter) + ? functionReturnsResolvedReference(getter, reference, boundary) + : resolvedLocalValueContainsReference(getter, reference, boundary); + }); +} + +function descriptorContainsReference(expression, reference, boundary) { + return resolvedLocalObjects(expression, boundary).some((descriptor) => + descriptor.properties.some((property) => { + const name = property.name && propertyNameText(property.name); + const value = + ts.isPropertyAssignment(property) && name === 'value' + ? property.initializer + : ts.isShorthandPropertyAssignment(property) && name === 'value' + ? property.name + : null; + if (value && resolvedLocalValueContainsReference(value, reference, boundary)) return true; + return name === 'get' && descriptorGetterContainsReference(property, reference, boundary); + }) + ); +} + +function executesOnBoundaryInstance(call, boundary) { + let owner = null; + for (let current = call.parent; current && current !== boundary; current = current.parent) { + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + if (!immediateConstructorIifeInvocation(current)) return false; + continue; + } + if (!owner && ts.isFunctionLike(current) && !ts.isArrowFunction(current)) owner = current; + } + owner ??= ts.isFunctionLike(boundary) ? boundary : null; + if (ts.isClassLike(boundary)) { + if (owner) return owner.parent === boundary && !isStaticMember(owner); + let member = call; + while (member.parent && member.parent !== boundary) member = member.parent; + return ( + member.parent === boundary && ts.isPropertyDeclaration(member) && !isStaticMember(member) + ); + } + return owner === boundary; +} + +function importBindingNames(statement) { + if (ts.isImportEqualsDeclaration(statement)) return [statement.name.text]; + const clause = + ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly + ? statement.importClause + : null; + if (!clause) return []; + const names = clause.name ? [clause.name.text] : []; + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) names.push(bindings.name.text); + if (bindings && ts.isNamedImports(bindings)) { + names.push( + ...bindings.elements.filter((item) => !item.isTypeOnly).map((item) => item.name.text) + ); + } + return names; +} + +function statementRuntimeBindingNames(statement) { + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.flatMap((declaration) => + bindingNames(declaration.name) + ); + } + const imports = importBindingNames(statement); + if (imports.length > 0) return imports; + const runtimeDeclaration = + ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement); + return runtimeDeclaration && statement.name && ts.isIdentifier(statement.name) + ? [statement.name.text] + : []; +} + +function blockDeclaresRuntimeValue(block, name) { + const statements = ts.isCaseBlock(block) + ? block.clauses.flatMap((clause) => clause.statements) + : block.statements; + return statements.some((statement) => statementRuntimeBindingNames(statement).includes(name)); +} + +function functionDeclaresRuntimeValue(functionLike, name) { + if ( + ((ts.isFunctionDeclaration(functionLike) || ts.isFunctionExpression(functionLike)) && + functionLike.name && + ts.isIdentifier(functionLike.name) && + functionLike.name.text === name) || + functionLike.parameters.some((parameter) => bindingNames(parameter.name).includes(name)) + ) { + return true; + } + let found = false; + const visit = (node) => { + if (found || (node !== functionLike && ts.isFunctionLike(node))) return; + if ( + ts.isVariableDeclarationList(node) && + (node.flags & ts.NodeFlags.BlockScoped) === 0 && + node.declarations.some((declaration) => bindingNames(declaration.name).includes(name)) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + if (functionLike.body) visit(functionLike.body); + return found; +} + +function isLexicallyShadowedRuntimeValue(reference) { + const name = reference.text; + const sourceFile = reference.getSourceFile(); + let current = reference.parent; + while (current && current !== sourceFile) { + if ( + (ts.isBlock(current) || ts.isCaseBlock(current)) && + blockDeclaresRuntimeValue(current, name) + ) { + return true; + } + if ( + ts.isCatchClause(current) && + current.variableDeclaration && + bindingNames(current.variableDeclaration.name).includes(name) + ) { + return true; + } + if (ts.isFunctionLike(current) && functionDeclaresRuntimeValue(current, name)) { + return true; + } + if (ts.isClassExpression(current) && current.name?.text === name) { + return true; + } + const initializer = + (ts.isForStatement(current) || ts.isForInOrOfStatement(current)) && current.initializer; + if ( + initializer && + ts.isVariableDeclarationList(initializer) && + initializer.declarations.some((declaration) => bindingNames(declaration.name).includes(name)) + ) { + return true; + } + current = current.parent; + } + return blockDeclaresRuntimeValue(sourceFile, name); +} + +function isGlobalMutatorReceiver(receiver) { + return ( + ts.isIdentifier(receiver) && + ['Object', 'Reflect'].includes(receiver.text) && + !isLexicallyShadowedRuntimeValue(receiver) + ); +} + +function publicInstanceMutatorMember(call, reference, boundary) { + const method = memberAccess(call.expression); + if ( + !method || + !isGlobalMutatorReceiver(method.receiver) || + !isBoundaryInstanceExpression(call.arguments[0], boundary) || + !executesOnBoundaryInstance(call, boundary) + ) { + return null; + } + if (method.name === 'assign') { + return objectAssignReferenceMember(call.arguments.slice(1), reference, boundary); + } else if ( + method.name === 'set' && + call.arguments[2] && + resolvedLocalValueContainsReference(call.arguments[2], reference, boundary) + ) { + const name = unwrapExpression(call.arguments[1]); + return publicInstanceMemberName( + boundary, + name && (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) ? name.text : '*' + ); + } else if ( + method.name === 'defineProperty' && + descriptorContainsReference(call.arguments[2], reference, boundary) + ) { + const name = unwrapExpression(call.arguments[1]); + return publicInstanceMemberName( + boundary, + name && (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) ? name.text : '*' + ); + } else if (method.name === 'defineProperties') { + for (const descriptors of resolvedLocalObjects(call.arguments[1], boundary)) { + for (const property of descriptors.properties) { + if ( + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + descriptorContainsReference( + ts.isPropertyAssignment(property) ? property.initializer : property.name, + reference, + boundary + ) + ) { + return publicInstanceMemberName(boundary, propertyNameText(property.name)); + } + } + } + } + return null; +} + +function publicInstanceWriteSelection(reference, boundary) { + let selection = null; + const visit = (node) => { + if (selection || (node !== boundary && ts.isClassLike(node))) return; + if ( + ts.isBinaryExpression(node) && + ts.isAssignmentOperator(node.operatorToken.kind) && + executesOnBoundaryInstance(node, boundary) && + resolvedLocalValueContainsReference(node.right, reference, boundary) + ) { + const localMember = isPublicInstanceAssignment(node.left, boundary); + if (localMember !== null) { + selection = { getterOnly: false, localMember }; + return; + } + } + if (ts.isCallExpression(node)) { + const localMember = publicInstanceMutatorMember(node, reference, boundary); + if (localMember !== null) { + selection = { getterOnly: false, localMember }; + return; + } + } + ts.forEachChild(node, visit); + }; + visit(boundary); + return selection; +} + +export function publicConstructorSelection(reference, boundary) { + const writeSelection = publicInstanceWriteSelection(reference, boundary); + if (writeSelection) return writeSelection; + + let current = reference; + let returned = false; + while (current.parent && current.parent !== boundary) { + const parent = current.parent; + if (ts.isReturnStatement(parent)) returned = true; + if (ts.isYieldExpression(parent)) returned = true; + if ( + ts.isBinaryExpression(parent) && + ts.isAssignmentOperator(parent.operatorToken.kind) && + parent.right === current && + executesOnBoundaryInstance(parent, boundary) + ) { + const localMember = isPublicInstanceAssignment(parent.left, boundary); + if (localMember !== null) { + return { getterOnly: false, localMember }; + } + } + if (ts.isCallExpression(parent)) { + const localMember = publicInstanceMutatorMember(parent, reference, boundary); + if (localMember !== null) { + return { getterOnly: false, localMember }; + } + } + if ( + ts.isParameter(parent) && + ts.isConstructorDeclaration(parent.parent) && + parent.parent.parent === boundary && + ts.isParameterPropertyDeclaration(parent, parent.parent) && + isPublicInstanceMember(parent) + ) { + return { getterOnly: false, localMember: propertyNameText(parent.name) }; + } + if ( + (ts.isPropertyDeclaration(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isMethodDeclaration(parent)) && + parent.parent === boundary && + isPublicInstanceMember(parent) && + (ts.isPropertyDeclaration(parent) || returned) + ) { + return { getterOnly: false, localMember: propertyNameText(parent.name) }; + } + if (ts.isFunctionLike(parent)) returned = false; + current = parent; + } + return null; +} diff --git a/scripts/ci/feature-public-descriptor-analysis.mjs b/scripts/ci/feature-public-descriptor-analysis.mjs new file mode 100644 index 0000000000..a17976d5b6 --- /dev/null +++ b/scripts/ci/feature-public-descriptor-analysis.mjs @@ -0,0 +1,285 @@ +import ts from 'typescript'; + +import { + isCommonJsExportsObject, + memberAccess, + propertyNameText, + rootBindingName, + unwrapExpression, +} from './feature-export-analysis.mjs'; +import { resolveObjectLiterals } from './feature-object-resolution.mjs'; +import { accessPath } from './feature-public-object-analysis.mjs'; + +function collectTopLevelAssignments(sourceFile) { + const assignments = new Map(); + const addAssignment = (name, expression, position) => { + const values = assignments.get(name) ?? []; + values.push({ expression, position }); + assignments.set(name, values); + }; + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + addAssignment(declaration.name.text, declaration.initializer, declaration.pos); + } + } + continue; + } + if (!ts.isExpressionStatement(statement)) continue; + const expression = unwrapExpression(statement.expression); + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(unwrapExpression(expression.left)) + ) { + addAssignment( + unwrapExpression(expression.left).text, + expression.right, + expression.getStart(sourceFile) + ); + } + } + return assignments; +} + +function resolveDescriptorObjects(expression, assignments, beforePosition) { + return resolveObjectLiterals(expression, beforePosition, (name, position) => { + const candidates = (assignments.get(name) ?? []) + .filter((assignment) => assignment.position < position) + .sort((left, right) => right.position - left.position); + const latest = candidates[0]; + return latest + ? { + beforePosition: latest.position, + expression: latest.expression, + key: `${name}:${latest.position}`, + } + : null; + }); +} + +function resolveDescriptorMapEntries(expression, assignments, beforePosition, visited = new Set()) { + const current = expression && unwrapExpression(expression); + const entries = resolveDescriptorObjects(expression, assignments, beforePosition).flatMap( + (descriptorMap) => { + const mapKey = `${descriptorMap.pos}:${descriptorMap.end}`; + if (visited.has(mapKey)) return []; + const entries = new Map(); + const nextVisited = new Set(visited).add(mapKey); + for (const property of descriptorMap.properties) { + if (ts.isSpreadAssignment(property)) { + for (const entry of resolveDescriptorMapEntries( + property.expression, + assignments, + beforePosition, + nextVisited + )) { + entries.set(entry.name, { + ...entry, + visibilityReferences: [ + property.expression, + ...(entry.visibilityReferences ?? entry.references), + ], + }); + } + } else if ( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) + ) { + entries.set(propertyNameText(property.name), { + expression: ts.isPropertyAssignment(property) ? property.initializer : property.name, + name: propertyNameText(property.name), + references: [ts.isPropertyAssignment(property) ? property.initializer : property.name], + }); + } + } + return [...entries.values()]; + } + ); + return current + ? entries.map((entry) => ({ + ...entry, + visibilityReferences: [ + current, + ...(entry.visibilityReferences ?? entry.references).filter( + (reference) => reference !== current + ), + ], + })) + : entries; +} + +function collectDescriptorGetterProperties(descriptor, getterProperties) { + for (const property of descriptor.properties) { + if ( + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + propertyNameText(property.name) === 'get' + ) { + getterProperties.add(property); + } + } +} + +function isPublicTarget(expression, publicTargets, position, memberPath = []) { + const current = expression && unwrapExpression(expression); + if (!current) return false; + const root = rootBindingName(current); + if (isCommonJsExportsObject(current)) { + const targets = publicTargets.commonJsTargetsAt(position); + return root === 'exports' ? targets.directExportsActive : targets.directModuleExportsActive; + } + const target = accessPath(current); + return ( + target !== null && + (publicTargets + .localOwnersAt(position, { + name: target.root, + path: [...target.path, ...memberPath], + }) + .has(target.root) || + publicTargets.commonJsTargetsAt(position).has(root)) + ); +} + +export function collectConsumedDescriptorGetterProperties(sourceFile, publicTargets) { + const assignments = collectTopLevelAssignments(sourceFile); + const getterProperties = new Set(); + const referenceMembers = new Map(); + const addReferenceMember = (reference, member) => { + if (!ts.isIdentifier(reference)) return; + const members = referenceMembers.get(reference) ?? new Set(); + members.add(member); + referenceMembers.set(reference, members); + }; + const visit = (node) => { + if (ts.isCallExpression(node)) { + const method = memberAccess(node.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + method.name === 'create' + ) { + for (const entry of resolveDescriptorMapEntries( + node.arguments[1], + assignments, + node.getStart(sourceFile) + )) { + for (const descriptor of resolveDescriptorObjects( + entry.expression, + assignments, + node.getStart(sourceFile) + )) { + collectDescriptorGetterProperties(descriptor, getterProperties); + } + } + } + const isDescriptorApi = + method && + ts.isIdentifier(method.receiver) && + ['Object', 'Reflect'].includes(method.receiver.text); + if (isDescriptorApi && method.name === 'defineProperty') { + const descriptorExpression = node.arguments[2] && unwrapExpression(node.arguments[2]); + for (const descriptor of resolveDescriptorObjects( + node.arguments[2], + assignments, + node.getStart(sourceFile) + )) { + const references = [descriptorExpression, descriptor].filter(Boolean); + if ( + !references.some((reference) => + isPublicTarget( + node.arguments[0], + publicTargets, + reference.getStart(sourceFile), + node.arguments[1] && ts.isStringLiteralLike(unwrapExpression(node.arguments[1])) + ? [unwrapExpression(node.arguments[1]).text] + : ['*'] + ) + ) + ) { + continue; + } + const beforeCount = getterProperties.size; + collectDescriptorGetterProperties(descriptor, getterProperties); + if (descriptorExpression && getterProperties.size > beforeCount) { + getterProperties.add(descriptorExpression); + } + } + } else if (isDescriptorApi && method.name === 'defineProperties') { + for (const entry of resolveDescriptorMapEntries( + node.arguments[1], + assignments, + node.getStart(sourceFile) + )) { + for (const descriptor of resolveDescriptorObjects( + entry.expression, + assignments, + node.getStart(sourceFile) + )) { + const visibilityReferences = [ + ...(entry.visibilityReferences ?? entry.references), + descriptor, + ]; + if ( + !visibilityReferences.some((reference) => + isPublicTarget(node.arguments[0], publicTargets, reference.getStart(sourceFile), [ + entry.name, + ]) + ) + ) { + continue; + } + const beforeCount = getterProperties.size; + collectDescriptorGetterProperties(descriptor, getterProperties); + if (getterProperties.size > beforeCount) { + for (const reference of [...entry.references, descriptor]) { + getterProperties.add(reference); + } + for (const reference of entry.visibilityReferences ?? []) { + if (!entry.references.includes(reference)) { + addReferenceMember(reference, entry.name); + } + } + } + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return { getterProperties, referenceMembers }; +} + +export function consumedDescriptorGetterMembersForReference( + node, + sourceFile, + consumedDescriptorGetters +) { + const members = new Set(); + let current = node; + while (current.parent && current.parent !== sourceFile) { + for (const member of consumedDescriptorGetters.referenceMembers.get(current) ?? []) { + members.add(member); + } + if (consumedDescriptorGetters.getterProperties.has(current)) members.add('*'); + const parent = current.parent; + if ( + (ts.isPropertyAssignment(parent) || ts.isShorthandPropertyAssignment(parent)) && + consumedDescriptorGetters.getterProperties.has(parent) + ) { + members.add('*'); + } + current = parent; + } + return [...members]; +} + +export function isConsumedDescriptorGetterReference(node, sourceFile, consumedDescriptorGetters) { + return ( + consumedDescriptorGetterMembersForReference(node, sourceFile, consumedDescriptorGetters) + .length > 0 + ); +} diff --git a/scripts/ci/feature-public-descriptor-state.mjs b/scripts/ci/feature-public-descriptor-state.mjs new file mode 100644 index 0000000000..a945abe46f --- /dev/null +++ b/scripts/ci/feature-public-descriptor-state.mjs @@ -0,0 +1,182 @@ +import ts from 'typescript'; + +import { propertyNameText, unwrapExpression } from './feature-export-analysis.mjs'; +import { resolveObjectLiterals } from './feature-object-resolution.mjs'; + +function resolveDescriptorObjects(expression, bindingModel, beforePosition) { + return resolveObjectLiterals(expression, beforePosition, (name, position) => { + const key = bindingModel.bindingAt(name, position); + const binding = key && bindingModel.versions.get(key); + return binding + ? { + beforePosition: binding.position, + expression: binding.initializer, + key, + } + : null; + }); +} + +function literalBoolean(expression) { + const value = unwrapExpression(expression); + if (value.kind === ts.SyntaxKind.TrueKeyword) return true; + if (value.kind === ts.SyntaxKind.FalseKeyword) return false; + return undefined; +} + +function descriptorBooleanStates( + descriptor, + name, + bindingModel, + beforePosition, + initialStates = new Set([undefined]), + visited = new Set() +) { + const key = `${descriptor.pos}:${descriptor.end}`; + if (visited.has(key)) return initialStates; + const nextVisited = new Set(visited).add(key); + let states = initialStates; + for (const property of descriptor.properties) { + if (ts.isSpreadAssignment(property)) { + if (!bindingModel) { + states = new Set([undefined]); + continue; + } + const spreadObjects = resolveDescriptorObjects( + property.expression, + bindingModel, + beforePosition + ); + if (spreadObjects.length === 0) { + states = new Set([undefined]); + continue; + } + states = new Set( + spreadObjects.flatMap((spreadObject) => [ + ...descriptorBooleanStates( + spreadObject, + name, + bindingModel, + beforePosition, + states, + nextVisited + ), + ]) + ); + } else if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === name) { + states = new Set([literalBoolean(property.initializer)]); + } + } + return states; +} + +export function descriptorBooleanSetting(descriptor, name, bindingModel, beforePosition) { + const states = descriptorBooleanStates(descriptor, name, bindingModel, beforePosition); + return states.size === 1 ? [...states][0] : undefined; +} + +export function descriptorIsEnumerable(descriptor, bindingModel, beforePosition) { + return descriptorBooleanSetting(descriptor, 'enumerable', bindingModel, beforePosition) === true; +} + +export function descriptorDefinesValue(descriptor) { + return descriptor.properties.some( + (property) => + (ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) || + ts.isMethodDeclaration(property)) && + ['get', 'set', 'value'].includes(propertyNameText(property.name)) + ); +} + +export function resolveDescriptorMapEntries( + expression, + bindingModel, + beforePosition, + visited = new Set() +) { + const current = expression && unwrapExpression(expression); + const entries = resolveDescriptorObjects(expression, bindingModel, beforePosition).flatMap( + (descriptorMap) => { + const mapKey = `${descriptorMap.pos}:${descriptorMap.end}`; + if (visited.has(mapKey)) return []; + const resolved = new Map(); + const nextVisited = new Set(visited).add(mapKey); + for (const property of descriptorMap.properties) { + if (ts.isSpreadAssignment(property)) { + for (const entry of resolveDescriptorMapEntries( + property.expression, + bindingModel, + beforePosition, + nextVisited + )) { + resolved.set(entry.name, { + ...entry, + references: [property.expression, ...entry.references], + }); + } + } else if ( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) + ) { + resolved.set(propertyNameText(property.name), { + expression: ts.isPropertyAssignment(property) ? property.initializer : property.name, + name: propertyNameText(property.name), + references: [ts.isPropertyAssignment(property) ? property.initializer : property.name], + }); + } + } + return [...resolved.values()]; + } + ); + return current && ts.isIdentifier(current) + ? entries.map((entry) => ({ + ...entry, + references: [current, ...entry.references.filter((reference) => reference !== current)], + })) + : entries; +} + +function staticDescriptorSetting(expression, name, bindingModel, beforePosition) { + const settings = resolveDescriptorObjects(expression, bindingModel, beforePosition).map( + (descriptor) => descriptorBooleanSetting(descriptor, name, bindingModel, beforePosition) + ); + return settings.length > 0 && settings.every((setting) => setting === settings[0]) + ? settings[0] + : undefined; +} + +export function staticDescriptorIsConfigurable(expression, bindingModel, beforePosition) { + return staticDescriptorSetting(expression, 'configurable', bindingModel, beforePosition); +} + +export function staticDescriptorIsWritable(expression, bindingModel, beforePosition) { + return staticDescriptorSetting(expression, 'writable', bindingModel, beforePosition); +} + +export function staticDescriptorMapProperties(expression, bindingModel, beforePosition) { + const properties = new Map(); + for (const entry of resolveDescriptorMapEntries(expression, bindingModel, beforePosition)) { + const configurable = staticDescriptorIsConfigurable( + entry.expression, + bindingModel, + beforePosition + ); + const writable = staticDescriptorIsWritable(entry.expression, bindingModel, beforePosition); + const previous = properties.get(entry.name); + const mergeSetting = (left, right) => (left === right ? left : undefined); + properties.set( + entry.name, + previous === undefined + ? { configurable, writable } + : { + configurable: mergeSetting(previous.configurable, configurable), + writable: mergeSetting(previous.writable, writable), + } + ); + } + return [...properties].map(([name, descriptor]) => ({ + ...descriptor, + path: [name], + })); +} diff --git a/scripts/ci/feature-public-export-policy.mjs b/scripts/ci/feature-public-export-policy.mjs new file mode 100644 index 0000000000..c45388763d --- /dev/null +++ b/scripts/ci/feature-public-export-policy.mjs @@ -0,0 +1,417 @@ +import { builtinModules } from 'node:module'; + +import { resolveProjectTarget } from './feature-module-resolution.mjs'; +import { isFeaturePublicEntrypoint } from './feature-source-files.mjs'; + +const IMPLEMENTATION_DIRECTORIES = new Set(['adapters', 'infrastructure']); +const CONCRETE_BOUNDARY_ROOTS = [ + 'src/preload', + 'src/renderer/api', + 'src/shared/api', + 'src/shared/ipc', + 'src/shared/transport', +]; +const CONCRETE_HOST_PACKAGES = new Set(['electron', 'fastify']); +const NODE_HOST_PACKAGE_ROOTS = new Set( + builtinModules.map((specifier) => specifier.replace(/^node:/, '').split('/')[0]) +); +const SOURCE_LEVEL_TRANSPARENT_HOST_SPECIFIERS = new Set(['module', 'node:module']); +const EXPORT_NAMESPACES = ['type', 'value']; + +function exportNamespacesForSource(sourcePath) { + return /\.[cm]?jsx?$/.test(sourcePath) ? ['value'] : EXPORT_NAMESPACES; +} + +function hasImplementationDirectory(filePath) { + return filePath.split('/').some((segment) => IMPLEMENTATION_DIRECTORIES.has(segment)); +} + +function isWithinConcreteBoundaryRoot(filePath) { + return CONCRETE_BOUNDARY_ROOTS.some( + (root) => filePath === root || filePath.startsWith(`${root}/`) + ); +} + +function externalPackageName(specifier) { + if (specifier.startsWith('@')) return specifier.split('/').slice(0, 2).join('/'); + return specifier.split('/')[0]; +} + +function isConcreteHostSpecifier(specifier) { + const packageName = externalPackageName(specifier); + return ( + NODE_HOST_PACKAGE_ROOTS.has(packageName.replace(/^node:/, '')) || + CONCRETE_HOST_PACKAGES.has(packageName) + ); +} + +function isConcreteExternalDependency(edge, target) { + return ( + target === null && + (edge.kind === 'import' || edge.kind === 'export') && + isConcreteHostSpecifier(edge.specifier) + ); +} + +function isConcreteBoundarySourceDependency(edge, target) { + return ( + isConcreteExternalDependency(edge, target) && + !SOURCE_LEVEL_TRANSPARENT_HOST_SPECIFIERS.has(edge.specifier) + ); +} + +function isConcreteExternalPublicReexport(reexport, target) { + return ( + isConcreteExternalDependency(reexport, target) && + (!reexport.isDependencyTrace || + !SOURCE_LEVEL_TRANSPARENT_HOST_SPECIFIERS.has(reexport.specifier)) + ); +} + +function isImplementationTarget(filePath) { + return hasImplementationDirectory(filePath) || isWithinConcreteBoundaryRoot(filePath); +} + +function featureLayer(filePath) { + const match = /^src\/features\/([^/]+)\/(main|preload|renderer)(?:\/|$)/.exec(filePath); + return match ? `${match[1]}:${match[2]}` : null; +} + +function isSameFeatureLayer(sourcePath, targetPath) { + const sourceLayer = featureLayer(sourcePath); + return sourceLayer !== null && sourceLayer === featureLayer(targetPath); +} + +function collectConcreteBoundarySources(edges, sourceFilePaths) { + const dependencies = edges.map((edge) => ({ + edge, + source: edge.source, + target: resolveProjectTarget(edge, sourceFilePaths), + })); + const projectDependencies = dependencies.filter(({ target }) => target !== null); + const projectBoundarySources = new Set(); + const projectBoundaryDependenciesBySource = new Map(); + let changed = true; + + while (changed) { + changed = false; + for (const dependency of projectDependencies) { + const { source, target } = dependency; + const reachesConcreteBoundary = + isWithinConcreteBoundaryRoot(target) || + (projectBoundarySources.has(target) && isSameFeatureLayer(source, target)); + if (projectBoundarySources.has(source) || !reachesConcreteBoundary) { + continue; + } + projectBoundarySources.add(source); + projectBoundaryDependenciesBySource.set(source, dependency); + changed = true; + } + } + + const externalBoundarySources = new Set(); + const externalBoundaryDependenciesBySource = new Map(); + changed = true; + while (changed) { + changed = false; + for (const dependency of dependencies) { + const { source, target } = dependency; + const directExternalBoundary = isConcreteBoundarySourceDependency(dependency.edge, target); + const reachesExternalBoundary = + directExternalBoundary || + (target !== null && + externalBoundarySources.has(target) && + isSameFeatureLayer(source, target)); + if (externalBoundarySources.has(source) || !reachesExternalBoundary) continue; + + externalBoundarySources.add(source); + externalBoundaryDependenciesBySource.set( + source, + directExternalBoundary ? dependency : externalBoundaryDependenciesBySource.get(target) + ); + changed = true; + } + } + + return { + externalBoundaryDependenciesBySource, + externalBoundarySources, + projectBoundaryDependenciesBySource, + projectBoundarySources, + }; +} + +function reexportsBySource(reexports) { + const grouped = new Map(); + for (const reexport of reexports) { + const sourceReexports = grouped.get(reexport.source) ?? []; + sourceReexports.push(reexport); + grouped.set(reexport.source, sourceReexports); + } + return grouped; +} + +function supportsNamespace(reexport, namespace) { + return namespace === 'type' || !reexport.isTypeOnly; +} + +function createExportResolver( + reexports, + { localTypeExportNamesBySource, localValueExportNamesBySource }, + sourceFilePaths +) { + const groupedReexports = reexportsBySource(reexports); + const exportCache = new Map(); + const localNames = (sourcePath, namespace) => + (namespace === 'type' ? localTypeExportNamesBySource : localValueExportNamesBySource).get( + sourcePath + ); + + const exportOrigins = (sourcePath, requestedExport, namespace, visited = new Set()) => { + const visitKey = `${sourcePath}:${requestedExport}:${namespace}`; + if (visited.has(visitKey)) return new Set(); + const nextVisited = new Set(visited).add(visitKey); + const sourceReexports = groupedReexports.get(sourcePath) ?? []; + + const explicitReexports = sourceReexports.filter( + (reexport) => + supportsNamespace(reexport, namespace) && + !reexport.isDependencyTrace && + !reexport.isExportStar && + reexport.exportedName === requestedExport + ); + if (explicitReexports.length > 0) { + return new Set( + explicitReexports.flatMap((reexport) => { + const targetPath = resolveProjectTarget(reexport, sourceFilePaths); + if (!targetPath) return [`${reexport.specifier}#${reexport.importedName}`]; + if (reexport.importedName === '*') return [`${targetPath}#*`]; + const origins = exportOrigins(targetPath, reexport.importedName, namespace, nextVisited); + return origins.size > 0 ? [...origins] : [`${targetPath}#${reexport.importedName}`]; + }) + ); + } + + if (localNames(sourcePath, namespace)?.has(requestedExport)) { + return new Set([`${sourcePath}#${requestedExport}`]); + } + + if ( + sourceReexports.some( + (reexport) => + supportsNamespace(reexport, namespace) && + reexport.isDependencyTrace && + reexport.exportedName === requestedExport + ) + ) { + return new Set([`${sourcePath}#${requestedExport}`]); + } + + if (requestedExport === 'default') return new Set(); + const starOrigins = sourceReexports + .filter((reexport) => reexport.isExportStar && supportsNamespace(reexport, namespace)) + .map((reexport) => { + const targetPath = resolveProjectTarget(reexport, sourceFilePaths); + return targetPath + ? exportOrigins(targetPath, requestedExport, namespace, nextVisited) + : new Set(); + }) + .filter((origins) => origins.size > 0); + const distinctOrigins = new Set(starOrigins.flatMap((origins) => [...origins])); + return distinctOrigins.size === 1 ? distinctOrigins : new Set(); + }; + + const exportedNames = (sourcePath, namespace, visited = new Set()) => { + const cacheKey = `${sourcePath}:${namespace}`; + if (exportCache.has(cacheKey)) return exportCache.get(cacheKey); + if (visited.has(cacheKey)) return new Set(); + const nextVisited = new Set(visited).add(cacheKey); + const names = new Set(localNames(sourcePath, namespace) ?? []); + const sourceReexports = groupedReexports.get(sourcePath) ?? []; + + for (const reexport of sourceReexports) { + if (supportsNamespace(reexport, namespace) && !reexport.isExportStar) { + names.add(reexport.exportedName); + } + } + const starNames = new Set(); + for (const reexport of sourceReexports.filter( + (candidate) => candidate.isExportStar && supportsNamespace(candidate, namespace) + )) { + const targetPath = resolveProjectTarget(reexport, sourceFilePaths); + if (!targetPath) continue; + for (const name of exportedNames(targetPath, namespace, nextVisited)) { + if (name !== 'default') starNames.add(name); + } + } + for (const name of starNames) { + if (names.has(name)) continue; + if (exportOrigins(sourcePath, name, namespace).size === 1) names.add(name); + } + exportCache.set(cacheKey, names); + return names; + }; + + return { exportOrigins, exportedNames, groupedReexports }; +} + +export function collectPublicApiImplementationExports({ + edges, + localTypeExportNamesBySource, + localValueExportNamesBySource, + reexports, + rule, + sourceFilePaths, +}) { + const { exportOrigins, exportedNames, groupedReexports } = createExportResolver( + reexports, + { localTypeExportNamesBySource, localValueExportNamesBySource }, + sourceFilePaths + ); + const { + externalBoundaryDependenciesBySource, + externalBoundarySources, + projectBoundaryDependenciesBySource, + projectBoundarySources, + } = collectConcreteBoundarySources(edges, sourceFilePaths); + const violations = []; + + for (const publicEntrypoint of [...sourceFilePaths].filter(isFeaturePublicEntrypoint).sort()) { + for (const reexport of groupedReexports + .get(publicEntrypoint) + ?.filter((candidate) => candidate.isExportStar) ?? []) { + const targetPath = resolveProjectTarget(reexport, sourceFilePaths); + const hasUnknownConcreteSurface = + (!targetPath && isConcreteExternalPublicReexport(reexport, targetPath)) || + (targetPath && + (isImplementationTarget(targetPath) || + projectBoundarySources.has(targetPath) || + externalBoundarySources.has(targetPath)) && + EXPORT_NAMESPACES.every((namespace) => exportedNames(targetPath, namespace).size === 0)); + if (!hasUnknownConcreteSurface) continue; + + violations.push({ + exportedName: '*', + importedName: '*', + line: reexport.line, + message: `public entrypoint ${publicEntrypoint} must not expose adapters, infrastructure, or concrete host boundaries`, + publicEntrypoint, + rule, + source: reexport.source, + specifier: reexport.specifier, + }); + } + + const visited = new Set(); + + const visit = (sourcePath, requestedExport, publicExportedName, namespace) => { + const visitKey = `${sourcePath}:${requestedExport}:${publicExportedName}:${namespace}`; + if (visited.has(visitKey)) return; + visited.add(visitKey); + + const sourceReexports = groupedReexports.get(sourcePath) ?? []; + const explicitReexports = sourceReexports.filter( + (reexport) => + supportsNamespace(reexport, namespace) && + !reexport.isExportStar && + !reexport.isDependencyTrace && + reexport.exportedName === requestedExport + ); + const dependencyTraces = sourceReexports.filter( + (reexport) => + supportsNamespace(reexport, namespace) && + reexport.isDependencyTrace && + reexport.exportedName === requestedExport + ); + const isLocalExport = ( + namespace === 'type' ? localTypeExportNamesBySource : localValueExportNamesBySource + ) + .get(sourcePath) + ?.has(requestedExport); + if (explicitReexports.length === 0 && dependencyTraces.length === 0 && isLocalExport) { + const boundaryDependency = + namespace === 'value' + ? (projectBoundaryDependenciesBySource.get(sourcePath) ?? + externalBoundaryDependenciesBySource.get(sourcePath)) + : undefined; + if (boundaryDependency) { + violations.push({ + exportedName: publicExportedName, + importedName: boundaryDependency.edge.importedNames?.[0] ?? '*', + line: boundaryDependency.edge.line, + message: `public entrypoint ${publicEntrypoint} must not expose adapters, infrastructure, or concrete host boundaries`, + publicEntrypoint, + rule, + source: boundaryDependency.edge.source, + specifier: boundaryDependency.edge.specifier, + }); + } + return; + } + const relevantReexports = + explicitReexports.length > 0 + ? explicitReexports + : dependencyTraces.length > 0 + ? dependencyTraces + : requestedExport === 'default' || + exportOrigins(sourcePath, requestedExport, namespace).size !== 1 + ? [] + : sourceReexports.filter( + (reexport) => reexport.isExportStar && supportsNamespace(reexport, namespace) + ); + + for (const reexport of relevantReexports) { + const targetPath = resolveProjectTarget(reexport, sourceFilePaths); + if (!targetPath) { + if (isConcreteExternalPublicReexport(reexport, targetPath)) { + violations.push({ + exportedName: publicExportedName, + importedName: reexport.isExportStar ? requestedExport : reexport.importedName, + line: reexport.line, + message: `public entrypoint ${publicEntrypoint} must not expose adapters, infrastructure, or concrete host boundaries`, + publicEntrypoint, + rule, + source: reexport.source, + specifier: reexport.specifier, + }); + } + continue; + } + if (reexport.isExportStar && !exportedNames(targetPath, namespace).has(requestedExport)) { + continue; + } + + if (isImplementationTarget(targetPath) || projectBoundarySources.has(targetPath)) { + const importedName = reexport.isExportStar ? requestedExport : reexport.importedName; + violations.push({ + exportedName: publicExportedName, + importedName, + line: reexport.line, + message: `public entrypoint ${publicEntrypoint} must not expose adapters, infrastructure, or concrete host boundaries`, + publicEntrypoint, + rule, + source: reexport.source, + specifier: reexport.specifier, + }); + continue; + } + + const targetExport = reexport.isExportStar ? requestedExport : reexport.importedName; + if (targetExport === '*') { + for (const exportedName of exportedNames(targetPath, namespace)) { + visit(targetPath, exportedName, publicExportedName, namespace); + } + } else { + visit(targetPath, targetExport, publicExportedName, namespace); + } + } + }; + + for (const namespace of exportNamespacesForSource(publicEntrypoint)) { + for (const exportedName of exportedNames(publicEntrypoint, namespace)) { + visit(publicEntrypoint, exportedName, exportedName, namespace); + } + } + } + return violations; +} diff --git a/scripts/ci/feature-public-identity-analysis.mjs b/scripts/ci/feature-public-identity-analysis.mjs new file mode 100644 index 0000000000..63148153c4 --- /dev/null +++ b/scripts/ci/feature-public-identity-analysis.mjs @@ -0,0 +1,57 @@ +import ts from 'typescript'; + +import { memberAccess, rootBindingName, unwrapExpression } from './feature-export-analysis.mjs'; +import { IDENTITY_WRAPPERS } from './feature-identity-wrappers.mjs'; + +export { IDENTITY_WRAPPERS }; + +export function constructedClassReferences(expression) { + const current = unwrapExpression(expression); + if (ts.isNewExpression(current)) { + const className = rootBindingName(current.expression); + return className ? [{ localName: className, position: current.expression.getStart() }] : []; + } + if (ts.isObjectLiteralExpression(current)) { + return current.properties.flatMap((property) => + ts.isPropertyAssignment(property) ? constructedClassReferences(property.initializer) : [] + ); + } + if (ts.isArrayLiteralExpression(current)) { + return current.elements.flatMap((element) => + ts.isOmittedExpression(element) ? [] : constructedClassReferences(element) + ); + } + if (ts.isConditionalExpression(current)) { + return [ + ...constructedClassReferences(current.whenTrue), + ...constructedClassReferences(current.whenFalse), + ]; + } + if (ts.isCallExpression(current)) { + const method = memberAccess(current.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + IDENTITY_WRAPPERS.has(method.name) && + current.arguments[0] + ) { + return constructedClassReferences(current.arguments[0]); + } + } + return []; +} + +export function constructedClassNames(expression) { + return constructedClassReferences(expression).map(({ localName }) => localName); +} + +export function directlyExportedClassNames(sourceFile, exportedLocalNames) { + return sourceFile.statements.flatMap((statement) => + ts.isClassDeclaration(statement) && + statement.name && + exportedLocalNames.has(statement.name.text) + ? [statement.name.text] + : [] + ); +} diff --git a/scripts/ci/feature-public-object-analysis.mjs b/scripts/ci/feature-public-object-analysis.mjs new file mode 100644 index 0000000000..fdde58dbb9 --- /dev/null +++ b/scripts/ci/feature-public-object-analysis.mjs @@ -0,0 +1,733 @@ +import ts from 'typescript'; + +import { LOGICAL_ASSIGNMENT_KINDS } from './feature-assignment-operators.mjs'; +import { memberAccess, propertyNameText, unwrapExpression } from './feature-export-analysis.mjs'; +import { visitDefiniteTopLevelExpressions } from './feature-definite-execution.mjs'; +import { isUnshadowedGlobalValueReference } from './feature-lexical-binding-analysis.mjs'; +import { resolveObjectLiterals as resolveObjectLiteralBindings } from './feature-object-resolution.mjs'; +import { accessPath, bindingAliasTargets } from './feature-public-access-path.mjs'; +import { + descriptorBooleanSetting, + descriptorDefinesValue, + descriptorIsEnumerable, + resolveDescriptorMapEntries, + staticDescriptorIsConfigurable, + staticDescriptorIsWritable, + staticDescriptorMapProperties, +} from './feature-public-descriptor-state.mjs'; +import { + collectOrdinaryPropertyDefinitions, + createPublicObjectState, + staticPublicValueState, +} from './feature-public-object-state.mjs'; + +export { accessPath }; +export { + staticDescriptorIsConfigurable, + staticDescriptorIsWritable, + staticDescriptorMapProperties, +}; + +export function propertyWriteAvailableAt(write) { + return write.availableAt ?? write.position; +} + +export function comparePropertyWriteOrder(left, right) { + const availabilityDelta = propertyWriteAvailableAt(left) - propertyWriteAvailableAt(right); + if (availabilityDelta !== 0) return availabilityDelta; + const leftOrder = left.availabilityOrder ?? [left.position]; + const rightOrder = right.availabilityOrder ?? [right.position]; + for (let index = 0; index < Math.max(leftOrder.length, rightOrder.length); index++) { + const orderDelta = (leftOrder[index] ?? -1) - (rightOrder[index] ?? -1); + if (orderDelta !== 0) return orderDelta; + } + return left.position - right.position; +} + +export function latestPropertyWriteBefore(writes, beforePosition, predicate) { + return writes + .filter((write) => propertyWriteAvailableAt(write) < beforePosition && predicate(write)) + .sort((left, right) => comparePropertyWriteOrder(right, left))[0]; +} + +export function propertyWriteWasOverwrittenBefore(writes, sourceWrite, beforePosition) { + return writes.some( + (write) => + comparePropertyWriteOrder(write, sourceWrite) > 0 && + propertyWriteAvailableAt(write) < beforePosition && + write.path.length <= sourceWrite.path.length && + write.path.every((segment, index) => segment === sourceWrite.path[index]) + ); +} + +export function propertyPathWasOverwrittenAfter(writes, source, path, afterPosition) { + return (writes.get(source) ?? []).some( + (write) => + propertyWriteAvailableAt(write) > afterPosition && + write.path.length <= path.length && + write.path.every((segment, index) => segment === path[index]) + ); +} + +function resolveObjectLiterals(expression, bindingModel, beforePosition) { + return resolveObjectLiteralBindings(expression, beforePosition, (name, position) => { + const key = bindingModel.bindingAt(name, position); + if (!key) return null; + const binding = bindingModel.versions.get(key); + return binding + ? { + beforePosition: binding.position, + expression: binding.initializer, + key, + } + : null; + }); +} + +function staticAliasedPublicValueState(expression, bindingModel, visited = new Set()) { + const current = unwrapExpression(expression); + const state = staticPublicValueState(current); + if (state !== 'unknown' || !ts.isIdentifier(current)) return state; + const key = bindingModel.bindingAt(current.text, current.getStart()); + if (!key || visited.has(key)) return state; + visited.add(key); + const binding = bindingModel.versions.get(key); + return binding + ? staticAliasedPublicValueState(binding.initializer, bindingModel, visited) + : state; +} + +export function collectTopLevelPropertyWrites(sourceFile, bindingModel, identityAliases = []) { + const writes = new Map(); + const objectState = createPublicObjectState(); + const lockedPrefixes = []; + const frozenPrefixes = []; + const aliasNeighbors = new Map(); + for (const [left, right] of identityAliases) { + const leftNeighbors = aliasNeighbors.get(left) ?? new Set(); + const rightNeighbors = aliasNeighbors.get(right) ?? new Set(); + leftNeighbors.add(right); + rightNeighbors.add(left); + aliasNeighbors.set(left, leftNeighbors); + aliasNeighbors.set(right, rightNeighbors); + } + const equivalentSourceKeys = (sourceKey) => { + const keys = new Set([sourceKey]); + const queue = [sourceKey]; + while (queue.length > 0) { + for (const neighbor of aliasNeighbors.get(queue.shift()) ?? []) { + if (!keys.has(neighbor)) { + keys.add(neighbor); + queue.push(neighbor); + } + } + } + return keys; + }; + const stateCopyRelations = collectCopyRelations(sourceFile, bindingModel) + .map((relation, order) => ({ ...relation, order })) + .sort((left, right) => left.copyPosition - right.copyPosition || left.order - right.order); + let stateCopyIndex = 0; + const applyCopiesBefore = (position) => { + while ( + stateCopyIndex < stateCopyRelations.length && + stateCopyRelations[stateCopyIndex].copyPosition < position + ) { + objectState.applyCopyRelation(stateCopyRelations[stateCopyIndex++], equivalentSourceKeys); + } + }; + const addWrite = ({ + availabilityOrder, + accessorKind, + availableAt, + configurable, + definition = false, + end, + enumerable = true, + logicalOperator, + path, + position, + referenceNodes, + referenceRanges, + recordsValue = true, + removed = false, + sourceKey, + valueState = 'unknown', + writable, + }) => { + if (!sourceKey || path.length === 0) return; + const locked = lockedPrefixes.some( + ({ path: prefix, sourceKey: lockedSource }) => + lockedSource === sourceKey && + path.length === prefix.length + 1 && + prefix.every((segment, index) => path[index] === segment) + ); + const frozen = frozenPrefixes.some( + ({ path: prefix, sourceKey: frozenSource }) => + frozenSource === sourceKey && + path.length === prefix.length + 1 && + prefix.every((segment, index) => path[index] === segment) + ); + const ranges = [ + ...(referenceRanges ?? []), + ...(referenceNodes ?? []).map((node) => ({ + end: node.end, + start: node.getStart(sourceFile), + })), + ]; + const stateResult = objectState.applyWrite({ + accessorKind, + configurable, + definition, + enumerable, + frozen, + locked, + logicalOperator, + path, + recordsValue, + referenceRanges: ranges, + removed, + sourceKey, + valueState, + writable, + }); + if (!stateResult.recordsWrite) return; + const rootWrites = writes.get(sourceKey) ?? []; + rootWrites.push({ + availabilityOrder, + availableAt, + end, + enumerable: stateResult.enumerable, + path, + position, + referenceRanges: stateResult.referenceRanges, + removed, + }); + writes.set(sourceKey, rootWrites); + }; + const conservativeWriteTargets = (expression, position) => { + const exactTargets = bindingAliasTargets(expression, position, bindingModel); + if (exactTargets.length > 0) return exactTargets; + + let current = unwrapExpression(expression); + const path = []; + while (true) { + const access = memberAccess(current); + if (access) { + path.unshift(access.name); + current = access.receiver; + continue; + } + if (ts.isElementAccessExpression(current) && current.argumentExpression) { + path.unshift('*'); + current = unwrapExpression(current.expression); + continue; + } + break; + } + if (!ts.isIdentifier(current)) return []; + const sourceKey = bindingModel.bindingAt(current.text, position); + return sourceKey ? [{ path, sourceKey }] : []; + }; + const addTargetWrite = ( + targetExpression, + node, + suffix = [], + enumerable = true, + referenceNodes = [node], + options = {} + ) => { + for (const target of conservativeWriteTargets(targetExpression, node.getStart(sourceFile))) { + addWrite({ + ...options, + end: node.end, + enumerable, + path: [...target.path, ...suffix], + position: node.getStart(sourceFile), + referenceNodes, + sourceKey: target.sourceKey, + }); + } + }; + const addDescriptorMapWrites = (targetExpression, mapExpression, node) => { + for (const entry of resolveDescriptorMapEntries( + mapExpression, + bindingModel, + node.getStart(sourceFile) + )) { + for (const descriptor of resolveObjectLiterals( + entry.expression, + bindingModel, + node.getStart(sourceFile) + )) { + addTargetWrite( + targetExpression, + node, + [entry.name], + descriptorIsEnumerable(descriptor, bindingModel, node.getStart(sourceFile)), + [...entry.references, descriptor], + { + configurable: descriptorBooleanSetting( + descriptor, + 'configurable', + bindingModel, + node.getStart(sourceFile) + ), + definition: true, + recordsValue: descriptorDefinesValue(descriptor), + writable: descriptorBooleanSetting( + descriptor, + 'writable', + bindingModel, + node.getStart(sourceFile) + ), + } + ); + } + } + }; + for (const [sourceKey, binding] of bindingModel.versions) { + for (const definition of collectOrdinaryPropertyDefinitions(binding.initializer)) { + addWrite({ + ...definition, + availabilityOrder: [definition.position], + availableAt: binding.position, + definition: true, + position: binding.position, + sourceKey, + valueState: definition.accessorKind + ? definition.valueState + : staticAliasedPublicValueState(definition.referenceNodes[0], bindingModel), + }); + } + } + const visit = (node) => { + applyCopiesBefore(node.getStart(sourceFile)); + if (ts.isDeleteExpression(node)) { + addTargetWrite(node.expression, node, [], true, [node], { removed: true }); + return; + } + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.EqualsToken || + LOGICAL_ASSIGNMENT_KINDS.has(node.operatorToken.kind)) + ) { + const target = unwrapExpression(node.left); + if (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) { + addTargetWrite(node.left, node, [], true, [node.right], { + logicalOperator: + node.operatorToken.kind === ts.SyntaxKind.EqualsToken + ? undefined + : node.operatorToken.kind, + valueState: staticPublicValueState(node.right), + }); + } + return; + } + if (!ts.isCallExpression(node)) return; + const method = memberAccess(node.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) || + !isUnshadowedGlobalValueReference(method.receiver) + ) { + return; + } + if ( + method.name === 'defineProperty' && + node.arguments[0] && + node.arguments[1] && + ts.isStringLiteralLike(unwrapExpression(node.arguments[1])) + ) { + const propertyName = unwrapExpression(node.arguments[1]).text; + const descriptors = resolveObjectLiterals( + node.arguments[2], + bindingModel, + node.getStart(sourceFile) + ); + if (descriptors.length === 0) { + addTargetWrite(node.arguments[0], node, [propertyName], false); + } + for (const descriptor of descriptors) { + addTargetWrite( + node.arguments[0], + node, + [propertyName], + descriptorIsEnumerable(descriptor, bindingModel, node.getStart(sourceFile)), + [node.arguments[2], descriptor].filter(Boolean), + { + configurable: descriptorBooleanSetting( + descriptor, + 'configurable', + bindingModel, + node.getStart(sourceFile) + ), + definition: true, + recordsValue: descriptorDefinesValue(descriptor), + writable: descriptorBooleanSetting( + descriptor, + 'writable', + bindingModel, + node.getStart(sourceFile) + ), + } + ); + } + } else if (method.name === 'defineProperties' && node.arguments[0] && node.arguments[1]) { + addDescriptorMapWrites(node.arguments[0], node.arguments[1], node); + } else if (['freeze', 'seal'].includes(method.name) && node.arguments[0]) { + for (const target of bindingAliasTargets( + node.arguments[0], + node.getStart(sourceFile), + bindingModel + )) { + for (const sourceKey of equivalentSourceKeys(target.sourceKey)) { + const aliasedTarget = { ...target, sourceKey }; + lockedPrefixes.push(aliasedTarget); + if (method.name === 'freeze') { + frozenPrefixes.push(aliasedTarget); + } + } + } + } else if ( + method.name === 'deleteProperty' && + node.arguments[0] && + node.arguments[1] && + ts.isStringLiteralLike(unwrapExpression(node.arguments[1])) + ) { + addTargetWrite( + node.arguments[0], + node, + [unwrapExpression(node.arguments[1]).text], + true, + [node], + { removed: true } + ); + } else if ( + method.name === 'set' && + node.arguments[0] && + node.arguments[1] && + ts.isStringLiteralLike(unwrapExpression(node.arguments[1])) + ) { + addTargetWrite(node.arguments[0], node, [unwrapExpression(node.arguments[1]).text]); + } else if (method.name === 'assign' && node.arguments[0]) { + for (const path of staticOverwrittenPaths( + [...node.arguments].slice(1), + bindingModel, + node.getStart(sourceFile) + )) { + addTargetWrite(node.arguments[0], node, path); + } + } + }; + visitDefiniteTopLevelExpressions(sourceFile, visit); + + for (const [sourceKey, binding] of bindingModel.versions) { + const initializer = unwrapExpression(binding.initializer); + if (!ts.isCallExpression(initializer)) continue; + const method = memberAccess(initializer.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + isUnshadowedGlobalValueReference(method.receiver) && + method.name === 'create' && + initializer.arguments[1] + ) { + for (const entry of resolveDescriptorMapEntries( + initializer.arguments[1], + bindingModel, + initializer.getStart(sourceFile) + )) { + for (const descriptor of resolveObjectLiterals( + entry.expression, + bindingModel, + initializer.getStart(sourceFile) + )) { + addWrite({ + configurable: descriptorBooleanSetting( + descriptor, + 'configurable', + bindingModel, + initializer.getStart(sourceFile) + ), + definition: true, + end: initializer.end, + enumerable: descriptorIsEnumerable( + descriptor, + bindingModel, + initializer.getStart(sourceFile) + ), + path: [entry.name], + position: initializer.getStart(sourceFile), + referenceNodes: [...entry.references, descriptor], + sourceKey, + writable: descriptorBooleanSetting( + descriptor, + 'writable', + bindingModel, + initializer.getStart(sourceFile) + ), + }); + } + } + } + } + return writes; +} + +export function staticOverwrittenPaths( + expressions, + bindingModel, + beforePosition = Number.POSITIVE_INFINITY +) { + const paths = []; + const collectProperties = (object, visited) => { + const objectKey = `${object.pos}:${object.end}`; + if (visited.has(objectKey)) return []; + const properties = new Map(); + const nextVisited = new Set(visited).add(objectKey); + for (const property of object.properties) { + if (ts.isSpreadAssignment(property)) { + const spreadObjects = bindingModel + ? resolveObjectLiterals(property.expression, bindingModel, beforePosition) + : []; + for (const spreadObject of spreadObjects) { + for (const spreadProperty of collectProperties(spreadObject, nextVisited)) { + properties.set(spreadProperty, spreadProperty); + } + } + } else if ( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) || + ts.isMethodDeclaration(property) || + ts.isGetAccessorDeclaration(property) || + ts.isSetAccessorDeclaration(property) + ) { + const name = propertyNameText(property.name); + properties.set(name, name); + } + } + return [...properties.values()]; + }; + for (const expression of expressions) { + const current = unwrapExpression(expression); + const objects = bindingModel + ? resolveObjectLiterals(current, bindingModel, beforePosition) + : ts.isObjectLiteralExpression(current) + ? [current] + : []; + for (const object of objects) { + paths.push(...collectProperties(object, new Set()).map((name) => [name])); + } + } + return paths; +} + +export function staticDescriptorMapPaths(expression, bindingModel, beforePosition) { + return resolveDescriptorMapEntries(expression, bindingModel, beforePosition).map(({ name }) => [ + name, + ]); +} + +export function staticOverwrittenPropertyPaths( + properties, + bindingModel, + beforePosition = Number.POSITIVE_INFINITY +) { + return properties.flatMap((property) => + ts.isSpreadAssignment(property) + ? staticOverwrittenPaths([property.expression], bindingModel, beforePosition) + : ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) || + ts.isMethodDeclaration(property) || + ts.isGetAccessorDeclaration(property) || + ts.isSetAccessorDeclaration(property) + ? [[propertyNameText(property.name)]] + : [] + ); +} + +export function copiedPropertyPath(relation, sourcePath) { + return [...(relation.targetPath ?? []), ...sourcePath.slice(relation.path.length)]; +} + +function addCopySources(relations, ownerKey, targetPath, callOrLiteral, sources, bindingModel) { + for (const [index, sourceExpression] of sources.entries()) { + const source = accessPath(sourceExpression); + const sourceKey = source && bindingModel.bindingAt(source.root, sourceExpression.getStart()); + if (!sourceKey) continue; + relations.push({ + copyKind: 'assign', + copyPosition: callOrLiteral.end, + overwrittenPaths: staticOverwrittenPaths( + sources.slice(index + 1), + bindingModel, + callOrLiteral.getStart() + ), + ownerKey, + path: source.path, + sourceKey, + targetPath, + }); + } +} + +export function collectCopyRelations(sourceFile, bindingModel) { + const relations = []; + const collectFromInitializer = (expression, ownerKey, targetPath = []) => { + const current = unwrapExpression(expression); + if (ts.isObjectLiteralExpression(current)) { + const properties = [...current.properties]; + for (const [index, property] of properties.entries()) { + if (ts.isSpreadAssignment(property)) { + const source = accessPath(property.expression); + const sourceKey = + source && bindingModel.bindingAt(source.root, property.getStart(sourceFile)); + if (sourceKey) { + relations.push({ + copyKind: 'spread', + copyPosition: current.end, + overwrittenPaths: staticOverwrittenPropertyPaths( + properties.slice(index + 1), + bindingModel, + current.getStart(sourceFile) + ), + ownerKey, + path: source.path, + sourceKey, + targetPath, + }); + } + } else if (ts.isPropertyAssignment(property)) { + collectFromInitializer(property.initializer, ownerKey, [ + ...targetPath, + propertyNameText(property.name), + ]); + } + } + return; + } + if (!ts.isCallExpression(current)) return; + const method = memberAccess(current.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + isUnshadowedGlobalValueReference(method.receiver) && + method.name === 'assign' + ) { + addCopySources( + relations, + ownerKey, + targetPath, + current, + [...current.arguments].slice(1), + bindingModel + ); + } + }; + for (const [ownerKey, binding] of bindingModel.versions) { + collectFromInitializer(binding.initializer, ownerKey); + } + visitDefiniteTopLevelExpressions(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const method = memberAccess(node.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + method.receiver.text !== 'Object' || + !isUnshadowedGlobalValueReference(method.receiver) || + method.name !== 'assign' || + !node.arguments[0] + ) { + return; + } + const target = accessPath(node.arguments[0]); + const ownerKey = target && bindingModel.bindingAt(target.root, node.getStart(sourceFile)); + if (ownerKey) { + addCopySources( + relations, + ownerKey, + target.path, + node, + [...node.arguments].slice(1), + bindingModel + ); + } + }); + return relations; +} + +export function materializeCopyRelationWrites(propertyWrites, relations) { + const writeKey = (sourceKey, write) => + JSON.stringify([ + sourceKey, + write.availableAt, + write.availabilityOrder, + write.position, + write.path, + write.originSourceKeys ?? [], + write.referenceRanges ?? [], + ]); + const known = new Set( + [...propertyWrites].flatMap(([sourceKey, writes]) => + writes.map((write) => writeKey(sourceKey, write)) + ) + ); + let changed = true; + while (changed) { + changed = false; + for (const [relationOrder, relation] of relations.entries()) { + const ownerWrites = propertyWrites.get(relation.ownerKey) ?? []; + const sourceWrites = propertyWrites.get(relation.sourceKey) ?? []; + for (const sourceWrite of sourceWrites) { + const overwrittenBeforeCopy = propertyWriteWasOverwrittenBefore( + sourceWrites, + sourceWrite, + relation.copyPosition + ); + if ( + propertyWriteAvailableAt(sourceWrite) >= relation.copyPosition || + !sourceWrite.enumerable || + overwrittenBeforeCopy || + !relation.path.every((segment, index) => sourceWrite.path[index] === segment) + ) { + continue; + } + const sourceRelativePath = sourceWrite.path.slice(relation.path.length); + if ( + relation.overwrittenPaths.some((overwrittenPath) => + overwrittenPath.every((segment, index) => sourceRelativePath[index] === segment) + ) + ) { + continue; + } + const copiedWrite = { + ...sourceWrite, + // References keep their original AST positions; visibility starts at the copy. + availableAt: relation.copyPosition, + availabilityOrder: [ + relationOrder, + ...(sourceWrite.availabilityOrder ?? [sourceWrite.position]), + ], + originSourceKeys: [ + ...new Set([...(sourceWrite.originSourceKeys ?? []), relation.sourceKey]), + ], + path: copiedPropertyPath(relation, sourceWrite.path), + }; + const key = writeKey(relation.ownerKey, copiedWrite); + if (known.has(key)) continue; + known.add(key); + ownerWrites.push(copiedWrite); + changed = true; + } + if (ownerWrites.length > 0) { + ownerWrites.sort(comparePropertyWriteOrder); + propertyWrites.set(relation.ownerKey, ownerWrites); + } + } + } + return propertyWrites; +} diff --git a/scripts/ci/feature-public-object-state.mjs b/scripts/ci/feature-public-object-state.mjs new file mode 100644 index 0000000000..54f83d7233 --- /dev/null +++ b/scripts/ci/feature-public-object-state.mjs @@ -0,0 +1,266 @@ +import ts from 'typescript'; + +import { memberAccess, propertyNameText, unwrapExpression } from './feature-export-analysis.mjs'; +import { isUnshadowedGlobalValueReference } from './feature-lexical-binding-analysis.mjs'; + +function uniqueRanges(ranges) { + return [...new Map(ranges.map((range) => [`${range.start}:${range.end}`, range])).values()]; +} + +export function staticPublicValueState(expression) { + const current = unwrapExpression(expression); + if ( + current.kind === ts.SyntaxKind.NullKeyword || + ts.isVoidExpression(current) || + (ts.isIdentifier(current) && + current.text === 'undefined' && + isUnshadowedGlobalValueReference(current)) + ) { + return 'nullish'; + } + if (current.kind === ts.SyntaxKind.FalseKeyword) return 'falsy'; + if (current.kind === ts.SyntaxKind.TrueKeyword) return 'truthy'; + if (ts.isNumericLiteral(current)) return Number(current.text) === 0 ? 'falsy' : 'truthy'; + if (ts.isBigIntLiteral(current)) { + return BigInt(current.text.slice(0, -1)) === 0n ? 'falsy' : 'truthy'; + } + if (ts.isStringLiteralLike(current)) return current.text.length === 0 ? 'falsy' : 'truthy'; + return ts.isObjectLiteralExpression(current) || + ts.isArrayLiteralExpression(current) || + ts.isFunctionLike(current) || + ts.isClassLike(current) || + ts.isNewExpression(current) + ? 'truthy' + : 'unknown'; +} + +function logicalAssignmentDecision(operator, currentState) { + if (currentState === 'unknown') return null; + if (operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken) { + return currentState === 'truthy'; + } + if (operator === ts.SyntaxKind.BarBarEqualsToken) { + return currentState !== 'truthy'; + } + return currentState === 'nullish'; +} + +function clearsExposedObject(operator, valueState) { + return ( + operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken && + (valueState === 'falsy' || valueState === 'nullish') + ); +} + +function mergedValueState(left, right) { + return left === right ? left : 'unknown'; +} + +export function collectOrdinaryPropertyDefinitions(expression, prefix = []) { + const definitions = []; + const collect = (value, pathPrefix) => { + const current = unwrapExpression(value); + if (ts.isCallExpression(current)) { + const method = memberAccess(current.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + method.name === 'assign' && + current.arguments[0] + ) { + collect(current.arguments[0], pathPrefix); + } + return; + } + if (!ts.isObjectLiteralExpression(current)) return; + for (const property of current.properties) { + if ( + !property.name || + (!ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) && + !ts.isMethodDeclaration(property) && + !ts.isGetAccessorDeclaration(property) && + !ts.isSetAccessorDeclaration(property)) + ) { + continue; + } + const path = [...pathPrefix, propertyNameText(property.name)]; + const accessorKind = ts.isGetAccessorDeclaration(property) + ? 'get' + : ts.isSetAccessorDeclaration(property) + ? 'set' + : undefined; + const propertyValue = ts.isPropertyAssignment(property) + ? property.initializer + : ts.isShorthandPropertyAssignment(property) + ? property.name + : property; + definitions.push({ + accessorKind, + configurable: true, + end: property.end, + enumerable: true, + path, + position: property.getStart(), + referenceNodes: [propertyValue], + valueState: accessorKind ? 'unknown' : staticPublicValueState(propertyValue), + writable: accessorKind ? false : true, + }); + if (ts.isPropertyAssignment(property)) collect(property.initializer, path); + } + }; + collect(expression, prefix); + return definitions; +} + +export function createPublicObjectState() { + const states = new Map(); + const stateKey = (sourceKey, path) => `${sourceKey}:${JSON.stringify(path)}`; + const removeDescendants = (sourceKey, path) => { + for (const [key, state] of states) { + if ( + state.sourceKey === sourceKey && + state.path.length > path.length && + path.every((segment, index) => state.path[index] === segment) + ) { + states.delete(key); + } + } + }; + const applyWrite = ({ + accessorKind, + configurable, + definition = false, + enumerable, + frozen = false, + locked = false, + logicalOperator, + path, + recordsValue = true, + referenceRanges = [], + removed = false, + sourceKey, + valueState = 'unknown', + writable, + }) => { + const key = stateKey(sourceKey, path); + const current = states.get(key); + if (removed) { + if (current?.configurable !== true || locked) return { recordsWrite: false }; + states.delete(key); + removeDescendants(sourceKey, path); + return { enumerable: true, recordsWrite: true, referenceRanges: [] }; + } + if (frozen || (!definition && current?.writable === false)) { + return { recordsWrite: false }; + } + + let decision = true; + let nextValueState = valueState; + let nextRanges = uniqueRanges(referenceRanges); + let definitelyReplaces = true; + if (logicalOperator) { + decision = logicalAssignmentDecision(logicalOperator, current?.valueState ?? 'nullish'); + if (decision === false) return { recordsWrite: false }; + const terminalClear = decision === null && clearsExposedObject(logicalOperator, valueState); + if (decision === null && !terminalClear) { + nextRanges = uniqueRanges([...(current?.referenceRanges ?? []), ...nextRanges]); + nextValueState = mergedValueState(current?.valueState ?? 'nullish', valueState); + definitelyReplaces = false; + } + } + + const next = { + configurable: definition + ? current?.configurable === false || locked + ? false + : (configurable ?? current?.configurable ?? false) + : (current?.configurable ?? true), + enumerable: definition + ? (enumerable ?? current?.enumerable ?? false) + : (current?.enumerable ?? true), + kind: accessorKind ? 'accessor' : definition ? (current?.kind ?? 'data') : 'data', + path, + referenceRanges: current?.referenceRanges ?? [], + sourceKey, + valueState: current?.valueState ?? 'unknown', + writable: definition + ? current?.configurable === false && current?.writable === false + ? false + : (writable ?? current?.writable ?? false) + : (current?.writable ?? true), + }; + if (recordsValue) { + if (accessorKind) { + const accessorRanges = current?.kind === 'accessor' ? { ...current.accessorRanges } : {}; + accessorRanges[accessorKind] = nextRanges; + next.accessorRanges = accessorRanges; + next.referenceRanges = uniqueRanges(Object.values(accessorRanges).flat()); + } else { + next.referenceRanges = nextRanges; + } + next.valueState = nextValueState; + if (definitelyReplaces) removeDescendants(sourceKey, path); + } + states.set(key, next); + return { + enumerable: next.enumerable, + recordsWrite: recordsValue, + referenceRanges: next.referenceRanges, + }; + }; + + const applyCopyRelation = (relation, equivalentSourceKeys) => { + const candidates = []; + for (const sourceKey of equivalentSourceKeys(relation.sourceKey)) { + for (const state of states.values()) { + if ( + state.sourceKey === sourceKey && + state.enumerable === true && + relation.path.every((segment, index) => state.path[index] === segment) + ) { + const relativePath = state.path.slice(relation.path.length); + if ( + relativePath.length > 0 && + !relation.overwrittenPaths.some((overwrittenPath) => + overwrittenPath.every((segment, index) => relativePath[index] === segment) + ) + ) { + candidates.push({ relativePath, state }); + } + } + } + } + candidates.sort((left, right) => left.relativePath.length - right.relativePath.length); + const rejectedRoots = new Set(); + for (const { relativePath, state } of candidates) { + const root = relativePath[0]; + if (rejectedRoots.has(root)) continue; + const targetPath = [...(relation.targetPath ?? []), ...relativePath]; + if (relativePath.length === 1) { + const spread = relation.copyKind === 'spread'; + const result = applyWrite({ + configurable: spread ? true : undefined, + definition: spread, + enumerable: spread ? true : undefined, + path: targetPath, + recordsValue: true, + referenceRanges: state.referenceRanges, + sourceKey: relation.ownerKey, + valueState: state.valueState, + writable: spread ? true : undefined, + }); + if (!result.recordsWrite) rejectedRoots.add(root); + } else { + states.set(stateKey(relation.ownerKey, targetPath), { + ...state, + path: targetPath, + sourceKey: relation.ownerKey, + }); + } + } + }; + + return { applyCopyRelation, applyWrite }; +} diff --git a/scripts/ci/feature-public-prototype-relation-analysis.mjs b/scripts/ci/feature-public-prototype-relation-analysis.mjs new file mode 100644 index 0000000000..f903dca665 --- /dev/null +++ b/scripts/ci/feature-public-prototype-relation-analysis.mjs @@ -0,0 +1,101 @@ +import ts from 'typescript'; + +import { memberAccess, unwrapExpression } from './feature-export-analysis.mjs'; +import { visitDefiniteTopLevelExpressions } from './feature-definite-execution.mjs'; +import { isUnshadowedGlobalValueReference } from './feature-lexical-binding-analysis.mjs'; +import { bindingAliasTargets } from './feature-public-access-path.mjs'; +import { collectOrdinaryPropertyDefinitions } from './feature-public-object-state.mjs'; + +export function collectPrototypeRelations(sourceFile, bindingModel) { + const relations = []; + let sequence = 0; + const relationSource = (expression, position) => { + const source = bindingAliasTargets(expression, position, bindingModel).at(-1); + if (source) { + return { + path: source.path, + sourceKey: source.sourceKey, + }; + } + const inlineWrites = collectOrdinaryPropertyDefinitions(expression).map((definition) => ({ + ...definition, + availableAt: position, + position: definition.position, + referenceRanges: definition.referenceNodes.map((reference) => ({ + end: reference.end, + start: reference.getStart(sourceFile), + })), + })); + return { + inlineWrites, + path: [], + sourceKey: null, + }; + }; + const pushRelation = ({ ownerKey, targetPath, sourceExpression, position }) => { + if (!ownerKey) return; + relations.push({ + ownerKey, + position, + sequence: sequence++, + targetPath, + ...relationSource(sourceExpression, position), + }); + }; + const addCallRelations = (node, initializerOwnerKey = null) => { + if (!ts.isCallExpression(node) || !node.arguments[1]) return; + const method = memberAccess(node.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + method.receiver.text !== 'Object' || + !isUnshadowedGlobalValueReference(method.receiver) || + method.name !== 'setPrototypeOf' + ) { + return; + } + const position = node.getStart(sourceFile); + if (initializerOwnerKey) { + pushRelation({ + ownerKey: initializerOwnerKey, + position, + sourceExpression: node.arguments[1], + targetPath: [], + }); + } + if (!node.arguments[0]) return; + for (const target of bindingAliasTargets(node.arguments[0], position, bindingModel)) { + pushRelation({ + ownerKey: target.sourceKey, + position, + sourceExpression: node.arguments[1], + targetPath: target.path, + }); + } + }; + for (const [ownerKey, binding] of bindingModel.versions) { + const initializer = unwrapExpression(binding.initializer); + addCallRelations(initializer, ownerKey); + if (!ts.isClassLike(initializer)) continue; + const heritage = initializer.heritageClauses?.find( + ({ token }) => token === ts.SyntaxKind.ExtendsKeyword + ); + const baseExpression = heritage?.types[0]?.expression; + if (!baseExpression) continue; + const position = initializer.getStart(sourceFile); + const base = bindingAliasTargets(baseExpression, position, bindingModel).at(-1); + if (!base) continue; + relations.push({ + ownerKey, + path: [...base.path, 'prototype'], + position, + sequence: sequence++, + sourceKey: base.sourceKey, + targetPath: ['prototype'], + }); + } + visitDefiniteTopLevelExpressions(sourceFile, (node) => { + addCallRelations(node); + }); + return relations; +} diff --git a/scripts/ci/feature-public-proxy-surface-analysis.mjs b/scripts/ci/feature-public-proxy-surface-analysis.mjs new file mode 100644 index 0000000000..acb65980ba --- /dev/null +++ b/scripts/ci/feature-public-proxy-surface-analysis.mjs @@ -0,0 +1,282 @@ +import ts from 'typescript'; + +import { + containsReference, + memberAccess, + propertyNameText, + unwrapExpression, +} from './feature-export-ast.mjs'; +import { IDENTITY_WRAPPERS } from './feature-identity-wrappers.mjs'; +import { isUnshadowedGlobalValueReference } from './feature-lexical-binding-analysis.mjs'; +import { resolvedLocalValueNodes } from './feature-constructor-local-value-analysis.mjs'; +import { visitDefiniteTopLevelExpressions } from './feature-definite-execution.mjs'; +import { pathWasOverwrittenAfter } from './feature-public-commonjs-analysis.mjs'; + +const PUBLIC_VALUE_TRAPS = new Set(['apply', 'construct', 'get', 'getPrototypeOf']); +const DESCRIPTOR_TRAP = 'getOwnPropertyDescriptor'; + +function valueAlternatives(expression, sourceFile, visited = new Set()) { + const current = expression && unwrapExpression(expression); + if (!current || visited.has(current)) return []; + const nextVisited = new Set(visited).add(current); + + if (ts.isIdentifier(current)) { + const resolved = resolvedLocalValueNodes(current, sourceFile, { captureOuter: true }); + const alternatives = resolved.filter((candidate) => unwrapExpression(candidate) !== current); + return alternatives.length > 0 + ? alternatives.flatMap((candidate) => + valueAlternatives(candidate, sourceFile, nextVisited) + ) + : [current]; + } + if (ts.isConditionalExpression(current)) { + return [ + ...valueAlternatives(current.whenTrue, sourceFile, nextVisited), + ...valueAlternatives(current.whenFalse, sourceFile, nextVisited), + ]; + } + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) { + return valueAlternatives(current.right, sourceFile, nextVisited); + } + if (ts.isCallExpression(current)) { + const method = memberAccess(current.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + isUnshadowedGlobalValueReference(method.receiver) && + IDENTITY_WRAPPERS.has(method.name) && + current.arguments[0] + ) { + return valueAlternatives(current.arguments[0], sourceFile, nextVisited); + } + } + return [current]; +} + +function callableAlternatives(expression, sourceFile) { + return valueAlternatives(expression, sourceFile).filter( + (candidate) => ts.isFunctionLike(candidate) && candidate.body + ); +} + +function returnedExpressions(callable) { + if (!callable.body) return []; + if (!ts.isBlock(callable.body)) return [callable.body]; + + const returned = []; + const visit = (node) => { + if (node !== callable && (ts.isFunctionLike(node) || ts.isClassLike(node))) return; + if (ts.isReturnStatement(node)) { + if (node.expression) returned.push(node.expression); + return; + } + ts.forEachChild(node, visit); + }; + visit(callable.body); + return returned; +} + +function propertyCallable(property, sourceFile) { + if (ts.isMethodDeclaration(property) || ts.isGetAccessorDeclaration(property)) { + return property.body ? [property] : []; + } + if (ts.isPropertyAssignment(property)) { + return callableAlternatives(property.initializer, sourceFile); + } + if (ts.isShorthandPropertyAssignment(property)) { + return callableAlternatives(property.name, sourceFile); + } + return []; +} + +function selectedTrapCallables(handler, trapName, sourceFile) { + for (const property of [...handler.properties].reverse()) { + if (ts.isSpreadAssignment(property)) return []; + if (!('name' in property) || !property.name) continue; + const name = propertyNameText(property.name); + if (name === '*') return []; + if (name === trapName) return propertyCallable(property, sourceFile); + } + return []; +} + +function effectiveDescriptorProperty(descriptor, propertyName) { + for (const property of [...descriptor.properties].reverse()) { + if (ts.isSpreadAssignment(property)) return null; + if (!('name' in property) || !property.name) continue; + const name = propertyNameText(property.name); + if (name === '*') return null; + if (name === propertyName) return property; + } + return null; +} + +function descriptorExposesReference(expression, reference, sourceFile) { + return valueAlternatives(expression, sourceFile).some((candidate) => { + const descriptor = unwrapExpression(candidate); + if (!ts.isObjectLiteralExpression(descriptor)) return false; + + const valueProperty = effectiveDescriptorProperty(descriptor, 'value'); + if ( + valueProperty && + ((ts.isPropertyAssignment(valueProperty) && + containsReference(valueProperty.initializer, reference)) || + (ts.isShorthandPropertyAssignment(valueProperty) && + containsReference(valueProperty.name, reference))) + ) { + return true; + } + + const getterProperty = effectiveDescriptorProperty(descriptor, 'get'); + if (!getterProperty) return false; + if ( + ts.isPropertyAssignment(getterProperty) && + ts.isIdentifier(unwrapExpression(getterProperty.initializer)) && + containsReference(getterProperty.initializer, reference) + ) { + return true; + } + return propertyCallable(getterProperty, sourceFile).some((getter) => + returnedExpressions(getter).some((returned) => containsReference(returned, reference)) + ); + }); +} + +function trapExposesReference(surface, reference, sourceFile) { + return returnedExpressions(surface.callable).some((returned) => + surface.trapName === DESCRIPTOR_TRAP + ? descriptorExposesReference(returned, reference, sourceFile) + : containsReference(returned, reference) + ); +} + +function isNativeProxyConstruction(node) { + const callee = ts.isNewExpression(node) && unwrapExpression(node.expression); + const globalAccess = callee && memberAccess(callee); + return ( + ts.isNewExpression(node) && + ((ts.isIdentifier(callee) && + callee.text === 'Proxy' && + isUnshadowedGlobalValueReference(callee)) || + (globalAccess?.name === 'Proxy' && + ts.isIdentifier(globalAccess.receiver) && + globalAccess.receiver.text === 'globalThis' && + isUnshadowedGlobalValueReference(globalAccess.receiver))) && + Boolean(node.arguments?.[1]) + ); +} + +function proxyConstructionsInPublicValue(expression) { + const proxies = []; + const visit = (node) => { + const current = unwrapExpression(node); + if (isNativeProxyConstruction(current)) { + proxies.push(current); + return; + } + if (ts.isFunctionLike(current) || ts.isClassLike(current)) return; + ts.forEachChild(current, visit); + }; + if (expression) visit(expression); + return proxies; +} + +function directCommonJsPublicValues(sourceFile, targetPathsAt, finalPropertyWrites) { + if (!targetPathsAt || !finalPropertyWrites) return []; + const publicValues = []; + visitDefiniteTopLevelExpressions(sourceFile, (node) => { + if ( + !ts.isBinaryExpression(node) || + node.operatorToken.kind !== ts.SyntaxKind.EqualsToken + ) { + return; + } + const targetPaths = targetPathsAt(node.left, node.getStart(sourceFile)); + if ( + targetPaths.some( + (path) => !pathWasOverwrittenAfter(finalPropertyWrites, path, node.end) + ) + ) { + publicValues.push({ expression: node.right, owner: targetPaths[0]?.[0] ?? '*' }); + } + }); + return publicValues; +} + +export function analyzePublicProxySurfaces({ + bindingModel, + commonJsFinalTargetPaths, + finalCommonJsPropertyWrites, + commonJsTargetPaths, + directCommonJsExpression, + identityOwners, + sourceFile, +}) { + const publicValues = []; + const publicKeys = new Set([...identityOwners.keys(), ...commonJsTargetPaths.keys()]); + for (const key of publicKeys) { + const binding = bindingModel.versions.get(key); + if (!binding) continue; + const commonJsPath = commonJsTargetPaths.get(key)?.[0] ?? []; + const owner = identityOwners.get(key) ?? commonJsPath[0] ?? '*'; + publicValues.push( + ...(binding.candidateInitializers ?? [binding.initializer]).map((expression) => ({ + expression, + owner, + })) + ); + } + for (const statement of sourceFile.statements) { + if (ts.isExportAssignment(statement)) { + publicValues.push({ expression: statement.expression, owner: 'default' }); + } + } + if (directCommonJsExpression) { + publicValues.push({ expression: directCommonJsExpression, owner: '*' }); + } + publicValues.push( + ...directCommonJsPublicValues( + sourceFile, + commonJsFinalTargetPaths, + finalCommonJsPropertyWrites + ) + ); + + const surfaces = []; + const seenCallables = new Set(); + for (const publicValue of publicValues) { + for (const proxy of proxyConstructionsInPublicValue(publicValue.expression)) { + const handlerExpression = proxy.arguments?.[1]; + for (const candidate of valueAlternatives(handlerExpression, sourceFile)) { + const handler = unwrapExpression(candidate); + if (!ts.isObjectLiteralExpression(handler)) continue; + for (const trapName of [...PUBLIC_VALUE_TRAPS, DESCRIPTOR_TRAP]) { + for (const callable of selectedTrapCallables(handler, trapName, sourceFile)) { + const key = `${publicValue.owner}:${trapName}:${callable.pos}:${callable.end}`; + if (seenCallables.has(key)) continue; + seenCallables.add(key); + surfaces.push({ callable, owner: publicValue.owner, trapName }); + } + } + } + } + } + + return { + classifyReference(reference) { + let insidePublicTrap = false; + for (const surface of surfaces) { + if (!containsReference(surface.callable, reference)) continue; + insidePublicTrap = true; + if (trapExposesReference(surface, reference, sourceFile)) { + return { + localName: surface.owner, + selection: { getterOnly: false, localMember: '*' }, + }; + } + } + return insidePublicTrap ? { selection: null } : undefined; + }, + }; +} diff --git a/scripts/ci/feature-public-reference-visibility.mjs b/scripts/ci/feature-public-reference-visibility.mjs new file mode 100644 index 0000000000..cafb726dfa --- /dev/null +++ b/scripts/ci/feature-public-reference-visibility.mjs @@ -0,0 +1,251 @@ +import ts from 'typescript'; + +import { LOGICAL_ASSIGNMENT_KINDS } from './feature-assignment-operators.mjs'; +import { + containsReference, + memberAccess, + propertyNameText, + unwrapExpression, +} from './feature-export-ast.mjs'; +import { + accessPath, + latestPropertyWriteBefore, + propertyPathWasOverwrittenAfter, + staticOverwrittenPaths, +} from './feature-public-object-analysis.mjs'; +import { publishedValueReferenceState } from './feature-public-value-flow-analysis.mjs'; + +function directObjectReferencePath(initializer, reference) { + const object = unwrapExpression(initializer); + if ( + !ts.isObjectLiteralExpression(object) || + reference.pos < object.pos || + reference.end > object.end + ) { + return null; + } + const path = []; + let current = reference; + while (current && current !== object) { + const parent = current.parent; + if (!parent || reference.pos < parent.pos || reference.end > parent.end) return null; + if ( + (ts.isPropertyAssignment(parent) || + ts.isShorthandPropertyAssignment(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isMethodDeclaration(parent)) && + parent.name + ) { + path.unshift(propertyNameText(parent.name)); + } else if (ts.isSpreadAssignment(parent)) { + return null; + } + current = parent; + } + return current === object && path.length > 0 ? path : null; +} + +function literalReferencePath(expression, reference) { + if (!containsReference(expression, reference)) return null; + const path = []; + let current = reference; + while (current !== expression) { + const parent = current.parent; + if (!parent || !containsReference(parent, reference)) return null; + if (ts.isPropertyAssignment(parent) && containsReference(parent.initializer, reference)) { + path.unshift(propertyNameText(parent.name)); + } else if ( + ts.isShorthandPropertyAssignment(parent) && + containsReference(parent.name, reference) + ) { + path.unshift(propertyNameText(parent.name)); + } else if (ts.isArrayLiteralExpression(parent)) { + path.unshift(String(parent.elements.indexOf(current))); + } else if (ts.isSpreadAssignment(parent)) { + return null; + } + current = parent; + } + return path; +} + +function mutationReferencePath(expression, reference, bindingModel, sourceFile) { + const current = unwrapExpression(expression); + let target; + let valuePath; + if (ts.isBinaryExpression(current) && ts.isAssignmentOperator(current.operatorToken.kind)) { + target = accessPath(current.left); + valuePath = literalReferencePath(current.right, reference); + } else if (ts.isCallExpression(current)) { + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) || + !current.arguments[0] + ) { + return null; + } + target = accessPath(current.arguments[0]); + if (method.name === 'assign') { + const sources = [...current.arguments].slice(1); + const sourceIndex = sources.findIndex((source) => containsReference(source, reference)); + if (sourceIndex < 0) return null; + valuePath = literalReferencePath(sources[sourceIndex], reference); + if ( + valuePath && + staticOverwrittenPaths( + sources.slice(sourceIndex + 1), + bindingModel, + current.getStart(sourceFile) + ).some( + (path) => + path.length <= valuePath.length && + path.every((segment, index) => segment === valuePath[index]) + ) + ) { + return false; + } + } else if ( + ['defineProperty', 'set'].includes(method.name) && + current.arguments[1] && + ts.isStringLiteralLike(unwrapExpression(current.arguments[1])) && + current.arguments.some( + (argument, index) => index >= 2 && containsReference(argument, reference) + ) + ) { + valuePath = [unwrapExpression(current.arguments[1]).text]; + } else if ( + method.name === 'defineProperties' && + current.arguments[1] && + containsReference(current.arguments[1], reference) + ) { + const descriptorPath = literalReferencePath(current.arguments[1], reference); + valuePath = descriptorPath?.length ? [descriptorPath[0]] : null; + } else { + return null; + } + } + if (!target || valuePath === null || valuePath === undefined) return null; + const position = current.getStart(sourceFile); + const key = bindingModel.bindingAt(target.root, position); + return key + ? { + key, + logicalAssignment: + ts.isBinaryExpression(current) && + target.path.length > 0 && + LOGICAL_ASSIGNMENT_KINDS.has(current.operatorToken.kind), + path: [...target.path, ...valuePath], + position: current.end, + } + : null; +} + +function mutationValueExpressions(expression) { + const current = unwrapExpression(expression); + if (ts.isBinaryExpression(current) && ts.isAssignmentOperator(current.operatorToken.kind)) { + return [current.right]; + } + if (!ts.isCallExpression(current)) return []; + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + !['Object', 'Reflect'].includes(method.receiver.text) + ) { + return []; + } + if (method.name === 'assign') return [...current.arguments].slice(1); + if (['defineProperty', 'set'].includes(method.name)) { + return [...current.arguments].slice(2); + } + return method.name === 'defineProperties' && current.arguments[1] ? [current.arguments[1]] : []; +} + +function mutationPublishesScalarizedReference(expression, reference) { + return mutationValueExpressions(expression).some( + (value) => + containsReference(value, reference) && + publishedValueReferenceState(value, reference) === 'scalarized' + ); +} + +function latestPathWrite(propertyWrites, key, path) { + return latestPropertyWriteBefore( + propertyWrites.get(key) ?? [], + Number.POSITIVE_INFINITY, + (write) => + write.path.length <= path.length && + write.path.every((segment, index) => segment === path[index]) + ); +} + +function writeContainsReference(write, reference) { + return Boolean( + write?.referenceRanges?.some( + (range) => range.start <= reference.getStart() && reference.end <= range.end + ) + ); +} + +function sourceWasMaterialized(propertyWrites, sourceKey) { + return [...propertyWrites.values()].some((writes) => + writes.some((write) => write.originSourceKeys?.includes(sourceKey)) + ); +} + +export function attachPublicReferenceQueries( + owners, + { + bindingModel, + capturedReferenceIsPublic, + publicBindingNames, + propertyWrites, + referenceOwner, + referenceOwnerForSelection, + sourceFile, + } +) { + owners.ownerForReference = (reference, selection) => + selection ? (referenceOwnerForSelection?.(reference, selection) ?? null) : referenceOwner; + owners.isBindingVersionPublic = (declaration) => + !ts.isIdentifier(declaration.name) || + !publicBindingNames.has(declaration.name.text) || + owners.has(declaration.name.text); + owners.isReferencePublic = (reference, declaration) => { + if (capturedReferenceIsPublic?.(reference)) return true; + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return true; + const valueState = publishedValueReferenceState(declaration.initializer, reference); + if (valueState === 'scalarized') return false; + const path = directObjectReferencePath(declaration.initializer, reference); + if (!path) return true; + const key = bindingModel.bindingAt(declaration.name.text, reference.getStart(sourceFile)); + if ( + key && + !publicBindingNames.has(declaration.name.text) && + !owners.has(declaration.name.text) && + sourceWasMaterialized(propertyWrites, key) + ) { + return false; + } + const latestWrite = key && latestPathWrite(propertyWrites, key, path); + return latestWrite ? writeContainsReference(latestWrite, reference) : true; + }; + owners.isMutationReferencePublic = (reference, expression) => { + if (mutationPublishesScalarizedReference(expression, reference)) return false; + const target = mutationReferencePath(expression, reference, bindingModel, sourceFile); + if (target?.logicalAssignment) { + return writeContainsReference( + latestPathWrite(propertyWrites, target.key, target.path), + reference + ); + } + if (capturedReferenceIsPublic?.(reference)) return true; + if (target === false) return false; + return ( + !target || + !propertyPathWasOverwrittenAfter(propertyWrites, target.key, target.path, target.position) + ); + }; +} diff --git a/scripts/ci/feature-public-snapshot-analysis.mjs b/scripts/ci/feature-public-snapshot-analysis.mjs new file mode 100644 index 0000000000..99f465f836 --- /dev/null +++ b/scripts/ci/feature-public-snapshot-analysis.mjs @@ -0,0 +1,63 @@ +import ts from 'typescript'; + +import { propertyNameText, unwrapExpression } from './feature-export-analysis.mjs'; +import { accessPath } from './feature-public-object-analysis.mjs'; + +export function snapshotExportSelection(expression, position) { + const selected = accessPath(expression); + return selected + ? { + name: selected.root, + path: selected.path, + position, + } + : null; +} + +export function collectSnapshotMemberRelations(snapshotLocalExports, bindingModel) { + return snapshotLocalExports.flatMap(({ name, path = [], position }) => { + if (path.length === 0) return []; + const sourceKey = bindingModel.bindingAt(name, position); + return sourceKey + ? [ + { + copyPosition: position, + owner: name, + ownerKey: null, + path, + sourceKey, + targetPath: [], + }, + ] + : []; + }); +} + +export function snapshotInitializerPathAt(initializer, position, prefix = []) { + const current = unwrapExpression(initializer); + if (position < current.getStart() || position > current.end) return null; + if (ts.isObjectLiteralExpression(current)) { + for (const property of current.properties) { + if (position < property.getStart() || position > property.end) continue; + if ( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) || + ts.isGetAccessorDeclaration(property) || + ts.isMethodDeclaration(property) + ) { + const path = [...prefix, propertyNameText(property.name)]; + const value = ts.isPropertyAssignment(property) ? property.initializer : property; + return snapshotInitializerPathAt(value, position, path) ?? path; + } + return null; + } + } + if (ts.isArrayLiteralExpression(current)) { + for (const [index, element] of current.elements.entries()) { + if (position < element.getStart() || position > element.end) continue; + const path = [...prefix, String(index)]; + return snapshotInitializerPathAt(element, position, path) ?? path; + } + } + return prefix; +} diff --git a/scripts/ci/feature-public-target-analysis.mjs b/scripts/ci/feature-public-target-analysis.mjs new file mode 100644 index 0000000000..64cc46923a --- /dev/null +++ b/scripts/ci/feature-public-target-analysis.mjs @@ -0,0 +1,783 @@ +import ts from 'typescript'; + +import { + commonJsExportPath, + isCommonJsExportsObject, + memberAccess, + rootBindingName, + unwrapExpression, +} from './feature-export-analysis.mjs'; +import { + collectBindingModel, + collectContainedBindingEntries, +} from './feature-binding-model-analysis.mjs'; +import { + collectCommonJsRootAssignments, + collectFinalCommonJsPropertyWrites, + commonJsReferenceIsPublic, + commonJsRootWrapperSources, + createExportsState, + lastCommonJsRootReplacement, + memberRelationIsAttachedAt, + pathWasOverwrittenAfter, +} from './feature-public-commonjs-analysis.mjs'; +import { + accessPath, + collectCopyRelations, + collectTopLevelPropertyWrites, + copiedPropertyPath, + latestPropertyWriteBefore, + materializeCopyRelationWrites, + propertyPathWasOverwrittenAfter, + propertyWriteAvailableAt, + propertyWriteWasOverwrittenBefore, + staticOverwrittenPaths, + staticOverwrittenPropertyPaths, +} from './feature-public-object-analysis.mjs'; +import { collectPrototypeRelations } from './feature-public-prototype-relation-analysis.mjs'; +import { + IDENTITY_WRAPPERS, + constructedClassReferences, +} from './feature-public-identity-analysis.mjs'; +import { visitDefiniteTopLevelExpressions } from './feature-definite-execution.mjs'; +import { isUnshadowedGlobalValueReference } from './feature-lexical-binding-analysis.mjs'; +import { attachPublicReferenceQueries } from './feature-public-reference-visibility.mjs'; +import { + collectSnapshotMemberRelations, + snapshotInitializerPathAt, +} from './feature-public-snapshot-analysis.mjs'; +import { + propagateCommonJsTargetPaths, + propagateIdentityOwners, +} from './feature-public-target-propagation.mjs'; +import { materializeIdentityAliasWrites } from './feature-public-write-alias-analysis.mjs'; +import { analyzePublicProxySurfaces } from './feature-public-proxy-surface-analysis.mjs'; + +const MODELED_GLOBAL_METHODS = new Set([ + ...IDENTITY_WRAPPERS, + 'create', + 'defineProperties', + 'defineProperty', + 'deleteProperty', + 'set', +]); + +function modeledGlobalReceiver(expression) { + const current = unwrapExpression(expression); + if (!ts.isCallExpression(current)) return null; + const method = memberAccess(current.expression); + return method && + ts.isIdentifier(method.receiver) && + ['Object', 'Reflect'].includes(method.receiver.text) && + MODELED_GLOBAL_METHODS.has(method.name) + ? method.receiver + : null; +} + +function isShadowedModeledGlobalCall(expression) { + const receiver = modeledGlobalReceiver(expression); + return receiver ? !isUnshadowedGlobalValueReference(receiver) : false; +} + +function referenceIsInsideShadowedModeledGlobalCall(reference, boundary) { + let current = reference.parent; + while (current) { + if (ts.isCallExpression(current) && isShadowedModeledGlobalCall(current)) return true; + if (current === boundary) return false; + current = current.parent; + } + return false; +} + +function directAliasSource(expression, bindingModel) { + let current = unwrapExpression(expression); + if (ts.isCallExpression(current)) { + const method = memberAccess(current.expression); + const isObjectIdentityWrapper = + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + IDENTITY_WRAPPERS.has(method.name); + if (isObjectIdentityWrapper) { + if (!isUnshadowedGlobalValueReference(method.receiver) || !current.arguments[0]) { + return null; + } + current = unwrapExpression(current.arguments[0]); + } + } + if (ts.isIdentifier(current)) { + const key = bindingModel.bindingAt(current.text, current.getStart()); + return key ? { key, path: [], symmetric: true } : null; + } + const access = accessPath(current); + if (access && access.path.length > 0) { + const key = bindingModel.bindingAt(access.root, current.getStart()); + return key ? { key, path: access.path, symmetric: false } : null; + } + return null; +} + +function addIdentityEdge(edges, source, target) { + const targets = edges.get(source) ?? new Set(); + targets.add(target); + edges.set(source, targets); +} + +function objectCreatePrototype(initializer, bindingModel) { + const current = unwrapExpression(initializer); + if (!ts.isCallExpression(current)) return null; + const method = memberAccess(current.expression); + if ( + !method || + !ts.isIdentifier(method.receiver) || + method.receiver.text !== 'Object' || + !isUnshadowedGlobalValueReference(method.receiver) || + method.name !== 'create' + ) { + return null; + } + const prototype = current.arguments[0] && accessPath(current.arguments[0]); + if (!prototype) return null; + const sourceKey = bindingModel.bindingAt(prototype.root, current.getStart()); + return sourceKey ? { path: prototype.path, sourceKey } : null; +} + +function buildIdentityEdges(bindingModel, propertyWrites) { + const edges = new Map(); + const identityAliases = []; + const memberRelations = []; + const pathWasOverwritten = (source, path, position) => + propertyPathWasOverwrittenAfter(propertyWrites, source, path, position); + for (const [key, binding] of bindingModel.versions) { + if (binding.forcedAlias?.skipIdentity) continue; + for (const initializer of binding.candidateInitializers ?? [binding.initializer]) { + const directAlias = directAliasSource(initializer, bindingModel); + const hasInvocationAlternatives = (binding.candidateInitializers?.length ?? 0) > 1; + const alias = binding.forcedAlias + ? { + ...directAlias, + path: binding.forcedAlias.path, + symmetric: binding.forcedAlias.symmetric && !hasInvocationAlternatives, + } + : directAlias && { + ...directAlias, + symmetric: directAlias.symmetric && !hasInvocationAlternatives, + }; + if (alias) { + const liveAttached = !pathWasOverwritten(alias.key, alias.path, binding.position); + if (alias.path.length > 0) { + memberRelations.push({ + copyPosition: binding.position, + liveAttached, + ownerKey: key, + path: alias.path, + sourceKey: alias.key, + targetPath: [], + }); + if (liveAttached) addIdentityEdge(edges, alias.key, key); + } else if (liveAttached) { + addIdentityEdge(edges, alias.key, key); + if (alias.symmetric) { + addIdentityEdge(edges, key, alias.key); + identityAliases.push([alias.key, key]); + } + } + continue; + } + const prototype = objectCreatePrototype(initializer, bindingModel); + if (prototype?.path.length === 0) { + addIdentityEdge(edges, key, prototype.sourceKey); + } else if (prototype) { + memberRelations.push({ + ownerKey: key, + path: prototype.path, + sourceKey: prototype.sourceKey, + }); + } + for (const contained of collectContainedBindingEntries(initializer, bindingModel)) { + if (!pathWasOverwritten(key, contained.path, binding.position)) { + addIdentityEdge(edges, key, contained.key); + } + } + } + } + return { edges, identityAliases, memberRelations }; +} + +function collectCommonJsSeeds(sourceFile, bindingModel, rootAssignments, exportsActiveAt) { + const seeds = new Map(); + const addSeed = (key, path = []) => { + if (!key) return; + const paths = seeds.get(key) ?? []; + if ( + !paths.some( + (candidate) => + candidate.length === path.length && + candidate.every((segment, index) => segment === path[index]) + ) + ) { + paths.push(path); + seeds.set(key, paths); + } + }; + const lastModuleReset = lastCommonJsRootReplacement(rootAssignments, exportsActiveAt); + const finalRootPosition = lastModuleReset?.position ?? -1; + + for (const [key, binding] of bindingModel.versions) { + const root = rootBindingName(binding.initializer); + const exportPath = commonJsExportPath(unwrapExpression(binding.initializer)); + if ( + exportPath !== null && + binding.position > finalRootPosition && + (root !== 'exports' || exportsActiveAt(binding.position)) + ) { + addSeed(key, exportPath); + } + } + if (lastModuleReset) { + const value = unwrapExpression(lastModuleReset.expression); + if (ts.isIdentifier(value)) { + const key = bindingModel.bindingAt(value.text, lastModuleReset.position); + addSeed(key); + } + for (const entry of collectContainedBindingEntries(value, bindingModel)) { + addSeed(entry.key, entry.path); + } + for (const source of commonJsRootWrapperSources( + value, + bindingModel, + lastModuleReset.position + )) { + if (source.path.length === 0) addSeed(source.sourceKey); + } + } + + const visit = (node) => { + if (ts.isFunctionLike(node) || ts.isClassLike(node)) return; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + node.getStart(sourceFile) >= finalRootPosition && + isCommonJsExportsObject(unwrapExpression(node.left)) + ) { + const targetRoot = rootBindingName(node.left); + if (targetRoot !== 'exports' || exportsActiveAt(node.getStart(sourceFile))) { + const value = unwrapExpression(node.right); + const targetPath = commonJsExportPath(node.left) ?? []; + if (ts.isIdentifier(value)) { + const key = bindingModel.bindingAt(value.text, node.getStart(sourceFile)); + addSeed(key, targetPath); + } + for (const entry of collectContainedBindingEntries(value, bindingModel)) { + addSeed(entry.key, [...targetPath, ...entry.path]); + } + } + } + ts.forEachChild(node, visit); + }; + visitDefiniteTopLevelExpressions(sourceFile, visit); + return seeds; +} + +function collectCommonJsCopyRelations( + sourceFile, + bindingModel, + finalRootPosition, + exportsActiveAt +) { + const relations = []; + const targetIsActive = (target, position) => + position >= finalRootPosition && + isCommonJsExportsObject(target) && + (rootBindingName(target) !== 'exports' || exportsActiveAt(position)); + const addSources = (target, sources, copyPosition) => { + const targetPath = commonJsExportPath(target); + if (targetPath === null) return; + for (const [index, expression] of sources.entries()) { + const source = accessPath(expression); + const sourceKey = + source && bindingModel.bindingAt(source.root, expression.getStart(sourceFile)); + if (sourceKey) { + relations.push({ + copyPosition, + overwrittenPaths: staticOverwrittenPaths( + sources.slice(index + 1), + bindingModel, + copyPosition + ), + path: source.path, + sourceKey, + targetPath, + }); + } + } + }; + const visit = (node) => { + if (ts.isFunctionLike(node) || ts.isClassLike(node)) return; + const position = node.getStart(sourceFile); + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + targetIsActive(node.left, position) + ) { + const value = unwrapExpression(node.right); + if (ts.isObjectLiteralExpression(value)) { + const properties = [...value.properties]; + for (const [index, property] of properties.entries()) { + if (!ts.isSpreadAssignment(property)) continue; + const source = accessPath(property.expression); + const sourceKey = source && bindingModel.bindingAt(source.root, property.getStart()); + if (sourceKey) { + relations.push({ + copyPosition: value.end, + overwrittenPaths: staticOverwrittenPropertyPaths( + properties.slice(index + 1), + bindingModel, + value.getStart(sourceFile) + ), + path: source.path, + sourceKey, + targetPath: commonJsExportPath(node.left) ?? [], + }); + } + } + } else if (ts.isCallExpression(value)) { + const method = memberAccess(value.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + isUnshadowedGlobalValueReference(method.receiver) && + method.name === 'assign' + ) { + addSources(node.left, [...value.arguments], value.end); + } + } + } else if (ts.isCallExpression(node)) { + const method = memberAccess(node.expression); + if ( + method && + ts.isIdentifier(method.receiver) && + method.receiver.text === 'Object' && + isUnshadowedGlobalValueReference(method.receiver) && + method.name === 'assign' && + node.arguments[0] && + targetIsActive(node.arguments[0], position) + ) { + addSources(node.arguments[0], [...node.arguments].slice(1), node.end); + } + } + ts.forEachChild(node, visit); + }; + visitDefiniteTopLevelExpressions(sourceFile, visit); + return relations; +} + +export function analyzePublicTargets(sourceFile, exportedLocalNames, snapshotLocalExports = []) { + const bindingModel = collectBindingModel(sourceFile); + let propertyWrites = collectTopLevelPropertyWrites(sourceFile, bindingModel); + const allCopyRelations = collectCopyRelations(sourceFile, bindingModel); + materializeCopyRelationWrites(propertyWrites, allCopyRelations); + let identityAnalysis = buildIdentityEdges(bindingModel, propertyWrites); + propertyWrites = collectTopLevelPropertyWrites( + sourceFile, + bindingModel, + identityAnalysis.identityAliases + ); + materializeCopyRelationWrites(propertyWrites, allCopyRelations); + identityAnalysis = buildIdentityEdges(bindingModel, propertyWrites); + const { edges: identityEdges, identityAliases, memberRelations } = identityAnalysis; + materializeIdentityAliasWrites(propertyWrites, identityAliases); + materializeCopyRelationWrites(propertyWrites, allCopyRelations); + const aliasNeighbors = new Map(); + for (const [left, right] of identityAliases) { + const leftNeighbors = aliasNeighbors.get(left) ?? new Set(); + const rightNeighbors = aliasNeighbors.get(right) ?? new Set(); + leftNeighbors.add(right); + rightNeighbors.add(left); + aliasNeighbors.set(left, leftNeighbors); + aliasNeighbors.set(right, rightNeighbors); + } + const equivalentOwnerKeys = (ownerKey) => { + const keys = new Set([ownerKey]); + const queue = [ownerKey]; + while (queue.length > 0) { + for (const neighbor of aliasNeighbors.get(queue.shift()) ?? []) { + if (keys.has(neighbor)) continue; + keys.add(neighbor); + queue.push(neighbor); + } + } + return keys; + }; + const prototypeRelations = collectPrototypeRelations(sourceFile, bindingModel).flatMap( + (relation) => + [...equivalentOwnerKeys(relation.ownerKey)].map((ownerKey) => ({ + ...relation, + ownerKey, + })) + ); + memberRelations.push(...prototypeRelations.filter(({ sourceKey }) => sourceKey)); + const stableExportOwners = [...exportedLocalNames] + .map((name) => [bindingModel.bindingAt(name, Number.POSITIVE_INFINITY), name]) + .filter(([key]) => key !== null); + const snapshotExportOwners = snapshotLocalExports + .filter(({ path = [] }) => path.length === 0) + .map(({ name, position }) => [bindingModel.bindingAt(name, position), name]) + .filter(([key]) => key !== null); + const identityOwners = propagateIdentityOwners( + [...stableExportOwners, ...snapshotExportOwners], + identityEdges + ); + const publicBindingNames = new Set([ + ...exportedLocalNames, + ...snapshotLocalExports.map(({ name }) => name), + ]); + const rootAssignments = collectCommonJsRootAssignments(sourceFile); + const exportsActiveAt = createExportsState(rootAssignments); + const finalRootReplacement = lastCommonJsRootReplacement(rootAssignments, exportsActiveAt); + const finalRootPosition = finalRootReplacement?.position ?? -1; + const commonJsRootWrapperRelations = finalRootReplacement + ? commonJsRootWrapperSources( + finalRootReplacement.expression, + bindingModel, + finalRootReplacement.position + ).filter(({ path }) => path.length > 0) + : []; + const commonJsTargetPaths = propagateCommonJsTargetPaths( + collectCommonJsSeeds(sourceFile, bindingModel, rootAssignments, exportsActiveAt), + identityAliases, + memberRelations + ); + const commonJsTargetAliases = new Set(commonJsTargetPaths.keys()); + const publicMemberRelations = [ + ...collectSnapshotMemberRelations(snapshotLocalExports, bindingModel).map((relation) => ({ + ...relation, + directMemberCapture: true, + })), + ...commonJsRootWrapperRelations.flatMap((relation) => { + const source = bindingModel.versions.get(relation.sourceKey); + return source + ? [ + { + ...relation, + copyPosition: finalRootPosition, + owner: source.name, + ownerKey: null, + targetPath: [], + }, + ] + : []; + }), + ...memberRelations.flatMap((relation) => { + const owner = identityOwners.get(relation.ownerKey); + return owner ? [{ ...relation, owner }] : []; + }), + ]; + const copyRelations = allCopyRelations.flatMap((relation) => { + const owner = identityOwners.get(relation.ownerKey); + return owner ? [{ ...relation, owner }] : []; + }); + const commonJsCopyRelations = collectCommonJsCopyRelations( + sourceFile, + bindingModel, + finalRootPosition, + exportsActiveAt + ); + const commonJsFinalTargetPaths = (target, position) => { + if (position < finalRootPosition) return []; + const directPath = commonJsExportPath(target); + if ( + directPath !== null && + (rootBindingName(target) !== 'exports' || exportsActiveAt(position)) + ) { + return [directPath]; + } + const alias = accessPath(target); + const aliasKey = alias && bindingModel.bindingAt(alias.root, position); + return (aliasKey ? (commonJsTargetPaths.get(aliasKey) ?? []) : []).map((publicPrefix) => [ + ...publicPrefix, + ...alias.path, + ]); + }; + const finalCommonJsPropertyWrites = collectFinalCommonJsPropertyWrites( + sourceFile, + commonJsFinalTargetPaths, + bindingModel + ); + const publicProxySurfaces = analyzePublicProxySurfaces({ + bindingModel, + commonJsFinalTargetPaths, + commonJsTargetPaths, + directCommonJsExpression: finalRootReplacement?.expression, + finalCommonJsPropertyWrites, + identityOwners, + sourceFile, + }); + const writeContainsPosition = (write, position) => + write.referenceRanges?.length + ? write.referenceRanges.some((range) => range.start <= position && position <= range.end) + : write.position <= position && position <= write.end; + const relationMatchesAt = (relation, position, queriedSourceKey = relation.sourceKey) => { + const source = bindingModel.versions.get(relation.sourceKey); + if ( + !source || + bindingModel.bindingAt(source.name, relation.copyPosition) !== relation.sourceKey + ) { + return false; + } + const sourceWrites = propertyWrites.get(relation.sourceKey) ?? []; + const currentWrite = latestPropertyWriteBefore( + sourceWrites, + relation.copyPosition, + (write) => + writeContainsPosition(write, position) && + (queriedSourceKey === relation.sourceKey || + write.originSourceKeys?.includes(queriedSourceKey)) + ); + if ( + !currentWrite || + !relation.path.every((segment, index) => currentWrite.path[index] === segment) + ) { + return false; + } + const wasOverwrittenBeforeCopy = propertyWriteWasOverwrittenBefore( + sourceWrites, + currentWrite, + relation.copyPosition + ); + const overwrittenByTarget = relation.overwrittenPaths?.some((overwrittenPath) => + overwrittenPath.every( + (segment, index) => currentWrite.path[relation.path.length + index] === segment + ) + ); + const copiedPath = copiedPropertyPath(relation, currentWrite.path); + return ( + position < relation.copyPosition && + currentWrite.enumerable && + !wasOverwrittenBeforeCopy && + !overwrittenByTarget && + !pathWasOverwrittenAfter(finalCommonJsPropertyWrites, copiedPath, relation.copyPosition) + ); + }; + const localOwnersAt = (position, selectedSourcePath = null) => { + const owners = new Map(); + let referenceOwner = null; + let capturedReferenceIsPublic = false; + for (const [key, binding] of bindingModel.versions) { + const owner = identityOwners.get(key); + if (owner && binding.initializer.pos <= position && position <= binding.initializer.end) { + owners.set(binding.name, owner); + } + } + for (const name of bindingModel.eventsByName.keys()) { + const key = bindingModel.bindingAt(name, position); + const owner = key && identityOwners.get(key); + if (owner) owners.set(name, owner); + } + for (const name of exportedLocalNames) { + if (!bindingModel.eventsByName.has(name) && !owners.has(name)) { + owners.set(name, name); + } + } + for (const relation of [...publicMemberRelations, ...copyRelations]) { + const source = bindingModel.versions.get(relation.sourceKey); + if ( + !source || + bindingModel.bindingAt(source.name, relation.copyPosition ?? position) !== + relation.sourceKey + ) { + continue; + } + const sourceWrites = propertyWrites.get(relation.sourceKey) ?? []; + const currentWrite = latestPropertyWriteBefore( + sourceWrites, + relation.copyPosition ?? Number.POSITIVE_INFINITY, + (write) => + writeContainsPosition(write, position) && + (!selectedSourcePath || + source.name !== selectedSourcePath.name || + (write.path.length === selectedSourcePath.path.length && + write.path.every((segment, index) => segment === selectedSourcePath.path[index]))) + ); + const insideSourceInitializer = + source.initializer.getStart(sourceFile) <= position && position <= source.initializer.end; + const initializerPath = insideSourceInitializer + ? snapshotInitializerPathAt(source.initializer, position) + : null; + const directInitializerAlias = + insideSourceInitializer && ts.isIdentifier(unwrapExpression(source.initializer)); + const initializerWasOverwrittenBeforeCopy = + initializerPath && + sourceWrites.some( + (write) => + propertyWriteAvailableAt(write) > source.position && + propertyWriteAvailableAt(write) < relation.copyPosition && + write.path.length <= initializerPath.length && + write.path.every((segment, index) => segment === initializerPath[index]) + ); + const pathMatches = + (currentWrite && + relation.path.every((segment, index) => currentWrite.path[index] === segment)) || + (initializerPath && + ((initializerPath.length === 0 && directInitializerAlias) || + relation.path.every((segment, index) => segment === initializerPath[index])) && + !initializerWasOverwrittenBeforeCopy); + if (relation.copyPosition === undefined) { + if (pathMatches && !owners.has(source.name)) { + owners.set(source.name, relation.owner); + } + continue; + } + const wasOverwrittenBeforeCopy = + currentWrite && + propertyWriteWasOverwrittenBefore(sourceWrites, currentWrite, relation.copyPosition); + const overwrittenByTarget = + currentWrite && + relation.overwrittenPaths?.some((overwrittenPath) => + overwrittenPath.every( + (segment, index) => currentWrite.path[relation.path.length + index] === segment + ) + ); + const overwrittenAfterCopy = + currentWrite && + propertyPathWasOverwrittenAfter( + propertyWrites, + relation.ownerKey, + copiedPropertyPath(relation, currentWrite.path), + relation.copyPosition + ); + if ( + position < relation.copyPosition && + pathMatches && + (insideSourceInitializer || relation.directMemberCapture || currentWrite?.enumerable) && + !wasOverwrittenBeforeCopy && + !overwrittenByTarget && + !overwrittenAfterCopy + ) { + capturedReferenceIsPublic = + capturedReferenceIsPublic || + Boolean( + insideSourceInitializer || + (currentWrite && writeContainsPosition(currentWrite, position)) + ); + if (currentWrite && writeContainsPosition(currentWrite, position)) { + referenceOwner ??= relation.owner; + } + const visibleSources = [relation.sourceKey, ...(currentWrite?.originSourceKeys ?? [])]; + for (const sourceKey of visibleSources) { + const visibleSource = bindingModel.versions.get(sourceKey); + if ( + visibleSource && + bindingModel.bindingAt(visibleSource.name, position) === sourceKey && + !owners.has(visibleSource.name) + ) { + owners.set(visibleSource.name, relation.owner); + } + } + } + } + attachPublicReferenceQueries(owners, { + bindingModel, + capturedReferenceIsPublic: (reference) => + capturedReferenceIsPublic && reference.getStart(sourceFile) === position, + publicBindingNames, + propertyWrites, + referenceOwner, + referenceOwnerForSelection: (reference, { localMember, localNames }) => { + if (localMember === null || localMember === undefined) return null; + for (const localName of localNames) { + const selectedOwners = localOwnersAt(reference.getStart(sourceFile), { + name: localName, + path: [localMember], + }); + const owner = selectedOwners.get(localName); + if (owner) return owner; + } + return null; + }, + sourceFile, + }); + const isReferencePublic = owners.isReferencePublic; + owners.isReferencePublic = (reference, declaration) => + declaration.initializer && + referenceIsInsideShadowedModeledGlobalCall(reference, declaration.initializer) + ? false + : isReferencePublic(reference, declaration); + const isMutationReferencePublic = owners.isMutationReferencePublic; + owners.isMutationReferencePublic = (reference, expression) => + isShadowedModeledGlobalCall(expression) + ? false + : isMutationReferencePublic(reference, expression); + owners.atPosition = localOwnersAt; + return owners; + }; + const commonJsTargetsAt = (position) => ({ + atPosition: commonJsTargetsAt, + directExportsActive: position >= finalRootPosition && exportsActiveAt(position), + directModuleExportsActive: position >= finalRootPosition, + isReferencePublic: (expression, reference) => + commonJsReferenceIsPublic( + expression, + reference, + finalCommonJsPropertyWrites, + bindingModel, + commonJsFinalTargetPaths + ), + has: (name) => { + const key = bindingModel.bindingAt(name, position); + return key + ? commonJsTargetAliases.has(key) || + commonJsCopyRelations.some((relation) => relationMatchesAt(relation, position, key)) + : false; + }, + hasPath: (name, path) => { + const key = bindingModel.bindingAt(name, position); + if (!key) return false; + if (commonJsTargetAliases.has(key)) return true; + return commonJsRootWrapperRelations.some( + (relation) => + relation.sourceKey === key && + relation.path.every((segment, index) => segment === path[index]) && + memberRelationIsAttachedAt(propertyWrites, relation, finalRootPosition, position) + ); + }, + }); + const constructorExports = []; + const withConstructorBinding = (classReference) => ({ + ...classReference, + bindingKey: bindingModel.bindingAt(classReference.localName, classReference.position), + }); + const copyConstructorOwners = new Map( + copyRelations.map(({ owner, sourceKey }) => [sourceKey, owner]) + ); + for (const [key, binding] of bindingModel.versions) { + const owner = identityOwners.get(key) ?? copyConstructorOwners.get(key); + if (!owner) continue; + for (const classReference of constructedClassReferences(binding.initializer)) { + constructorExports.push({ + exportedName: owner, + ...withConstructorBinding(classReference), + }); + } + } + for (const statement of sourceFile.statements) { + if (!ts.isExportAssignment(statement)) continue; + for (const classReference of constructedClassReferences(statement.expression)) { + constructorExports.push({ + exportedName: 'default', + ...withConstructorBinding(classReference), + }); + } + } + return { + classifyProxyReference: publicProxySurfaces.classifyReference, + commonJsTargetAliases, + commonJsTargetsAt, + constructorExports, + localOwners: localOwnersAt(Number.POSITIVE_INFINITY), + localOwnersAt, + propertyWrites, + prototypeRelations, + }; +} diff --git a/scripts/ci/feature-public-target-propagation.mjs b/scripts/ci/feature-public-target-propagation.mjs new file mode 100644 index 0000000000..32049ded6c --- /dev/null +++ b/scripts/ci/feature-public-target-propagation.mjs @@ -0,0 +1,57 @@ +function appendUniquePath(pathsByKey, key, path) { + const paths = pathsByKey.get(key) ?? []; + if ( + paths.some( + (candidate) => + candidate.length === path.length && + candidate.every((segment, index) => segment === path[index]) + ) + ) { + return false; + } + paths.push(path); + pathsByKey.set(key, paths); + return true; +} + +export function propagateIdentityOwners(initialOwners, edges) { + const owners = new Map(initialOwners); + const queue = [...owners.keys()]; + while (queue.length > 0) { + const source = queue.shift(); + const owner = owners.get(source); + if (!owner) continue; + for (const target of edges.get(source) ?? []) { + if (owners.has(target)) continue; + owners.set(target, owner); + queue.push(target); + } + } + return owners; +} + +export function propagateCommonJsTargetPaths(initialPaths, identityAliases, memberRelations) { + const pathsByKey = new Map( + [...initialPaths].map(([key, paths]) => [key, paths.map((path) => [...path])]) + ); + let changed = true; + while (changed) { + changed = false; + for (const [left, right] of identityAliases) { + for (const path of pathsByKey.get(left) ?? []) { + changed = appendUniquePath(pathsByKey, right, [...path]) || changed; + } + for (const path of pathsByKey.get(right) ?? []) { + changed = appendUniquePath(pathsByKey, left, [...path]) || changed; + } + } + for (const relation of memberRelations) { + if (relation.liveAttached !== true) continue; + for (const path of pathsByKey.get(relation.sourceKey) ?? []) { + changed = + appendUniquePath(pathsByKey, relation.ownerKey, [...path, ...relation.path]) || changed; + } + } + } + return pathsByKey; +} diff --git a/scripts/ci/feature-public-value-flow-analysis.mjs b/scripts/ci/feature-public-value-flow-analysis.mjs new file mode 100644 index 0000000000..ab1857b87a --- /dev/null +++ b/scripts/ci/feature-public-value-flow-analysis.mjs @@ -0,0 +1,128 @@ +import ts from 'typescript'; + +import { LOGICAL_ASSIGNMENT_KINDS } from './feature-assignment-operators.mjs'; +import { containsReference, unwrapExpression } from './feature-export-ast.mjs'; +import { isUnshadowedGlobalValueReference } from './feature-lexical-binding-analysis.mjs'; + +const VALUE_PRESERVING_BINARY_OPERATORS = new Set([ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, +]); + +function isUnshadowedBooleanCall(call) { + const callee = unwrapExpression(call.expression); + return ( + ts.isIdentifier(callee) && callee.text === 'Boolean' && isUnshadowedGlobalValueReference(callee) + ); +} + +export function publishedValueReferenceState(expression, reference) { + if (!containsReference(expression, reference)) return 'unknown'; + let current = reference; + let returned = false; + + while (current !== expression) { + const parent = current.parent; + if (!parent || !containsReference(parent, reference)) return 'unknown'; + if ( + ((ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent) || + ts.isAwaitExpression(parent)) && + parent.expression === current) || + ts.isArrayLiteralExpression(parent) || + ts.isObjectLiteralExpression(parent) || + ts.isSpreadAssignment(parent) || + ts.isSpreadElement(parent) || + ts.isShorthandPropertyAssignment(parent) || + ts.isJsxExpression(parent) + ) { + current = parent; + continue; + } + if (ts.isPropertyAssignment(parent)) { + if (!containsReference(parent.initializer, reference)) return 'scalarized'; + current = parent; + continue; + } + if (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) { + if (!containsReference(parent.expression, reference)) return 'scalarized'; + current = parent; + continue; + } + if (ts.isConditionalExpression(parent)) { + if (containsReference(parent.condition, reference)) return 'scalarized'; + current = parent; + continue; + } + if (ts.isBinaryExpression(parent)) { + const operator = parent.operatorToken.kind; + if (operator === ts.SyntaxKind.CommaToken) { + if (!containsReference(parent.right, reference)) return 'scalarized'; + } else if (operator === ts.SyntaxKind.EqualsToken || LOGICAL_ASSIGNMENT_KINDS.has(operator)) { + if (!containsReference(parent.right, reference)) return 'scalarized'; + } else if (!VALUE_PRESERVING_BINARY_OPERATORS.has(operator)) { + return 'scalarized'; + } + current = parent; + continue; + } + if (ts.isCallExpression(parent)) { + if ( + parent.arguments.some((argument) => containsReference(argument, reference)) && + isUnshadowedBooleanCall(parent) + ) { + return 'scalarized'; + } + current = parent; + continue; + } + if (ts.isNewExpression(parent) || ts.isTaggedTemplateExpression(parent)) { + current = parent; + continue; + } + if (ts.isReturnStatement(parent) || ts.isYieldExpression(parent)) { + returned = true; + current = parent; + continue; + } + if ( + returned && + (ts.isBlock(parent) || + ts.isCaseClause(parent) || + ts.isDefaultClause(parent) || + ts.isIfStatement(parent) || + ts.isSwitchStatement(parent) || + ts.isTryStatement(parent) || + ts.isCatchClause(parent) || + ts.isLabeledStatement(parent)) + ) { + current = parent; + continue; + } + if ( + ts.isFunctionLike(parent) && + parent === unwrapExpression(expression) && + (returned || parent.body === current) + ) { + returned = false; + current = parent; + continue; + } + if ( + ts.isPrefixUnaryExpression(parent) || + ts.isPostfixUnaryExpression(parent) || + ts.isTypeOfExpression(parent) || + ts.isVoidExpression(parent) || + ts.isDeleteExpression(parent) || + ts.isTemplateExpression(parent) + ) { + return 'scalarized'; + } + return 'unknown'; + } + return 'reaches'; +} diff --git a/scripts/ci/feature-public-write-alias-analysis.mjs b/scripts/ci/feature-public-write-alias-analysis.mjs new file mode 100644 index 0000000000..7d4dd715da --- /dev/null +++ b/scripts/ci/feature-public-write-alias-analysis.mjs @@ -0,0 +1,55 @@ +import { comparePropertyWriteOrder } from './feature-public-object-analysis.mjs'; + +function writeKey(write) { + return JSON.stringify([ + write.availableAt, + write.availabilityOrder, + write.end, + write.enumerable, + write.originSourceKeys ?? [], + write.path, + write.position, + write.referenceRanges ?? [], + ]); +} + +export function materializeIdentityAliasWrites(propertyWrites, aliases) { + const neighbors = new Map(); + for (const [left, right] of aliases) { + const leftNeighbors = neighbors.get(left) ?? new Set(); + const rightNeighbors = neighbors.get(right) ?? new Set(); + leftNeighbors.add(right); + rightNeighbors.add(left); + neighbors.set(left, leftNeighbors); + neighbors.set(right, rightNeighbors); + } + + const visited = new Set(); + for (const start of neighbors.keys()) { + if (visited.has(start)) continue; + const component = new Set([start]); + const queue = [start]; + while (queue.length > 0) { + const current = queue.shift(); + if (visited.has(current)) continue; + visited.add(current); + for (const neighbor of neighbors.get(current) ?? []) { + if (!component.has(neighbor)) { + component.add(neighbor); + queue.push(neighbor); + } + } + } + + const merged = new Map( + [...component].flatMap((key) => + (propertyWrites.get(key) ?? []).map((write) => [writeKey(write), write]) + ) + ); + const writes = [...merged.values()].sort(comparePropertyWriteOrder); + for (const key of component) { + if (writes.length > 0) propertyWrites.set(key, writes); + } + } + return propertyWrites; +} diff --git a/scripts/ci/feature-reexport-origin-analysis.mjs b/scripts/ci/feature-reexport-origin-analysis.mjs new file mode 100644 index 0000000000..fdb377ded5 --- /dev/null +++ b/scripts/ci/feature-reexport-origin-analysis.mjs @@ -0,0 +1,76 @@ +import { resolveProjectTarget } from './feature-module-resolution.mjs'; + +function selectedReexportName(reexport, exportedName) { + return reexport.isExportStar && exportedName !== '*' + ? exportedName + : reexport.importedName; +} + +export function isContractProjectTarget(targetPath) { + return ( + /^src\/shared\/contracts(?:\/|$)/.test(targetPath) || + /^src\/features\/[^/]+\/contracts(?:\/|$)/.test(targetPath) + ); +} + +export function dependencyHasForbiddenReexportOrigin( + edge, + { + localTypeExportNamesBySource, + localValueExportNamesBySource, + reexportsBySource, + sourceFilePaths, + }, + originIsForbidden +) { + const targetPath = resolveProjectTarget(edge, sourceFilePaths); + if (!targetPath) return false; + + const visit = (source, exportedName, namespace, visited) => { + const key = `${source}:${exportedName}:${namespace}`; + if (visited.has(key)) return false; + const nextVisited = new Set(visited).add(key); + const localExportNamesBySource = + namespace === 'type' + ? localTypeExportNamesBySource + : localValueExportNamesBySource; + if (exportedName !== '*' && localExportNamesBySource.get(source)?.has(exportedName)) { + return false; + } + const reexports = reexportsBySource.get(source) ?? []; + const hasExplicitReexport = + exportedName !== '*' && + reexports.some( + (reexport) => + (namespace === 'type' || !reexport.isTypeOnly) && + !reexport.isExportStar && + reexport.exportedName === exportedName + ); + return reexports.some((reexport) => { + if (namespace === 'value' && reexport.isTypeOnly) return false; + if ( + exportedName !== '*' && + reexport.exportedName !== exportedName && + (!reexport.isExportStar || hasExplicitReexport) + ) { + return false; + } + if (originIsForbidden(reexport)) return true; + const origin = resolveProjectTarget(reexport, sourceFilePaths); + return ( + origin !== null && + visit(origin, selectedReexportName(reexport, exportedName), namespace, nextVisited) + ); + }); + }; + + const typeOnlyImportedNames = new Set(edge.typeOnlyImportedNames ?? []); + return (edge.importedNames ?? ['*']).some((name) => + visit( + targetPath, + name, + edge.isTypeOnly || typeOnlyImportedNames.has(name) ? 'type' : 'value', + new Set() + ) + ); +} diff --git a/scripts/ci/feature-source-files.mjs b/scripts/ci/feature-source-files.mjs new file mode 100644 index 0000000000..8d092b9f73 --- /dev/null +++ b/scripts/ci/feature-source-files.mjs @@ -0,0 +1,50 @@ +import { readdirSync } from 'node:fs'; +import path from 'node:path'; + +const SOURCE_EXTENSIONS = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']); +const EXCLUDED_DIRECTORIES = new Set(['__fixtures__', '__tests__', 'fixtures', 'node_modules']); +const TEST_FILE_PATTERN = /\.(?:spec|test)\.[^.]+$/; +const DECLARATION_FILE_PATTERN = /\.d\.(?:cts|mts|ts)$/; +const GENERATED_DECLARATION_FILES = new Set([ + 'src/features/localization/renderer/i18next.d.ts', + 'src/features/localization/renderer/resources.d.ts', +]); + +function normalizePath(filePath) { + return filePath.split(path.sep).join('/'); +} + +function isProductionSourcePath(filePath) { + const normalized = normalizePath(filePath); + const segments = normalized.split('/'); + if (!normalized.startsWith('src/')) return false; + if (segments.some((segment) => EXCLUDED_DIRECTORIES.has(segment))) return false; + if (TEST_FILE_PATTERN.test(normalized) || GENERATED_DECLARATION_FILES.has(normalized)) return false; + return SOURCE_EXTENSIONS.has(path.extname(normalized)); +} + +export function isFeaturePublicEntrypoint(filePath) { + const segments = normalizePath(filePath).split('/'); + if (segments.length < 4 || segments[0] !== 'src' || segments[1] !== 'features') return false; + + const featureRelativePath = segments.slice(3).join('/'); + const declarationExtension = DECLARATION_FILE_PATTERN.exec(featureRelativePath)?.[0]; + const extension = declarationExtension ?? path.extname(featureRelativePath); + if (!declarationExtension && !SOURCE_EXTENSIONS.has(extension)) return false; + + const entrypointPath = featureRelativePath.slice(0, -extension.length); + return /^(?:(?:contracts|main|preload|renderer)\/)?index$/.test(entrypointPath); +} + +export function collectProductionSourceFiles(directoryPath, repoRoot) { + return readdirSync(directoryPath, { withFileTypes: true }).flatMap((entry) => { + if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) return []; + + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) return collectProductionSourceFiles(entryPath, repoRoot); + if (!entry.isFile()) return []; + + const relativePath = normalizePath(path.relative(repoRoot, entryPath)); + return isProductionSourcePath(relativePath) ? [relativePath] : []; + }); +} diff --git a/scripts/ci/feature-static-value-analysis.mjs b/scripts/ci/feature-static-value-analysis.mjs new file mode 100644 index 0000000000..2c84506245 --- /dev/null +++ b/scripts/ci/feature-static-value-analysis.mjs @@ -0,0 +1,275 @@ +import ts from 'typescript'; + +import { memberAccess, propertyNameText, unwrapExpression } from './feature-export-ast.mjs'; + +const UNKNOWN_STATIC_VALUE = Symbol('unknown-static-value'); + +function bindingNameContains(bindingName, name) { + if (ts.isIdentifier(bindingName)) return bindingName.text === name; + return bindingName.elements.some( + (element) => ts.isBindingElement(element) && bindingNameContains(element.name, name) + ); +} + +function directLexicalScopeBindsName(node, name) { + const statements = + ts.isBlock(node) || ts.isSourceFile(node) + ? node.statements + : ts.isCaseBlock(node) + ? node.clauses.flatMap((clause) => [...clause.statements]) + : []; + for (const statement of statements) { + if (ts.isVariableStatement(statement)) { + if (ts.isSourceFile(node) || statement.declarationList.flags & ts.NodeFlags.BlockScoped) { + if ( + statement.declarationList.declarations.some((declaration) => + bindingNameContains(declaration.name, name) + ) + ) { + return true; + } + } + continue; + } + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement)) && + statement.name && + ts.isIdentifier(statement.name) + ) { + if (statement.name.text === name) return true; + } + } + return false; +} + +function callableBindsName(callable, name) { + return ( + callable.parameters.some((parameter) => bindingNameContains(parameter.name, name)) || + (callable.name && ts.isIdentifier(callable.name) && callable.name.text === name) + ); +} + +function sourceFileImportsValue(sourceFile, name) { + return sourceFile.statements.some((statement) => { + if (ts.isImportEqualsDeclaration(statement)) { + return !statement.isTypeOnly && statement.name.text === name; + } + if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) return false; + const clause = statement.importClause; + if (!clause) return false; + if (clause.name?.text === name) return true; + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) return bindings.name.text === name; + return ( + bindings && + ts.isNamedImports(bindings) && + bindings.elements.some((element) => !element.isTypeOnly && element.name.text === name) + ); + }); +} + +function scopeHasVarBinding(scope, name) { + let found = false; + const visit = (node) => { + if (found || (node !== scope && ts.isFunctionLike(node))) return; + if ( + ts.isVariableDeclarationList(node) && + (node.flags & ts.NodeFlags.BlockScoped) === 0 && + node.declarations.some((declaration) => bindingNameContains(declaration.name, name)) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(scope); + return found; +} + +function loopInitializerBindsName(node, name) { + const initializer = + ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node) + ? node.initializer + : null; + return ( + initializer && + ts.isVariableDeclarationList(initializer) && + initializer.declarations.some((declaration) => bindingNameContains(declaration.name, name)) + ); +} + +function isUnshadowedGlobalReference(reference) { + const name = reference.text; + const sourceFile = reference.getSourceFile(); + let current = reference.parent; + while (current && current !== sourceFile) { + if ( + (ts.isBlock(current) || ts.isCaseBlock(current)) && + directLexicalScopeBindsName(current, name) + ) { + return false; + } + if ( + ts.isCatchClause(current) && + current.variableDeclaration && + bindingNameContains(current.variableDeclaration.name, name) + ) { + return false; + } + if (ts.isFunctionLike(current)) { + if (callableBindsName(current, name) || scopeHasVarBinding(current, name)) return false; + } + if (ts.isClassExpression(current) && current.name?.text === name) return false; + if (loopInitializerBindsName(current, name)) return false; + current = current.parent; + } + return ( + !sourceFileImportsValue(sourceFile, name) && + !directLexicalScopeBindsName(sourceFile, name) && + !scopeHasVarBinding(sourceFile, name) + ); +} + +function staticPrimitiveValue(expression, resolveIdentifier, resolving = new Set()) { + const current = unwrapExpression(expression); + if (ts.isIdentifier(current) && resolveIdentifier) { + const resolvedValues = resolveIdentifier(current) ?? []; + const candidates = Array.isArray(resolvedValues) ? resolvedValues : [resolvedValues]; + let resolvedValue = UNKNOWN_STATIC_VALUE; + for (const resolved of candidates) { + if (!resolved || resolving.has(resolved)) return UNKNOWN_STATIC_VALUE; + const candidateValue = staticPrimitiveValue( + resolved, + resolveIdentifier, + new Set(resolving).add(resolved) + ); + if (candidateValue === UNKNOWN_STATIC_VALUE) return UNKNOWN_STATIC_VALUE; + if (resolvedValue === UNKNOWN_STATIC_VALUE) { + resolvedValue = candidateValue; + } else if (!Object.is(resolvedValue, candidateValue)) { + return UNKNOWN_STATIC_VALUE; + } + } + if (resolvedValue !== UNKNOWN_STATIC_VALUE) return resolvedValue; + } + if (current.kind === ts.SyntaxKind.TrueKeyword) return true; + if (current.kind === ts.SyntaxKind.FalseKeyword) return false; + if (current.kind === ts.SyntaxKind.NullKeyword) return null; + if ( + ts.isIdentifier(current) && + current.text === 'undefined' && + isUnshadowedGlobalReference(current) + ) { + return undefined; + } + if (ts.isPropertyAccessExpression(current) && current.name.text === 'undefined') { + const receiver = unwrapExpression(current.expression); + if ( + ts.isIdentifier(receiver) && + receiver.text === 'globalThis' && + isUnshadowedGlobalReference(receiver) + ) { + return undefined; + } + } + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) { + return staticPrimitiveValue(current.right, resolveIdentifier, resolving); + } + if (ts.isVoidExpression(current)) return undefined; + if (ts.isStringLiteralLike(current)) return current.text; + if (ts.isNumericLiteral(current)) return Number(current.text); + if (ts.isBigIntLiteral(current)) return BigInt(current.text.slice(0, -1)); + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = staticPrimitiveValue(current.left, resolveIdentifier, resolving); + const right = staticPrimitiveValue(current.right, resolveIdentifier, resolving); + return typeof left === 'string' && typeof right === 'string' + ? left + right + : UNKNOWN_STATIC_VALUE; + } + if (ts.isTemplateExpression(current)) { + let value = current.head.text; + for (const span of current.templateSpans) { + const expressionValue = staticPrimitiveValue(span.expression, resolveIdentifier, resolving); + if (expressionValue === UNKNOWN_STATIC_VALUE) return UNKNOWN_STATIC_VALUE; + value += String(expressionValue) + span.literal.text; + } + return value; + } + if (ts.isPrefixUnaryExpression(current)) { + if (current.operator === ts.SyntaxKind.ExclamationToken) { + const operand = staticPrimitiveValue(current.operand, resolveIdentifier, resolving); + return operand === UNKNOWN_STATIC_VALUE ? UNKNOWN_STATIC_VALUE : !operand; + } + const operand = staticPrimitiveValue(current.operand, resolveIdentifier, resolving); + if (operand === UNKNOWN_STATIC_VALUE) return UNKNOWN_STATIC_VALUE; + if (current.operator === ts.SyntaxKind.MinusToken) { + return typeof operand === 'bigint' || typeof operand === 'number' + ? -operand + : UNKNOWN_STATIC_VALUE; + } + if (current.operator === ts.SyntaxKind.PlusToken && typeof operand === 'number') { + return +operand; + } + } + return UNKNOWN_STATIC_VALUE; +} + +function definitelyDefinedExpression(expression) { + const current = unwrapExpression(expression); + return ( + ts.isObjectLiteralExpression(current) || + ts.isArrayLiteralExpression(current) || + ts.isFunctionLike(current) || + ts.isClassLike(current) || + ts.isNewExpression(current) + ); +} + +export function staticUndefinedness(expression) { + const value = staticPrimitiveValue(expression); + if (value !== UNKNOWN_STATIC_VALUE) return value === undefined; + return definitelyDefinedExpression(expression) ? false : null; +} + +export function staticTruthiness(expression) { + const value = staticPrimitiveValue(expression); + return value === UNKNOWN_STATIC_VALUE ? null : Boolean(value); +} + +export function staticNullishness(expression) { + const value = staticPrimitiveValue(expression); + return value === UNKNOWN_STATIC_VALUE ? null : value === null || value === undefined; +} + +export function staticStrictEquality(left, right) { + const leftValue = staticPrimitiveValue(left); + const rightValue = staticPrimitiveValue(right); + return leftValue === UNKNOWN_STATIC_VALUE || rightValue === UNKNOWN_STATIC_VALUE + ? null + : leftValue === rightValue; +} + +export function staticPropertyKey(name) { + if (!name) return null; + if (!ts.isComputedPropertyName(name)) return propertyNameText(name); + const value = staticPrimitiveValue(name.expression); + return value === UNKNOWN_STATIC_VALUE ? null : String(value); +} + +export function staticMemberAccess(expression, resolveIdentifier) { + const directAccess = memberAccess(expression); + if (directAccess) return directAccess; + + const current = unwrapExpression(expression); + if (!ts.isElementAccessExpression(current) || !current.argumentExpression) return null; + const name = staticStringValue(current.argumentExpression, resolveIdentifier); + return name === null ? null : { name, receiver: unwrapExpression(current.expression) }; +} + +export function staticStringValue(expression, resolveIdentifier) { + const value = staticPrimitiveValue(expression, resolveIdentifier); + return typeof value === 'string' ? value : null; +} diff --git a/scripts/ci/feature-synchronous-array-callback-analysis.mjs b/scripts/ci/feature-synchronous-array-callback-analysis.mjs new file mode 100644 index 0000000000..37b829458d --- /dev/null +++ b/scripts/ci/feature-synchronous-array-callback-analysis.mjs @@ -0,0 +1,211 @@ +import ts from 'typescript'; + +import { callableTarget, callMethod } from './feature-callable-analysis.mjs'; +import { unwrapExpression } from './feature-export-ast.mjs'; +import { staticTruthiness } from './feature-static-value-analysis.mjs'; + +const SYNCHRONOUS_ARRAY_CALLBACK_METHODS = new Set([ + 'every', + 'filter', + 'find', + 'findIndex', + 'findLast', + 'findLastIndex', + 'flatMap', + 'forEach', + 'map', + 'reduce', + 'reduceRight', + 'some', +]); +const REVERSE_ARRAY_CALLBACK_METHODS = new Set([ + 'findLast', + 'findLastIndex', + 'reduceRight', +]); +const STOPS_ON_TRUTHY_METHODS = new Set([ + 'find', + 'findIndex', + 'findLast', + 'findLastIndex', + 'some', +]); +const UNKNOWN_REDUCER_ACCUMULATOR = ts.factory.createObjectLiteralExpression(); + +function statementOutcomes(statement) { + if (ts.isReturnStatement(statement)) { + return [{ expression: statement.expression, kind: 'return' }]; + } + if (ts.isThrowStatement(statement)) return [{ kind: 'throw' }]; + if (ts.isBlock(statement)) return statementListOutcomes(statement.statements); + if (ts.isIfStatement(statement)) { + const truthiness = staticTruthiness(statement.expression); + const thenOutcomes = statementOutcomes(statement.thenStatement); + const elseOutcomes = statement.elseStatement + ? statementOutcomes(statement.elseStatement) + : [{ kind: 'normal' }]; + if (truthiness === true) return thenOutcomes; + if (truthiness === false) return elseOutcomes; + return [...thenOutcomes, ...elseOutcomes]; + } + if ( + ts.isExpressionStatement(statement) || + ts.isVariableStatement(statement) || + ts.isEmptyStatement(statement) + ) { + return [{ kind: 'normal' }]; + } + return [{ kind: 'normal' }, { kind: 'unknown' }]; +} + +function statementListOutcomes(statements) { + let outcomes = [{ kind: 'normal' }]; + for (const statement of statements) { + outcomes = outcomes.flatMap((outcome) => + outcome.kind === 'normal' ? statementOutcomes(statement) : [outcome] + ); + } + return outcomes; +} + +function callbackOutcomes(callable) { + const outcomes = ts.isBlock(callable.body) + ? statementListOutcomes(callable.body.statements) + : [{ expression: callable.body, kind: 'return' }]; + return outcomes.map((outcome) => + outcome.kind === 'normal' ? { expression: undefined, kind: 'return' } : outcome + ); +} + +function callbackStopsAfterFirstCall(method, callable) { + return callbackOutcomes(callable).every( + (outcome) => + outcome.kind === 'throw' || + (outcome.kind === 'return' && + ((STOPS_ON_TRUTHY_METHODS.has(method) && + outcome.expression && + staticTruthiness(outcome.expression) === true) || + (method === 'every' && + (!outcome.expression || staticTruthiness(outcome.expression) === false)))) + ); +} + +function callbackArguments(element, receiver) { + return [element.element, ts.factory.createNumericLiteral(element.index), receiver]; +} + +function reducerResultCandidates(callable, invocation) { + return [ + ...new Set( + callbackOutcomes(callable).flatMap((outcome) => { + if (outcome.kind === 'throw') return []; + if (outcome.kind === 'unknown') return [UNKNOWN_REDUCER_ACCUMULATOR]; + const current = outcome.expression && unwrapExpression(outcome.expression); + const parameterIndex = + current && ts.isIdentifier(current) + ? callable.parameters.findIndex( + (parameter) => + ts.isIdentifier(parameter.name) && parameter.name.text === current.text + ) + : -1; + if (parameterIndex >= 0) { + return ( + invocation.argumentCandidates?.[parameterIndex] ?? [ + invocation.arguments[parameterIndex], + ] + ).filter(Boolean); + } + return [outcome.expression ?? UNKNOWN_REDUCER_ACCUMULATOR]; + }) + ), + ]; +} + +function reducerInvocations(elements, receiver, initialValue, callable) { + const callbackElements = initialValue ? elements : elements.slice(1); + let accumulatorCandidates = [initialValue ?? elements[0].element]; + return callbackElements.map((element) => { + const invocation = { + argumentCandidates: [ + accumulatorCandidates, + [element.element], + [ts.factory.createNumericLiteral(element.index)], + [receiver], + ], + arguments: [ + accumulatorCandidates[0], + element.element, + ts.factory.createNumericLiteral(element.index), + receiver, + ], + }; + accumulatorCandidates = reducerResultCandidates(callable, invocation); + return invocation; + }); +} + +export function executedSynchronousArrayCallbackForCall(node) { + if ( + !ts.isCallExpression(node) || + node.questionDotToken || + node.expression.questionDotToken + ) { + return null; + } + const method = callMethod(node.expression); + const receiver = method && unwrapExpression(method.receiver); + const callback = node.arguments[0] && callableTarget(node.arguments[0]); + if ( + !method || + !SYNCHRONOUS_ARRAY_CALLBACK_METHODS.has(method.name) || + !receiver || + !ts.isArrayLiteralExpression(receiver) || + !callback + ) { + return null; + } + const definiteElements = receiver.elements.flatMap((element, index) => + ts.isOmittedExpression(element) || ts.isSpreadElement(element) + ? [] + : [{ element, index }] + ); + const minimumElements = + method.name === 'reduce' || method.name === 'reduceRight' + ? node.arguments.length >= 2 + ? 1 + : 2 + : 1; + if (definiteElements.length < minimumElements) return null; + let elements = REVERSE_ARRAY_CALLBACK_METHODS.has(method.name) + ? [...definiteElements].reverse() + : definiteElements; + const isReducer = method.name === 'reduce' || method.name === 'reduceRight'; + const initialValue = isReducer && node.arguments.length >= 2 ? node.arguments[1] : null; + if (callbackStopsAfterFirstCall(method.name, callback)) { + elements = elements.slice(0, isReducer && !initialValue ? 2 : 1); + } + const invocations = isReducer + ? reducerInvocations(elements, receiver, initialValue, callback) + : elements.map((element) => ({ arguments: callbackArguments(element, receiver) })); + const elementBindings = + isReducer + ? elements.map((element, index) => ({ + element: element.element, + index: element.index, + parameterIndex: initialValue || index > 0 ? 1 : 0, + })) + : elements.map((element) => ({ + element: element.element, + index: element.index, + parameterIndex: 0, + })); + return { + arguments: invocations[0].arguments, + call: node, + callable: callback, + elementBindings, + invocations, + method: method.name, + receiver, + }; +} diff --git a/scripts/ci/feature-type-scope-analysis.mjs b/scripts/ci/feature-type-scope-analysis.mjs new file mode 100644 index 0000000000..896f7c38b3 --- /dev/null +++ b/scripts/ci/feature-type-scope-analysis.mjs @@ -0,0 +1,49 @@ +import ts from 'typescript'; + +function typeParameterMatches(parameter, name) { + return ts.isIdentifier(parameter.name) && parameter.name.text === name; +} + +function conditionalTypeInfersName(conditionalType, name) { + let found = false; + const visit = (node) => { + if (found || ts.isConditionalTypeNode(node)) return; + if (ts.isInferTypeNode(node) && typeParameterMatches(node.typeParameter, name)) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(conditionalType.extendsType); + return found; +} + +export function isShadowedTypeReference(node, sourceFile) { + let child = node; + let current = node.parent; + while (current && current !== sourceFile) { + if ( + 'typeParameters' in current && + current.typeParameters?.some((parameter) => typeParameterMatches(parameter, node.text)) + ) { + return true; + } + if ( + ts.isMappedTypeNode(current) && + child !== current.typeParameter && + typeParameterMatches(current.typeParameter, node.text) + ) { + return true; + } + if ( + ts.isConditionalTypeNode(current) && + child === current.trueType && + conditionalTypeInfersName(current, node.text) + ) { + return true; + } + child = current; + current = current.parent; + } + return false; +} diff --git a/scripts/ci/hosted-runtime-environment-policy.json b/scripts/ci/hosted-runtime-environment-policy.json new file mode 100644 index 0000000000..594aac8069 --- /dev/null +++ b/scripts/ci/hosted-runtime-environment-policy.json @@ -0,0 +1,130 @@ +{ + "format": "agent-teams.hosted-runtime-environment-policy/v1", + "schemaVersion": 1, + "authority": "mutable_current_head_source", + "environmentKeyPrefix": "HOSTED_LIFECYCLE_", + "entries": [ + { + "name": "HOSTED_LIFECYCLE_ORCHESTRATOR_HIGH_WATER_ROOT", + "role": "owner_high_water_state_root", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": [ + "docker/Dockerfile", + "docker/docker-compose.e2e.yml", + "docker/docker-compose.yml", + "src/main/standalone.ts" + ] + }, + { + "name": "HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR", + "role": "orchestrator_run_directory", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": ["docker/docker-compose.yml"] + }, + { + "name": "HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET", + "role": "orchestrator_socket_path", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": [ + "docker/docker-compose.e2e.yml", + "docker/docker-compose.yml", + "src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts", + "src/main/standalone.ts" + ] + }, + { + "name": "HOSTED_LIFECYCLE_ORCHESTRATOR_TEST_ONLY_INLINE_TRUST_ANCHOR", + "role": "test_gate", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": ["src/main/standaloneHostedLifecycleTrustAnchor.ts"] + }, + { + "name": "HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR", + "role": "authentication_material", + "secretClass": "hmac_authentication_key", + "redactionRule": "name_only", + "providerChildExposure": "forbidden", + "sourceAuthority": [ + "src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts", + "src/main/standaloneHostedLifecycleTrustAnchor.ts" + ] + }, + { + "name": "HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR_FILE", + "role": "authentication_secret_reference", + "secretClass": "secret_reference_path", + "redactionRule": "name_only", + "providerChildExposure": "forbidden", + "sourceAuthority": [ + "docker/docker-compose.e2e.yml", + "docker/docker-compose.yml", + "src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts", + "src/main/standaloneHostedLifecycleTrustAnchor.ts" + ] + }, + { + "name": "HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE", + "role": "owner_admission_manifest_path", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": [ + "docker/docker-compose.e2e.yml", + "docker/docker-compose.yml", + "src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts" + ] + }, + { + "name": "HOSTED_LIFECYCLE_OWNER_ARTIFACT_DIGEST", + "role": "forbidden_legacy_bootstrap_control", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": ["src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts"] + }, + { + "name": "HOSTED_LIFECYCLE_OWNER_AUTHORITY", + "role": "forbidden_legacy_bootstrap_control", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": ["src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts"] + }, + { + "name": "HOSTED_LIFECYCLE_OWNER_IMAGE_REFERENCE", + "role": "forbidden_legacy_bootstrap_control", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": ["src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts"] + }, + { + "name": "HOSTED_LIFECYCLE_OWNER_PROTOCOL_VERSION", + "role": "forbidden_legacy_bootstrap_control", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": ["src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts"] + }, + { + "name": "HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE", + "role": "owner_release_pin_path", + "secretClass": "none", + "redactionRule": "not_applicable", + "providerChildExposure": "forbidden", + "sourceAuthority": [ + "docker/docker-compose.e2e.yml", + "docker/docker-compose.yml", + "src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts" + ] + } + ] +} diff --git a/scripts/ci/rebuild-better-sqlite3-node.cjs b/scripts/ci/rebuild-better-sqlite3-node.cjs index df40c15cd6..fca5bfca81 100644 --- a/scripts/ci/rebuild-better-sqlite3-node.cjs +++ b/scripts/ci/rebuild-better-sqlite3-node.cjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-require-imports */ +/* global console, process, require */ const fs = require('node:fs'); const path = require('node:path'); @@ -12,14 +14,16 @@ function fail(message, error) { process.exit(1); } -let packageRoot; -try { - packageRoot = path.dirname(require.resolve('better-sqlite3-node/package.json')); -} catch (error) { - fail('failed to resolve package root', error); -} - -fs.rmSync(path.join(packageRoot, 'build'), { recursive: true, force: true }); +const packages = ['better-sqlite3', 'better-sqlite3-node'].map((packageName) => { + try { + return { + packageName, + packageRoot: path.dirname(require.resolve(`${packageName}/package.json`)), + }; + } catch (error) { + fail(`failed to resolve ${packageName} package root`, error); + } +}); const env = { ...process.env }; delete env.npm_config_runtime; @@ -30,27 +34,31 @@ delete env.npm_config_target_arch; delete env.npm_config_target_platform; delete env.npm_config_build_from_source; -const rebuild = spawnSync('npm', ['rebuild'], { - cwd: packageRoot, - env, - stdio: 'inherit', - shell: process.platform === 'win32', -}); +for (const { packageName, packageRoot } of packages) { + fs.rmSync(path.join(packageRoot, 'build'), { recursive: true, force: true }); -if (rebuild.status !== 0) { - fail(`npm rebuild failed with status ${rebuild.status ?? 'unknown'}`); -} + const rebuild = spawnSync('npm', ['rebuild'], { + cwd: packageRoot, + env, + stdio: 'inherit', + shell: process.platform === 'win32', + }); -try { - const Database = require('better-sqlite3-node'); - const db = new Database(':memory:'); - const row = db.prepare('select 1 as ok').get(); - db.close(); - if (row?.ok !== 1) { - fail('sqlite smoke query returned unexpected result'); + if (rebuild.status !== 0) { + fail(`${packageName} npm rebuild failed with status ${rebuild.status ?? 'unknown'}`); } -} catch (error) { - fail('sqlite smoke query failed after rebuild', error); -} -console.log('[better-sqlite3-node] Node ABI rebuild verified'); + try { + const Database = require(packageName); + const db = new Database(':memory:'); + const row = db.prepare('select 1 as ok').get(); + db.close(); + if (row?.ok !== 1) { + fail(`${packageName} sqlite smoke query returned unexpected result`); + } + } catch (error) { + fail(`${packageName} sqlite smoke query failed after rebuild`, error); + } + + console.log(`[better-sqlite3-node] ${packageName} Node ABI rebuild verified`); +} diff --git a/scripts/ci/resolve-architecture-baseline-ref.mjs b/scripts/ci/resolve-architecture-baseline-ref.mjs new file mode 100644 index 0000000000..f086237760 --- /dev/null +++ b/scripts/ci/resolve-architecture-baseline-ref.mjs @@ -0,0 +1,70 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const NO_PRIOR_COMMIT_REF = '0'.repeat(40); + +const scriptPath = fileURLToPath(import.meta.url); +const SHA_PATTERN = /^[0-9a-f]{40}$/i; + +function assertCommitRef(value, label) { + if (!SHA_PATTERN.test(value ?? '')) { + throw new Error(`${label} must be a 40-character commit SHA`); + } + return value; +} + +export function firstParentFromCommitObject(commitObject) { + for (const line of commitObject.split(/\r?\n/)) { + if (line === '') break; + if (!line.startsWith('parent ')) continue; + return assertCommitRef(line.slice('parent '.length), 'commit parent'); + } + return null; +} + +function runGit(args, root) { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +export function resolveArchitectureBaselineRef({ + eventBaselineRef, + git = runGit, + headSha, + root = process.cwd(), +}) { + let baselineRef = assertCommitRef(eventBaselineRef, 'event architecture baseline ref'); + + if (baselineRef === NO_PRIOR_COMMIT_REF) { + const currentHead = assertCommitRef(headSha, 'GITHUB_SHA'); + const commitObject = git(['cat-file', 'commit', currentHead], root); + baselineRef = firstParentFromCommitObject(commitObject) ?? NO_PRIOR_COMMIT_REF; + } + + if (baselineRef !== NO_PRIOR_COMMIT_REF) { + git(['fetch', '--no-tags', '--depth=1', 'origin', baselineRef], root); + git(['cat-file', '-e', `${baselineRef}^{commit}`], root); + } + + return baselineRef; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + process.stdout.write( + `${resolveArchitectureBaselineRef({ + eventBaselineRef: process.env.ARCHITECTURE_EVENT_BASELINE_REF, + headSha: process.env.GITHUB_SHA, + })}\n` + ); + } catch (error) { + console.error( + `[architecture-baseline] ${error instanceof Error ? error.message : String(error)}` + ); + process.exitCode = 1; + } +} diff --git a/scripts/ci/source-file-size-baseline.json b/scripts/ci/source-file-size-baseline.json index 79a116e2c8..ca2c5bae9c 100644 --- a/scripts/ci/source-file-size-baseline.json +++ b/scripts/ci/source-file-size-baseline.json @@ -17,7 +17,6 @@ "src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts": 1680, "src/features/change-review-history/main/infrastructure/ReviewDraftHistoryStore.ts": 1321, "src/features/codex-account/main/composition/createCodexAccountFeature.ts": 1003, - "src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerOps.ts": 809, "src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts": 2478, "src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts": 985, "src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts": 801, @@ -29,22 +28,19 @@ "src/features/runtime-provider-management/main/infrastructure/cli-companion/RuntimeProviderCliCompanionService.ts": 838, "src/features/runtime-provider-management/main/infrastructure/OpenCodeLocalProviderConnector.ts": 1018, "src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts": 2101, - "src/features/runtime-provider-management/renderer/RuntimeLocalProviderSetupDialog.tsx": 1676, + "src/features/runtime-provider-management/renderer/RuntimeLocalProviderSetupDialog.tsx": 1673, "src/features/runtime-provider-management/renderer/RuntimeProviderQuickConnect.tsx": 826, "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx": 2927, - "src/features/terminal-workspace/renderer/ui/TerminalWorkspacePanel.tsx": 4513, "src/features/token-usage/core/domain/snapshotProjection.ts": 937, - "src/features/token-usage/renderer/view-models/tokenUsageViewModel.ts": 1481, "src/features/token-usage/renderer/ui/TokenUsageDashboard.tsx": 2170, - "src/main/index.ts": 3719, + "src/features/token-usage/renderer/view-models/tokenUsageViewModel.ts": 1481, + "src/main/index.ts": 3715, "src/main/ipc/config.ts": 1230, "src/main/ipc/configValidation.ts": 1050, - "src/main/ipc/review.ts": 4498, - "src/main/ipc/teams.ts": 5661, "src/main/services/discovery/ProjectScanner.ts": 2111, "src/main/services/infrastructure/CliInstallerService.ts": 1690, - "src/main/services/infrastructure/ConfigManager.ts": 1401, - "src/main/services/infrastructure/FileWatcher.ts": 1504, + "src/main/services/infrastructure/ConfigManager.ts": 1353, + "src/main/services/infrastructure/FileWatcher.ts": 1503, "src/main/services/infrastructure/NotificationManager.ts": 1526, "src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts": 1045, "src/main/services/runtime/ClaudeMultimodelBridgeService.ts": 2010, @@ -57,15 +53,15 @@ "src/main/services/team/opencode/delivery/OpenCodeMemberMessageDeliveryService.ts": 1578, "src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts": 1286, "src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts": 1442, - "src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts": 807, + "src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts": 801, "src/main/services/team/opencode/permissions/RuntimePermission.ts": 964, - "src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts": 1087, + "src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts": 1077, "src/main/services/team/opencode/store/RuntimeStoreManifest.ts": 1210, "src/main/services/team/provisioning/TeamProvisioningBootstrapTranscript.ts": 1661, "src/main/services/team/provisioning/TeamProvisioningInboxRelayPolicy.ts": 890, "src/main/services/team/provisioning/TeamProvisioningMemberLifecycle.ts": 2678, "src/main/services/team/provisioning/TeamProvisioningOpenCodeMemberInboxRelay.ts": 1155, - "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeDelivery.ts": 1009, + "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeDelivery.ts": 930, "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts": 964, "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimePermissions.ts": 967, "src/main/services/team/provisioning/TeamProvisioningPrepareCoordinator.ts": 972, @@ -73,19 +69,18 @@ "src/main/services/team/provisioning/TeamProvisioningProviderDiagnostics.ts": 1287, "src/main/services/team/provisioning/TeamProvisioningRuntimeLaunchSelection.ts": 945, "src/main/services/team/provisioning/TeamProvisioningRuntimeSnapshot.ts": 2062, - "src/main/services/team/provisioning/TeamProvisioningServiceComposition.ts": 843, + "src/main/services/team/provisioning/TeamProvisioningServiceComposition.ts": 816, "src/main/services/team/provisioning/TeamProvisioningStreamEvents.ts": 1212, "src/main/services/team/ReviewApplierService.ts": 2062, - "src/main/services/team/ReviewDecisionStore.ts": 1970, + "src/main/services/team/ReviewDecisionStore.ts": 1914, "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts": 1591, "src/main/services/team/TaskChangeComputer.ts": 1357, "src/main/services/team/TaskChangeLedgerReader.ts": 2159, "src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts": 2516, "src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts": 1364, - "src/main/services/team/TeamBackupService.ts": 1236, + "src/main/services/team/TeamBackupService.ts": 1196, "src/main/services/team/TeamBootstrapStateReader.ts": 908, - "src/main/services/team/TeamConfigReader.ts": 1151, - "src/main/services/team/TeamDataService.ts": 4102, + "src/main/services/team/TeamConfigReader.ts": 1052, "src/main/services/team/TeamInboxReader.ts": 820, "src/main/services/team/TeamLaunchStateEvaluator.ts": 1059, "src/main/services/team/TeamLogSourceTracker.ts": 1088, @@ -95,13 +90,12 @@ "src/main/services/team/TeamTaskActivityIntervalService.ts": 892, "src/main/services/team/TeamTaskReader.ts": 882, "src/main/services/team/TeamTranscriptProjectResolver.ts": 1558, - "src/main/utils/atomicWrite.ts": 822, - "src/main/utils/childProcess.ts": 965, + "src/main/utils/atomicWrite.ts": 814, + "src/main/utils/childProcess.ts": 931, "src/main/utils/jsonl.ts": 957, "src/main/workers/team-fs-worker.ts": 2151, - "src/preload/constants/ipcChannels.ts": 843, "src/preload/index.ts": 2094, - "src/renderer/api/httpClient.ts": 1907, + "src/renderer/api/httpClient.ts": 1872, "src/renderer/components/chat/ChatHistory.tsx": 1039, "src/renderer/components/chat/viewers/MarkdownViewer.tsx": 1219, "src/renderer/components/dashboard/CliStatusBanner.tsx": 2854, @@ -121,7 +115,7 @@ "src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts": 1640, "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx": 1206, "src/renderer/components/team/dialogs/TaskDetailDialog.tsx": 1717, - "src/renderer/components/team/dialogs/TeamModelSelector.tsx": 3864, + "src/renderer/components/team/dialogs/TeamModelSelector.tsx": 3862, "src/renderer/components/team/editor/EditorFileTree.tsx": 907, "src/renderer/components/team/editor/ProjectEditorOverlay.tsx": 929, "src/renderer/components/team/kanban/KanbanBoard.tsx": 919, @@ -133,21 +127,19 @@ "src/renderer/components/team/messages/MessageComposer.tsx": 1344, "src/renderer/components/team/messages/MessagesPanel.tsx": 1722, "src/renderer/components/team/ProvisioningProgressBlock.tsx": 862, - "src/renderer/components/team/review/ChangeReviewDialog.tsx": 5441, "src/renderer/components/team/review/CodeMirrorDiffView.tsx": 975, - "src/renderer/components/team/TeamDetailView.tsx": 3839, + "src/renderer/components/team/TeamDetailView.tsx": 3831, "src/renderer/components/team/TeamListView.tsx": 1568, "src/renderer/components/team/useTeamChangesSummaries.ts": 826, "src/renderer/components/ui/MentionableTextarea.tsx": 1433, "src/renderer/index.css": 1950, "src/renderer/index.html": 1441, "src/renderer/store/index.ts": 2608, - "src/renderer/store/slices/changeReviewSlice.ts": 2417, + "src/renderer/store/slices/changeReviewSlice.ts": 2410, "src/renderer/store/slices/cliInstallerSlice.ts": 1765, "src/renderer/store/slices/editorSlice.ts": 1483, "src/renderer/store/slices/extensionsSlice.ts": 1420, "src/renderer/store/slices/tabSlice.ts": 886, - "src/renderer/store/slices/teamSlice.ts": 5111, "src/renderer/utils/contextTracker.ts": 1126, "src/renderer/utils/memberHelpers.ts": 1756, "src/renderer/utils/memberLaunchDiagnostics.ts": 983, @@ -156,7 +148,7 @@ "src/renderer/utils/teamModelAvailability.ts": 983, "src/renderer/utils/teamModelCatalog.ts": 868, "src/renderer/utils/teamProvisioningPresentation.ts": 1414, - "src/shared/types/api.ts": 1158, + "src/shared/types/api.ts": 1151, "src/shared/types/team.ts": 1958 } } diff --git a/scripts/ci/source-file-size-legacy.json b/scripts/ci/source-file-size-legacy.json new file mode 100644 index 0000000000..5e7841017f --- /dev/null +++ b/scripts/ci/source-file-size-legacy.json @@ -0,0 +1,149 @@ +{ + "agent-teams-controller/src/internal/agenda.js": 914, + "agent-teams-controller/src/internal/tasks.js": 1233, + "agent-teams-controller/src/internal/taskStore.js": 1302, + "landing/assets/styles/cyberpunk-hero.scss": 2343, + "landing/components/layout/AppHeader.vue": 949, + "landing/components/sections/ComparisonSection.vue": 1168, + "landing/components/sections/DownloadSection.vue": 1110, + "landing/product-docs/.vitepress/config.ts": 829, + "packages/agent-graph/src/canvas/draw-agents.ts": 1201, + "packages/agent-graph/src/layout/stableSlots.ts": 2963, + "packages/agent-graph/src/ui/GraphCanvas.tsx": 856, + "packages/agent-graph/src/ui/GraphView.tsx": 1611, + "src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts": 1680, + "src/features/change-review-history/main/infrastructure/ReviewDraftHistoryStore.ts": 1321, + "src/features/codex-account/main/composition/createCodexAccountFeature.ts": 1003, + "src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts": 2478, + "src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts": 985, + "src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts": 801, + "src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts": 2355, + "src/features/organizations/core/domain/organizationGraph.ts": 963, + "src/features/organizations/renderer/adapters/organizationGraphAdapter.ts": 1664, + "src/features/organizations/renderer/ui/OrganizationEditorPanel.tsx": 942, + "src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts": 2685, + "src/features/runtime-provider-management/main/infrastructure/cli-companion/RuntimeProviderCliCompanionService.ts": 838, + "src/features/runtime-provider-management/main/infrastructure/OpenCodeLocalProviderConnector.ts": 1018, + "src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts": 2101, + "src/features/runtime-provider-management/renderer/RuntimeLocalProviderSetupDialog.tsx": 1673, + "src/features/runtime-provider-management/renderer/RuntimeProviderQuickConnect.tsx": 826, + "src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx": 2927, + "src/features/token-usage/core/domain/snapshotProjection.ts": 937, + "src/features/token-usage/renderer/ui/TokenUsageDashboard.tsx": 2170, + "src/features/token-usage/renderer/view-models/tokenUsageViewModel.ts": 1481, + "src/main/index.ts": 3715, + "src/main/ipc/config.ts": 1230, + "src/main/ipc/configValidation.ts": 1050, + "src/main/services/discovery/ProjectScanner.ts": 2111, + "src/main/services/infrastructure/CliInstallerService.ts": 1690, + "src/main/services/infrastructure/ConfigManager.ts": 1353, + "src/main/services/infrastructure/FileWatcher.ts": 1503, + "src/main/services/infrastructure/NotificationManager.ts": 1526, + "src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts": 1045, + "src/main/services/runtime/ClaudeMultimodelBridgeService.ts": 2010, + "src/main/services/runtime/ProviderConnectionService.ts": 1773, + "src/main/services/schedule/SchedulerService.ts": 850, + "src/main/services/team/AgentTeamsMcpHttpServer.ts": 1410, + "src/main/services/team/ChangeExtractorService.ts": 1739, + "src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts": 1182, + "src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts": 898, + "src/main/services/team/opencode/delivery/OpenCodeMemberMessageDeliveryService.ts": 1578, + "src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts": 1286, + "src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts": 1442, + "src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts": 801, + "src/main/services/team/opencode/permissions/RuntimePermission.ts": 964, + "src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts": 1077, + "src/main/services/team/opencode/store/RuntimeStoreManifest.ts": 1210, + "src/main/services/team/provisioning/TeamProvisioningBootstrapTranscript.ts": 1661, + "src/main/services/team/provisioning/TeamProvisioningInboxRelayPolicy.ts": 890, + "src/main/services/team/provisioning/TeamProvisioningMemberLifecycle.ts": 2678, + "src/main/services/team/provisioning/TeamProvisioningOpenCodeMemberInboxRelay.ts": 1155, + "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeDelivery.ts": 930, + "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts": 964, + "src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimePermissions.ts": 967, + "src/main/services/team/provisioning/TeamProvisioningPrepareCoordinator.ts": 972, + "src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts": 1160, + "src/main/services/team/provisioning/TeamProvisioningProviderDiagnostics.ts": 1287, + "src/main/services/team/provisioning/TeamProvisioningRuntimeLaunchSelection.ts": 945, + "src/main/services/team/provisioning/TeamProvisioningRuntimeSnapshot.ts": 2062, + "src/main/services/team/provisioning/TeamProvisioningServiceComposition.ts": 816, + "src/main/services/team/provisioning/TeamProvisioningStreamEvents.ts": 1212, + "src/main/services/team/ReviewApplierService.ts": 2062, + "src/main/services/team/ReviewDecisionStore.ts": 1914, + "src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts": 1591, + "src/main/services/team/TaskChangeComputer.ts": 1357, + "src/main/services/team/TaskChangeLedgerReader.ts": 2159, + "src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts": 2516, + "src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts": 1364, + "src/main/services/team/TeamBackupService.ts": 1196, + "src/main/services/team/TeamBootstrapStateReader.ts": 908, + "src/main/services/team/TeamConfigReader.ts": 1052, + "src/main/services/team/TeamInboxReader.ts": 820, + "src/main/services/team/TeamLaunchStateEvaluator.ts": 1059, + "src/main/services/team/TeamLogSourceTracker.ts": 1088, + "src/main/services/team/TeamMemberLogsFinder.ts": 2349, + "src/main/services/team/TeamMemberRuntimeAdvisoryService.ts": 964, + "src/main/services/team/TeamMessageFeedService.ts": 1094, + "src/main/services/team/TeamTaskActivityIntervalService.ts": 892, + "src/main/services/team/TeamTaskReader.ts": 882, + "src/main/services/team/TeamTranscriptProjectResolver.ts": 1558, + "src/main/utils/atomicWrite.ts": 814, + "src/main/utils/childProcess.ts": 931, + "src/main/utils/jsonl.ts": 957, + "src/main/workers/team-fs-worker.ts": 2151, + "src/preload/index.ts": 2094, + "src/renderer/api/httpClient.ts": 1872, + "src/renderer/components/chat/ChatHistory.tsx": 1039, + "src/renderer/components/chat/viewers/MarkdownViewer.tsx": 1219, + "src/renderer/components/dashboard/CliStatusBanner.tsx": 2854, + "src/renderer/components/extensions/skills/SkillEditorDialog.tsx": 904, + "src/renderer/components/extensions/skills/SkillsPanel.tsx": 812, + "src/renderer/components/runtime/providerConnectionUi.ts": 989, + "src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx": 3311, + "src/renderer/components/settings/sections/CliStatusSection.tsx": 887, + "src/renderer/components/sidebar/DateGroupedSessions.tsx": 1130, + "src/renderer/components/sidebar/GlobalTaskList.tsx": 1861, + "src/renderer/components/splash/splashScene.ts": 1015, + "src/renderer/components/team/activity/ActivityItem.tsx": 2032, + "src/renderer/components/team/activity/ActivityTimeline.tsx": 1073, + "src/renderer/components/team/activity/LeadThoughtsGroup.tsx": 1186, + "src/renderer/components/team/dialogs/CreateTeamDialog.tsx": 3160, + "src/renderer/components/team/dialogs/LaunchTeamDialog.tsx": 3333, + "src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts": 1640, + "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx": 1206, + "src/renderer/components/team/dialogs/TaskDetailDialog.tsx": 1717, + "src/renderer/components/team/dialogs/TeamModelSelector.tsx": 3862, + "src/renderer/components/team/editor/EditorFileTree.tsx": 907, + "src/renderer/components/team/editor/ProjectEditorOverlay.tsx": 929, + "src/renderer/components/team/kanban/KanbanBoard.tsx": 919, + "src/renderer/components/team/kanban/KanbanTaskCard.tsx": 1081, + "src/renderer/components/team/members/MemberCard.tsx": 1586, + "src/renderer/components/team/members/MemberDraftRow.tsx": 1018, + "src/renderer/components/team/members/MemberList.tsx": 1370, + "src/renderer/components/team/members/MemberLogsTab.tsx": 985, + "src/renderer/components/team/messages/MessageComposer.tsx": 1344, + "src/renderer/components/team/messages/MessagesPanel.tsx": 1722, + "src/renderer/components/team/ProvisioningProgressBlock.tsx": 862, + "src/renderer/components/team/review/CodeMirrorDiffView.tsx": 975, + "src/renderer/components/team/TeamDetailView.tsx": 3831, + "src/renderer/components/team/TeamListView.tsx": 1568, + "src/renderer/components/team/useTeamChangesSummaries.ts": 826, + "src/renderer/components/ui/MentionableTextarea.tsx": 1433, + "src/renderer/index.css": 1950, + "src/renderer/store/index.ts": 2608, + "src/renderer/store/slices/changeReviewSlice.ts": 2410, + "src/renderer/store/slices/cliInstallerSlice.ts": 1765, + "src/renderer/store/slices/editorSlice.ts": 1483, + "src/renderer/store/slices/extensionsSlice.ts": 1420, + "src/renderer/store/slices/tabSlice.ts": 886, + "src/renderer/utils/contextTracker.ts": 1126, + "src/renderer/utils/memberHelpers.ts": 1756, + "src/renderer/utils/memberLaunchDiagnostics.ts": 983, + "src/renderer/utils/openCodeModelRecommendations.ts": 1335, + "src/renderer/utils/sessionAnalyzer.ts": 1348, + "src/renderer/utils/teamModelAvailability.ts": 983, + "src/renderer/utils/teamModelCatalog.ts": 868, + "src/renderer/utils/teamProvisioningPresentation.ts": 1414, + "src/shared/types/api.ts": 1151, + "src/shared/types/team.ts": 1958 +} diff --git a/scripts/ci/update-feature-architecture-baseline.mjs b/scripts/ci/update-feature-architecture-baseline.mjs new file mode 100644 index 0000000000..fe0e1cc9dc --- /dev/null +++ b/scripts/ci/update-feature-architecture-baseline.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + collectFeatureArchitectureViolations, + compareViolations, + toBaselineEntry, +} from './feature-architecture-policy.mjs'; + +if (!process.argv.includes('--write')) { + throw new Error('pass --write to replace the feature architecture baseline'); +} + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), '..', '..'); +const baselinePath = path.join(repoRoot, 'scripts/ci/feature-architecture-baseline.json'); +const { violations } = collectFeatureArchitectureViolations(repoRoot); +const entries = violations.map(toBaselineEntry).sort(compareViolations); + +writeFileSync( + baselinePath, + `${JSON.stringify({ version: 2, violations: entries }, null, 2)}\n` +); +console.log(`[feature-architecture] Wrote ${entries.length} exact baseline entries`); diff --git a/scripts/ci/verify-feature-architecture.mjs b/scripts/ci/verify-feature-architecture.mjs new file mode 100644 index 0000000000..8b253aa56a --- /dev/null +++ b/scripts/ci/verify-feature-architecture.mjs @@ -0,0 +1,341 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + FEATURE_ARCHITECTURE_RULES, + collectFeatureArchitectureViolations, + compareViolations, + toBaselineEntry, + violationKey, +} from './feature-architecture-policy.mjs'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), '..', '..'); +const baselineRelativePath = 'scripts/ci/feature-architecture-baseline.json'; +const BASELINE_VERSION = 2; +const KNOWN_RULES = new Set(Object.values(FEATURE_ARCHITECTURE_RULES)); + +function diagnostic(code, message, entry) { + return { code, entry, message }; +} + +function sameObjectKeys(value, expectedKeys) { + const actualKeys = Object.keys(value).sort(); + return ( + actualKeys.length === expectedKeys.length && + actualKeys.every((key, index) => key === expectedKeys[index]) + ); +} + +export function validateFeatureArchitectureBaseline(manifest) { + const diagnostics = []; + if ( + typeof manifest !== 'object' || + manifest === null || + Array.isArray(manifest) || + !sameObjectKeys(manifest, ['version', 'violations']) + ) { + return { + diagnostics: [ + diagnostic( + 'invalid-baseline-shape', + 'baseline must contain exactly version and violations', + null + ), + ], + entries: [], + }; + } + if (manifest.version !== BASELINE_VERSION || !Array.isArray(manifest.violations)) { + return { + diagnostics: [ + diagnostic( + 'invalid-baseline-version', + `baseline version must be ${BASELINE_VERSION} and violations must be an array`, + null + ), + ], + entries: [], + }; + } + + const entries = []; + const seenKeys = new Set(); + for (const entry of manifest.violations) { + const isObject = typeof entry === 'object' && entry !== null && !Array.isArray(entry); + const isPublicApiExport = + isObject && entry.rule === FEATURE_ARCHITECTURE_RULES.publicApiImplementationExport; + const expectedKeys = isPublicApiExport + ? ['exportedName', 'importedName', 'publicEntrypoint', 'rule', 'source', 'specifier'] + : ['rule', 'source', 'specifier']; + if ( + !isObject || + !sameObjectKeys(entry, expectedKeys) || + typeof entry.rule !== 'string' || + typeof entry.source !== 'string' || + typeof entry.specifier !== 'string' || + (isPublicApiExport && + (typeof entry.publicEntrypoint !== 'string' || + typeof entry.exportedName !== 'string' || + typeof entry.importedName !== 'string')) + ) { + const missingPublicIdentity = + isPublicApiExport && + (!('publicEntrypoint' in entry) || + !('exportedName' in entry) || + !('importedName' in entry)); + diagnostics.push( + diagnostic( + missingPublicIdentity ? 'missing-public-export-identity' : 'invalid-baseline-entry', + isPublicApiExport + ? 'public API export entries must contain publicEntrypoint, exportedName, and importedName strings' + : 'dependency entries must contain exactly rule, source, and specifier strings', + entry + ) + ); + continue; + } + if ( + entry.source.length === 0 || + !entry.source.startsWith('src/') || + entry.source.includes('\\') || + entry.specifier.length === 0 || + (isPublicApiExport && + (entry.publicEntrypoint.length === 0 || + !entry.publicEntrypoint.startsWith('src/features/') || + entry.publicEntrypoint.includes('\\') || + entry.exportedName.length === 0 || + entry.importedName.length === 0)) + ) { + diagnostics.push( + diagnostic( + 'invalid-baseline-path', + 'baseline paths and public symbol identities must be non-empty and normalized', + entry + ) + ); + continue; + } + if (!KNOWN_RULES.has(entry.rule)) { + diagnostics.push( + diagnostic('unknown-baseline-rule', `unknown architecture rule ${entry.rule}`, entry) + ); + continue; + } + const key = violationKey(entry); + if (seenKeys.has(key)) { + diagnostics.push( + diagnostic('duplicate-baseline-entry', 'baseline entry is duplicated', entry) + ); + continue; + } + seenKeys.add(key); + entries.push(entry); + } + + const sortedEntries = [...entries].sort(compareViolations); + if (entries.some((entry, index) => violationKey(entry) !== violationKey(sortedEntries[index]))) { + diagnostics.push( + diagnostic( + 'unsorted-baseline', + 'baseline entries must use canonical rule/path ordering', + null + ) + ); + } + + return { diagnostics, entries }; +} + +export function evaluateFeatureArchitectureRatchet({ + baselineEntries, + baselineReferenceEntries, + violations, +}) { + const diagnostics = []; + const actualByKey = new Map(violations.map((violation) => [violationKey(violation), violation])); + const baselineByKey = new Map(baselineEntries.map((entry) => [violationKey(entry), entry])); + + for (const [key, violation] of actualByKey) { + if (!baselineByKey.has(key)) { + diagnostics.push( + diagnostic('new-architecture-violation', violation.message, toBaselineEntry(violation)) + ); + } + } + for (const [key, entry] of baselineByKey) { + if (!actualByKey.has(key)) { + diagnostics.push( + diagnostic( + 'stale-baseline-entry', + 'the dependency edge no longer violates the policy; remove this baseline entry', + entry + ) + ); + } + } + + if (baselineReferenceEntries !== null) { + const referenceKeys = new Set(baselineReferenceEntries.map(violationKey)); + for (const [key, entry] of baselineByKey) { + if (!referenceKeys.has(key)) { + diagnostics.push( + diagnostic( + 'baseline-expansion', + 'new baseline exceptions are forbidden; fix the dependency direction instead', + entry + ) + ); + } + } + } + + return diagnostics; +} + +function readManifest(source, label) { + try { + return JSON.parse(source); + } catch (error) { + throw new Error( + `${label} is not valid JSON: ${error instanceof Error ? error.message : error}` + ); + } +} + +function analyzeBaselineReferenceSource(baselineRef, root) { + const checkoutRoot = mkdtempSync(path.join(tmpdir(), 'feature-architecture-base-')); + const archivePath = path.join(checkoutRoot, 'source.tar'); + try { + execFileSync( + 'git', + ['archive', '--format=tar', `--output=${archivePath}`, baselineRef, '--', 'src'], + { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] } + ); + execFileSync('tar', ['-xf', archivePath, '-C', checkoutRoot], { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return collectFeatureArchitectureViolations(checkoutRoot).violations.map(toBaselineEntry); + } catch (error) { + throw new Error( + `baseline at ${baselineRef} has no manifest and its source could not be analyzed: ${ + error instanceof Error ? error.message : error + }` + ); + } finally { + rmSync(checkoutRoot, { force: true, recursive: true }); + } +} + +function readBaselineReferenceManifest(baselineRef, root) { + if (!baselineRef) return null; + if (!/^[0-9a-f]{40}$/i.test(baselineRef)) { + throw new Error('FEATURE_ARCHITECTURE_BASELINE_REF must be a 40-character commit SHA'); + } + if (/^0{40}$/.test(baselineRef)) return null; + try { + execFileSync('git', ['cat-file', '-e', `${baselineRef}^{commit}`], { + cwd: root, + stdio: 'ignore', + }); + } catch { + throw new Error(`FEATURE_ARCHITECTURE_BASELINE_REF commit ${baselineRef} is unavailable`); + } + + const objectName = `${baselineRef}:${baselineRelativePath}`; + try { + execFileSync('git', ['cat-file', '-e', objectName], { cwd: root, stdio: 'ignore' }); + } catch { + return analyzeBaselineReferenceSource(baselineRef, root); + } + + const manifest = readManifest( + execFileSync('git', ['show', objectName], { cwd: root, encoding: 'utf8' }), + `baseline at ${baselineRef}` + ); + const validation = validateFeatureArchitectureBaseline(manifest); + if (validation.diagnostics.length > 0) { + throw new Error(`baseline at ${baselineRef} is invalid`); + } + const currentPolicyBaseEntries = analyzeBaselineReferenceSource(baselineRef, root); + const entriesByKey = new Map( + [...validation.entries, ...currentPolicyBaseEntries].map((entry) => [ + violationKey(entry), + entry, + ]) + ); + return [...entriesByKey.values()].sort(compareViolations); +} + +function formatDiagnostic({ code, entry, message }) { + const location = entry + ? `${entry.source}:${entry.specifier}${ + entry.publicEntrypoint ? ` (public via ${entry.publicEntrypoint})` : '' + }${entry.exportedName ? ` [exports ${entry.exportedName} from ${entry.importedName}]` : ''}` + : baselineRelativePath; + return ` - [${code}] ${location}: ${message}`; +} + +export function verifyFeatureArchitecture({ + baselineRef = process.env.FEATURE_ARCHITECTURE_BASELINE_REF, + root = repoRoot, +} = {}) { + const manifest = readManifest( + readFileSync(path.join(root, baselineRelativePath), 'utf8'), + 'baseline' + ); + const validation = validateFeatureArchitectureBaseline(manifest); + if (validation.diagnostics.length > 0) { + throw new Error( + `Feature architecture baseline is invalid:\n${validation.diagnostics + .map(formatDiagnostic) + .join('\n')}` + ); + } + + const { sourceFileCount, violations } = collectFeatureArchitectureViolations(root); + const diagnostics = evaluateFeatureArchitectureRatchet({ + baselineEntries: validation.entries, + baselineReferenceEntries: readBaselineReferenceManifest(baselineRef, root), + violations, + }); + if (diagnostics.length > 0) { + throw new Error( + `Feature architecture policy failed:\n${diagnostics.map(formatDiagnostic).join('\n')}` + ); + } + + return { + baselineEntries: validation.entries, + sourceFileCount, + violations, + }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + const result = verifyFeatureArchitecture(); + console.log( + `[feature-architecture] OK: ${result.sourceFileCount} production source files, ` + + `${result.baselineEntries.length} exact legacy dependency edges, 0 new violations` + ); + if (process.argv.includes('--report')) { + for (const violation of result.violations) { + console.log( + ` - [${violation.rule}] ${violation.source}:${violation.line} -> ${violation.specifier}` + ); + } + } + } catch (error) { + console.error( + `[feature-architecture] ${error instanceof Error ? error.message : String(error)}` + ); + process.exitCode = 1; + } +} diff --git a/scripts/ci/verify-hosted-container-compose-rendering.d.mts b/scripts/ci/verify-hosted-container-compose-rendering.d.mts new file mode 100644 index 0000000000..47f10d1e9a --- /dev/null +++ b/scripts/ci/verify-hosted-container-compose-rendering.d.mts @@ -0,0 +1,24 @@ +export interface RenderedHostedCompose { + services: Record; + [property: string]: unknown; +} + +export interface RenderHostedContainerHardeningComposeOptions { + profile: 'personal' | 'keycloak'; + root?: string; + dockerBinary?: string; + environment?: NodeJS.ProcessEnv; +} + +export function parseRenderedHostedCompose( + value: string | RenderedHostedCompose +): RenderedHostedCompose; + +export function restoreExplicitBindCreateHostPathFalse( + renderedCompose: T, + rawComposeSource: string +): T; + +export function renderHostedContainerHardeningCompose( + options: RenderHostedContainerHardeningComposeOptions +): RenderedHostedCompose; diff --git a/scripts/ci/verify-hosted-container-compose-rendering.mjs b/scripts/ci/verify-hosted-container-compose-rendering.mjs new file mode 100644 index 0000000000..f944ca86bb --- /dev/null +++ b/scripts/ci/verify-hosted-container-compose-rendering.mjs @@ -0,0 +1,109 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parse as parseYaml } from 'yaml'; + +import { + COMPOSE_PATH, + DEFAULT_RENDER_ENVIRONMENT, + PROFILES, + isObject, +} from './verify-hosted-container-hardening-contracts.mjs'; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = resolve(SCRIPT_DIRECTORY, '../..'); + +export function parseRenderedHostedCompose(value) { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (!isObject(parsed) || !isObject(parsed.services)) { + throw new Error('rendered Compose JSON must contain services'); + } + return parsed; +} + +export function restoreExplicitBindCreateHostPathFalse(renderedCompose, rawComposeSource) { + const rawCompose = parseYaml(rawComposeSource); + if (!isObject(rawCompose) || !isObject(rawCompose.services)) { + throw new Error('raw Compose YAML must contain services'); + } + + for (const [serviceName, rawService] of Object.entries(rawCompose.services)) { + const renderedService = renderedCompose.services[serviceName]; + if ( + !isObject(rawService) || + !Array.isArray(rawService.volumes) || + !isObject(renderedService) || + !Array.isArray(renderedService.volumes) + ) { + continue; + } + + const explicitFalseTargets = new Set( + rawService.volumes + .filter( + (mount) => + isObject(mount) && + mount.type === 'bind' && + typeof mount.target === 'string' && + isObject(mount.bind) && + mount.bind.create_host_path === false + ) + .map((mount) => mount.target) + ); + + for (const renderedMount of renderedService.volumes) { + if ( + !isObject(renderedMount) || + renderedMount.type !== 'bind' || + !explicitFalseTargets.has(renderedMount.target) || + (isObject(renderedMount.bind) && renderedMount.bind.create_host_path !== undefined) + ) { + continue; + } + if (!isObject(renderedMount.bind)) renderedMount.bind = {}; + renderedMount.bind.create_host_path = false; + } + } + + return renderedCompose; +} + +/** Renders exactly one profile through Docker Compose's non-executing config command. */ +export function renderHostedContainerHardeningCompose(options = {}) { + const profile = options.profile; + if (!PROFILES.includes(profile)) { + throw new Error('profile must be personal or keycloak'); + } + + const root = resolve(options.root ?? REPOSITORY_ROOT); + const composePath = join(root, COMPOSE_PATH); + const rawComposeSource = readFileSync(composePath, 'utf8'); + const dockerBinary = options.dockerBinary ?? defaultDockerBinary(); + const environment = { ...process.env, ...DEFAULT_RENDER_ENVIRONMENT, ...options.environment }; + const rendered = spawnSync( + dockerBinary, + ['compose', '-f', composePath, '--profile', profile, 'config', '--format', 'json'], + { + cwd: root, + encoding: 'utf8', + env: environment, + killSignal: 'SIGKILL', + maxBuffer: 5 * 1024 * 1024, + timeout: 30_000, + } + ); + + if (rendered.status !== 0 || rendered.error) { + throw new Error(`docker compose config failed for ${profile}`); + } + return restoreExplicitBindCreateHostPathFalse( + parseRenderedHostedCompose(rendered.stdout), + rawComposeSource + ); +} + +function defaultDockerBinary() { + return existsSync('/usr/bin/docker') ? '/usr/bin/docker' : 'docker'; +} diff --git a/scripts/ci/verify-hosted-container-hardening-contracts.mjs b/scripts/ci/verify-hosted-container-hardening-contracts.mjs new file mode 100644 index 0000000000..7c1bb17617 --- /dev/null +++ b/scripts/ci/verify-hosted-container-hardening-contracts.mjs @@ -0,0 +1,69 @@ +export const RESULT_FORMAT = 'hosted-container-hardening-verifier-result/v2'; +export const COMPOSE_PATH = 'docker/docker-compose.yml'; +export const PROFILES = Object.freeze(['personal', 'keycloak']); +export const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/iu; +export const POSITIVE_DURATION_PATTERN = /^[1-9]\d*(?:ms|s|m|h)(?:\d+(?:ms|s|m|h))*$/u; + +// prettier-ignore +export const PROFILE_SERVICES = Object.freeze({ personal: Object.freeze(['agent-teams-personal', 'agent-teams-lifecycle-trust-init', 'caddy-personal']), keycloak: Object.freeze(['agent-teams-keycloak', 'agent-teams-keycloak-secret-init', 'agent-teams-lifecycle-trust-init', 'caddy', 'keycloak', 'keycloak-postgres', 'keycloak-volume-init']) }); +// prettier-ignore +export const LONG_RUNNING_SERVICES = new Set(['agent-teams-personal', 'agent-teams-keycloak', 'caddy', 'caddy-personal', 'keycloak', 'keycloak-postgres']); +// prettier-ignore +export const EXPECTED_USERS = Object.freeze({ 'agent-teams-personal': '1000:1000', 'agent-teams-keycloak': '1000:1000', 'agent-teams-keycloak-secret-init': '1000:1000', 'agent-teams-lifecycle-trust-init': '1000:1000', caddy: '1000:1000', 'caddy-personal': '1000:1000', keycloak: '1000:0', 'keycloak-postgres': '70:70', 'keycloak-volume-init': '1000:1000' }); +// prettier-ignore +export const EXPECTED_DEPENDENCIES = Object.freeze({ 'agent-teams-personal': Object.freeze({ 'agent-teams-lifecycle-trust-init': 'service_completed_successfully', 'caddy-personal': 'service_healthy' }), 'agent-teams-keycloak': Object.freeze({ 'agent-teams-keycloak-secret-init': 'service_completed_successfully', 'agent-teams-lifecycle-trust-init': 'service_completed_successfully', caddy: 'service_healthy', keycloak: 'service_healthy', 'keycloak-volume-init': 'service_completed_successfully' }), keycloak: Object.freeze({ caddy: 'service_healthy', 'keycloak-postgres': 'service_healthy', 'keycloak-volume-init': 'service_completed_successfully' }), 'keycloak-volume-init': Object.freeze({ caddy: 'service_healthy' }) }); +// prettier-ignore +export const APP_HEALTHCHECK = Object.freeze(['CMD', 'node', '-e', "fetch('http://127.0.0.1:3456/api/auth/status').then(r=>{if(!r.ok||r.headers.get('x-agent-teams-lifecycle-owner-readiness')!=='ready')process.exit(1)}).catch(()=>process.exit(1))"]); +// prettier-ignore +export const CADDY_HEALTHCHECK = Object.freeze(['CMD-SHELL', 'test -s /data/caddy/pki/authorities/local/root.crt && wget -q --spider http://127.0.0.1:2019/config/']); +export const POSTGRES_HEALTHCHECK = Object.freeze([ + 'CMD-SHELL', + 'pg_isready -U keycloak -d keycloak', +]); +// prettier-ignore +export const EXPECTED_TMPFS = Object.freeze({ 'agent-teams-personal': Object.freeze(['/run/agent-teams:mode=0700,uid=1000,gid=1000', '/tmp:mode=1777']), 'agent-teams-keycloak': Object.freeze(['/run/agent-teams:mode=0700,uid=1000,gid=1000', '/tmp:mode=1777']), caddy: Object.freeze(['/tmp:mode=1777']), 'caddy-personal': Object.freeze(['/tmp:mode=1777']), keycloak: Object.freeze(['/opt/keycloak/data/import:mode=0700,uid=1000,gid=0', '/opt/keycloak/data/tmp:mode=0700,uid=1000,gid=0', '/run/keycloak:mode=0700,uid=1000,gid=0', '/tmp:mode=1777']), 'keycloak-postgres': Object.freeze(['/tmp:mode=1777', '/var/run/postgresql:mode=0775,uid=70,gid=70']), 'keycloak-volume-init': Object.freeze([]), 'agent-teams-keycloak-secret-init': Object.freeze([]), 'agent-teams-lifecycle-trust-init': Object.freeze([]) }); +// prettier-ignore +export const DEFAULT_RENDER_ENVIRONMENT = Object.freeze({ AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP: '{"format":"agent-teams.team-lifecycle-read-bootstrap/v1"}', CLAUDE_DIR: '/tmp/agent-teams-hosted-config-claude', HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR: '/tmp/agent-teams-hosted-config-orchestrator', HOSTED_SECRETS_DIR: '/tmp/agent-teams-hosted-config-secrets', NODE_IMAGE_DIGEST: `sha256:${'a'.repeat(64)}`, KEYCLOAK_IMAGE_DIGEST: `sha256:${'b'.repeat(64)}`, POSTGRES_IMAGE_DIGEST: `sha256:${'c'.repeat(64)}`, CADDY_IMAGE_DIGEST: `sha256:${'d'.repeat(64)}` }); + +export function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function compareText(left, right) { + return String(left).localeCompare(String(right)); +} + +export function sameValues(actual, expected) { + if (!Array.isArray(actual)) return expected.length === 0; + return ( + JSON.stringify([...actual].sort(compareText)) === + JSON.stringify([...expected].sort(compareText)) + ); +} + +export function sameSequence(actual, expected) { + return Array.isArray(actual) && JSON.stringify(actual) === JSON.stringify(expected); +} + +export function isPositive(value) { + const number = typeof value === 'number' ? value : Number(value); + return Number.isFinite(number) && number > 0; +} + +export function isPositiveDuration(value) { + return typeof value === 'string' && POSITIVE_DURATION_PATTERN.test(value); +} + +export function resultFor(checkedServices, checkedProfiles, violations) { + const uniqueViolations = [...new Set(violations)].sort(compareText); + return { + format: RESULT_FORMAT, + status: uniqueViolations.length === 0 ? 'passed' : 'failed', + summary: { + checkedProfiles, + checkedServices, + violations: uniqueViolations.length, + }, + violations: uniqueViolations, + }; +} diff --git a/scripts/ci/verify-hosted-container-hardening.d.mts b/scripts/ci/verify-hosted-container-hardening.d.mts new file mode 100644 index 0000000000..923f209ae4 --- /dev/null +++ b/scripts/ci/verify-hosted-container-hardening.d.mts @@ -0,0 +1,120 @@ +export type HostedContainerHardeningProfile = 'personal' | 'keycloak'; + +export interface HostedContainerHardeningResult { + format: 'hosted-container-hardening-verifier-result/v2'; + status: 'passed' | 'failed'; + summary: { + checkedProfiles: number; + checkedServices: number; + violations: number; + }; + violations: string[]; +} + +export interface HostedContainerHardeningMount { + type?: string; + source?: string; + target?: string; + read_only?: boolean; + [property: string]: unknown; +} + +export interface HostedContainerHardeningBuild { + context?: string; + dockerfile?: string; + target?: string; + args: Record; + [property: string]: unknown; +} + +export interface HostedContainerHardeningNetwork { + internal?: boolean; + [property: string]: unknown; +} + +export interface HostedContainerHardeningNetworkAttachment { + [property: string]: unknown; +} + +export interface HostedContainerHardeningPort { + target?: number; + published?: string | number; + protocol?: string; + [property: string]: unknown; +} + +export interface HostedContainerHardeningService { + user?: string; + read_only?: boolean; + cap_add?: string[]; + cap_drop?: string[]; + security_opt?: string[]; + pids_limit?: number | string; + cpus?: number | string; + mem_limit?: string; + stop_grace_period?: string; + restart?: string; + volumes?: HostedContainerHardeningMount[]; + healthcheck?: Record; + depends_on?: Record>; + networks?: Record; + build?: HostedContainerHardeningBuild; + image?: string; + privileged?: boolean; + devices?: Array<{ path?: string; [property: string]: unknown }>; + volumes_from?: string[]; + pid?: string; + ipc?: string; + command?: string | string[]; + environment?: Record; + secrets?: Array>; + entrypoint?: string | string[]; + tmpfs?: string[]; + ports?: HostedContainerHardeningPort[]; + network_mode?: string; + [property: string]: unknown; +} + +export interface HostedContainerHardeningCompose { + services: Record; + networks: Record; + volumes?: Record>; + secrets?: Record>; + [property: string]: unknown; +} + +export interface HostedContainerHardeningComposeInput { + services?: Record; + [property: string]: unknown; +} + +export interface HostedContainerHardeningOptions { + root?: string; + profile?: string; + dockerBinary?: string; + environment?: NodeJS.ProcessEnv; + dockerfile?: string; + volumeInitializer?: string; + renderedCompose?: string | HostedContainerHardeningCompose | HostedContainerHardeningComposeInput; + renderedComposes?: Partial< + Record< + HostedContainerHardeningProfile, + string | HostedContainerHardeningCompose | HostedContainerHardeningComposeInput + > + >; +} + +export interface RenderHostedContainerHardeningComposeOptions { + profile: HostedContainerHardeningProfile; + root?: string; + dockerBinary?: string; + environment?: NodeJS.ProcessEnv; +} + +export function verifyHostedContainerHardening( + options?: HostedContainerHardeningOptions +): HostedContainerHardeningResult; + +export function renderHostedContainerHardeningCompose( + options: RenderHostedContainerHardeningComposeOptions +): HostedContainerHardeningCompose; diff --git a/scripts/ci/verify-hosted-container-hardening.mjs b/scripts/ci/verify-hosted-container-hardening.mjs new file mode 100644 index 0000000000..94e49adcbb --- /dev/null +++ b/scripts/ci/verify-hosted-container-hardening.mjs @@ -0,0 +1,792 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + APP_HEALTHCHECK, + CADDY_HEALTHCHECK, + DIGEST_PATTERN, + EXPECTED_DEPENDENCIES, + EXPECTED_TMPFS, + EXPECTED_USERS, + LONG_RUNNING_SERVICES, + POSTGRES_HEALTHCHECK, + PROFILES, + PROFILE_SERVICES, + isObject, + isPositive, + isPositiveDuration, + resultFor, + sameSequence, + sameValues, +} from './verify-hosted-container-hardening-contracts.mjs'; +import { + parseRenderedHostedCompose, + renderHostedContainerHardeningCompose, +} from './verify-hosted-container-compose-rendering.mjs'; +import * as lifecycleOwner from './verify-hosted-lifecycle-owner-container-contract.mjs'; +import { verifyHostedNoTerminalDockerfile } from './verify-hosted-no-terminal-artifact.mjs'; + +export { renderHostedContainerHardeningCompose }; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = resolve(SCRIPT_DIRECTORY, '../..'); + +/** + * Verifies the effective Docker Compose model, not indentation or YAML spelling. + * With no injected rendered configuration, this uses only `docker compose config`. + * It never starts, builds, pulls, or otherwise runs a container. + */ +export function verifyHostedContainerHardening(options = {}) { + const sources = loadSources(options); + const violations = [...sources.violations]; + const profiles = requestedProfiles(options, violations); + let checkedServices = 0; + + for (const profile of profiles) { + const rendered = loadRenderedCompose(profile, options, sources.root, violations); + if (!rendered) continue; + checkedServices += verifyRenderedProfile(profile, rendered, sources.root, violations); + } + + if (sources.dockerfile) verifyDockerfile(sources.dockerfile, violations); + if (sources.volumeInitializer) verifyVolumeInitializer(sources.volumeInitializer, violations); + + return resultFor(checkedServices, profiles.length, violations); +} + +function loadSources(options) { + const root = resolve(options.root ?? REPOSITORY_ROOT); + const sources = { root, violations: [] }; + for (const [key, relativePath] of [ + ['dockerfile', 'docker/Dockerfile'], + ['volumeInitializer', 'docker/hosted-volume-init.sh'], + ]) { + if (typeof options[key] === 'string') { + sources[key] = options[key]; + continue; + } + try { + sources[key] = readFileSync(join(root, relativePath), 'utf8'); + } catch { + sources.violations.push(`input_unreadable:${key}`); + } + } + return sources; +} + +function requestedProfiles(options, violations) { + if (options.profile === undefined) return PROFILES; + if (PROFILES.includes(options.profile)) return [options.profile]; + violations.push('profile_invalid'); + return []; +} + +function loadRenderedCompose(profile, options, root, violations) { + try { + const injected = options.renderedComposes?.[profile] ?? options.renderedCompose; + if (injected !== undefined) return parseRenderedHostedCompose(injected); + return renderHostedContainerHardeningCompose({ + profile, + root, + dockerBinary: options.dockerBinary, + environment: options.environment, + }); + } catch { + violations.push(`compose_render_failed:${profile}`); + return null; + } +} + +function verifyRenderedProfile(profile, rendered, root, violations) { + const services = rendered.services; + const expectedNames = PROFILE_SERVICES[profile]; + verifyExactNames(Object.keys(services), expectedNames, `profile:${profile}:service`, violations); + + for (const serviceName of expectedNames) { + const service = services[serviceName]; + if (!isObject(service)) continue; + verifyServiceProfile(serviceName, profile, service, violations); + verifyServiceCommonHardening(serviceName, service, violations); + verifyDependencies(serviceName, service, violations); + verifyServiceMounts(serviceName, service, violations); + verifyServiceSecrets(serviceName, service, violations); + verifyServiceNetworks(serviceName, service, violations); + verifyImageContract(serviceName, service, root, violations); + } + + verifyTopLevelNetworks(profile, rendered, violations); + verifyTopLevelVolumes(profile, rendered, violations); + verifyApplicationDataContract(profile, rendered, violations); + lifecycleOwner.verifyHostedLifecycleOwnerContainerContract(profile, rendered, violations); + verifyTopLevelSecrets(profile, rendered, violations); + verifyPortPolicy(services, violations); + verifyHealthContracts(services, violations); + verifyInitializerCommands(services, violations); + verifyOidcSecretHandoff(services, violations); + verifyKeycloakRuntimeContract(services, violations); + + return expectedNames.length; +} + +function verifyServiceProfile(serviceName, profile, service, violations) { + const expectedProfiles = serviceName === 'agent-teams-lifecycle-trust-init' ? [] : [profile]; + if (!sameValues(service.profiles, expectedProfiles)) + violations.push(`service:${serviceName}:profile_invalid`); +} + +function verifyServiceCommonHardening(serviceName, service, violations) { + if (service.user !== EXPECTED_USERS[serviceName]) { + violations.push(`service:${serviceName}:user_invalid`); + } + if (service.init !== true) violations.push(`service:${serviceName}:init_required`); + if (service.read_only !== true) violations.push(`service:${serviceName}:read_only_required`); + if (!sameValues(service.cap_drop, ['ALL'])) { + violations.push(`service:${serviceName}:cap_drop_all_required`); + } + if (!sameValues(service.security_opt, ['no-new-privileges:true'])) { + violations.push(`service:${serviceName}:security_opt_invalid`); + } + if (!isPositive(service.pids_limit)) { + violations.push(`service:${serviceName}:pids_limit_required`); + } + if (!isPositive(service.cpus)) violations.push(`service:${serviceName}:cpu_limit_required`); + if (!isPositive(service.mem_limit)) { + violations.push(`service:${serviceName}:memory_limit_required`); + } + if (!isPositiveDuration(service.stop_grace_period)) { + violations.push(`service:${serviceName}:stop_grace_period_required`); + } + + const caddy = serviceName === 'caddy' || serviceName === 'caddy-personal'; + if (!sameValues(service.cap_add, caddy ? ['NET_BIND_SERVICE'] : [])) { + violations.push(`service:${serviceName}:capability_contract_invalid`); + } + if (service.privileged === true) violations.push(`service:${serviceName}:privileged_forbidden`); + if ( + service.devices !== undefined && + (!Array.isArray(service.devices) || service.devices.length > 0) + ) { + violations.push(`service:${serviceName}:devices_forbidden`); + } + if ( + service.volumes_from !== undefined && + (!Array.isArray(service.volumes_from) || service.volumes_from.length > 0) + ) { + violations.push(`service:${serviceName}:volumes_from_forbidden`); + } + if (service.pid === 'host') { + violations.push(`service:${serviceName}:host_pid_namespace_forbidden`); + } + if (service.ipc === 'host') { + violations.push(`service:${serviceName}:host_ipc_namespace_forbidden`); + } + + const expectedRestart = LONG_RUNNING_SERVICES.has(serviceName) ? 'unless-stopped' : 'no'; + if (service.restart !== expectedRestart) { + violations.push(`service:${serviceName}:restart_policy_invalid`); + } + + if (!sameValues(service.tmpfs, EXPECTED_TMPFS[serviceName])) { + violations.push(`service:${serviceName}:tmpfs_contract_invalid`); + } +} + +function verifyDependencies(serviceName, service, violations) { + const expected = EXPECTED_DEPENDENCIES[serviceName] ?? {}; + const dependencies = isObject(service.depends_on) ? service.depends_on : {}; + if (!sameValues(Object.keys(dependencies), Object.keys(expected))) { + violations.push(`service:${serviceName}:dependency_contract_invalid`); + return; + } + for (const [dependency, condition] of Object.entries(expected)) { + const contract = dependencies[dependency]; + if (!isObject(contract) || contract.condition !== condition || contract.required !== true) { + violations.push(`service:${serviceName}:dependency_contract_invalid`); + return; + } + } +} + +function verifyServiceMounts(serviceName, service, violations) { + const expected = expectedMounts(serviceName); + const actual = Array.isArray(service.volumes) ? service.volumes : []; + if (actual.length !== expected.length) { + violations.push(`service:${serviceName}:mount_contract_invalid`); + return; + } + + for (const contract of expected) { + const mount = actual.find((candidate) => candidate?.target === contract.target); + if (!mount || !mountMatches(mount, contract)) { + violations.push(`service:${serviceName}:mount_contract_invalid`); + return; + } + } +} + +function expectedMounts(serviceName) { + const claude = { type: 'bind', target: '/data/.claude', readOnly: true }; + const state = { + type: 'volume', + source: 'agent-teams-data', + target: '/data/.agent-teams', + copyUpRequired: true, + }; + const applicationData = { + type: 'volume', + source: 'agent-teams-application-data', + target: '/data/.agent-teams/data', + copyUpRequired: true, + }; + const caddyTrust = { + type: 'volume', + source: 'agent-teams-keycloak-trust', + target: '/caddy-trust', + readOnly: true, + }; + const lifecycleTrust = { + type: 'volume', + source: 'agent-teams-lifecycle-trust', + target: '/run/agent-teams-lifecycle-trust', + readOnly: true, + }; + switch (serviceName) { + case 'agent-teams-personal': + return [ + claude, + state, + applicationData, + lifecycleTrust, + ...lifecycleOwner.LIFECYCLE_OWNER_MOUNTS, + ]; + case 'agent-teams-keycloak': + return [ + claude, + state, + applicationData, + caddyTrust, + { + type: 'volume', + source: 'agent-teams-keycloak-secret', + target: '/run/agent-teams-oidc', + readOnly: true, + }, + lifecycleTrust, + ...lifecycleOwner.LIFECYCLE_OWNER_MOUNTS, + ]; + case 'keycloak': + return [ + { + type: 'bind', + target: '/opt/keycloak/realm-template/realm-agent-teams.json', + readOnly: true, + sourceSuffix: '/docker/keycloak/realm-agent-teams.json', + }, + caddyTrust, + ]; + case 'keycloak-postgres': + return [ + { type: 'volume', source: 'keycloak-postgres-data', target: '/var/lib/postgresql/data' }, + ]; + case 'caddy': + return caddyMounts('./caddy/Caddyfile', 'caddy-data', 'caddy-config'); + case 'caddy-personal': + return caddyMounts( + './caddy/Caddyfile.personal', + 'caddy-personal-data', + 'caddy-personal-config' + ); + case 'keycloak-volume-init': + return [ + { type: 'volume', source: 'caddy-data', target: '/caddy-data', readOnly: true }, + { type: 'volume', source: 'agent-teams-keycloak-trust', target: '/caddy-trust' }, + ]; + case 'agent-teams-keycloak-secret-init': + return [ + { + type: 'volume', + source: 'agent-teams-keycloak-secret', + target: '/run/agent-teams-oidc', + }, + ]; + case 'agent-teams-lifecycle-trust-init': + return [ + { + type: 'volume', + source: 'agent-teams-lifecycle-trust', + target: '/run/agent-teams-lifecycle-trust', + copyUpRequired: true, + }, + ]; + default: + return []; + } +} + +function caddyMounts(caddyfile, dataVolume, configVolume) { + return [ + { + type: 'bind', + target: '/etc/caddy/Caddyfile', + readOnly: true, + sourceSuffix: `/docker/${caddyfile.slice(2)}`, + }, + { type: 'volume', source: dataVolume, target: '/data' }, + { type: 'volume', source: configVolume, target: '/config' }, + ]; +} + +function mountMatches(mount, contract) { + if (mount.type !== contract.type || mount.target !== contract.target) return false; + if ((mount.read_only === true) !== (contract.readOnly === true)) return false; + if (contract.source && mount.source !== contract.source) return false; + if (contract.sourceSuffix && !String(mount.source).endsWith(contract.sourceSuffix)) return false; + if (contract.absoluteSource === true && !isAbsolute(String(mount.source))) return false; + if (contract.createHostPath === false && mount.bind?.create_host_path !== false) return false; + if (contract.copyUpRequired === true && mount.volume?.nocopy === true) return false; + return true; +} + +function verifyServiceSecrets(serviceName, service, violations) { + const expected = expectedSecrets(serviceName); + const actual = Array.isArray(service.secrets) ? service.secrets : []; + if (actual.length !== expected.length) { + violations.push(`service:${serviceName}:secret_contract_invalid`); + return; + } + for (const secret of expected) { + if ( + !actual.some( + (candidate) => candidate?.source === secret.source && candidate?.target === secret.target + ) + ) { + violations.push(`service:${serviceName}:secret_contract_invalid`); + return; + } + } +} + +function expectedSecrets(serviceName) { + if (serviceName === 'agent-teams-lifecycle-trust-init') { + return lifecycleOwner.LIFECYCLE_TRUST_SECRETS; + } + if (serviceName === 'keycloak') { + return [ + { source: 'oidc_client_secret', target: '/run/secrets/oidc_client_secret' }, + { source: 'keycloak_admin_password', target: '/run/secrets/keycloak_admin_password' }, + { source: 'keycloak_database_password', target: '/run/secrets/keycloak_database_password' }, + ]; + } + if (serviceName === 'keycloak-postgres') { + return [ + { source: 'keycloak_database_password', target: '/run/secrets/keycloak_database_password' }, + ]; + } + if (serviceName === 'agent-teams-keycloak-secret-init') { + return [{ source: 'oidc_client_secret', target: '/run/secrets/oidc_client_secret' }]; + } + return []; +} + +function verifyServiceNetworks(serviceName, service, violations) { + const expected = expectedNetworks(serviceName); + const actual = isObject(service.networks) ? Object.keys(service.networks) : []; + if (!sameValues(actual, expected)) { + violations.push(`service:${serviceName}:network_contract_invalid`); + return; + } + + if (expected.length === 0) { + if (service.network_mode !== 'none') { + violations.push(`service:${serviceName}:network_contract_invalid`); + } + return; + } + if (service.network_mode !== undefined) { + violations.push(`service:${serviceName}:network_contract_invalid`); + } +} + +function expectedNetworks(serviceName) { + if (serviceName === 'keycloak') return ['hosted', 'keycloak-backend']; + if (serviceName === 'keycloak-postgres') return ['keycloak-backend']; + if ( + serviceName === 'agent-teams-personal' || + serviceName === 'agent-teams-keycloak' || + serviceName === 'caddy' || + serviceName === 'caddy-personal' + ) { + return ['hosted']; + } + return []; +} + +function verifyImageContract(serviceName, service, root, violations) { + if ( + serviceName === 'agent-teams-personal' || + serviceName === 'agent-teams-keycloak' || + serviceName === 'keycloak-volume-init' || + serviceName === 'agent-teams-keycloak-secret-init' || + serviceName === 'agent-teams-lifecycle-trust-init' + ) { + if ( + !buildContractMatches(service, root, undefined, [ + 'NODE_IMAGE_DIGEST', + 'KEYCLOAK_IMAGE_DIGEST', + ]) + ) { + violations.push(`service:${serviceName}:image_contract_invalid`); + } + return; + } + if (serviceName === 'keycloak') { + if (!buildContractMatches(service, root, 'keycloak-runtime', ['KEYCLOAK_IMAGE_DIGEST'])) { + violations.push('service:keycloak:image_contract_invalid'); + } + return; + } + + const expectedImage = + serviceName === 'keycloak-postgres' + ? /^postgres:17\.5-alpine@sha256:[a-f0-9]{64}$/iu + : /^caddy:2\.10\.0-alpine@sha256:[a-f0-9]{64}$/iu; + if (typeof service.image !== 'string' || !expectedImage.test(service.image)) { + violations.push(`service:${serviceName}:image_contract_invalid`); + } +} + +function buildContractMatches(service, root, target, requiredArguments) { + if (!isObject(service.build) || service.image !== undefined) return false; + if (service.build.dockerfile !== 'docker/Dockerfile' || service.build.target !== target) + return false; + if (service.build.context !== root) return false; + if (!isObject(service.build.args)) return false; + if (!sameValues(Object.keys(service.build.args), requiredArguments)) return false; + return requiredArguments.every((argument) => + DIGEST_PATTERN.test(service.build.args[argument] ?? '') + ); +} + +function verifyTopLevelNetworks(profile, rendered, violations) { + const networks = isObject(rendered.networks) ? rendered.networks : {}; + const expected = profile === 'keycloak' ? ['hosted', 'keycloak-backend'] : ['hosted']; + for (const networkName of expected) { + const network = networks[networkName]; + if (!isObject(network) || network.internal !== true || !hasValidSubnet(network)) { + violations.push(`network:${networkName}:contract_invalid`); + } + } + if (profile === 'keycloak') { + const backendMembers = Object.entries(rendered.services) + .filter(([, service]) => isObject(service.networks) && 'keycloak-backend' in service.networks) + .map(([serviceName]) => serviceName); + if (!sameValues(backendMembers, ['keycloak', 'keycloak-postgres'])) { + violations.push('network:keycloak-backend:postgres_isolation_invalid'); + } + } +} + +function hasValidSubnet(network) { + const subnet = network.ipam?.config?.[0]?.subnet; + return typeof subnet === 'string' && /^\d{1,3}(?:\.\d{1,3}){3}\/[1-9]\d?$/u.test(subnet); +} + +function verifyTopLevelVolumes(profile, rendered, violations) { + const volumes = isObject(rendered.volumes) ? rendered.volumes : {}; + for (const volumeName of ['agent-teams-data', 'agent-teams-application-data']) { + if (!isObject(volumes[volumeName])) { + violations.push(`volume:${volumeName}:missing`); + } + } + if (profile !== 'keycloak') return; + for (const name of ['agent-teams-keycloak-secret', 'agent-teams-keycloak-trust']) { + const volume = volumes[name]; + if ( + !isObject(volume) || + volume.driver_opts !== undefined || + (volume.driver !== undefined && volume.driver !== 'local') + ) { + violations.push(`volume:${name}:persistence_contract_invalid`); + } + } + for (const volumeName of ['keycloak-postgres-data', 'caddy-data', 'caddy-config']) { + if (!isObject(volumes[volumeName])) { + violations.push(`volume:${volumeName}:missing`); + } + } +} + +function verifyApplicationDataContract(profile, rendered, violations) { + const application = rendered.services?.[`agent-teams-${profile}`]; + const volumes = rendered.volumes; + const projectName = rendered.name; + if (!isObject(application)) return; + + if ( + application.environment?.AUTH_DATA_DIR !== '/data/.agent-teams/data' || + (profile === 'keycloak' && + application.environment?.AUTH_IDENTITY_KEY_FILE !== + '/data/.agent-teams/data/hosted-auth-secrets/identity.key') + ) { + violations.push(`service:agent-teams-${profile}:application_data_path_invalid`); + } + + if ( + !isObject(volumes) || + typeof projectName !== 'string' || + volumes['agent-teams-data']?.name !== `${projectName}_agent-teams-instance-lock` || + volumes['agent-teams-application-data']?.name !== `${projectName}_agent-teams-data` + ) { + violations.push(`profile:${profile}:application_data_volume_identity_invalid`); + } +} + +function verifyTopLevelSecrets(profile, rendered, violations) { + const secrets = isObject(rendered.secrets) ? rendered.secrets : {}; + if (profile !== 'keycloak') return; + for (const name of [ + 'oidc_client_secret', + 'keycloak_admin_password', + 'keycloak_database_password', + ]) { + if (!isObject(secrets[name]) || !String(secrets[name].file).endsWith(`/${name}`)) { + violations.push(`secret:${name}:file_contract_invalid`); + } + } +} + +function verifyPortPolicy(services, violations) { + for (const [serviceName, service] of Object.entries(services)) { + const ports = Array.isArray(service.ports) ? service.ports : []; + const caddy = serviceName === 'caddy' || serviceName === 'caddy-personal'; + if (!caddy && ports.length > 0) { + violations.push(`service:${serviceName}:published_port_forbidden`); + continue; + } + if (caddy && !hasCaddyPortContract(ports)) { + violations.push(`service:${serviceName}:published_port_contract_invalid`); + } + } +} + +function hasCaddyPortContract(ports) { + if (ports.length !== 2) return false; + const redirect = ports.find((port) => port?.target === 80 && port?.published === '80'); + const https = ports.find( + (port) => isPositive(port?.target) && String(port?.target) === String(port?.published) + ); + return Boolean(redirect && https && redirect !== https); +} + +function verifyHealthContracts(services, violations) { + for (const [serviceName, service] of Object.entries(services)) { + if (!LONG_RUNNING_SERVICES.has(serviceName)) { + if (service.healthcheck !== undefined) { + violations.push(`service:${serviceName}:initializer_healthcheck_forbidden`); + } + continue; + } + const healthcheck = service.healthcheck; + if (!isObject(healthcheck) || !healthContractMatches(serviceName, healthcheck)) { + violations.push(`service:${serviceName}:healthcheck_contract_invalid`); + } + } +} + +function healthContractMatches(serviceName, healthcheck) { + if ( + !isPositiveDuration(healthcheck.interval) || + !isPositiveDuration(healthcheck.timeout) || + !isPositive(healthcheck.retries) || + !isPositiveDuration(healthcheck.start_period) + ) { + return false; + } + + if (serviceName === 'agent-teams-personal' || serviceName === 'agent-teams-keycloak') { + return sameSequence(healthcheck.test, APP_HEALTHCHECK); + } + if (serviceName === 'caddy' || serviceName === 'caddy-personal') { + return sameSequence(healthcheck.test, CADDY_HEALTHCHECK); + } + if (serviceName === 'keycloak-postgres') { + return sameSequence(healthcheck.test, POSTGRES_HEALTHCHECK); + } + if (serviceName === 'keycloak') { + const test = healthcheck.test; + return ( + Array.isArray(test) && + test[0] === 'CMD-SHELL' && + typeof test[1] === 'string' && + test[1].includes('/health/ready') && + test[1].includes('127.0.0.1/9000') && + test[1].includes("grep -q '200 OK'") + ); + } + return false; +} + +function verifyInitializerCommands(services, violations) { + const expected = { + 'keycloak-volume-init': ['/usr/local/bin/hosted-volume-init', 'caddy-trust'], + 'agent-teams-keycloak-secret-init': ['/usr/local/bin/hosted-volume-init', 'oidc-client-secret'], + 'agent-teams-lifecycle-trust-init': [ + '/usr/local/bin/hosted-volume-init', + 'lifecycle-trust-anchor', + ], + }; + for (const [serviceName, command] of Object.entries(expected)) { + if (!services[serviceName]) continue; + if (!sameSequence(services[serviceName]?.command, command)) { + violations.push(`service:${serviceName}:initializer_command_invalid`); + } + } +} + +function verifyOidcSecretHandoff(services, violations) { + const application = services['agent-teams-keycloak']; + const initializer = services['agent-teams-keycloak-secret-init']; + if (!application || !initializer) return; + + if ( + services.keycloak && + [application, services.keycloak].some( + (service) => + Array.isArray(service.volumes) && + service.volumes.some((mount) => mount?.source === 'caddy-data') + ) + ) { + violations.push('caddy_private_volume_exposure'); + } + + if ( + application.environment?.OIDC_CLIENT_SECRET_FILE !== '/run/agent-teams-oidc/oidc-client-secret' + ) { + violations.push('service:agent-teams-keycloak:oidc_secret_path_invalid'); + } + if (!lifecycleOwner.hasOnlyLifecycleTrustSecret(application.secrets)) { + violations.push('service:agent-teams-keycloak:direct_secret_mount_forbidden'); + } + + const applicationSecretMount = Array.isArray(application.volumes) + ? application.volumes.find((mount) => mount?.target === '/run/agent-teams-oidc') + : undefined; + const initializerSecretMount = Array.isArray(initializer.volumes) + ? initializer.volumes.find((mount) => mount?.target === '/run/agent-teams-oidc') + : undefined; + if ( + !mountMatches(applicationSecretMount ?? {}, { + type: 'volume', + source: 'agent-teams-keycloak-secret', + target: '/run/agent-teams-oidc', + readOnly: true, + }) || + !mountMatches(initializerSecretMount ?? {}, { + type: 'volume', + source: 'agent-teams-keycloak-secret', + target: '/run/agent-teams-oidc', + }) + ) { + violations.push('oidc_secret_handoff_mount_contract_invalid'); + } +} + +function verifyKeycloakRuntimeContract(services, violations) { + const keycloak = services.keycloak; + if (!keycloak) return; + const command = Array.isArray(keycloak.command) ? keycloak.command[0] : undefined; + if ( + !sameSequence(keycloak.entrypoint, ['/bin/bash', '-euc']) || + typeof command !== 'string' || + !command.includes('start --optimized --import-realm') || + keycloak.environment?.KC_DB !== 'postgres' || + keycloak.environment?.KC_HEALTH_ENABLED !== 'true' + ) { + violations.push('service:keycloak:optimized_runtime_contract_invalid'); + } +} + +function verifyDockerfile(dockerfile, violations) { + // prettier-ignore + const requiredTexts = ['ARG KEYCLOAK_VERSION=26.3.2', 'ARG KEYCLOAK_IMAGE_DIGEST', 'FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}@${KEYCLOAK_IMAGE_DIGEST} AS keycloak-build', 'RUN /opt/keycloak/bin/kc.sh build --db=postgres --health-enabled=true', 'FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}@${KEYCLOAK_IMAGE_DIGEST} AS keycloak-runtime', 'COPY --from=keycloak-build /opt/keycloak/ /opt/keycloak/', 'touch /caddy-trust/root.crt', 'USER 1000', 'FROM base\n', 'COPY docker/hosted-volume-init.sh /usr/local/bin/hosted-volume-init', '/var/lib/agent-teams/lifecycle-owner-high-water', '/run/agent-teams-lifecycle-trust', 'chown node:node /run/agent-teams /run/agent-teams-oidc /run/agent-teams-lifecycle-trust /caddy-trust /var/lib/agent-teams/lifecycle-owner-high-water', 'chmod 700 /run/agent-teams /run/agent-teams-oidc /run/agent-teams-lifecycle-trust /caddy-trust /var/lib/agent-teams/lifecycle-owner-high-water', 'install -o node -g node -m 0600 /dev/null /run/agent-teams-oidc/oidc-client-secret', 'install -o node -g node -m 0600 /dev/null /run/agent-teams-lifecycle-trust/trust-anchor', 'install -o node -g node -m 0600 /dev/null /run/agent-teams-lifecycle-trust/release-owner-pin.json', 'install -o node -g node -m 0600 /dev/null /caddy-trust/root.crt', 'chmod 0555 /usr/local/bin/hosted-volume-init', '\nUSER node\n']; + for (const requiredText of requiredTexts) { + if (!dockerfile.includes(requiredText)) { + violations.push('dockerfile_hardening_contract_invalid'); + break; + } + } + if (dockerfile.includes('gosu')) violations.push('dockerfile_root_dropper_forbidden'); + + const noTerminal = verifyHostedNoTerminalDockerfile(dockerfile); + for (const violation of noTerminal.violations) { + violations.push(`hosted_terminal_guard:${violation}`); + } +} + +function verifyVolumeInitializer(initializer, violations) { + // prettier-ignore + const requiredTexts = ['set -eu', 'caddy-trust)', '[ "$(id -u)" -ne 1000 ]', "readonly caddy_root='/caddy-data/caddy/pki/authorities/local'", "readonly trust_directory='/caddy-trust'", 'readonly trust_certificate="$trust_directory/root.crt"', '1000:1000:600|1000:1000:444', 'find "$trust_directory" -mindepth 1 -maxdepth 1 ! -name root.crt -print -quit', 'chmod 0600 "$trust_certificate"', 'install -m 0444 "$root_certificate" "$trust_certificate"', "stat -c '%u:%g:%a' \"$trust_certificate\")\" != '1000:1000:444'", 'oidc-client-secret)', "readonly runtime_directory='/run/agent-teams-oidc'", 'OIDC runtime secret placeholder is unavailable', "stat -c '%u:%g'", 'chmod 0600 "$runtime_secret"', 'install -m 0400 "$source_secret" "$runtime_secret"', 'lifecycle-trust-anchor)', "readonly source_anchor='/run/secrets/lifecycle_orchestrator_trust_anchor'", "readonly source_release_pin='/run/secrets/lifecycle_owner_release_pin'", "readonly runtime_directory='/run/agent-teams-lifecycle-trust'", '1000:1000:600|1000:1000:400', '! -name release-owner-pin.json', 'case "$source_size" in', 'tail -c 1 "$source_anchor"', "grep -Eq '^[0-9a-f]{64}$'", '64 lowercase hexadecimal characters', 'release_pin_size', 'install -m 0400 "$source_anchor" "$runtime_anchor"', 'install -m 0400 "$source_release_pin" "$runtime_release_pin"', "stat -c '%u:%g:%a'"]; + for (const requiredText of requiredTexts) { + if (!initializer.includes(requiredText)) { + violations.push('volume_initializer_contract_invalid'); + break; + } + } + for (const forbiddenText of ['root.key', 'gosu', 'chown ', 'chmod 0711']) { + if (initializer.includes(forbiddenText)) { + violations.push('volume_initializer_privilege_contract_invalid'); + break; + } + } +} + +function verifyExactNames(actual, expected, prefix, violations) { + if (!sameValues(actual, expected)) violations.push(`${prefix}_inventory_invalid`); +} + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + const value = argv[index + 1]; + if (argument === '--root' && value) { + options.root = value; + index += 1; + continue; + } + if (argument === '--profile' && value && PROFILES.includes(value)) { + options.profile = value; + index += 1; + continue; + } + if (argument === '--rendered-compose' && value) { + options.renderedCompose = readFileSync(value, 'utf8'); + index += 1; + continue; + } + throw new Error( + 'usage: verify-hosted-container-hardening.mjs [--root ] [--profile ] [--rendered-compose ]' + ); + } + if (options.renderedCompose !== undefined && options.profile === undefined) { + throw new Error('--rendered-compose requires --profile'); + } + return options; +} + +function main() { + let result; + try { + result = verifyHostedContainerHardening(parseArguments(process.argv.slice(2))); + } catch { + result = resultFor(0, 0, ['invalid_arguments']); + } + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exitCode = result.status === 'passed' ? 0 : 1; +} + +const entryPointUrl = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null; +if (entryPointUrl === import.meta.url) main(); diff --git a/scripts/ci/verify-hosted-lifecycle-owner-container-contract.mjs b/scripts/ci/verify-hosted-lifecycle-owner-container-contract.mjs new file mode 100644 index 0000000000..0ce77cc6c7 --- /dev/null +++ b/scripts/ci/verify-hosted-lifecycle-owner-container-contract.mjs @@ -0,0 +1,278 @@ +import { isAbsolute, resolve } from 'node:path'; + +import { isObject } from './verify-hosted-container-hardening-contracts.mjs'; + +const BROAD_LIFECYCLE_RUN_DIRECTORIES = new Set([ + '/', + '/bin', + '/boot', + '/data', + '/dev', + '/etc', + '/home', + '/lib', + '/lib64', + '/media', + '/mnt', + '/opt', + '/private', + '/private/tmp', + '/proc', + '/root', + '/run', + '/sbin', + '/srv', + '/sys', + '/tmp', + '/Users', + '/usr', + '/var', + '/var/run', + '/Volumes', + '/workspace', + '/workspaces', +]); + +export const LIFECYCLE_OWNER_MOUNTS = Object.freeze([ + Object.freeze({ + type: 'bind', + target: '/run/agent-teams-orchestrator', + readOnly: true, + absoluteSource: true, + createHostPath: false, + }), + Object.freeze({ + type: 'volume', + source: 'agent-teams-lifecycle-owner-high-water', + target: '/var/lib/agent-teams/lifecycle-owner-high-water', + readOnly: false, + copyUpRequired: true, + }), +]); + +export const LIFECYCLE_TRUST_SECRET = Object.freeze({ + source: 'lifecycle_orchestrator_trust_anchor', + target: '/run/secrets/lifecycle_orchestrator_trust_anchor', +}); +export const LIFECYCLE_RELEASE_PIN_SECRET = Object.freeze({ + source: 'lifecycle_owner_release_pin', + target: '/run/secrets/lifecycle_owner_release_pin', +}); +export const LIFECYCLE_TRUST_SECRETS = Object.freeze([ + LIFECYCLE_TRUST_SECRET, + LIFECYCLE_RELEASE_PIN_SECRET, +]); + +const LIFECYCLE_TRUST_VOLUME = 'agent-teams-lifecycle-trust'; +const LIFECYCLE_TRUST_TARGET = '/run/agent-teams-lifecycle-trust'; +const LIFECYCLE_TRUST_INITIALIZER = 'agent-teams-lifecycle-trust-init'; +const LIFECYCLE_OWNER_MANIFEST_PATH = + '/run/agent-teams-orchestrator/lifecycle-owner-admission.json'; +const LEGACY_OWNER_ENVIRONMENT_KEYS = Object.freeze([ + 'HOSTED_LIFECYCLE_OWNER_ARTIFACT_DIGEST', + 'HOSTED_LIFECYCLE_OWNER_IMAGE_REFERENCE', + 'HOSTED_LIFECYCLE_OWNER_PROTOCOL_VERSION', + 'HOSTED_LIFECYCLE_OWNER_AUTHORITY', +]); + +function isPrivateManagedLocalVolume(volume, expectedName) { + return ( + isObject(volume) && + volume.name === expectedName && + (volume.external === undefined || volume.external === false) && + volume.driver_opts === undefined && + (volume.driver === undefined || volume.driver === 'local') + ); +} + +export function hasOnlyLifecycleTrustSecret(secrets) { + return ( + !Array.isArray(secrets) || + secrets.every((secret) => + LIFECYCLE_TRUST_SECRETS.some((expected) => expected.source === secret?.source) + ) + ); +} + +export function isNarrowExternalLifecycleRunDirectory(value) { + return ( + typeof value === 'string' && + isAbsolute(value) && + resolve(value) === value && + !BROAD_LIFECYCLE_RUN_DIRECTORIES.has(value) + ); +} + +export function verifyHostedLifecycleOwnerContainerContract(profile, rendered, violations) { + const applicationName = `agent-teams-${profile}`; + const application = rendered.services?.[applicationName]; + const trustInitializer = rendered.services?.[LIFECYCLE_TRUST_INITIALIZER]; + const volumes = rendered.volumes; + const secrets = rendered.secrets; + const projectName = rendered.name; + if (!isObject(application)) return; + + const lifecycleSocketConsumers = Object.entries(rendered.services ?? {}) + .filter(([, service]) => { + if (!isObject(service)) return false; + const mounts = Array.isArray(service.volumes) ? service.volumes : []; + return ( + service.environment?.HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET !== undefined || + mounts.some((mount) => mount?.target === '/run/agent-teams-orchestrator') + ); + }) + .map(([serviceName]) => serviceName); + if (lifecycleSocketConsumers.length !== 1 || lifecycleSocketConsumers[0] !== applicationName) { + violations.push(`profile:${profile}:external_lifecycle_owner_consumer_inventory_invalid`); + } + if ( + application.container_name !== 'agent-teams-hosted-controller' || + (application.deploy?.replicas !== undefined && application.deploy.replicas !== 1) + ) { + violations.push(`service:${applicationName}:lifecycle_consumer_singleton_contract_invalid`); + } + const lifecycleRunMount = Array.isArray(application.volumes) + ? application.volumes.find((mount) => mount?.target === '/run/agent-teams-orchestrator') + : undefined; + if (!isNarrowExternalLifecycleRunDirectory(lifecycleRunMount?.source)) { + violations.push(`service:${applicationName}:external_lifecycle_owner_source_invalid`); + } + const lifecycleBootstrapConsumers = Object.entries(rendered.services ?? {}) + .filter( + ([, service]) => + isObject(service) && + service.environment?.AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP !== undefined + ) + .map(([serviceName]) => serviceName); + if ( + lifecycleBootstrapConsumers.length !== 1 || + lifecycleBootstrapConsumers[0] !== applicationName + ) { + violations.push(`profile:${profile}:lifecycle_read_bootstrap_consumer_inventory_invalid`); + } + const lifecycleManifestConsumers = Object.entries(rendered.services ?? {}) + .filter( + ([, service]) => + isObject(service) && + service.environment?.HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE !== undefined + ) + .map(([serviceName]) => serviceName); + if ( + lifecycleManifestConsumers.length !== 1 || + lifecycleManifestConsumers[0] !== applicationName + ) { + violations.push(`profile:${profile}:lifecycle_owner_manifest_consumer_inventory_invalid`); + } + const lifecycleReleasePinConsumers = Object.entries(rendered.services ?? {}) + .filter( + ([, service]) => + isObject(service) && + service.environment?.HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE !== undefined + ) + .map(([serviceName]) => serviceName); + if ( + lifecycleReleasePinConsumers.length !== 1 || + lifecycleReleasePinConsumers[0] !== applicationName + ) { + violations.push(`profile:${profile}:lifecycle_owner_release_pin_consumer_inventory_invalid`); + } + + if ( + application.environment?.HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET !== + '/run/agent-teams-orchestrator/orchestrator-lifecycle.sock' || + application.environment?.HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE !== + LIFECYCLE_OWNER_MANIFEST_PATH || + application.environment?.HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR_FILE !== + `${LIFECYCLE_TRUST_TARGET}/trust-anchor` || + application.environment?.HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE !== + `${LIFECYCLE_TRUST_TARGET}/release-owner-pin.json` || + application.environment?.HOSTED_LIFECYCLE_ORCHESTRATOR_HIGH_WATER_ROOT !== + '/var/lib/agent-teams/lifecycle-owner-high-water' + ) { + violations.push(`service:${applicationName}:application_data_path_invalid`); + } + if ( + typeof application.environment?.AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP !== 'string' || + application.environment.AGENT_TEAMS_HOSTED_TEAM_LIFECYCLE_READ_BOOTSTRAP.length === 0 + ) { + violations.push(`service:${applicationName}:lifecycle_read_bootstrap_binding_invalid`); + } + if ( + application.environment?.AGENT_TEAMS_HOSTED_PHASE2_READ_BOOTSTRAP !== undefined || + application.environment?.HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR !== undefined || + LEGACY_OWNER_ENVIRONMENT_KEYS.some( + (environmentKey) => application.environment?.[environmentKey] !== undefined + ) + ) { + violations.push(`service:${applicationName}:lifecycle_owner_environment_fallback_forbidden`); + } + const highWaterVolume = volumes?.['agent-teams-lifecycle-owner-high-water']; + if (!isObject(highWaterVolume)) { + violations.push('volume:agent-teams-lifecycle-owner-high-water:missing'); + } else if ( + typeof projectName !== 'string' || + !isPrivateManagedLocalVolume( + highWaterVolume, + `${projectName}_agent-teams-lifecycle-owner-high-water` + ) + ) { + violations.push('volume:agent-teams-lifecycle-owner-high-water:persistence_contract_invalid'); + } + const lifecycleTrustSourceConsumers = Object.entries(rendered.services ?? {}) + .filter( + ([, service]) => + isObject(service) && + Array.isArray(service.secrets) && + service.secrets.some((secret) => + LIFECYCLE_TRUST_SECRETS.some((expected) => expected.source === secret?.source) + ) + ) + .map(([serviceName]) => serviceName); + if ( + lifecycleTrustSourceConsumers.length !== 1 || + lifecycleTrustSourceConsumers[0] !== LIFECYCLE_TRUST_INITIALIZER + ) { + violations.push(`profile:${profile}:lifecycle_trust_source_consumer_inventory_invalid`); + } + if ( + !isObject(trustInitializer) || + typeof projectName !== 'string' || + !isPrivateManagedLocalVolume( + volumes?.[LIFECYCLE_TRUST_VOLUME], + `${projectName}_${LIFECYCLE_TRUST_VOLUME}` + ) + ) { + violations.push(`profile:${profile}:lifecycle_trust_handoff_invalid`); + } + if ( + isObject(volumes?.[LIFECYCLE_TRUST_VOLUME]) && + (typeof projectName !== 'string' || + !isPrivateManagedLocalVolume( + volumes[LIFECYCLE_TRUST_VOLUME], + `${projectName}_${LIFECYCLE_TRUST_VOLUME}` + )) + ) { + violations.push(`volume:${LIFECYCLE_TRUST_VOLUME}:persistence_contract_invalid`); + } + if ( + typeof projectName !== 'string' || + volumes?.['agent-teams-lifecycle-owner-high-water']?.name !== + `${projectName}_agent-teams-lifecycle-owner-high-water` + ) { + violations.push(`profile:${profile}:application_data_volume_identity_invalid`); + } + if ( + !isObject(secrets?.lifecycle_orchestrator_trust_anchor) || + !String(secrets.lifecycle_orchestrator_trust_anchor.file).endsWith( + '/lifecycle_orchestrator_trust_anchor' + ) + ) { + violations.push('secret:lifecycle_orchestrator_trust_anchor:file_contract_invalid'); + } + if ( + !isObject(secrets?.lifecycle_owner_release_pin) || + !String(secrets.lifecycle_owner_release_pin.file).endsWith('/lifecycle_owner_release_pin.json') + ) { + violations.push('secret:lifecycle_owner_release_pin:file_contract_invalid'); + } +} diff --git a/scripts/ci/verify-hosted-no-terminal-artifact.mjs b/scripts/ci/verify-hosted-no-terminal-artifact.mjs new file mode 100644 index 0000000000..0572fb5305 --- /dev/null +++ b/scripts/ci/verify-hosted-no-terminal-artifact.mjs @@ -0,0 +1,742 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { + existsSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + statSync, +} from 'node:fs'; +import { extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const FORBIDDEN_HOSTED_PACKAGES = Object.freeze([ + 'node-pty', + 'ssh2', + 'cpu-features', + 'terminal-platform-node', +]); + +export const PNPM_INSTALL_METADATA = Object.freeze([ + 'node_modules/.modules.yaml', + 'node_modules/.pnpm-workspace-state-v1.json', + 'node_modules/.pnpm/lock.yaml', +]); + +export const HOSTED_RENDERER_GRAPH_MANIFEST = 'hosted-renderer-graph.json'; + +const FORBIDDEN_PACKAGE_SET = new Set(FORBIDDEN_HOSTED_PACKAGES); +const RUNTIME_SOURCE_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs']); +const FORBIDDEN_SPECIFIER = String.raw`(?:(?:node-pty|ssh2|cpu-features|terminal-platform-node)(?:\/[^'"\s)]+)?|@terminal-platform(?:\/[^'"\s)]+)?)`; +const RUNTIME_LOAD_PATTERNS = Object.freeze([ + new RegExp( + String.raw`\b(?:require(?:\.resolve)?|module\.require|import|[A-Za-z_$][\w$]*require)\s*\(\s*['"](${FORBIDDEN_SPECIFIER})['"]`, + 'g' + ), + new RegExp(String.raw`\b(?:from|import)\s*['"](${FORBIDDEN_SPECIFIER})['"]`, 'g'), +]); + +function normalizedSegments(path) { + return path.split(/[\\/]+/u).filter(Boolean); +} + +function normalizedReference(reference) { + return reference.replaceAll('\\', '/').replace(/^\0/u, '').split('?')[0]; +} + +function referencesPackage(reference, packageName) { + return ( + reference === packageName || + reference.startsWith(`${packageName}/`) || + reference.includes(`/node_modules/${packageName}/`) || + reference.endsWith(`/node_modules/${packageName}`) || + reference.includes(`/node_modules/.pnpm/${packageName}@`) + ); +} + +/** Shared by the build plugin and artifact verifier so graph policy cannot drift. */ +export function classifyForbiddenHostedRendererReference(referenceValue) { + if (typeof referenceValue !== 'string' || referenceValue.length === 0) { + return { kind: 'invalid_renderer_graph_reference', reference: String(referenceValue) }; + } + const reference = normalizedReference(referenceValue); + const pathReference = posix.normalize(`/${reference.replace(/^\/+/, '')}`); + const lowercasePathReference = pathReference.toLowerCase(); + const violation = (kind) => ({ kind, reference: referenceValue }); + + if ( + pathReference.includes('/src/renderer/App.') || + pathReference.includes('/src/renderer/main.') || + reference === '@renderer/App' || + reference === '@renderer/main' + ) { + return violation('desktop_renderer_entry'); + } + if ( + pathReference.includes('/src/renderer/store/') || + pathReference.includes('/src/renderer/store.') || + reference === '@renderer/store' || + reference.startsWith('@renderer/store/') + ) { + return violation('desktop_renderer_store'); + } + if ( + pathReference.includes('/src/renderer/api/') || + pathReference.includes('/src/renderer/api.') || + reference === '@renderer/api' || + reference.startsWith('@renderer/api/') + ) { + return violation('broad_renderer_api'); + } + if ( + (lowercasePathReference.includes('/src/renderer/') && + lowercasePathReference.includes('notification')) || + reference === '@renderer/notifications' || + reference.startsWith('@renderer/notifications/') + ) { + return violation('desktop_renderer_notifications'); + } + if ( + pathReference.includes('/src/features/app-close-coordination/') || + reference === '@features/app-close-coordination' || + reference.startsWith('@features/app-close-coordination/') + ) { + return violation('desktop_app_close'); + } + if ( + (lowercasePathReference.includes('/src/renderer/') && + lowercasePathReference.includes('sentry')) || + referencesPackage(reference, '@sentry/electron') || + referencesPackage(reference, '@sentry/react') + ) { + return violation('desktop_sentry'); + } + if ( + (lowercasePathReference.includes('/src/renderer/') && + (lowercasePathReference.includes('telemetry') || + lowercasePathReference.includes('posthog') || + lowercasePathReference.includes('/analytics/'))) || + referencesPackage(reference, 'posthog-js') + ) { + return violation('desktop_telemetry'); + } + if ( + pathReference.includes('/src/main/') || + pathReference.includes('/src/preload/') || + reference === '@main' || + reference.startsWith('@main/') || + reference === '@preload' || + reference.startsWith('@preload/') || + referencesPackage(reference, 'electron') || + referencesPackage(reference, 'electron-updater') + ) { + return violation('electron_process_boundary'); + } + if ( + pathReference.includes('/src/features/terminal-') || + (lowercasePathReference.includes('/src/renderer/') && + lowercasePathReference.includes('terminal')) || + pathReference.includes('/packages/terminal-') || + pathReference.includes('/vendor/terminal-platform/') || + reference.startsWith('@terminal-platform/') || + reference.includes('/node_modules/@terminal-platform/') || + reference.includes('/node_modules/.pnpm/@terminal-platform+') || + referencesPackage(reference, 'terminal-platform-node') || + referencesPackage(reference, 'node-pty') || + referencesPackage(reference, 'ssh2') || + referencesPackage(reference, 'cpu-features') || + reference.startsWith('@xterm/') || + reference.includes('/node_modules/@xterm/') + ) { + return violation('terminal_ui_or_runtime'); + } + return null; +} + +function forbiddenVirtualStoreEntry(segment) { + return ( + FORBIDDEN_HOSTED_PACKAGES.some((packageName) => segment.startsWith(`${packageName}@`)) || + segment.startsWith('@terminal-platform+') + ); +} + +export function classifyForbiddenArtifactPath(path) { + const segments = normalizedSegments(path); + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + const nextSegment = segments[index + 1]; + + if (segment === 'resources' && nextSegment === 'terminal-platform') { + return 'terminal_platform_resource'; + } + if (segment === '.pnpm' && nextSegment && forbiddenVirtualStoreEntry(nextSegment)) { + return 'pnpm_virtual_store_payload'; + } + if (segment === 'node_modules') { + if (FORBIDDEN_PACKAGE_SET.has(nextSegment)) return 'forbidden_package'; + if (nextSegment === '@terminal-platform') return 'terminal_platform_package_scope'; + } + } + return null; +} + +function readableRuntimeSource(path) { + return RUNTIME_SOURCE_EXTENSIONS.has(extname(path).toLowerCase()); +} + +function hostedRendererRuntimeSource(artifactPath) { + // The standalone server retains optional desktop-service fallbacks but receives no forbidden + // package payload. Browser chunks have the stricter invariant proven by this scan and graph. + return artifactPath.startsWith('out/renderer/') && readableRuntimeSource(artifactPath); +} + +function collectRuntimeLoads(path, artifactPath) { + if (!readableRuntimeSource(path)) return []; + + const source = readFileSync(path, 'utf8'); + const loads = []; + for (const pattern of RUNTIME_LOAD_PATTERNS) { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + const line = source.slice(0, match.index).split('\n').length; + loads.push({ path: artifactPath, line, specifier: match[1] }); + } + } + return loads; +} + +function isWithinArtifactRoot(root, target) { + const targetFromRoot = relative(root, target); + return ( + targetFromRoot === '' || + (!isAbsolute(targetFromRoot) && + targetFromRoot !== '..' && + !targetFromRoot.startsWith(`..${sep}`)) + ); +} + +function artifactPathFromCanonicalTarget(root, target) { + return relative(root, target).split(sep).join('/'); +} + +function scanArtifact(root) { + const forbiddenPaths = []; + const forbiddenLoads = []; + const canonicalRoot = realpathSync(root); + const stack = [canonicalRoot]; + const traversedDirectories = new Set(); + const scannedFiles = new Set(); + + const scanFileOnce = (absolutePath, artifactPath) => { + if (!hostedRendererRuntimeSource(artifactPath)) return; + if (scannedFiles.has(absolutePath)) return; + scannedFiles.add(absolutePath); + forbiddenLoads.push(...collectRuntimeLoads(absolutePath, artifactPath)); + }; + + while (stack.length > 0) { + const directory = stack.pop(); + if (traversedDirectories.has(directory)) continue; + traversedDirectories.add(directory); + const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name) + ); + + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + const artifactPath = artifactPathFromCanonicalTarget(canonicalRoot, absolutePath); + const kind = classifyForbiddenArtifactPath(artifactPath); + if (kind && !entry.isSymbolicLink()) { + forbiddenPaths.push({ kind, path: artifactPath }); + continue; + } + + if (entry.isSymbolicLink()) { + const target = readlinkSync(absolutePath); + if (kind) forbiddenPaths.push({ kind, path: artifactPath }); + + let canonicalTarget; + try { + canonicalTarget = realpathSync(absolutePath); + } catch { + forbiddenPaths.push({ + kind: 'symlink_target_realpath_failed', + path: artifactPath, + target, + }); + continue; + } + if (!isWithinArtifactRoot(canonicalRoot, canonicalTarget)) { + forbiddenPaths.push({ + kind: 'symlink_target_outside_artifact', + path: artifactPath, + target, + }); + continue; + } + + let targetStats; + try { + targetStats = statSync(canonicalTarget); + } catch { + forbiddenPaths.push({ kind: 'symlink_target_stat_failed', path: artifactPath, target }); + continue; + } + + const canonicalTargetPath = artifactPathFromCanonicalTarget(canonicalRoot, canonicalTarget); + const targetKind = classifyForbiddenArtifactPath(canonicalTargetPath); + if (targetKind) { + forbiddenPaths.push({ + kind: 'forbidden_symlink_target', + path: artifactPath, + target: canonicalTargetPath, + }); + } + if (targetStats.isDirectory()) { + stack.push(canonicalTarget); + } else if (targetStats.isFile()) { + scanFileOnce(canonicalTarget, artifactPath); + } else { + forbiddenPaths.push({ + kind: 'symlink_target_unsupported_type', + path: artifactPath, + target, + }); + } + continue; + } + if (entry.isDirectory()) { + stack.push(absolutePath); + continue; + } + if (entry.isFile()) scanFileOnce(absolutePath, artifactPath); + } + } + + forbiddenPaths.sort((left, right) => left.path.localeCompare(right.path)); + forbiddenLoads.sort( + (left, right) => left.path.localeCompare(right.path) || left.line - right.line + ); + return { forbiddenPaths, forbiddenLoads }; +} + +function removePnpmInstallMetadata(root) { + const removed = []; + for (const artifactPath of PNPM_INSTALL_METADATA) { + const absolutePath = join(root, ...artifactPath.split('/')); + if (!existsSync(absolutePath)) continue; + rmSync(absolutePath, { force: true }); + removed.push(artifactPath); + } + return removed; +} + +export function pruneForbiddenHostedPackages(rootPath) { + const root = resolve(rootPath); + const { forbiddenPaths } = scanArtifact(root); + const removed = []; + + for (const violation of forbiddenPaths) { + const absolutePath = resolve(root, violation.path); + if (absolutePath !== root && absolutePath.startsWith(`${root}${sep}`)) { + rmSync(absolutePath, { recursive: true, force: true }); + removed.push(violation.path); + } + } + + removed.push(...removePnpmInstallMetadata(root)); + return [...new Set(removed)].sort(); +} + +function verifyBetterSqlite3(root, requireFunctionalProof) { + const packagePath = join(root, 'node_modules', 'better-sqlite3'); + if (!existsSync(packagePath)) { + return { functional: false, present: false, violation: 'better_sqlite3_missing' }; + } + if (!requireFunctionalProof) return { functional: null, present: true }; + + try { + const requireFromArtifact = createRequire(join(root, 'package.json')); + const Database = requireFromArtifact('better-sqlite3'); + const database = new Database(':memory:'); + const result = database.prepare('SELECT 1 AS value').get(); + database.close(); + if (result?.value !== 1) throw new Error('unexpected SELECT result'); + return { functional: true, present: true }; + } catch (error) { + return { + functional: false, + present: true, + violation: `better_sqlite3_load_failed:${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value, keys) { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function isSortedUniqueStrings(value) { + return ( + Array.isArray(value) && + value.every((entry) => typeof entry === 'string') && + value.every((entry, index) => index === 0 || value[index - 1].localeCompare(entry) < 0) + ); +} + +function isCanonicalGraphModuleId(value) { + if (typeof value !== 'string' || value.length === 0 || value.includes('\\')) return false; + const unprefixed = value.startsWith('\0') ? value.slice(1) : value; + const path = unprefixed.split('?')[0]; + return ( + path.length > 0 && + !isAbsolute(path) && + path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') + ); +} + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function collectRendererJavaScript(rendererRoot) { + if (!existsSync(rendererRoot) || !statSync(rendererRoot).isDirectory()) return []; + const paths = []; + const stack = [rendererRoot]; + while (stack.length > 0) { + const directory = stack.pop(); + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name) + )) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + stack.push(path); + } else if (entry.isFile() && ['.cjs', '.js', '.mjs'].includes(extname(entry.name))) { + paths.push(relative(rendererRoot, path).split(sep).join('/')); + } + } + } + return paths.sort((left, right) => left.localeCompare(right)); +} + +export function verifyHostedRendererGraph(rootPath) { + const root = resolve(rootPath); + const rendererRoot = join(root, 'out', 'renderer'); + const manifestPath = join(rendererRoot, HOSTED_RENDERER_GRAPH_MANIFEST); + const indexPath = join(rendererRoot, 'index.html'); + const violations = []; + + if (!existsSync(indexPath) || !statSync(indexPath).isFile()) { + violations.push('hosted_renderer_index_missing'); + } + if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) { + violations.push('hosted_renderer_graph_manifest_missing'); + return { + ok: false, + manifestPath, + chunkPaths: collectRendererJavaScript(rendererRoot), + violations, + }; + } + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch { + violations.push('hosted_renderer_graph_manifest_invalid_json'); + return { + ok: false, + manifestPath, + chunkPaths: collectRendererJavaScript(rendererRoot), + violations, + }; + } + if ( + !isRecord(manifest) || + !hasExactKeys(manifest, ['schemaVersion', 'entryHtml', 'chunks', 'modules', 'graphSha256']) || + manifest.schemaVersion !== 1 || + manifest.entryHtml !== 'index.html' || + !Array.isArray(manifest.chunks) || + !Array.isArray(manifest.modules) || + typeof manifest.graphSha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(manifest.graphSha256) + ) { + violations.push('hosted_renderer_graph_manifest_schema_invalid'); + return { + ok: false, + manifestPath, + chunkPaths: collectRendererJavaScript(rendererRoot), + violations, + }; + } + + const graph = { + schemaVersion: manifest.schemaVersion, + entryHtml: manifest.entryHtml, + chunks: manifest.chunks, + modules: manifest.modules, + }; + if (sha256(JSON.stringify(graph)) !== manifest.graphSha256) { + violations.push('hosted_renderer_graph_digest_mismatch'); + } + + const manifestChunkPaths = []; + const chunkModuleIds = new Set(); + for (const chunk of manifest.chunks) { + if ( + !isRecord(chunk) || + !hasExactKeys(chunk, ['fileName', 'imports', 'dynamicImports', 'moduleIds', 'sha256']) || + typeof chunk.fileName !== 'string' || + !/^(?:assets\/)?[^/]+\.js$/u.test(chunk.fileName) || + !isSortedUniqueStrings(chunk.imports) || + !isSortedUniqueStrings(chunk.dynamicImports) || + !isSortedUniqueStrings(chunk.moduleIds) || + typeof chunk.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(chunk.sha256) + ) { + violations.push('hosted_renderer_graph_chunk_invalid'); + continue; + } + manifestChunkPaths.push(chunk.fileName); + for (const reference of [...chunk.imports, ...chunk.dynamicImports]) { + const violation = classifyForbiddenHostedRendererReference(reference); + if (violation) { + violations.push( + `hosted_renderer_graph_forbidden_reference:${violation.kind}:${violation.reference}` + ); + } + } + for (const moduleId of chunk.moduleIds) chunkModuleIds.add(moduleId); + const chunkPath = join(rendererRoot, ...chunk.fileName.split('/')); + if (!existsSync(chunkPath) || !statSync(chunkPath).isFile()) { + violations.push(`hosted_renderer_graph_chunk_missing:${chunk.fileName}`); + } else if (sha256(readFileSync(chunkPath)) !== chunk.sha256) { + violations.push(`hosted_renderer_graph_chunk_digest_mismatch:${chunk.fileName}`); + } + } + if (!isSortedUniqueStrings(manifestChunkPaths)) { + violations.push('hosted_renderer_graph_chunks_not_sorted_unique'); + } + + const moduleIds = []; + for (const module of manifest.modules) { + if ( + !isRecord(module) || + !hasExactKeys(module, [ + 'id', + 'importedSpecifiers', + 'resolvedImports', + 'resolvedDynamicImports', + ]) || + !isCanonicalGraphModuleId(module.id) || + !isSortedUniqueStrings(module.importedSpecifiers) || + !isSortedUniqueStrings(module.resolvedImports) || + !isSortedUniqueStrings(module.resolvedDynamicImports) || + !module.resolvedImports.every(isCanonicalGraphModuleId) || + !module.resolvedDynamicImports.every(isCanonicalGraphModuleId) + ) { + violations.push('hosted_renderer_graph_module_invalid'); + continue; + } + moduleIds.push(module.id); + for (const reference of [ + module.id, + ...module.importedSpecifiers, + ...module.resolvedImports, + ...module.resolvedDynamicImports, + ]) { + const violation = classifyForbiddenHostedRendererReference(reference); + if (violation) { + violations.push( + `hosted_renderer_graph_forbidden_reference:${violation.kind}:${violation.reference}` + ); + } + } + } + if (!isSortedUniqueStrings(moduleIds)) { + violations.push('hosted_renderer_graph_modules_not_sorted_unique'); + } + const moduleIdSet = new Set(moduleIds); + if (!moduleIdSet.has('src/renderer/hosted/main.tsx')) { + violations.push('hosted_renderer_graph_entry_module_missing'); + } + for (const moduleId of chunkModuleIds) { + if (!moduleIdSet.has(moduleId)) { + violations.push(`hosted_renderer_graph_chunk_module_missing:${moduleId}`); + } + } + for (const moduleId of moduleIdSet) { + if (!chunkModuleIds.has(moduleId)) { + violations.push(`hosted_renderer_graph_unbound_module:${moduleId}`); + } + } + for (const module of manifest.modules) { + if (!isRecord(module)) continue; + for (const resolvedId of [ + ...(Array.isArray(module.resolvedImports) ? module.resolvedImports : []), + ...(Array.isArray(module.resolvedDynamicImports) ? module.resolvedDynamicImports : []), + ]) { + if (typeof resolvedId === 'string' && !moduleIdSet.has(resolvedId)) { + violations.push(`hosted_renderer_graph_resolved_module_missing:${resolvedId}`); + } + } + } + + const chunkPaths = collectRendererJavaScript(rendererRoot); + if (JSON.stringify(manifestChunkPaths) !== JSON.stringify(chunkPaths)) { + violations.push('hosted_renderer_graph_chunk_inventory_mismatch'); + } + return { ok: violations.length === 0, manifestPath, chunkPaths, violations }; +} + +export function verifyHostedNoTerminalArtifact( + rootPath, + { requireBetterSqlite3 = false, requireHostedRendererGraph = false } = {} +) { + const root = resolve(rootPath); + if (!existsSync(root) || !statSync(root).isDirectory()) { + throw new Error(`Artifact root is not a directory: ${root}`); + } + + const scan = scanArtifact(root); + const betterSqlite3 = verifyBetterSqlite3(root, requireBetterSqlite3); + const hostedRendererGraph = requireHostedRendererGraph + ? verifyHostedRendererGraph(root) + : { ok: true, required: false, violations: [] }; + const violations = [ + ...scan.forbiddenPaths.map(({ kind, path }) => `${kind}:${path}`), + ...scan.forbiddenLoads.map( + ({ path, line, specifier }) => `forbidden_runtime_load:${path}:${line}:${specifier}` + ), + ...(betterSqlite3.violation ? [betterSqlite3.violation] : []), + ...hostedRendererGraph.violations, + ]; + + return { + ok: violations.length === 0, + root, + forbiddenPaths: scan.forbiddenPaths, + forbiddenLoads: scan.forbiddenLoads, + betterSqlite3, + hostedRendererGraph, + violations, + }; +} + +export function verifyHostedNoTerminalDockerfile(source) { + const violations = []; + const finalStageIndex = source.lastIndexOf('\nFROM base\n'); + const prodDepsIndex = source.indexOf('FROM base AS prod-deps'); + const finalNodeModulesCopyIndex = source.indexOf( + 'COPY --from=prod-deps /app/node_modules ./node_modules' + ); + + if (prodDepsIndex < 0) violations.push('prod_deps_stage_missing'); + if (finalStageIndex < 0) violations.push('final_stage_missing'); + if (finalNodeModulesCopyIndex < 0) violations.push('prod_node_modules_copy_missing'); + if (!/pnpm rebuild better-sqlite3(?:\s|\\|$)/u.test(source)) { + violations.push('better_sqlite3_rebuild_missing'); + } + if ( + new RegExp( + String.raw`pnpm\s+rebuild[^\n]*(?:node-pty|ssh2|cpu-features|terminal-platform-node|@terminal-platform)`, + 'u' + ).test(source) + ) { + violations.push('forbidden_runtime_rebuild'); + } + if ( + new RegExp( + String.raw`require(?:\.resolve)?\s*\(\s*['"](?:(?:node-pty|ssh2|cpu-features|terminal-platform-node)(?:\/[^'"]+)?|@terminal-platform(?:\/[^'"]+)?)['"]`, + 'u' + ).test(source) + ) { + violations.push('forbidden_runtime_require'); + } + + const pruneAssertion = + 'verify-hosted-no-terminal-artifact.mjs --root /app --prune --require-better-sqlite3'; + const finalAssertion = + 'verify-hosted-no-terminal-artifact.mjs --root /app --require-better-sqlite3 --require-hosted-renderer-graph'; + const pruneIndex = source.indexOf(pruneAssertion); + const finalAssertionIndex = source.indexOf(finalAssertion, Math.max(pruneIndex + 1, 0)); + if (pruneIndex < prodDepsIndex || pruneIndex >= finalNodeModulesCopyIndex) { + violations.push('prod_deps_prune_assertion_missing_or_misordered'); + } + if (finalAssertionIndex < finalNodeModulesCopyIndex || finalAssertionIndex < finalStageIndex) { + violations.push('final_artifact_assertion_missing_or_misordered'); + } + + const finalStage = finalStageIndex < 0 ? '' : source.slice(finalStageIndex); + if (/COPY[^\n]*(?:resources\/terminal-platform|vendor\/terminal-platform)/u.test(finalStage)) { + violations.push('terminal_platform_copied_to_final_stage'); + } + if (!finalStage.includes('COPY --from=prod-deps /app/package.json ./')) { + violations.push('runtime_package_manifest_missing'); + } + if (/COPY --from=prod-deps[^\n]*pnpm-lock\.yaml/u.test(finalStage)) { + violations.push('desktop_lockfile_copied_to_final_stage'); + } + + return { ok: violations.length === 0, violations }; +} + +function parseArguments(argv) { + const options = { + root: process.cwd(), + prune: false, + requireBetterSqlite3: false, + requireHostedRendererGraph: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--prune') { + options.prune = true; + } else if (argument === '--require-better-sqlite3') { + options.requireBetterSqlite3 = true; + } else if (argument === '--require-hosted-renderer-graph') { + options.requireHostedRendererGraph = true; + } else if (argument === '--root') { + const root = argv[index + 1]; + if (!root) throw new Error('--root requires a path'); + options.root = isAbsolute(root) ? root : resolve(root); + index += 1; + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + return options; +} + +export function runCli(argv = process.argv.slice(2)) { + const options = parseArguments(argv); + const removed = options.prune ? pruneForbiddenHostedPackages(options.root) : []; + const result = verifyHostedNoTerminalArtifact(options.root, { + requireBetterSqlite3: options.requireBetterSqlite3, + requireHostedRendererGraph: options.requireHostedRendererGraph, + }); + process.stdout.write(`${JSON.stringify({ ...result, removed }, null, 2)}\n`); + return result.ok ? 0 : 1; +} + +const entryPointUrl = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null; +if (entryPointUrl === import.meta.url) { + try { + process.exitCode = runCli(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/ci/verify-hosted-phase0-evidence-integrity.mjs b/scripts/ci/verify-hosted-phase0-evidence-integrity.mjs new file mode 100644 index 0000000000..a611088da2 --- /dev/null +++ b/scripts/ci/verify-hosted-phase0-evidence-integrity.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const CANONICAL_INDEX_PATH = + 'docs/research/hosted-web/phase-0/freeze/current-canonical/evidence-index.json'; + +const FROZEN_W2_COMMIT = '6d54e7c60d29812de5b96e471761486fbbc0842c'; + +export const FROZEN_W2_CANONICAL_FILES = Object.freeze([ + Object.freeze({ + evidenceId: 'P0.W2.ENVIRONMENT_PROVENANCE', + path: 'docs/research/hosted-web/phase-0/provider-runtime/environment-provenance.json', + sha256: '302cc6081598a182df58478f6d50288228e037db5f14c33df72c512fcc5b3d81', + }), + Object.freeze({ + evidenceId: 'P0.W2.CREDENTIAL_EXPOSURE_MATRIX', + path: 'docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json', + sha256: '5f039a47a65ced6556f5c096e4135d18aeea3377db1f21fb347025c1af5b36e1', + }), + Object.freeze({ + evidenceId: 'P0.W2.RUNTIME_SCANNER', + path: 'scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts', + sha256: '92a28a82c279467f844a48743c3c87ef4bea89c991bb3d0e06424052a3738f7c', + }), + Object.freeze({ + evidenceId: null, + path: 'test/architecture/hosted-web/phase-0/provider-runtime/fixtures/environment-semantics.json', + sha256: '3f952a58400fd40188c900673b4a4399b7144ebb9faba6018254073f5a9ad430', + }), +]); + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readRegularFile(root, relativePath, errors) { + const absolutePath = resolve(root, relativePath); + try { + const stat = lstatSync(absolutePath); + if (!stat.isFile()) { + errors.push(`${relativePath}: expected a regular file`); + return null; + } + return readFileSync(absolutePath); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + errors.push(`${relativePath}: missing or unreadable (${detail})`); + return null; + } +} + +function readCanonicalIndex(root, errors) { + const bytes = readRegularFile(root, CANONICAL_INDEX_PATH, errors); + if (bytes === null) return null; + + let index; + try { + index = JSON.parse(bytes.toString('utf8')); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + errors.push(`${CANONICAL_INDEX_PATH}: invalid JSON (${detail})`); + return null; + } + + if (!isRecord(index) || !Array.isArray(index.evidence)) { + errors.push(`${CANONICAL_INDEX_PATH}: expected an object with an evidence array`); + return null; + } + return index; +} + +function verifyIndexedRecord(index, expected, errors) { + const candidates = index.evidence.filter( + (row) => isRecord(row) && (row.evidenceId === expected.evidenceId || row.path === expected.path) + ); + + if (candidates.length === 0) { + errors.push(`${CANONICAL_INDEX_PATH}: missing ${expected.evidenceId} at ${expected.path}`); + return; + } + if (candidates.length !== 1) { + errors.push( + `${CANONICAL_INDEX_PATH}: duplicate ${expected.evidenceId}/${expected.path} records (${candidates.length})` + ); + return; + } + + const [record] = candidates; + const exactFields = [ + ['evidenceId', expected.evidenceId], + ['laneId', 'w2'], + ['path', expected.path], + ['sha256', expected.sha256], + ['integratedAtCommit', FROZEN_W2_COMMIT], + ]; + for (const [field, expectedValue] of exactFields) { + if (record[field] !== expectedValue) { + errors.push( + `${CANONICAL_INDEX_PATH}: ${expected.evidenceId} ${field} mismatch (expected ${expectedValue}, received ${String(record[field])})` + ); + } + } +} + +export function verifyHostedPhase0EvidenceIntegrity(root) { + const repositoryRoot = resolve(root); + const errors = []; + const index = readCanonicalIndex(repositoryRoot, errors); + + if (index !== null) { + for (const expected of FROZEN_W2_CANONICAL_FILES) { + if (expected.evidenceId !== null) verifyIndexedRecord(index, expected, errors); + } + } + + for (const expected of FROZEN_W2_CANONICAL_FILES) { + const bytes = readRegularFile(repositoryRoot, expected.path, errors); + if (bytes === null) continue; + const actualHash = createHash('sha256').update(bytes).digest('hex'); + if (actualHash !== expected.sha256) { + errors.push( + `${expected.path}: sha256 mismatch (expected ${expected.sha256}, received ${actualHash})` + ); + } + } + + return errors; +} + +function parseRoot(args) { + if (args.length === 0) return resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + if (args.length === 2 && args[0] === '--root' && args[1]) return resolve(args[1]); + throw new Error('usage: verify-hosted-phase0-evidence-integrity.mjs [--root ]'); +} + +function main() { + let root; + try { + root = parseRoot(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + const errors = verifyHostedPhase0EvidenceIntegrity(root); + if (errors.length > 0) { + console.error('Hosted Phase 0 evidence integrity verification failed:'); + for (const error of errors) console.error(`- ${error}`); + process.exitCode = 1; + return; + } + console.log( + `Hosted Phase 0 evidence integrity verified (${FROZEN_W2_CANONICAL_FILES.length} canonical files).` + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/ci/verify-hosted-runtime-environment-policy.mjs b/scripts/ci/verify-hosted-runtime-environment-policy.mjs new file mode 100644 index 0000000000..494935fb8e --- /dev/null +++ b/scripts/ci/verify-hosted-runtime-environment-policy.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = resolve(SCRIPT_DIRECTORY, '../..'); +const POLICY_PATH = 'scripts/ci/hosted-runtime-environment-policy.json'; +const POLICY_FORMAT = 'agent-teams.hosted-runtime-environment-policy/v1'; +const RESULT_FORMAT = 'agent-teams.hosted-runtime-environment-policy-verifier-result/v1'; +const KEY_PREFIX = 'HOSTED_LIFECYCLE_'; +const KEY_PATTERN = /^HOSTED_LIFECYCLE_[A-Z0-9_]+$/u; +const SOURCE_ROOTS = Object.freeze(['src', 'docker']); +const SOURCE_EXTENSIONS = new Set([ + '.cjs', + '.cts', + '.js', + '.json', + '.jsx', + '.mjs', + '.mts', + '.sh', + '.ts', + '.tsx', +]); +const DOCKER_EXTENSIONS = new Set(['.yaml', '.yml']); +const TOP_LEVEL_FIELDS = Object.freeze([ + 'format', + 'schemaVersion', + 'authority', + 'environmentKeyPrefix', + 'entries', +]); +const ENTRY_FIELDS = Object.freeze([ + 'name', + 'role', + 'secretClass', + 'redactionRule', + 'providerChildExposure', + 'sourceAuthority', +]); +const ROLES = new Set([ + 'authentication_material', + 'authentication_secret_reference', + 'forbidden_legacy_bootstrap_control', + 'orchestrator_run_directory', + 'orchestrator_socket_path', + 'owner_admission_manifest_path', + 'owner_high_water_state_root', + 'owner_release_pin_path', + 'test_gate', +]); +const SECRET_CLASSES = new Set(['none', 'hmac_authentication_key', 'secret_reference_path']); +const REDACTION_RULES = new Set(['name_only', 'not_applicable']); +const REQUIRED_SEMANTICS = Object.freeze({ + HOSTED_LIFECYCLE_ORCHESTRATOR_HIGH_WATER_ROOT: Object.freeze({ + role: 'owner_high_water_state_root', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_ORCHESTRATOR_RUN_DIR: Object.freeze({ + role: 'orchestrator_run_directory', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_ORCHESTRATOR_SOCKET: Object.freeze({ + role: 'orchestrator_socket_path', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_ORCHESTRATOR_TEST_ONLY_INLINE_TRUST_ANCHOR: Object.freeze({ + role: 'test_gate', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR: Object.freeze({ + role: 'authentication_material', + secretClass: 'hmac_authentication_key', + redactionRule: 'name_only', + }), + HOSTED_LIFECYCLE_ORCHESTRATOR_TRUST_ANCHOR_FILE: Object.freeze({ + role: 'authentication_secret_reference', + secretClass: 'secret_reference_path', + redactionRule: 'name_only', + }), + HOSTED_LIFECYCLE_OWNER_ADMISSION_MANIFEST_FILE: Object.freeze({ + role: 'owner_admission_manifest_path', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_OWNER_ARTIFACT_DIGEST: Object.freeze({ + role: 'forbidden_legacy_bootstrap_control', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_OWNER_AUTHORITY: Object.freeze({ + role: 'forbidden_legacy_bootstrap_control', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_OWNER_IMAGE_REFERENCE: Object.freeze({ + role: 'forbidden_legacy_bootstrap_control', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_OWNER_PROTOCOL_VERSION: Object.freeze({ + role: 'forbidden_legacy_bootstrap_control', + secretClass: 'none', + redactionRule: 'not_applicable', + }), + HOSTED_LIFECYCLE_OWNER_RELEASE_PIN_FILE: Object.freeze({ + role: 'owner_release_pin_path', + secretClass: 'none', + redactionRule: 'not_applicable', + }), +}); + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function normalizedPath(root, path) { + return relative(root, path).split(sep).join('/'); +} + +function isScannableSource(path) { + const extension = extname(path).toLowerCase(); + return ( + SOURCE_EXTENSIONS.has(extension) || + DOCKER_EXTENSIONS.has(extension) || + path.endsWith('/Dockerfile') + ); +} + +function walkScannableSources(root) { + const paths = []; + for (const sourceRoot of SOURCE_ROOTS) { + const pending = [join(root, sourceRoot)]; + while (pending.length > 0) { + const directory = pending.pop(); + const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name) + ); + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) pending.push(path); + else if (entry.isFile() && isScannableSource(path)) paths.push(path); + } + } + } + return paths.sort(); +} + +function sourceDocuments(root, injectedSources) { + if (injectedSources !== undefined) { + if (!isRecord(injectedSources)) throw new TypeError('sources_must_be_path_content_record'); + return Object.entries(injectedSources) + .map(([path, content]) => { + if (typeof content !== 'string') throw new TypeError(`source_not_text:${path}`); + return [path.replaceAll('\\', '/'), content]; + }) + .sort(([left], [right]) => left.localeCompare(right)); + } + return walkScannableSources(root).map((path) => [ + normalizedPath(root, path), + readFileSync(path, 'utf8'), + ]); +} + +function discoveredNames(content, path) { + const names = new Set(); + const quotedName = /(['"])(HOSTED_LIFECYCLE_[A-Z0-9_]+)\1/gu; + for (const match of content.matchAll(quotedName)) names.add(match[2]); + + if (path.startsWith('src/')) { + const propertyAccess = + /\b(?:process\s*\.\s*env|environment|env|[A-Za-z_$][\w$]*(?:Environment|Env))\s*(?:\?\.\s*|\.\s*)(HOSTED_LIFECYCLE_[A-Z0-9_]+)/gu; + for (const match of content.matchAll(propertyAccess)) names.add(match[1]); + } + if (path.startsWith('docker/')) { + for (const match of content.matchAll(/\bHOSTED_LIFECYCLE_[A-Z0-9_]+\b/gu)) names.add(match[0]); + } + return names; +} + +/** Discovers current source authorities without consulting the authored policy. */ +export function discoverHostedLifecycleEnvironmentAccesses(options = {}) { + const root = resolve(options.root ?? REPOSITORY_ROOT); + const accessByName = new Map(); + for (const [path, content] of sourceDocuments(root, options.sources)) { + for (const name of discoveredNames(content, path)) { + if (!KEY_PATTERN.test(name)) continue; + const authorities = accessByName.get(name) ?? new Set(); + authorities.add(path); + accessByName.set(name, authorities); + } + } + return new Map( + [...accessByName.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, authorities]) => [name, [...authorities].sort()]) + ); +} + +function exactFields(value, expected, location, violations) { + if (!isRecord(value)) { + violations.push(`${location}:record_required`); + return false; + } + const actual = Object.keys(value).sort(); + const required = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(required)) { + violations.push(`${location}:fields_invalid`); + return false; + } + return true; +} + +function sameValues(left, right) { + return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort()); +} + +function validatePolicyHeader(policy, violations) { + if (!exactFields(policy, TOP_LEVEL_FIELDS, 'policy', violations)) return false; + if (policy.format !== POLICY_FORMAT) violations.push('policy:format_invalid'); + if (policy.schemaVersion !== 1) violations.push('policy:schema_version_invalid'); + if (policy.authority !== 'mutable_current_head_source') + violations.push('policy:authority_invalid'); + if (policy.environmentKeyPrefix !== KEY_PREFIX) violations.push('policy:key_prefix_invalid'); + if (!Array.isArray(policy.entries)) { + violations.push('policy:entries_array_required'); + return false; + } + return true; +} + +function validateEntry(entry, index, violations) { + const location = `policy_entry:${index}`; + if (!exactFields(entry, ENTRY_FIELDS, location, violations)) return false; + const name = KEY_PATTERN.test(entry.name) ? entry.name : ``; + const namedLocation = `policy_entry:${name}`; + if (!KEY_PATTERN.test(entry.name)) violations.push(`${location}:name_invalid`); + if (!ROLES.has(entry.role)) violations.push(`${namedLocation}:role_invalid`); + if (!SECRET_CLASSES.has(entry.secretClass)) + violations.push(`${namedLocation}:secret_class_invalid`); + if (!REDACTION_RULES.has(entry.redactionRule)) + violations.push(`${namedLocation}:redaction_rule_invalid`); + if (entry.providerChildExposure !== 'forbidden') { + violations.push(`${namedLocation}:provider_child_exposure_forbidden`); + } + if ( + !Array.isArray(entry.sourceAuthority) || + entry.sourceAuthority.length === 0 || + entry.sourceAuthority.some( + (path) => + typeof path !== 'string' || + path.length === 0 || + path.startsWith('/') || + path.includes('\\') || + path.split('/').includes('..') + ) + ) { + violations.push(`${namedLocation}:source_authority_invalid`); + } else if (new Set(entry.sourceAuthority).size !== entry.sourceAuthority.length) { + violations.push(`${namedLocation}:source_authority_duplicate`); + } + + const required = REQUIRED_SEMANTICS[entry.name]; + if (required !== undefined) { + for (const field of ['role', 'secretClass', 'redactionRule']) { + if (entry[field] !== required[field]) { + violations.push(`${namedLocation}:${field}_semantic_downgrade`); + } + } + } + if (entry.secretClass === 'none' && entry.redactionRule !== 'not_applicable') { + violations.push(`${namedLocation}:non_secret_redaction_invalid`); + } + if (entry.secretClass !== 'none' && entry.redactionRule !== 'name_only') { + violations.push(`${namedLocation}:secret_redaction_must_be_name_only`); + } + return typeof entry.name === 'string' && KEY_PATTERN.test(entry.name); +} + +/** Validates exact current-source coverage, classification, redaction, and child exposure. */ +export function validateHostedRuntimeEnvironmentPolicy(policy, discovered) { + const violations = []; + if (!validatePolicyHeader(policy, violations)) return violations; + + const entriesByName = new Map(); + for (const [index, entry] of policy.entries.entries()) { + if (!validateEntry(entry, index, violations)) continue; + if (entriesByName.has(entry.name)) violations.push(`policy_entry:${entry.name}:duplicate`); + else entriesByName.set(entry.name, entry); + } + + for (const [name, authorities] of discovered) { + const entry = entriesByName.get(name); + if (entry === undefined) { + violations.push(`coverage:missing_policy_entry:${name}`); + continue; + } + if (Array.isArray(entry.sourceAuthority) && !sameValues(entry.sourceAuthority, authorities)) { + violations.push(`policy_entry:${name}:source_authority_stale`); + } + } + for (const name of entriesByName.keys()) { + if (!discovered.has(name)) violations.push(`coverage:policy_entry_without_source:${name}`); + } + return [...new Set(violations)].sort(); +} + +function loadPolicy(root, injectedPolicy) { + if (injectedPolicy !== undefined) return injectedPolicy; + return JSON.parse(readFileSync(join(root, POLICY_PATH), 'utf8')); +} + +/** Returns names and violations only; environment values and source contents never enter results. */ +export function verifyHostedRuntimeEnvironmentPolicy(options = {}) { + const root = resolve(options.root ?? REPOSITORY_ROOT); + let policy; + let discovered; + try { + policy = loadPolicy(root, options.policy); + } catch { + return { + format: RESULT_FORMAT, + status: 'failed', + summary: { discoveredKeys: 0, policyEntries: 0, violations: 1 }, + violations: ['policy:unreadable_or_invalid_json'], + }; + } + try { + discovered = discoverHostedLifecycleEnvironmentAccesses({ root, sources: options.sources }); + } catch { + return { + format: RESULT_FORMAT, + status: 'failed', + summary: { + discoveredKeys: 0, + policyEntries: Array.isArray(policy?.entries) ? policy.entries.length : 0, + violations: 1, + }, + violations: ['source_authority:discovery_failed'], + }; + } + const violations = validateHostedRuntimeEnvironmentPolicy(policy, discovered); + return { + format: RESULT_FORMAT, + status: violations.length === 0 ? 'passed' : 'failed', + summary: { + discoveredKeys: discovered.size, + policyEntries: Array.isArray(policy?.entries) ? policy.entries.length : 0, + violations: violations.length, + }, + violations, + }; +} + +const invokedPath = + process.argv[1] === undefined ? null : pathToFileURL(resolve(process.argv[1])).href; +if (invokedPath === import.meta.url) { + const result = verifyHostedRuntimeEnvironmentPolicy(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (result.status !== 'passed') process.exitCode = 1; +} diff --git a/scripts/ci/verify-source-file-size.mjs b/scripts/ci/verify-source-file-size.mjs new file mode 100644 index 0000000000..5662970449 --- /dev/null +++ b/scripts/ci/verify-source-file-size.mjs @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + evaluateSourceFileSizes as evaluateBroadSourceFileSizes, + readWorkingTreeRecords as readBroadWorkingTreeRecords, + strictSourceFileSizeViolations, +} from './check-source-file-size.mjs'; + +export const MAX_PRODUCTION_SOURCE_LINES = 800; + +export const SOURCE_ROOTS = [ + 'src', + 'packages', + 'agent-teams-controller/src', + 'landing', + 'mcp-server/src', +]; +const SOURCE_EXTENSIONS = new Set([ + '.cjs', + '.css', + '.cts', + '.js', + '.jsx', + '.mjs', + '.mts', + '.less', + '.sass', + '.scss', + '.ts', + '.tsx', + '.vue', +]); +const EXCLUDED_DIRECTORIES = new Set([ + '.nuxt', + '.output', + '__fixtures__', + '__tests__', + 'coverage', + 'dist', + 'fixtures', + 'node_modules', + 'public', +]); +const TEST_FILE_PATTERN = /\.(?:spec|test)\.[^.]+$/; +const DECLARATION_FILE_PATTERN = /\.d\.(?:cts|mts|ts)$/; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), '..', '..'); +const legacyManifestPath = path.join(repoRoot, 'scripts', 'ci', 'source-file-size-legacy.json'); +const legacyManifestRelativePath = normalizeRelativePath( + path.relative(repoRoot, legacyManifestPath) +); +const policyManifestPath = path.join(repoRoot, 'scripts', 'ci', 'source-file-size-baseline.json'); +const policyManifestRelativePath = normalizeRelativePath( + path.relative(repoRoot, policyManifestPath) +); +const workspacePath = path.join(repoRoot, 'pnpm-workspace.yaml'); +const NO_PRIOR_COMMIT_REF = '0'.repeat(40); + +function normalizeRelativePath(filePath) { + return filePath.split(path.sep).join('/'); +} + +export function countPhysicalLines(source) { + if (source.length === 0) return 0; + const separators = source.match(/\r\n|\r|\n/g)?.length ?? 0; + return separators + (source.endsWith('\n') || source.endsWith('\r') ? 0 : 1); +} + +export function isProductionSourcePath(filePath) { + const normalized = normalizeRelativePath(filePath); + const segments = normalized.split('/'); + const belongsToSourceRoot = SOURCE_ROOTS.some( + (root) => normalized === root || normalized.startsWith(`${root}/`) + ); + + if (!belongsToSourceRoot) return false; + if (segments.some((segment) => EXCLUDED_DIRECTORIES.has(segment))) return false; + if (DECLARATION_FILE_PATTERN.test(normalized)) return false; + if (TEST_FILE_PATTERN.test(normalized)) return false; + return SOURCE_EXTENSIONS.has(path.extname(normalized)); +} + +export function parseWorkspacePackagePatterns(workspaceSource) { + const packageBlock = + /^packages:\s*\n((?:[ \t]+-[^\n]*(?:\n|$))*)/m.exec(workspaceSource)?.[1] ?? ''; + return packageBlock + .split(/\r?\n/) + .map((line) => /^[ \t]+-[ \t]+(.+?)[ \t]*$/.exec(line)?.[1]) + .filter(Boolean) + .map((pattern) => pattern.replace(/^(['"])(.*)\1$/, '$2')); +} + +export function evaluateWorkspaceSourceCoverage({ + sourceRoots = SOURCE_ROOTS, + workspacePackagePatterns, +}) { + return workspacePackagePatterns + .filter( + (pattern) => + !sourceRoots.some( + (root) => + root === pattern || root.startsWith(`${pattern}/`) || pattern.startsWith(`${root}/`) + ) + ) + .map((pattern) => ({ + code: 'uncovered-workspace-package', + filePath: 'pnpm-workspace.yaml', + message: `workspace package ${pattern} has no production source root`, + })); +} + +export function collectFiles(rootPath) { + return readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => { + if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) return []; + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) return collectFiles(entryPath); + return entry.isFile() ? [entryPath] : []; + }); +} + +export function collectProductionSourceLineCounts(root = repoRoot) { + const lineCounts = new Map(); + + for (const sourceRoot of SOURCE_ROOTS) { + const absoluteRoot = path.join(root, sourceRoot); + for (const absolutePath of collectFiles(absoluteRoot)) { + const relativePath = normalizeRelativePath(path.relative(root, absolutePath)); + if (!isProductionSourcePath(relativePath)) continue; + lineCounts.set(relativePath, countPhysicalLines(readFileSync(absolutePath, 'utf8'))); + } + } + + return lineCounts; +} + +export function evaluateSourceFileSizePolicy({ + lineCounts, + legacyMaxLines, + maxLines = MAX_PRODUCTION_SOURCE_LINES, +}) { + const diagnostics = []; + + for (const [filePath, legacyLimit] of Object.entries(legacyMaxLines).sort(([left], [right]) => + left.localeCompare(right) + )) { + if (!Number.isSafeInteger(legacyLimit) || legacyLimit <= maxLines) { + diagnostics.push({ + code: 'invalid-legacy-cap', + filePath, + message: `legacy cap must be an integer greater than ${maxLines}, got ${legacyLimit}`, + }); + continue; + } + if (!isProductionSourcePath(filePath)) { + diagnostics.push({ + code: 'invalid-legacy-path', + filePath, + message: 'legacy exception does not point to an included production source file', + }); + continue; + } + + const actualLines = lineCounts.get(filePath); + if (actualLines === undefined) { + diagnostics.push({ + code: 'missing-legacy-file', + filePath, + message: 'legacy exception points to a missing file and must be removed', + }); + continue; + } + if (actualLines <= maxLines) { + diagnostics.push({ + code: 'stale-legacy-exception', + filePath, + message: `${actualLines} lines is within the ${maxLines}-line limit; remove the exception`, + }); + continue; + } + if (actualLines !== legacyLimit) { + diagnostics.push({ + code: actualLines > legacyLimit ? 'legacy-file-grew' : 'legacy-cap-not-tight', + filePath, + message: `${actualLines} lines does not match the ratcheted cap ${legacyLimit}`, + }); + } + } + + for (const [filePath, actualLines] of [...lineCounts.entries()].sort(([left], [right]) => + left.localeCompare(right) + )) { + if (actualLines > maxLines && legacyMaxLines[filePath] === undefined) { + diagnostics.push({ + code: 'unapproved-oversized-file', + filePath, + message: `${actualLines} lines exceeds the ${maxLines}-line production source limit`, + }); + } + } + + return diagnostics; +} + +export function evaluateLegacyManifestRatchet({ + baselineLegacyMaxLines, + baselineSourceLineCounts = new Map(), + legacyMaxLines, +}) { + if (baselineLegacyMaxLines === null) return []; + + const diagnostics = []; + for (const [filePath, legacyLimit] of Object.entries(legacyMaxLines).sort(([left], [right]) => + left.localeCompare(right) + )) { + const manifestLimit = baselineLegacyMaxLines[filePath] ?? 0; + const sourceLimit = baselineSourceLineCounts.get(filePath) ?? 0; + const effectiveBaselineLimit = Math.max(manifestLimit, sourceLimit); + if (effectiveBaselineLimit <= MAX_PRODUCTION_SOURCE_LINES) { + diagnostics.push({ + code: 'new-legacy-exception', + filePath, + message: 'new legacy exceptions are forbidden; split the file below the global limit', + }); + } else if (legacyLimit > effectiveBaselineLimit) { + diagnostics.push({ + code: 'raised-legacy-cap', + filePath, + message: + `legacy cap ${legacyLimit} exceeds the base source/manifest cap ` + + `${effectiveBaselineLimit}`, + }); + } + } + + return diagnostics; +} + +export function evaluatePolicyManifestRatchet({ baselinePolicy, policy }) { + if (baselinePolicy === null) return []; + + const diagnostics = []; + if (policy.maxLines > baselinePolicy.maxLines) { + diagnostics.push({ + code: 'raised-global-limit', + filePath: policyManifestRelativePath, + message: `global limit ${policy.maxLines} exceeds the base limit ${baselinePolicy.maxLines}`, + }); + } + diagnostics.push( + ...evaluateLegacyManifestRatchet({ + baselineLegacyMaxLines: baselinePolicy.legacy, + legacyMaxLines: policy.legacy, + }) + ); + return diagnostics; +} + +export function evaluatePolicyCurrentFiles({ policy, records }) { + return strictSourceFileSizeViolations(evaluateBroadSourceFileSizes(records, policy)).map( + ({ code, message, path: filePath }) => ({ + code, + filePath: filePath ?? policyManifestRelativePath, + message, + }) + ); +} + +function readBaselineManifest(baselineRef, manifestRelativePath) { + if (!baselineRef) return null; + if (!/^[0-9a-f]{40}$/i.test(baselineRef)) { + throw new Error('SOURCE_FILE_SIZE_BASELINE_REF must be a 40-character commit SHA'); + } + + const objectName = `${baselineRef}:${manifestRelativePath}`; + try { + execFileSync('git', ['cat-file', '-e', objectName], { + cwd: repoRoot, + stdio: 'ignore', + }); + } catch { + return null; + } + + return JSON.parse( + execFileSync('git', ['show', objectName], { + cwd: repoRoot, + encoding: 'utf8', + }) + ); +} + +function readBaselineSourceLineCounts(baselineRef, filePaths) { + if (!baselineRef) return new Map(); + if (!/^[0-9a-f]{40}$/i.test(baselineRef)) { + throw new Error('SOURCE_FILE_SIZE_BASELINE_REF must be a 40-character commit SHA'); + } + + const lineCounts = new Map(); + for (const filePath of filePaths) { + const objectName = `${baselineRef}:${filePath}`; + try { + execFileSync('git', ['cat-file', '-e', objectName], { + cwd: repoRoot, + stdio: 'ignore', + }); + } catch { + continue; + } + lineCounts.set( + filePath, + countPhysicalLines( + execFileSync('git', ['show', objectName], { + cwd: repoRoot, + encoding: 'utf8', + }) + ) + ); + } + return lineCounts; +} + +export function verifySourceFileSizePolicy(root = repoRoot, { requireBaseline = false } = {}) { + const legacyMaxLines = JSON.parse(readFileSync(legacyManifestPath, 'utf8')); + const policy = JSON.parse(readFileSync(policyManifestPath, 'utf8')); + const configuredBaselineRef = process.env.SOURCE_FILE_SIZE_BASELINE_REF; + const baselineRef = configuredBaselineRef === NO_PRIOR_COMMIT_REF ? null : configuredBaselineRef; + const baselineRequired = requireBaseline && configuredBaselineRef !== NO_PRIOR_COMMIT_REF; + if (baselineRequired && !baselineRef) { + throw new Error('SOURCE_FILE_SIZE_BASELINE_REF is required in source-size ratchet mode'); + } + const baselineLegacyManifest = readBaselineManifest(baselineRef, legacyManifestRelativePath); + // Pre-ratchet baselines have no legacy manifest; exact baseline source counts below + // still bound every current exception when a real baseline ref was supplied. + const baselineLegacyMaxLines = baselineLegacyManifest ?? (baselineRef ? {} : null); + const baselinePolicy = readBaselineManifest(baselineRef, policyManifestRelativePath); + if (baselineRequired && baselinePolicy === null) { + throw new Error( + `SOURCE_FILE_SIZE_BASELINE_REF must resolve to ${policyManifestRelativePath} ` + + 'in source-size ratchet mode' + ); + } + const baselineSourceLineCounts = readBaselineSourceLineCounts( + baselineRef, + Object.keys(legacyMaxLines) + ); + const workspacePackagePatterns = parseWorkspacePackagePatterns( + readFileSync(workspacePath, 'utf8') + ); + const lineCounts = collectProductionSourceLineCounts(root); + const diagnostics = [ + ...evaluateWorkspaceSourceCoverage({ workspacePackagePatterns }), + ...evaluatePolicyManifestRatchet({ baselinePolicy, policy }), + ...evaluatePolicyCurrentFiles({ + policy, + records: readBroadWorkingTreeRecords(root), + }), + ...evaluateLegacyManifestRatchet({ + baselineLegacyMaxLines, + baselineSourceLineCounts, + legacyMaxLines, + }), + ...evaluateSourceFileSizePolicy({ lineCounts, legacyMaxLines }), + ]; + + if (diagnostics.length > 0) { + const details = diagnostics + .map(({ code, filePath, message }) => ` - [${code}] ${filePath}: ${message}`) + .join('\n'); + throw new Error(`Production source file-size policy failed:\n${details}`); + } + + return { + legacyFileCount: Object.keys(legacyMaxLines).length, + productionFileCount: lineCounts.size, + }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + const result = verifySourceFileSizePolicy(repoRoot, { + requireBaseline: process.argv.includes('--require-baseline'), + }); + console.log( + `[source-file-size] OK: ${result.productionFileCount} production files, ` + + `${result.legacyFileCount} ratcheted legacy exceptions, ` + + `${MAX_PRODUCTION_SOURCE_LINES}-line limit` + ); + } catch (error) { + console.error(`[source-file-size] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/e2e/change-review-hunk-desktop.mjs b/scripts/e2e/change-review-hunk-desktop.mjs index 3724a0d4f6..ce9930ad47 100644 --- a/scripts/e2e/change-review-hunk-desktop.mjs +++ b/scripts/e2e/change-review-hunk-desktop.mjs @@ -799,6 +799,52 @@ async function main() { await assertViewportFits(); await assertDiskLines(fixture.changedFile, 'after-0', 'after-1'); + // Exercise the public bulk-Reject control before the longer mixed-history flow. + // Restart on both sides of Undo so the test proves the disk mutation and the + // durable Undo/Redo cursor, not only the optimistic renderer state. + await client.waitFor(enabledButtonWithText('Reject All'), 'Reject All for durable rollback'); + await client.domClick(enabledButtonWithText('Reject All')); + await client.waitFor(`document.body?.innerText.includes('12 rejected')`, 'bulk Reject action'); + await waitForDiskLines(fixture.changedFile, 'before-0', 'before-1'); + await client.waitFor( + `document.querySelector('button[aria-label^="Review history:"][aria-label$="; saved"]')`, + 'durable bulk Reject action' + ); + + await restartApp(port, fixture); + await openReview(); + await client.waitFor( + `document.body?.innerText.includes('12 rejected') && + Boolean(${enabledButtonWithText('Undo')})`, + 'bulk Reject restored after restart' + ); + await assertDiskLines(fixture.changedFile, 'before-0', 'before-1'); + + await client.domClick(enabledButtonWithText('Undo')); + await client.waitFor(`document.body?.innerText.includes('12 pending')`, 'bulk Reject Undo'); + await waitForDiskLines(fixture.changedFile, 'after-0', 'after-1'); + await client.waitFor( + `document.querySelector('button[aria-label^="Review history:"][aria-label$="; saved"]')`, + 'durable bulk Reject Undo' + ); + + await restartApp(port, fixture); + await openReview(); + await client.waitFor( + `document.body?.innerText.includes('12 pending') && + Boolean(${enabledButtonWithText('Redo')})`, + 'bulk Reject Redo restored after restart' + ); + await client.domClick(enabledButtonWithText('Redo')); + await client.waitFor(`document.body?.innerText.includes('12 rejected')`, 'bulk Reject Redo'); + await waitForDiskLines(fixture.changedFile, 'before-0', 'before-1'); + await client.domClick(enabledButtonWithText('Undo')); + await client.waitFor( + `document.body?.innerText.includes('12 pending')`, + 'bulk Reject cleanup Undo' + ); + await waitForDiskLines(fixture.changedFile, 'after-0', 'after-1'); + await client.waitFor(enabledButtonWithText('Accept All'), 'Accept All for history restore'); await client.domClick(enabledButtonWithText('Accept All')); await client.waitFor(`document.body?.innerText.includes('12 accepted')`, 'bulk Accept action'); @@ -1323,6 +1369,7 @@ async function main() { process.stdout.write( `Changes desktop E2E passed (dev:mcp${singleWindowMode ? ', single-window' : ''}): ` + + 'Reject All -> restart -> Undo -> restart -> Redo -> Undo -> ' + 'Accept All -> Reject file -> Restore back -> hydrate -> Restore forward -> ' + 'Undo to start -> hydrate -> stale branch -> reload -> reversible recovery -> ' + 'Discard backup -> reload -> prior snapshot -> discard -> reload -> ' + @@ -1471,6 +1518,7 @@ async function main() { process.stdout.write( `Changes desktop E2E passed (${devMcpMode ? 'dev:mcp' : 'preview'}): ` + + `Reject All -> restart -> Undo -> restart -> Redo -> Undo -> ` + `Accept All -> Reject file -> Restore back -> restart -> Restore forward -> ` + `exact disk/history -> ` + `Reject -> Undo -> Redo -> restart -> exact history -> external conflict -> Reload -> ` + @@ -1488,17 +1536,30 @@ try { if (client) { const diagnostics = await client .evaluate( - `({ - url: location.href, - title: document.title, - bodyTail: document.body?.innerText.slice(-3000) ?? '', - hunkToolbars: Array.from(document.querySelectorAll('[data-review-floating-toolbar="true"]')) - .map((toolbar) => ({ - text: toolbar.textContent, - display: getComputedStyle(toolbar).display, - rect: toolbar.getBoundingClientRect().toJSON(), - })), - })` + `(() => { + return { + url: location.href, + title: document.title, + bodyTail: document.body?.innerText.slice(-3000) ?? '', + loadingState: (() => { + const content = document.querySelector('[data-review-change-set-loading]'); + return content + ? { + changeSet: content.getAttribute('data-review-change-set-loading'), + decisions: content.getAttribute('data-review-decision-hydration'), + drafts: content.getAttribute('data-review-draft-hydration'), + } + : null; + })(), + hunkToolbars: Array.from( + document.querySelectorAll('[data-review-floating-toolbar="true"]') + ).map((toolbar) => ({ + text: toolbar.textContent, + display: getComputedStyle(toolbar).display, + rect: toolbar.getBoundingClientRect().toJSON(), + })), + }; + })()` ) .catch((diagnosticError) => ({ diagnosticError: String(diagnosticError) })); process.stderr.write(`Renderer diagnostics:\n${JSON.stringify(diagnostics, null, 2)}\n`); diff --git a/scripts/e2e/hosted-v1/appImageCleanup.ts b/scripts/e2e/hosted-v1/appImageCleanup.ts new file mode 100644 index 0000000000..4706e13808 --- /dev/null +++ b/scripts/e2e/hosted-v1/appImageCleanup.ts @@ -0,0 +1,58 @@ +export interface HostedV1SharedAppImageLifecycleInput { + readonly appImage: string; + readonly environment: NodeJS.ProcessEnv; + readonly removeImage: (appImage: string, environment: NodeJS.ProcessEnv) => Promise; +} + +/** Probes, removes, and re-probes only the run's unique image tag. */ +export async function removeHostedV1AppImage( + appImage: string, + environment: NodeJS.ProcessEnv, + runDocker: (args: readonly string[], environment: NodeJS.ProcessEnv) => Promise +): Promise { + const inspect = () => + runDocker( + ['image', 'ls', '--quiet', '--no-trunc', '--filter', `reference=${appImage}`], + environment + ); + const observedIds = await inspect(); + if (observedIds === '') return; + const imageIds = observedIds.split(/\r?\n/u); + if (imageIds.length !== 1 || !/^sha256:[0-9a-f]{64}$/u.test(imageIds[0] ?? '')) { + throw new Error('hosted_e2e_app_image_cleanup_probe_invalid'); + } + await runDocker(['image', 'rm', '--force', appImage], environment); + if ((await inspect()) !== '') throw new Error('hosted_e2e_app_image_cleanup_incomplete'); +} + +/** + * Marks cleanup eligibility before the Docker build client starts. A terminated build can publish + * its tag before the client reports success, so waiting for a successful return would leak it. + */ +export function createHostedV1SharedAppImageLifecycle( + input: HostedV1SharedAppImageLifecycleInput +): { + readonly markBuildAttempted: () => void; + readonly cleanup: (runnerError: unknown) => Promise<{ readonly runnerError: unknown }>; +} { + let buildAttempted = false; + return { + markBuildAttempted: () => { + buildAttempted = true; + }, + cleanup: async (runnerError) => { + if (!buildAttempted) return { runnerError }; + try { + await input.removeImage(input.appImage, input.environment); + return { runnerError }; + } catch (cleanupError) { + return { + runnerError: new AggregateError( + [runnerError, cleanupError].filter((value) => value !== null), + 'hosted_e2e_image_cleanup_failed' + ), + }; + } + }, + }; +} diff --git a/scripts/e2e/hosted-v1/foregroundSubprocess.ts b/scripts/e2e/hosted-v1/foregroundSubprocess.ts new file mode 100644 index 0000000000..ddcd716506 --- /dev/null +++ b/scripts/e2e/hosted-v1/foregroundSubprocess.ts @@ -0,0 +1,231 @@ +import { spawn } from 'node:child_process'; +import { performance } from 'node:perf_hooks'; + +const HOSTED_V1_PROCESS_GROUP_TERM_GRACE_MS = 5_000; +const HOSTED_V1_PROCESS_GROUP_KILL_GRACE_MS = 5_000; +const HOSTED_V1_PROCESS_GROUP_POLL_MS = 25; + +type HostedV1ProcessGroupSignal = 'SIGKILL' | 'SIGTERM'; + +export interface HostedV1ForegroundChild { + readonly pid?: number; + once(event: 'error', listener: (error: Error) => void): this; + once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + removeListener(event: 'error', listener: (error: Error) => void): this; + removeListener( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void + ): this; +} + +export type HostedV1ForegroundSpawn = ( + command: string, + args: readonly string[], + options: Readonly<{ + cwd: string; + detached: true; + env: NodeJS.ProcessEnv; + stdio: 'inherit'; + }> +) => HostedV1ForegroundChild; + +export interface HostedV1ProcessGroupDrainOperations { + readonly exists: () => boolean; + readonly send: (signal: HostedV1ProcessGroupSignal) => boolean; + readonly now?: () => number; + readonly wait?: (milliseconds: number) => Promise; +} + +function assertHostedV1ProcessGroupDuration(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`hosted_e2e_process_group_${name}_invalid`); + } +} + +async function waitForHostedV1ProcessGroupExit(input: { + readonly graceMs: number; + readonly operations: HostedV1ProcessGroupDrainOperations; + readonly pollMs: number; +}): Promise { + const now = input.operations.now ?? (() => performance.now()); + const wait = + input.operations.wait ?? + ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const startedAt = now(); + if (!Number.isFinite(startedAt)) throw new Error('hosted_e2e_process_group_clock_invalid'); + const deadline = startedAt + input.graceMs; + if (!Number.isFinite(deadline)) throw new Error('hosted_e2e_process_group_clock_invalid'); + + for (;;) { + if (!input.operations.exists()) return true; + const observedAt = now(); + if (!Number.isFinite(observedAt)) throw new Error('hosted_e2e_process_group_clock_invalid'); + const remainingMs = Math.max(0, Math.floor(deadline - observedAt)); + if (remainingMs === 0) return false; + await wait(Math.min(input.pollMs, remainingMs)); + } +} + +/** + * Terminates the complete test-owned process group and does not return until every member has + * disappeared. This keeps Playwright workers and Chromium descendants from surviving an aborted + * package-manager wrapper. + */ +export async function drainHostedV1ProcessGroup(input: { + readonly operations: HostedV1ProcessGroupDrainOperations; + readonly killGraceMs?: number; + readonly pollMs?: number; + readonly termGraceMs?: number; +}): Promise { + const termGraceMs = input.termGraceMs ?? HOSTED_V1_PROCESS_GROUP_TERM_GRACE_MS; + const killGraceMs = input.killGraceMs ?? HOSTED_V1_PROCESS_GROUP_KILL_GRACE_MS; + const pollMs = input.pollMs ?? HOSTED_V1_PROCESS_GROUP_POLL_MS; + assertHostedV1ProcessGroupDuration(termGraceMs, 'term_grace'); + assertHostedV1ProcessGroupDuration(killGraceMs, 'kill_grace'); + if (!Number.isSafeInteger(pollMs) || pollMs < 1) { + throw new Error('hosted_e2e_process_group_poll_invalid'); + } + + if (!input.operations.send('SIGTERM')) return; + if ( + await waitForHostedV1ProcessGroupExit({ + graceMs: termGraceMs, + operations: input.operations, + pollMs, + }) + ) { + return; + } + if (!input.operations.send('SIGKILL')) return; + if ( + !(await waitForHostedV1ProcessGroupExit({ + graceMs: killGraceMs, + operations: input.operations, + pollMs, + })) + ) { + throw new Error('hosted_e2e_process_group_cleanup_failed'); + } +} + +function isMissingHostedV1ProcessGroup(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'ESRCH' + ); +} + +function hostedV1ProcessGroupOperations( + processGroupId: number +): HostedV1ProcessGroupDrainOperations { + const signal = (requestedSignal: HostedV1ProcessGroupSignal | 0): boolean => { + try { + process.kill(-processGroupId, requestedSignal); + return true; + } catch (error) { + if (isMissingHostedV1ProcessGroup(error)) return false; + throw error; + } + }; + return Object.freeze({ + exists: () => signal(0), + send: (requestedSignal: HostedV1ProcessGroupSignal) => signal(requestedSignal), + }); +} + +function hostedV1SubprocessAbortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error('hosted_e2e_foreground_subprocess_aborted'); +} + +/** Runs one foreground command in a private POSIX process group with bounded descendant cleanup. */ +export function runHostedV1ForegroundSubprocess(input: { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + readonly drainProcessGroup?: (processGroupId: number) => Promise; + readonly signal?: AbortSignal; + readonly spawnProcess?: HostedV1ForegroundSpawn; + readonly timeoutMs: number; +}): Promise { + if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1) { + return Promise.reject(new Error('hosted_e2e_subprocess_timeout_invalid')); + } + if (input.signal?.aborted === true) { + return Promise.reject(hostedV1SubprocessAbortReason(input.signal)); + } + + return new Promise((resolveRun, rejectRun) => { + const spawnProcess: HostedV1ForegroundSpawn = + input.spawnProcess ?? + ((command, args, options) => spawn(command, [...args], options) as HostedV1ForegroundChild); + const child = spawnProcess(input.command, input.args, { + cwd: input.cwd, + detached: true, + env: input.environment, + stdio: 'inherit', + }); + let settled = false; + let terminationStarted = false; + + const removeListeners = (): void => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', onAbort); + child.removeListener('error', onError); + child.removeListener('exit', onExit); + }; + const settle = (error: unknown): void => { + if (settled) return; + settled = true; + removeListeners(); + if (error === null) resolveRun(); + else rejectRun(error); + }; + const drainAndSettle = (result: Error | null): void => { + if (settled || terminationStarted) return; + terminationStarted = true; + clearTimeout(timeout); + input.signal?.removeEventListener('abort', onAbort); + const processGroupId = child.pid; + if (!Number.isSafeInteger(processGroupId) || (processGroupId ?? 0) < 1) { + settle(result); + return; + } + const drainProcessGroup = + input.drainProcessGroup ?? + ((groupId: number) => + drainHostedV1ProcessGroup({ operations: hostedV1ProcessGroupOperations(groupId) })); + void drainProcessGroup(processGroupId as number).then( + () => settle(result), + (cleanupError: unknown) => + settle( + new AggregateError( + [result, cleanupError].filter((value) => value !== null), + 'hosted_e2e_foreground_subprocess_cleanup_failed' + ) + ) + ); + }; + const onAbort = (): void => drainAndSettle(hostedV1SubprocessAbortReason(input.signal!)); + const onError = (error: Error): void => drainAndSettle(error); + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + if (terminationStarted) return; + drainAndSettle( + code === 0 ? null : new Error(`${input.command} exited with ${code ?? signal ?? 'unknown'}`) + ); + }; + + child.once('error', onError); + child.once('exit', onExit); + input.signal?.addEventListener('abort', onAbort, { once: true }); + const timeout = setTimeout( + () => drainAndSettle(new Error(`${input.command} exceeded timeout ${input.timeoutMs}ms`)), + input.timeoutMs + ); + if (input.signal?.aborted === true) onAbort(); + }); +} diff --git a/scripts/e2e/hosted-v1/run.ts b/scripts/e2e/hosted-v1/run.ts new file mode 100644 index 0000000000..8effbd8400 --- /dev/null +++ b/scripts/e2e/hosted-v1/run.ts @@ -0,0 +1,2270 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + access, + chmod, + chown, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { encodeReplayCursor } from '../../../src/features/coordination-events'; +import { ProjectScanner } from '../../../src/main/services/discovery/ProjectScanner'; +import { + assertHostedV1MarkerOwnedRoot, + createHostedV1Sandbox, + E2E_FORBIDDEN_WORKSPACE_ID, + E2E_PROJECT_WORKSPACE_ID, + E2E_RUNTIME_WORKSPACE_ID, + E2E_TEAM_ID, + E2E_TEAM_NAME, + E2E_TEAM_RUNTIME_WORKSPACE_ID, + E2E_WORKSPACE_ID, + type HostedV1Sandbox, +} from '../../../test/fixtures/hosted-v1/createSandbox'; +import { + HOSTED_V1_BROWSER_SUITES, + parseHostedV1BrowserSuite, +} from '../../../test/fixtures/hosted-v1/browserSuites'; +import { createHostedV1SharedAppImageLifecycle, removeHostedV1AppImage } from './appImageCleanup'; +import { runHostedV1ForegroundSubprocess } from './foregroundSubprocess'; + +export { createHostedV1SharedAppImageLifecycle, removeHostedV1AppImage } from './appImageCleanup'; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const composeFile = join(repositoryRoot, 'docker', 'docker-compose.e2e.yml'); +const playwrightConfig = join(repositoryRoot, 'test', 'e2e', 'hosted-v1', 'playwright.config.ts'); +const requiredDigest = /^sha256:[0-9a-f]{64}$/; +const providerEnvironmentKey = + /(?:ANTHROPIC|CLAUDE|CODEX|OPENAI|OPENCODE|GEMINI|GOOGLE.*API|CURSOR|GITHUB_TOKEN|GH_TOKEN|API_KEY|AUTH_TOKEN|OAUTH_TOKEN)/i; +const ambientContainerContextKey = /^(?:COMPOSE_|DOCKER_CONTEXT$|DOCKER_HOST$)/u; +const sanitizedEnvironment = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !providerEnvironmentKey.test(key) && !ambientContainerContextKey.test(key) + ) +); +const deploymentId = 'deployment_hosted-v1-e2e'; +const PLAYWRIGHT_ARTIFACT_TOTAL_MAX_BYTES = 4 * 1024 * 1024; +const PLAYWRIGHT_ARTIFACT_FILE_MAX_BYTES = 16 * 1024; +const PLAYWRIGHT_BINARY_ARTIFACT = /\.(?:jpe?g|png|webp|webm|zip)$/iu; +const HOSTED_V1_EVIDENCE_TEXT_MAX_BYTES = 16 * 1024 * 1024; +const HOSTED_V1_SUBPROCESS_TIMEOUT_MS = 15 * 60_000; +const HOSTED_V1_DOCKER_BUILD_TIMEOUT_MS = 30 * 60_000; +const HOSTED_V1_SOURCE_HEAD_LABEL = 'org.agent-teams.hosted-e2e.source-head-commit'; +const HOSTED_V1_SOURCE_PATCH_LABEL = 'org.agent-teams.hosted-e2e.source-patch-sha256'; +let activeRunAbortSignal: AbortSignal | undefined; +type ScenarioMode = 'oidc' | 'oidc-viewer' | 'personal'; +export { parseHostedV1BrowserSuite } from '../../../test/fixtures/hosted-v1/browserSuites'; +export const CADDY_HTTPS_TARGET_PORT = 443; +const CADDY_HTTPS_PUBLISHED_PORT_MIN = 49_152; +const CADDY_HTTPS_PUBLISHED_PORT_MAX = 65_535; + +export interface HostedV1ScannerEvidence { + readonly expectedProjectFound: boolean; + readonly expectedRuntimeWorkspaceId: string; + readonly projectCount: number; + readonly projects: readonly { + readonly runtimeWorkspaceId: string; + readonly sessionCount: number | undefined; + }[]; +} + +type HostedV1InterruptSignal = 'SIGINT' | 'SIGTERM'; + +export interface HostedV1ProbeBodyReader { + readonly read: () => Promise< + Readonly<{ done: false; value: Uint8Array }> | Readonly<{ done: true; value?: Uint8Array }> + >; + readonly cancel: (reason?: unknown) => Promise | unknown; +} + +export interface HostedV1FetchLikeResponse { + readonly headers: Readonly<{ + get(name: string): string | null; + }>; + readonly body: Readonly<{ + getReader(): HostedV1ProbeBodyReader; + }> | null; +} + +export interface HostedV1OriginalHttpResponseLike { + readonly body: () => Promise; + readonly headersArray: () => Promise< + readonly { readonly name: string; readonly value: string }[] + >; + readonly request: () => Readonly<{ method(): string }>; + readonly status: () => number; + readonly url: () => string; +} + +export interface HostedV1OriginalHttpResponseCapture { + readonly capture: 'playwright_original_response'; + readonly method: string; + readonly url: string; + readonly status: number; + readonly declaredBodyBytes: number; + readonly bodyBytes: number; + readonly rawBody: string; +} + +export interface HostedV1ProbeDeadlineBudget { + readonly overallDeadlineMs: number; + readonly remainingMs: () => number; + readonly nextAttemptTimeoutMs: () => number; + readonly clipRetryDelayMs: (requestedDelayMs: number) => number; +} + +function assertHostedV1ProbeDuration(value: number, name: string, allowZero: boolean): void { + if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) { + throw new Error(`hosted_e2e_probe_${name}_invalid`); + } +} + +/** + * Creates one fixed, monotonic deadline for a retrying probe. Every attempt and retry delay is + * clipped to the same remaining overall budget, so retries cannot reset the operation deadline. + */ +export function createHostedV1ProbeDeadlineBudget(input: { + readonly overallTimeoutMs: number; + readonly perAttemptTimeoutMs: number; + readonly now?: () => number; +}): HostedV1ProbeDeadlineBudget { + assertHostedV1ProbeDuration(input.overallTimeoutMs, 'overall_timeout', false); + assertHostedV1ProbeDuration(input.perAttemptTimeoutMs, 'attempt_timeout', false); + const clock = input.now ?? (() => performance.now()); + const readClock = (): number => { + const value = clock(); + if (!Number.isFinite(value)) throw new Error('hosted_e2e_probe_clock_invalid'); + return value; + }; + let lastObservedNowMs = readClock(); + const overallDeadlineMs = lastObservedNowMs + input.overallTimeoutMs; + if (!Number.isFinite(overallDeadlineMs)) { + throw new Error('hosted_e2e_probe_overall_timeout_invalid'); + } + const remainingMs = (): number => { + lastObservedNowMs = Math.max(lastObservedNowMs, readClock()); + return Math.max(0, Math.floor(overallDeadlineMs - lastObservedNowMs)); + }; + const requireRemainingMs = (): number => { + const remaining = remainingMs(); + if (remaining <= 0) throw new Error('hosted_e2e_probe_overall_deadline_exhausted'); + return remaining; + }; + return Object.freeze({ + overallDeadlineMs, + remainingMs, + nextAttemptTimeoutMs: () => Math.min(input.perAttemptTimeoutMs, requireRemainingMs()), + clipRetryDelayMs: (requestedDelayMs: number) => { + assertHostedV1ProbeDuration(requestedDelayMs, 'retry_delay', true); + return Math.min(requestedDelayMs, requireRemainingMs()); + }, + }); +} + +function hostedV1ProbeAbortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error('hosted_e2e_probe_aborted', { cause: signal.reason }); +} + +function cancelHostedV1ProbeReader(reader: HostedV1ProbeBodyReader, reason: unknown): void { + try { + void Promise.resolve(reader.cancel(reason)).catch(() => undefined); + } catch { + // Preserve the primary bound/abort failure; cancellation is best-effort teardown. + } +} + +function cancelHostedV1ProbeBody(response: HostedV1FetchLikeResponse, reason: unknown): void { + if (response.body === null) return; + try { + cancelHostedV1ProbeReader(response.body.getReader(), reason); + } catch { + // Preserve the primary header validation failure if the body cannot be locked. + } +} + +function readHostedV1ProbeChunk( + reader: HostedV1ProbeBodyReader, + signal: AbortSignal, + cancel: (reason: unknown) => void +): Promise< + Readonly<{ done: false; value: Uint8Array }> | Readonly<{ done: true; value?: Uint8Array }> +> { + if (signal.aborted) { + const reason = hostedV1ProbeAbortReason(signal); + cancel(reason); + return Promise.reject(reason); + } + let removeAbortListener = (): void => undefined; + const aborted = new Promise((_resolve, reject) => { + const onAbort = (): void => { + const reason = hostedV1ProbeAbortReason(signal); + cancel(reason); + reject(reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + removeAbortListener = () => signal.removeEventListener('abort', onAbort); + // Cover an abort racing between the pre-check and listener registration. + if (signal.aborted) onAbort(); + }); + return Promise.race([Promise.resolve().then(() => reader.read()), aborted]).finally( + removeAbortListener + ); +} + +/** Reads a Fetch-like response body while refusing to accept more than `maximumBytes` raw bytes. */ +export async function readHostedV1ProbeResponseBody( + response: HostedV1FetchLikeResponse, + input: { + readonly maximumBytes: number; + readonly signal: AbortSignal; + } +): Promise { + if (!Number.isSafeInteger(input.maximumBytes) || input.maximumBytes < 0) { + throw new Error('hosted_e2e_probe_body_byte_limit_invalid'); + } + if (input.signal.aborted) { + const reason = hostedV1ProbeAbortReason(input.signal); + cancelHostedV1ProbeBody(response, reason); + throw reason; + } + + let contentLength: string | null; + try { + contentLength = response.headers.get('content-length'); + } catch (cause) { + const error = new Error('hosted_e2e_probe_content_length_invalid', { cause }); + cancelHostedV1ProbeBody(response, error); + throw error; + } + let declaredContentLength: number | null = null; + if (contentLength !== null) { + if (!/^[0-9]+$/u.test(contentLength)) { + const error = new Error('hosted_e2e_probe_content_length_invalid'); + cancelHostedV1ProbeBody(response, error); + throw error; + } + const normalizedContentLength = contentLength.replace(/^0+/u, '') || '0'; + const maximumBytes = String(input.maximumBytes); + if ( + normalizedContentLength.length > maximumBytes.length || + (normalizedContentLength.length === maximumBytes.length && + normalizedContentLength > maximumBytes) + ) { + const error = new Error('hosted_e2e_probe_body_byte_limit_exceeded'); + cancelHostedV1ProbeBody(response, error); + throw error; + } + declaredContentLength = Number(normalizedContentLength); + } + + if (response.body === null) { + if (declaredContentLength !== null && declaredContentLength > 0) { + throw new Error('hosted_e2e_probe_body_missing'); + } + return ''; + } + + const reader = response.body.getReader(); + let cancelled = false; + const cancel = (reason: unknown): void => { + if (cancelled) return; + cancelled = true; + cancelHostedV1ProbeReader(reader, reason); + }; + const chunks: Uint8Array[] = []; + let receivedBytes = 0; + try { + for (;;) { + const result = await readHostedV1ProbeChunk(reader, input.signal, cancel); + if (result.done) { + if (result.value !== undefined) { + const error = new Error('hosted_e2e_probe_body_chunk_invalid'); + cancel(error); + throw error; + } + break; + } + if (!(result.value instanceof Uint8Array)) { + const error = new Error('hosted_e2e_probe_body_chunk_invalid'); + cancel(error); + throw error; + } + if (result.value.byteLength > input.maximumBytes - receivedBytes) { + const error = new Error('hosted_e2e_probe_body_byte_limit_exceeded'); + cancel(error); + throw error; + } + receivedBytes += result.value.byteLength; + // Do not retain an arbitrarily large backing buffer through a small Uint8Array view. + chunks.push(new Uint8Array(result.value)); + } + } catch (error) { + cancel(error); + throw error; + } + + const body = new Uint8Array(receivedBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(body); + } catch (cause) { + throw new Error('hosted_e2e_probe_body_utf8_invalid', { cause }); + } +} + +/** + * Captures the already-observed Playwright response without replaying its request. Playwright's + * `body()` buffers the entire decoded entity, so it is called only after the original header array + * proves one identity-encoded Content-Length within the hard byte cap. Responses without that + * transport proof are deliberately unusable as bounded E2E evidence. + */ +export async function captureOriginalHostedV1HttpResponse( + response: HostedV1OriginalHttpResponseLike, + input: { + readonly maximumBytes: number; + readonly overallDeadlineAtMs: number; + } +): Promise { + if ( + !Number.isSafeInteger(input.maximumBytes) || + input.maximumBytes < 0 || + !Number.isSafeInteger(input.overallDeadlineAtMs) + ) { + throw new Error('hosted_e2e_original_response_limits_invalid'); + } + + const headers = await response.headersArray(); + const named = (name: string) => headers.filter((header) => header.name.toLowerCase() === name); + const contentLengths = named('content-length'); + const contentEncodings = named('content-encoding'); + const transferEncodings = named('transfer-encoding'); + if ( + contentLengths.length !== 1 || + contentEncodings.length > 1 || + transferEncodings.length !== 0 || + (contentEncodings.length === 1 && contentEncodings[0]?.value.toLowerCase() !== 'identity') + ) { + throw new Error('hosted_e2e_original_response_transport_bound_invalid'); + } + const contentLength = contentLengths[0]?.value; + if (contentLength === undefined || !/^(?:0|[1-9][0-9]*)$/u.test(contentLength)) { + throw new Error('hosted_e2e_original_response_content_length_invalid'); + } + const normalizedMaximum = String(input.maximumBytes); + if ( + contentLength.length > normalizedMaximum.length || + (contentLength.length === normalizedMaximum.length && contentLength > normalizedMaximum) + ) { + throw new Error('hosted_e2e_original_response_body_too_large'); + } + const declaredBodyBytes = Number(contentLength); + if (!Number.isSafeInteger(declaredBodyBytes)) { + throw new Error('hosted_e2e_original_response_content_length_invalid'); + } + + const remainingMs = input.overallDeadlineAtMs - Date.now(); + if (remainingMs <= 0) throw new Error('hosted_e2e_original_response_deadline'); + let deadline: ReturnType | undefined; + const bodyBytes = await Promise.race([ + response.body(), + new Promise((_resolve, reject) => { + deadline = setTimeout( + () => reject(new Error('hosted_e2e_original_response_deadline')), + remainingMs + ); + }), + ]).finally(() => { + if (deadline !== undefined) clearTimeout(deadline); + }); + if (!(bodyBytes instanceof Uint8Array) || bodyBytes.byteLength !== declaredBodyBytes) { + throw new Error('hosted_e2e_original_response_content_length_mismatch'); + } + let rawBody: string; + try { + rawBody = new TextDecoder('utf-8', { fatal: true }).decode(bodyBytes); + } catch { + throw new Error('hosted_e2e_original_response_body_utf8_invalid'); + } + const request = response.request(); + return Object.freeze({ + capture: 'playwright_original_response', + method: request.method(), + url: response.url(), + status: response.status(), + declaredBodyBytes, + bodyBytes: bodyBytes.byteLength, + rawBody, + }); +} + +export function registerHostedV1InterruptHandlers(input: { + readonly once: (signal: HostedV1InterruptSignal, listener: () => void) => void; + readonly remove: (signal: HostedV1InterruptSignal, listener: () => void) => void; +}): Readonly<{ signal: AbortSignal; dispose(): void }> { + const controller = new AbortController(); + const interrupt = (signal: HostedV1InterruptSignal): void => { + controller.abort(new Error(`hosted_e2e_interrupted:${signal}`)); + }; + const onSigint = (): void => interrupt('SIGINT'); + const onSigterm = (): void => interrupt('SIGTERM'); + input.once('SIGINT', onSigint); + input.once('SIGTERM', onSigterm); + let disposed = false; + return Object.freeze({ + signal: controller.signal, + dispose: () => { + if (disposed) return; + disposed = true; + input.remove('SIGINT', onSigint); + input.remove('SIGTERM', onSigterm); + }, + }); +} + +export function beginHostedV1CleanupSignalScope(input: { + readonly activeSignal: AbortSignal; + readonly replaceActiveSignal: (signal: AbortSignal) => void; +}): Readonly<{ + readonly cleanupSignal: AbortSignal; + readonly interruptedSignal: AbortSignal; + restore(): void; +}> { + const cleanupController = new AbortController(); + input.replaceActiveSignal(cleanupController.signal); + let restored = false; + return Object.freeze({ + cleanupSignal: cleanupController.signal, + interruptedSignal: input.activeSignal, + restore: () => { + if (restored) return; + restored = true; + input.replaceActiveSignal(input.activeSignal); + }, + }); +} + +export function mergeHostedV1CleanupInterruption( + interruptedSignal: AbortSignal, + runnerError: unknown +): unknown { + if (!interruptedSignal.aborted || runnerError !== null) return runnerError; + return interruptedSignal.reason instanceof Error + ? interruptedSignal.reason + : new Error('hosted_e2e_interrupted'); +} + +export type ProjectAccessClassification = + | 'grant_null' + | 'grant_unavailable' + | 'project_admitted' + | 'public_mapping_mismatch' + | 'registration_inactive' + | 'scanner_empty' + | 'scanner_unavailable'; + +interface HostedV1ControllerProjectEvidence { + readonly exactExpectedPublicProject: boolean; + readonly projectCount: number | null; + readonly rawRuntimeIdentityAbsent: boolean; + readonly rawRuntimePathAbsent: boolean; + readonly status: 'observed' | 'unavailable'; +} + +interface HostedV1GrantEvidence { + readonly classification: ProjectAccessClassification; + readonly expectedPublicWorkspaceId: string; + readonly expectedPublicWorkspaceMapped: boolean; + readonly expectedRuntimeWorkspaceId: string; + readonly fixturePrincipalFound: boolean; + readonly fixturePrincipalGrantFound: boolean; + readonly controllerProjectEvidence: HostedV1ControllerProjectEvidence; + readonly registrationStatus: string | null; + readonly schemaVersion: 1; + readonly storageLayout: 'app-data/data/storage/app.db'; +} + +interface ComposeUpWithExplicitPortInput { + readonly buildImage?: () => Promise; + readonly createEnvironment: (port: number) => NodeJS.ProcessEnv; + readonly publishedPort: number; + readonly readCaddyPublishers: (environment: NodeJS.ProcessEnv) => Promise; + readonly startCaddy: (environment: NodeJS.ProcessEnv) => Promise; + readonly startRemainingServices: (environment: NodeJS.ProcessEnv) => Promise; +} + +interface BuildHostedV1AppImageInput { + readonly composeArgs: readonly string[]; + readonly environment: NodeJS.ProcessEnv; + readonly runDocker: ( + args: readonly string[], + environment: NodeJS.ProcessEnv, + timeoutMs: number + ) => Promise; +} + +export interface HostedV1SourceDeclaration { + readonly schemaVersion: 1; + readonly declaration: 'git-head-and-working-tree-patch-digest'; + readonly headCommit: string; + readonly patchBytes: number; + readonly patchSha256: string; + readonly untrackedPaths: 0; +} + +export interface HostedV1AppImageEvidence { + readonly schemaVersion: 1; + readonly appImage: string; + readonly imageId: string; + readonly repoDigests: readonly string[]; + readonly sourceDeclarationCorrelation: { + readonly headCommit: string; + readonly patchSha256: string; + }; +} + +export async function buildHostedV1AppImage(input: BuildHostedV1AppImageInput): Promise { + await input.runDocker( + [...input.composeArgs, 'build', 'hosted-controller'], + input.environment, + HOSTED_V1_DOCKER_BUILD_TIMEOUT_MS + ); +} + +/** + * A production owner handoff is consumed after its first successful readiness lease. Replacing the + * owner socket therefore requires a fresh signed manifest and a controller process that admits + * that successor; reconnecting the old controller across a new socket inode is intentionally + * forbidden. + */ +export async function restartHostedV1LifecycleOwner(input: { + readonly compose: (...args: readonly string[]) => Promise; +}): Promise { + await input.compose('stop', '--timeout', '45', 'hosted-controller'); + await input.compose('restart', 'fake-runtime'); + await input.compose('up', '--no-build', '--detach', '--wait', '--no-deps', 'fake-runtime'); + await input.compose('up', '--no-build', '--detach', '--wait', '--no-deps', 'hosted-controller'); +} + +export function markerDerivedCaddyPublishedPort(marker: string): number { + if (!/^[0-9a-f]{48}$/u.test(marker)) throw new Error('hosted_e2e_marker_invalid'); + const range = CADDY_HTTPS_PUBLISHED_PORT_MAX - CADDY_HTTPS_PUBLISHED_PORT_MIN + 1; + return CADDY_HTTPS_PUBLISHED_PORT_MIN + (Number.parseInt(marker.slice(0, 8), 16) % range); +} + +export function allocateHostedV1CaddyPublishedPorts(markers: readonly string[]): readonly number[] { + const range = CADDY_HTTPS_PUBLISHED_PORT_MAX - CADDY_HTTPS_PUBLISHED_PORT_MIN + 1; + const used = new Set(); + return markers.map((marker) => { + let port = markerDerivedCaddyPublishedPort(marker); + while (used.has(port)) { + port = CADDY_HTTPS_PUBLISHED_PORT_MIN + ((port - CADDY_HTTPS_PUBLISHED_PORT_MIN + 1) % range); + } + used.add(port); + return port; + }); +} + +export function parseDockerComposeCaddyPort(output: string, expectedPublishedPort: number): number { + let observation: unknown; + try { + observation = JSON.parse(output); + } catch { + throw new Error('hosted_e2e_caddy_port_invalid'); + } + const container: unknown = Array.isArray(observation) + ? observation.length === 1 + ? observation[0] + : null + : observation; + if ( + typeof container !== 'object' || + container === null || + !('Service' in container) || + container.Service !== 'caddy' || + !('Publishers' in container) || + !Array.isArray(container.Publishers) + ) { + throw new Error('hosted_e2e_caddy_port_invalid'); + } + let publishedCaddyPort: number | null = null; + for (const publisher of container.Publishers) { + if (typeof publisher !== 'object' || publisher === null) { + throw new Error('hosted_e2e_caddy_port_invalid'); + } + const url = 'URL' in publisher ? publisher.URL : undefined; + const hostIp = 'HostIp' in publisher ? publisher.HostIp : undefined; + const port = 'PublishedPort' in publisher ? publisher.PublishedPort : undefined; + const targetPort = 'TargetPort' in publisher ? publisher.TargetPort : undefined; + const protocol = 'Protocol' in publisher ? publisher.Protocol : undefined; + if ( + (url !== undefined && typeof url !== 'string') || + (hostIp !== undefined && typeof hostIp !== 'string') || + typeof port !== 'number' || + !Number.isSafeInteger(port) || + port < 0 || + typeof targetPort !== 'number' || + !Number.isSafeInteger(targetPort) || + targetPort < 1 || + targetPort > 65_535 || + (protocol !== 'tcp' && protocol !== 'udp') + ) { + throw new Error('hosted_e2e_caddy_port_invalid'); + } + if (port === 0) { + if ((url ?? '') !== '' || (hostIp ?? '') !== '') { + throw new Error('hosted_e2e_caddy_port_invalid'); + } + continue; + } + const host = url === '' || url === undefined ? hostIp : url; + if ( + publishedCaddyPort !== null || + (url !== undefined && hostIp !== undefined && url !== '' && url !== hostIp) || + host !== '127.0.0.1' || + targetPort !== CADDY_HTTPS_TARGET_PORT || + protocol !== 'tcp' || + port < CADDY_HTTPS_PUBLISHED_PORT_MIN || + port > CADDY_HTTPS_PUBLISHED_PORT_MAX || + port !== expectedPublishedPort + ) { + throw new Error('hosted_e2e_caddy_port_invalid'); + } + publishedCaddyPort = port; + } + if (publishedCaddyPort === null) throw new Error('hosted_e2e_caddy_port_invalid'); + return publishedCaddyPort; +} + +export function assertDockerComposeServiceNotPublished( + output: string, + expectedService: 'fake-runtime' | 'hosted-controller' | 'synthetic-oidc' +): void { + let observation: unknown; + try { + observation = JSON.parse(output); + } catch { + throw new Error(`hosted_e2e_private_listener_observation_invalid:${expectedService}`); + } + const container: unknown = Array.isArray(observation) + ? observation.length === 1 + ? observation[0] + : null + : observation; + if ( + typeof container !== 'object' || + container === null || + !('Service' in container) || + container.Service !== expectedService || + !('Publishers' in container) || + !Array.isArray(container.Publishers) + ) { + throw new Error(`hosted_e2e_private_listener_observation_invalid:${expectedService}`); + } + for (const publisher of container.Publishers) { + if (typeof publisher !== 'object' || publisher === null) { + throw new Error(`hosted_e2e_private_listener_observation_invalid:${expectedService}`); + } + const url = 'URL' in publisher ? publisher.URL : undefined; + const hostIp = 'HostIp' in publisher ? publisher.HostIp : undefined; + const publishedPort = 'PublishedPort' in publisher ? publisher.PublishedPort : undefined; + const targetPort = 'TargetPort' in publisher ? publisher.TargetPort : undefined; + const protocol = 'Protocol' in publisher ? publisher.Protocol : undefined; + if ( + typeof publishedPort !== 'number' || + !Number.isSafeInteger(publishedPort) || + publishedPort < 0 + ) { + throw new Error(`hosted_e2e_private_listener_observation_invalid:${expectedService}`); + } + if (publishedPort > 0) { + throw new Error(`hosted_e2e_private_listener_published:${expectedService}`); + } + if ( + (url !== undefined && typeof url !== 'string') || + (hostIp !== undefined && typeof hostIp !== 'string') || + typeof targetPort !== 'number' || + !Number.isSafeInteger(targetPort) || + targetPort < 1 || + targetPort > 65_535 || + (protocol !== 'tcp' && protocol !== 'udp') + ) { + throw new Error(`hosted_e2e_private_listener_observation_invalid:${expectedService}`); + } + if ((url ?? '') !== '' || (hostIp ?? '') !== '') { + throw new Error(`hosted_e2e_private_listener_observation_invalid:${expectedService}`); + } + } +} + +export async function runComposeUpWithExplicitPort( + input: ComposeUpWithExplicitPortInput +): Promise { + await input.buildImage?.(); + const environment = input.createEnvironment(input.publishedPort); + await input.startCaddy(environment); + parseDockerComposeCaddyPort(await input.readCaddyPublishers(environment), input.publishedPort); + await input.startRemainingServices(environment); + return environment; +} + +export async function restoreHostedV1NodeAbi(input: { + readonly environment: NodeJS.ProcessEnv; + readonly runNode: (args: readonly string[], environment: NodeJS.ProcessEnv) => Promise; +}): Promise { + await input.runNode(['scripts/ci/rebuild-better-sqlite3-node.cjs'], input.environment); +} + +export function createHostedV1SourceDeclaration(input: { + readonly headCommit: string; + readonly patch: string; + readonly untracked: string; +}): HostedV1SourceDeclaration { + if (!/^[0-9a-f]{40}$/u.test(input.headCommit)) { + throw new Error('hosted_e2e_source_head_invalid'); + } + if (input.untracked.length !== 0) { + throw new Error('hosted_e2e_untracked_source_forbidden'); + } + return Object.freeze({ + schemaVersion: 1, + declaration: 'git-head-and-working-tree-patch-digest', + headCommit: input.headCommit, + patchBytes: Buffer.byteLength(input.patch), + patchSha256: createHash('sha256').update(input.patch).digest('hex'), + untrackedPaths: 0, + }); +} + +export function parseHostedV1AppImageEvidence( + output: string, + appImage: string, + source: HostedV1SourceDeclaration +): HostedV1AppImageEvidence { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + throw new Error('hosted_e2e_app_image_inspection_invalid'); + } + const image = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : null; + if (typeof image !== 'object' || image === null) { + throw new Error('hosted_e2e_app_image_inspection_invalid'); + } + const candidate = image as { + readonly Config?: { readonly Labels?: Record }; + readonly Id?: unknown; + readonly RepoDigests?: unknown; + readonly RepoTags?: unknown; + }; + const labels = candidate.Config?.Labels; + if ( + typeof candidate.Id !== 'string' || + !/^sha256:[0-9a-f]{64}$/u.test(candidate.Id) || + !Array.isArray(candidate.RepoTags) || + !candidate.RepoTags.every((tag) => typeof tag === 'string') || + !candidate.RepoTags.includes(appImage) || + !Array.isArray(candidate.RepoDigests) || + !candidate.RepoDigests.every((digest) => typeof digest === 'string') || + typeof labels !== 'object' || + labels === null || + labels[HOSTED_V1_SOURCE_HEAD_LABEL] !== source.headCommit || + labels[HOSTED_V1_SOURCE_PATCH_LABEL] !== source.patchSha256 + ) { + throw new Error('hosted_e2e_app_image_inspection_invalid'); + } + return Object.freeze({ + schemaVersion: 1, + appImage, + imageId: candidate.Id, + repoDigests: Object.freeze([...candidate.RepoDigests].sort()), + sourceDeclarationCorrelation: Object.freeze({ + headCommit: source.headCommit, + patchSha256: source.patchSha256, + }), + }); +} + +function envDigest( + name: 'NODE_IMAGE_DIGEST' | 'CADDY_IMAGE_DIGEST' | 'KEYCLOAK_IMAGE_DIGEST' +): string { + const value = process.env[name]; + if (!value || !requiredDigest.test(value)) { + throw new Error(`${name} must be an audited sha256 digest`); + } + return value; +} + +async function run( + command: string, + args: readonly string[], + options: { + readonly env?: NodeJS.ProcessEnv; + readonly capture?: boolean; + readonly timeoutMs?: number; + readonly trim?: boolean; + } = {} +): Promise { + const timeout = options.timeoutMs ?? HOSTED_V1_SUBPROCESS_TIMEOUT_MS; + if (!Number.isSafeInteger(timeout) || timeout < 1) { + throw new Error('hosted_e2e_subprocess_timeout_invalid'); + } + if (options.capture) { + const result = await execFileAsync(command, [...args], { + cwd: repositoryRoot, + env: options.env ?? sanitizedEnvironment, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + signal: activeRunAbortSignal, + timeout, + }); + const stdout = String(result.stdout); + return options.trim === false ? stdout : stdout.trim(); + } + await runHostedV1ForegroundSubprocess({ + args, + command, + cwd: repositoryRoot, + environment: options.env ?? sanitizedEnvironment, + signal: activeRunAbortSignal, + timeoutMs: timeout, + }); + return ''; +} + +async function collectHostedV1SourceDeclaration(): Promise { + const [headCommit, patch, untracked] = await Promise.all([ + run('git', ['rev-parse', '--verify', 'HEAD^{commit}'], { capture: true }), + run('git', ['diff', '--binary', '--full-index', 'HEAD', '--', '.'], { + capture: true, + trim: false, + }), + run('git', ['ls-files', '--others', '--exclude-standard', '-z'], { + capture: true, + trim: false, + }), + ]); + return createHostedV1SourceDeclaration({ headCommit, patch, untracked }); +} + +async function inspectHostedV1AppImage( + appImage: string, + source: HostedV1SourceDeclaration, + environment: NodeJS.ProcessEnv +): Promise { + const output = await run('docker', ['image', 'inspect', appImage], { + capture: true, + env: environment, + }); + return parseHostedV1AppImageEvidence(output, appImage, source); +} + +function safeError(error: unknown): string { + if (error instanceof AggregateError) { + const nested = [...error.errors].map((entry) => safeError(entry)).join('; '); + const cause = error.cause === undefined ? '' : `; cause=${safeError(error.cause)}`; + return `${error.message}; errors=[${nested}]${cause}`; + } + if (error instanceof Error) { + return error.cause === undefined + ? error.message + : `${error.message}; cause=${safeError(error.cause)}`; + } + return String(error); +} + +export function assertNoComposeResourcesRemain(input: { + readonly containers: string; + readonly networks: string; + readonly volumes: string; +}): void { + if (input.containers !== '' || input.networks !== '' || input.volumes !== '') { + throw new Error('hosted_e2e_compose_orphans_remain'); + } +} + +export function boundHostedV1EvidenceUtf8(value: string, maximumBytes: number): string { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) { + throw new Error('hosted_e2e_evidence_byte_limit_invalid'); + } + const encoded = Buffer.from(value, 'utf8'); + if (encoded.byteLength <= maximumBytes) return value; + return new TextDecoder('utf-8').decode(encoded.subarray(0, maximumBytes), { stream: true }); +} + +function boundHostedV1EvidenceDocument( + value: string, + kind: 'json' | 'text', + maximumBytes: number +): string { + const fullRedactedBytes = Buffer.byteLength(value); + if (fullRedactedBytes <= maximumBytes) return value; + const fullRedactedSha256 = createHash('sha256').update(value, 'utf8').digest('hex'); + let preview = boundHostedV1EvidenceUtf8(value, Math.max(0, Math.floor(maximumBytes / 2))); + for (;;) { + const envelope = JSON.stringify({ + schemaVersion: 1, + kind, + fullRedactedBytes, + fullRedactedSha256, + truncated: true, + preview, + }); + if (Buffer.byteLength(envelope) <= maximumBytes) return envelope; + if (preview.length === 0) throw new Error('hosted_e2e_evidence_bound_too_small'); + preview = boundHostedV1EvidenceUtf8(preview, Math.floor(Buffer.byteLength(preview) / 2)); + } +} + +export function redactEvidence( + value: string, + sandbox: HostedV1Sandbox, + pairingCode: string | null, + maximumBytes = HOSTED_V1_EVIDENCE_TEXT_MAX_BYTES +): string { + const placeholderForKey = (key: string): string | null => { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/gu, ''); + if (normalized === 'authorization' || normalized === 'proxyauthorization') { + return ''; + } + if ( + normalized === 'xagentteamscsrf' || + normalized === 'xcsrftoken' || + normalized === 'csrf' || + normalized.includes('csrftoken') + ) { + return ''; + } + if (normalized === 'cookie' || normalized === 'setcookie') return ''; + if (normalized === 'code' || normalized === 'state') return ''; + if (normalized.includes('trustanchor')) return ''; + if ( + normalized.includes('password') || + normalized.includes('passwd') || + normalized.includes('passphrase') + ) { + return ''; + } + if ( + normalized.includes('secret') || + normalized.includes('credential') || + normalized.includes('privatekey') + ) { + return ''; + } + if (normalized.includes('token') || normalized.includes('apikey')) return ''; + return null; + }; + const redactText = (text: string): string => { + let redacted = text + .replaceAll(repositoryRoot, '') + .replaceAll(sandbox.root, '') + .replaceAll('/workspaces/sandbox', '') + .replaceAll('/data/.claude', '') + .replaceAll('/data/.agent-teams', '') + .replaceAll('/run/agent-teams-orchestrator', '') + .replaceAll('/run/agent-teams', '') + .replaceAll(sandbox.lifecycleTrustAnchor, ''); + if (pairingCode) redacted = redacted.replaceAll(pairingCode, ''); + return redacted + .replace(/(^|[\r\n]\s*)((?:set-cookie|cookie)\s*[:=]\s*)[^\r\n]+/gimu, '$1$2') + .replace(/(__Host-agent-teams-[A-Za-z0-9_-]+["':=\s]+)[^;,\s"']+/gu, '$1') + .replace(/([?&](?:code|state)=)[^&\s"']+/giu, '$1') + .replace( + /([?&](?:api[_-]?key|[^&=]*(?:token|secret|password|passwd|passphrase|credential|private[_-]?key|trust[_-]?anchor)[^&=]*)=)[^&\s"'#]*/giu, + '$1' + ) + .replace(/(["']?(?:code|state)["']?\s*[:=]\s*["']?)[A-Za-z0-9._-]{16,}/giu, '$1') + .replace( + /((?:x-agent-teams-csrf|csrf[_-]?token|csrfToken)["':=\s]+)[A-Za-z0-9_-]{32,}/giu, + '$1' + ) + .replace( + /((?:authorization|proxy[_-]?authorization)["':=\s]+)(?:bearer\s+)?[^\r\n,;]+/giu, + '$1' + ) + .replace( + /((?:^|\s)--?(?:api[_-]?key|[^\s]*(?:token|secret|password|passwd|passphrase|credential|private[_-]?key|trust[_-]?anchor)[^\s]*)\s+)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+)/gimu, + '$1' + ) + .replace( + /(["']?(?:api[_-]?key|[^\s"':=,;]*(?:token|secret|password|passwd|passphrase|credential|private[_-]?key|trust[_-]?anchor)[^\s"':=,;]*)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+)/giu, + (_match, prefix: string) => { + const key = /["']?([^\s"':=]+)["']?\s*[:=]\s*$/u.exec(prefix)?.[1] ?? ''; + return `${prefix}${placeholderForKey(key) ?? ''}`; + } + ) + .replace(/[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/gu, ''); + }; + const redactJson = (input: unknown, key = ''): unknown => { + const placeholder = placeholderForKey(key); + if (placeholder !== null) { + if (typeof input === 'string') return placeholder; + if (Array.isArray(input)) return input.map(() => placeholder); + if (input !== null) return placeholder; + } + if (typeof input === 'string') return redactText(input); + if (Array.isArray(input)) return input.map((item) => redactJson(item)); + if (typeof input === 'object' && input !== null) { + return Object.fromEntries( + Object.entries(input).map(([entryKey, entryValue]) => [ + entryKey, + redactJson(entryValue, entryKey), + ]) + ); + } + return input; + }; + try { + return boundHostedV1EvidenceDocument( + JSON.stringify(redactJson(JSON.parse(value))), + 'json', + maximumBytes + ); + } catch { + return boundHostedV1EvidenceDocument(redactText(value), 'text', maximumBytes); + } +} + +export async function sanitizePlaywrightEvidence( + directory: string, + sandbox: HostedV1Sandbox, + pairingCode: string | null +): Promise { + let retainedBytes = 0; + const visit = async (current: string): Promise => { + const entries = (await readdir(current, { withFileTypes: true })).toSorted((left, right) => + left.name.localeCompare(right.name) + ); + for (const entry of entries) { + const path = join(current, entry.name); + if (entry.isSymbolicLink()) + throw new Error('hosted_e2e_playwright_artifact_symlink_forbidden'); + if (entry.isDirectory()) { + await visit(path); + continue; + } + if (!entry.isFile()) throw new Error('hosted_e2e_playwright_artifact_type_forbidden'); + if (PLAYWRIGHT_BINARY_ARTIFACT.test(entry.name)) { + await rm(path); + continue; + } + const raw = await readFile(path); + if (raw.includes(0)) { + await rm(path); + continue; + } + const available = Math.max(0, PLAYWRIGHT_ARTIFACT_TOTAL_MAX_BYTES - retainedBytes); + if (available === 0) { + await rm(path); + continue; + } + let decoded: string; + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode(raw); + } catch { + await rm(path); + continue; + } + const redacted = redactEvidence( + decoded, + sandbox, + pairingCode, + Math.min(PLAYWRIGHT_ARTIFACT_FILE_MAX_BYTES, available) + ); + await writeFile(path, redacted, { mode: 0o600 }); + retainedBytes += Buffer.byteLength(redacted); + } + }; + await visit(directory); +} + +export async function collectHostedV1ScannerEvidence( + sandbox: HostedV1Sandbox +): Promise { + const projects = await new ProjectScanner( + join(sandbox.claudeDir, 'projects'), + join(sandbox.claudeDir, 'todos') + ).scan(); + return Object.freeze({ + expectedProjectFound: projects.some((project) => project.id === E2E_RUNTIME_WORKSPACE_ID), + expectedRuntimeWorkspaceId: E2E_RUNTIME_WORKSPACE_ID, + projectCount: projects.length, + projects: Object.freeze( + projects.map((project) => + Object.freeze({ + runtimeWorkspaceId: project.id, + sessionCount: project.totalSessions, + }) + ) + ), + }); +} + +export function classifyHostedV1ProjectAccess(input: { + readonly controllerProjectCount: number | null; + readonly controllerProjectStatus: 'observed' | 'unavailable'; + readonly controllerExactExpectedProjectOnly: boolean; + readonly fixturePrincipalGrantFound: boolean; + readonly publicWorkspaceMapped: boolean; + readonly registrationStatus: string | null; + readonly scannerProjectFound: boolean; +}): ProjectAccessClassification { + if (!input.scannerProjectFound) return 'scanner_empty'; + if (input.registrationStatus !== 'active') return 'registration_inactive'; + if (!input.publicWorkspaceMapped) return 'public_mapping_mismatch'; + if (!input.fixturePrincipalGrantFound) return 'grant_null'; + if (input.controllerProjectStatus === 'unavailable') return 'scanner_unavailable'; + return input.controllerProjectCount === 1 && input.controllerExactExpectedProjectOnly + ? 'project_admitted' + : 'scanner_empty'; +} + +async function readControllerProjectEvidence( + observationFile: string +): Promise { + try { + const value = JSON.parse(await readFile(observationFile, 'utf8')) as { + readonly exactExpectedPublicProject?: unknown; + readonly projectCount?: unknown; + readonly rawRuntimeIdentityAbsent?: unknown; + readonly rawRuntimePathAbsent?: unknown; + readonly status?: unknown; + }; + if ( + value.status !== 'observed' || + !Number.isSafeInteger(value.projectCount) || + (value.projectCount as number) < 0 || + typeof value.exactExpectedPublicProject !== 'boolean' || + typeof value.rawRuntimeIdentityAbsent !== 'boolean' || + typeof value.rawRuntimePathAbsent !== 'boolean' + ) { + throw new Error('hosted_e2e_controller_project_observation_invalid'); + } + return Object.freeze({ + status: 'observed', + projectCount: value.projectCount as number, + exactExpectedPublicProject: value.exactExpectedPublicProject, + rawRuntimeIdentityAbsent: value.rawRuntimeIdentityAbsent, + rawRuntimePathAbsent: value.rawRuntimePathAbsent, + }); + } catch { + return Object.freeze({ + status: 'unavailable', + projectCount: null, + exactExpectedPublicProject: false, + rawRuntimeIdentityAbsent: false, + rawRuntimePathAbsent: false, + }); + } +} + +export async function collectHostedV1GrantEvidence(input: { + readonly appDataDir: string; + readonly authMode: ScenarioMode; + readonly controllerProjectObservationFile: string; + readonly expectedOidcIssuer: string; + readonly scannerEvidence: HostedV1ScannerEvidence; +}): Promise { + const { default: Database } = await import('better-sqlite3-node'); + const database = new Database(join(input.appDataDir, 'data', 'storage', 'app.db'), { + fileMustExist: true, + readonly: true, + }); + try { + const registration = database + .prepare( + `SELECT public_workspace_id AS publicWorkspaceId, status + FROM hosted_workspaces WHERE runtime_workspace_id = ?` + ) + .get(E2E_RUNTIME_WORKSPACE_ID) as + | { readonly publicWorkspaceId: string; readonly status: string } + | undefined; + const principal = ( + input.authMode === 'personal' + ? database.prepare( + `SELECT principals.user_id AS userId, + CASE WHEN grants.user_id IS NULL THEN 0 ELSE 1 END AS grantFound + FROM personal_owners AS principals + INNER JOIN users ON users.user_id = principals.user_id AND users.status = 'active' + LEFT JOIN hosted_workspace_grants AS grants + ON grants.user_id = principals.user_id + AND grants.runtime_workspace_id = ? + AND grants.grant_generation = 0 + LIMIT 1` + ) + : database.prepare( + `SELECT principals.user_id AS userId, + CASE WHEN grants.user_id IS NULL THEN 0 ELSE 1 END AS grantFound + FROM external_identities AS principals + INNER JOIN users ON users.user_id = principals.user_id AND users.status = 'active' + LEFT JOIN hosted_workspace_grants AS grants + ON grants.user_id = principals.user_id + AND grants.runtime_workspace_id = ? + AND grants.grant_generation = 0 + WHERE principals.issuer = ? + AND principals.provider_id = ? + AND principals.subject = ? + LIMIT 1` + ) + ).get( + E2E_RUNTIME_WORKSPACE_ID, + ...(input.authMode === 'personal' + ? [] + : [input.expectedOidcIssuer, 'synthetic-oidc', 'hosted-v1-e2e-owner']) + ) as { readonly grantFound: 0 | 1; readonly userId: string } | undefined; + const fixturePrincipalGrantFound = principal?.grantFound === 1; + const expectedPublicWorkspaceMapped = + registration?.publicWorkspaceId === E2E_PROJECT_WORKSPACE_ID; + const controllerProjectEvidence = await readControllerProjectEvidence( + input.controllerProjectObservationFile + ); + return Object.freeze({ + schemaVersion: 1, + storageLayout: 'app-data/data/storage/app.db', + classification: classifyHostedV1ProjectAccess({ + scannerProjectFound: input.scannerEvidence.expectedProjectFound, + controllerProjectCount: controllerProjectEvidence.projectCount, + controllerProjectStatus: controllerProjectEvidence.status, + controllerExactExpectedProjectOnly: + controllerProjectEvidence.exactExpectedPublicProject && + controllerProjectEvidence.rawRuntimeIdentityAbsent && + controllerProjectEvidence.rawRuntimePathAbsent, + registrationStatus: registration?.status ?? null, + publicWorkspaceMapped: expectedPublicWorkspaceMapped, + fixturePrincipalGrantFound, + }), + expectedRuntimeWorkspaceId: E2E_RUNTIME_WORKSPACE_ID, + expectedPublicWorkspaceId: E2E_PROJECT_WORKSPACE_ID, + registrationStatus: registration?.status ?? null, + expectedPublicWorkspaceMapped, + fixturePrincipalFound: principal !== undefined, + fixturePrincipalGrantFound, + controllerProjectEvidence, + }); + } finally { + database.close(); + } +} + +async function writeEvidence(path: string, value: string): Promise { + let kind: 'json' | 'text' = 'text'; + try { + JSON.parse(value); + kind = 'json'; + } catch { + // Preserve non-JSON logs as text; oversized values receive a typed JSON envelope. + } + const bounded = boundHostedV1EvidenceDocument(value, kind, HOSTED_V1_EVIDENCE_TEXT_MAX_BYTES - 1); + await writeFile(path, bounded.endsWith('\n') ? bounded : `${bounded}\n`, { mode: 0o600 }); +} + +async function chownTree(path: string, uid: number, gid: number): Promise { + const stat = await lstat(path); + if (stat.isSymbolicLink()) throw new Error('hosted_e2e_fixture_symlink_refused'); + await chown(path, uid, gid); + if (!stat.isDirectory()) return; + for (const entry of await readdir(path)) await chownTree(join(path, entry), uid, gid); +} + +interface ArtifactOwner { + readonly uid: number; + readonly gid: number; +} + +function invokingSudoOwner(): ArtifactOwner | null { + const uid = process.env.SUDO_UID; + const gid = process.env.SUDO_GID; + return uid && gid && /^\d+$/u.test(uid) && /^\d+$/u.test(gid) + ? { uid: Number(uid), gid: Number(gid) } + : null; +} + +async function createEvidenceDirectory( + sandbox: HostedV1Sandbox, + artifactOwner: ArtifactOwner | null +): Promise { + const configured = process.env.HOSTED_E2E_ARTIFACT_DIR; + if (!configured || !isAbsolute(configured) || resolve(configured) !== configured) { + throw new Error('HOSTED_E2E_ARTIFACT_DIR must be an absolute canonical directory'); + } + await mkdir(configured, { recursive: true, mode: 0o700 }); + const canonical = await realpath(configured); + const stat = await lstat(configured); + if ( + canonical !== configured || + !stat.isDirectory() || + stat.isSymbolicLink() || + (stat.mode & 0o077) !== 0 + ) { + throw new Error('HOSTED_E2E_ARTIFACT_DIR must be private and canonical'); + } + const relation = relative(sandbox.root, canonical); + if ( + !relation || + (!relation.startsWith(`..${sep}`) && relation !== '..' && !isAbsolute(relation)) + ) { + throw new Error('HOSTED_E2E_ARTIFACT_DIR must be outside the disposable sandbox'); + } + const repositoryRelation = relative(repositoryRoot, canonical); + if ( + !repositoryRelation || + (!repositoryRelation.startsWith(`..${sep}`) && + repositoryRelation !== '..' && + !isAbsolute(repositoryRelation)) + ) { + throw new Error('HOSTED_E2E_ARTIFACT_DIR must be outside the repository'); + } + if (artifactOwner !== null) { + // The root-run harness creates this private parent. Give the invoking runner ownership so the + // post-sudo artifact uploader can traverse it; keep 0700 and marker-owned children unchanged. + await chown(canonical, artifactOwner.uid, artifactOwner.gid); + await chmod(canonical, 0o700); + } + const directory = join(canonical, `hosted-v1-${sandbox.marker}`); + await mkdir(directory, { mode: 0o700 }); + return directory; +} + +async function captureFailureEvidence(input: { + readonly artifactDirectory: string; + readonly artifactKey: string; + readonly authMode: ScenarioMode; + readonly composeArgs: readonly string[]; + readonly composeEnv: NodeJS.ProcessEnv; + readonly caddyPublisherObservation: string | null; + readonly controllerProjectObservationFile: string; + readonly error: unknown; + readonly expectedOidcIssuer: string; + readonly pairingCode: string | null; + readonly scannerEvidence: HostedV1ScannerEvidence; + readonly sandbox: HostedV1Sandbox; +}): Promise { + const scenarioDirectory = join(input.artifactDirectory, input.artifactKey); + await mkdir(scenarioDirectory, { recursive: true, mode: 0o700 }); + if (input.caddyPublisherObservation !== null) { + await writeEvidence( + join(scenarioDirectory, 'caddy-publisher-observation.json'), + redactEvidence(input.caddyPublisherObservation, input.sandbox, input.pairingCode) + ); + } + await writeEvidence( + join(scenarioDirectory, 'failure.json'), + JSON.stringify( + { + schemaVersion: 1, + authMode: input.authMode, + status: 'failed', + error: redactEvidence(safeError(input.error), input.sandbox, input.pairingCode), + }, + null, + 2 + ) + ); + await writeEvidence( + join(scenarioDirectory, 'project-scanner.json'), + JSON.stringify(input.scannerEvidence, null, 2) + ); + try { + await writeEvidence( + join(scenarioDirectory, 'project-grant.json'), + JSON.stringify( + await collectHostedV1GrantEvidence({ + appDataDir: + input.authMode === 'personal' ? input.sandbox.appDataDir : input.sandbox.oidcAppDataDir, + authMode: input.authMode, + scannerEvidence: input.scannerEvidence, + controllerProjectObservationFile: input.controllerProjectObservationFile, + expectedOidcIssuer: input.expectedOidcIssuer, + }), + null, + 2 + ) + ); + } catch (captureError) { + await writeEvidence( + join(scenarioDirectory, 'project-grant.json'), + JSON.stringify( + { + schemaVersion: 1, + storageLayout: 'app-data/data/storage/app.db', + classification: 'grant_unavailable', + error: redactEvidence(safeError(captureError), input.sandbox, input.pairingCode), + }, + null, + 2 + ) + ); + } + for (const [name, args] of [ + [ + 'controller.log', + [...input.composeArgs, 'logs', '--no-color', '--timestamps', 'hosted-controller'], + ], + ['compose.log', [...input.composeArgs, 'logs', '--no-color', '--timestamps']], + ['compose-ps.json', [...input.composeArgs, 'ps', '--all', '--format', 'json']], + ] as const) { + try { + const output = await run('docker', args, { env: input.composeEnv, capture: true }); + await writeEvidence( + join(scenarioDirectory, name), + redactEvidence(output, input.sandbox, input.pairingCode) + ); + } catch (captureError) { + await writeEvidence( + join(scenarioDirectory, `${name}.error`), + redactEvidence(safeError(captureError), input.sandbox, input.pairingCode) + ); + } + } +} + +export function networkAddresses(marker: string): { + readonly app: string; + readonly caddy: string; + readonly oidc: string; + readonly subnet: string; +} { + const value = Number.parseInt(marker.slice(0, 4), 16); + const prefix = `10.${64 + ((value >> 8) % 64)}.${value & 0xff}`; + return Object.freeze({ + app: `${prefix}.3`, + caddy: `${prefix}.2`, + oidc: `${prefix}.4`, + subnet: `${prefix}.0/28`, + }); +} + +export function assertHostedV1ScenarioIsolation( + scenarios: readonly { + readonly authMode: ScenarioMode; + readonly caddyPublishedPort: number; + readonly composeProject: string; + readonly sandbox: HostedV1Sandbox; + }[] +): void { + if (scenarios.length !== 3 || new Set(scenarios.map(({ authMode }) => authMode)).size !== 3) { + throw new Error('hosted_e2e_scenario_set_invalid'); + } + const independentlyOwnedValues = scenarios.flatMap( + ({ caddyPublishedPort, composeProject, sandbox }) => [ + sandbox.root, + sandbox.appDataDir, + sandbox.oidcAppDataDir, + sandbox.claudeDir, + sandbox.fakeRuntimeStateDir, + sandbox.caddyDataDir, + sandbox.lifecycleHighWaterDir, + sandbox.lifecycleLauncherDir, + sandbox.lifecycleRunDir, + sandbox.lifecycleTrustDir, + sandbox.runDir, + sandbox.workspaceDir, + sandbox.lifecycleTrustAnchor, + composeProject, + String(caddyPublishedPort), + ] + ); + if (new Set(independentlyOwnedValues).size !== independentlyOwnedValues.length) { + throw new Error('hosted_e2e_scenario_state_leakage_risk'); + } +} + +export async function createMarkerOwnedHostedV1ScenarioSandbox( + root: string, + createSandbox: (candidateRoot: string) => Promise = createHostedV1Sandbox +): Promise { + const allocatedRoot = await lstat(root, { bigint: true }); + if (!allocatedRoot.isDirectory() || allocatedRoot.isSymbolicLink()) { + throw new Error('hosted_e2e_allocated_scenario_root_invalid'); + } + try { + return await createSandbox(root); + } catch (error) { + let removed = false; + const markerPath = join(root, '.agent-teams-hosted-v1-e2e-owner.json'); + try { + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + readonly marker?: unknown; + }; + if (typeof marker.marker === 'string') { + await assertHostedV1MarkerOwnedRoot(root, markerPath, marker.marker); + await rm(root, { recursive: true }); + removed = true; + } + } catch { + // Fall through to the exact allocation identity proof below. + } + if (!removed) { + const currentRoot = await lstat(root, { bigint: true }).catch(() => null); + if ( + currentRoot !== null && + currentRoot.isDirectory() && + !currentRoot.isSymbolicLink() && + currentRoot.dev === allocatedRoot.dev && + currentRoot.ino === allocatedRoot.ino + ) { + await rm(root, { recursive: true }); + } + } + throw error; + } +} + +export async function cleanupHostedV1SandboxRoots(input: { + readonly sandboxes: readonly HostedV1Sandbox[]; + readonly retainedRoots?: ReadonlySet; + readonly assertMarkerOwned?: (sandbox: HostedV1Sandbox) => Promise; + readonly removeRoot?: (root: string) => Promise; +}): Promise<{ + readonly cleanupError: AggregateError | null; + readonly removedMarkers: readonly string[]; + readonly retainedMarkers: readonly string[]; +}> { + const errors: unknown[] = []; + const removedMarkers: string[] = []; + const retainedMarkers: string[] = []; + const assertMarkerOwned = + input.assertMarkerOwned ?? + ((sandbox: HostedV1Sandbox) => + assertHostedV1MarkerOwnedRoot(sandbox.root, sandbox.markerPath, sandbox.marker)); + const removeRoot = input.removeRoot ?? ((root: string) => rm(root, { recursive: true })); + + for (const sandbox of input.sandboxes) { + if (input.retainedRoots?.has(sandbox.root) === true) { + retainedMarkers.push(sandbox.marker); + continue; + } + try { + await assertMarkerOwned(sandbox); + } catch (error) { + errors.push(error); + retainedMarkers.push(sandbox.marker); + continue; + } + try { + await removeRoot(sandbox.root); + removedMarkers.push(sandbox.marker); + } catch (error) { + errors.push(error); + retainedMarkers.push(sandbox.marker); + } + } + + return Object.freeze({ + cleanupError: + errors.length === 0 ? null : new AggregateError(errors, 'hosted_e2e_sandbox_cleanup_failed'), + removedMarkers: Object.freeze(removedMarkers), + retainedMarkers: Object.freeze(retainedMarkers), + }); +} + +async function runHostedV1Main( + interrupts: ReturnType +): Promise { + // Fail before Docker or sandbox I/O when a caller requests an unknown suite. + const browserSuite = parseHostedV1BrowserSuite(process.env.HOSTED_E2E_SUITE); + const suiteDefinition = HOSTED_V1_BROWSER_SUITES[browserSuite]; + const scenarioDefinitions = suiteDefinition.authModes.flatMap((authMode) => + suiteDefinition.cases.map((browserCase) => ({ + authMode, + browserCase, + scenarioKey: `${authMode}-${browserCase.id}`, + })) + ); + const nodeDigest = envDigest('NODE_IMAGE_DIGEST'); + const caddyDigest = envDigest('CADDY_IMAGE_DIGEST'); + const keycloakDigest = envDigest('KEYCLOAK_IMAGE_DIGEST'); + await run('docker', ['version'], { capture: true }); + await access(composeFile); + + const root = await mkdtemp(join(await realpath(tmpdir()), 'agent-teams-hosted-v1-e2e-')); + const sandbox = await createMarkerOwnedHostedV1ScenarioSandbox(root); + + const artifactOwner = invokingSudoOwner(); + let artifactDirectory: string; + try { + artifactDirectory = await createEvidenceDirectory(sandbox, artifactOwner); + } catch (error) { + await assertHostedV1MarkerOwnedRoot(sandbox.root, sandbox.markerPath, sandbox.marker); + await rm(sandbox.root, { recursive: true }); + throw error; + } + const retainedScenarioRoots = new Set(); + let runnerError: unknown = null; + let interruptedCleanupSignalScope: ReturnType | null = + null; + const appImage = `at-hosted-v1-${sandbox.marker.slice(0, 24)}-app:latest`; + let sourceDeclaration: HostedV1SourceDeclaration | null = null; + let appImageEvidence: HostedV1AppImageEvidence | null = null; + const sharedAppImageLifecycle = createHostedV1SharedAppImageLifecycle({ + appImage, + environment: sanitizedEnvironment, + removeImage: (image, environment) => + removeHostedV1AppImage(image, environment, (args, dockerEnvironment) => + run('docker', [...args], { capture: true, env: dockerEnvironment }) + ), + }); + const sandboxes: HostedV1Sandbox[] = [sandbox]; + const registeredScenarioRoots = new Set([sandbox.root]); + const composeProjects: Record = {}; + try { + const runSourceDeclaration = await collectHostedV1SourceDeclaration(); + sourceDeclaration = runSourceDeclaration; + await writeEvidence( + join(artifactDirectory, 'source-declaration.json'), + JSON.stringify(runSourceDeclaration, null, 2) + ); + for (let index = 1; index < scenarioDefinitions.length; index += 1) { + const scenarioRoot = await mkdtemp( + join( + await realpath(tmpdir()), + `agent-teams-hosted-v1-e2e-${scenarioDefinitions[index]?.scenarioKey ?? 'missing'}-` + ) + ); + registeredScenarioRoots.add(scenarioRoot); + sandboxes.push(await createMarkerOwnedHostedV1ScenarioSandbox(scenarioRoot)); + } + const caddyPublishedPorts = allocateHostedV1CaddyPublishedPorts( + sandboxes.map((scenarioSandbox) => scenarioSandbox.marker) + ); + const scenarioAllocations = scenarioDefinitions.map((definition, index) => { + const scenarioSandbox = sandboxes[index]; + const caddyPublishedPort = caddyPublishedPorts[index]; + if (!scenarioSandbox) throw new Error('hosted_e2e_scenario_sandbox_missing'); + if (caddyPublishedPort === undefined) throw new Error('hosted_e2e_scenario_port_missing'); + return { + authMode: definition.authMode, + scenarioKey: definition.scenarioKey, + sandbox: scenarioSandbox, + composeProject: `at-hosted-v1-${scenarioSandbox.marker.slice(0, 24)}`, + caddyPublishedPort, + }; + }); + if (browserSuite === 'core') { + assertHostedV1ScenarioIsolation(scenarioAllocations); + } else if ( + new Set(scenarioAllocations.map(({ sandbox: allocation }) => allocation.root)).size !== + scenarioAllocations.length || + new Set(scenarioAllocations.map(({ composeProject }) => composeProject)).size !== + scenarioAllocations.length || + new Set(scenarioAllocations.map(({ caddyPublishedPort }) => caddyPublishedPort)).size !== + scenarioAllocations.length + ) { + throw new Error('hosted_e2e_scenario_isolation_invalid'); + } + if ( + registeredScenarioRoots.size !== scenarioDefinitions.length || + sandboxes.some(({ root }) => !registeredScenarioRoots.has(root)) + ) { + throw new Error('hosted_e2e_scenario_root_registration_invalid'); + } + if (process.getuid?.() !== 0) + throw new Error('hosted_e2e_requires_root_for_image_lock_fixture'); + const appUid = 1000; + const appGid = 1000; + const domain = 'hosted-v1-e2e.localhost'; + const oidcDomain = 'oidc-v1-e2e.localhost'; + + const browserPath = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (!browserPath || !isAbsolute(browserPath)) { + throw new Error('PLAYWRIGHT_BROWSERS_PATH must name the preinstalled Chromium cache'); + } + const browserEnvironment: NodeJS.ProcessEnv = { + ...sanitizedEnvironment, + PLAYWRIGHT_BROWSERS_PATH: browserPath, + }; + const eventCursor = encodeReplayCursor({ + deploymentId, + eventEpoch: `epoch-initial-v1-${createHash('sha256').update(deploymentId).digest('hex').slice(0, 24)}`, + eventSequence: 0, + }); + + for (const [index, definition] of scenarioDefinitions.entries()) { + const { authMode, browserCase, scenarioKey } = definition; + const scenarioSandbox = sandboxes[index]; + if (!scenarioSandbox) throw new Error('hosted_e2e_scenario_sandbox_missing'); + await restoreHostedV1NodeAbi({ + environment: sanitizedEnvironment, + runNode: (args, environment) => + run('node', args, { env: environment }).then(() => undefined), + }); + const scannerEvidence = await collectHostedV1ScannerEvidence(scenarioSandbox); + const projectSuffix = scenarioSandbox.marker.slice(0, 24); + const composeProject = `at-hosted-v1-${projectSuffix}`; + if (!projectSuffix || composeProject.length > 63) throw new Error('e2e_project_name_invalid'); + composeProjects[scenarioKey] = composeProject; + const network = networkAddresses(scenarioSandbox.marker); + const caddyPublishedPort = scenarioAllocations[index]?.caddyPublishedPort; + if (caddyPublishedPort === undefined) throw new Error('hosted_e2e_scenario_port_missing'); + const expectedOidcIssuer = `https://${oidcDomain}:${caddyPublishedPort}`; + const appDataDir = + authMode === 'personal' ? scenarioSandbox.appDataDir : scenarioSandbox.oidcAppDataDir; + await run('node', ['--import', 'tsx', 'test/fixtures/hosted-v1/seedContainer.ts', 'seed'], { + env: { + ...sanitizedEnvironment, + E2E_SEED_APP_DATA_ROOT: appDataDir, + E2E_SEED_AUTH_MODE: authMode === 'personal' ? 'personal' : 'oidc', + E2E_SEED_CLAUDE_ROOT: scenarioSandbox.claudeDir, + ...(browserCase.id === 'slow-consumer' + ? { E2E_SEED_COORDINATION_EVENT_COUNT: '16384' } + : {}), + E2E_FAKE_RUNTIME_STATE_ROOT: scenarioSandbox.fakeRuntimeStateDir, + E2E_SEED_MARKER_PATH: scenarioSandbox.markerPath, + E2E_SEED_OIDC_ISSUER: expectedOidcIssuer, + }, + }); + const dataDir = join(appDataDir, 'data'); + const lockDir = join(appDataDir, 'instance-lock'); + const lockPath = join(lockDir, 'instance.lock'); + await mkdir(dataDir, { recursive: true, mode: 0o700 }); + await mkdir(lockDir, { recursive: true, mode: 0o555 }); + await writeFile(lockPath, '', { mode: 0o444 }); + await chown(appDataDir, 0, appGid); + await chmod(appDataDir, 0o1770); + await chown(lockDir, 0, 0); + await chmod(lockDir, 0o555); + await chown(lockPath, 0, 0); + await chmod(lockPath, 0o444); + for (const writable of [dataDir, join(appDataDir, 'storage'), join(appDataDir, 'logs')]) { + await chownTree(writable, appUid, appGid); + await chmod(writable, 0o700); + } + await Promise.all( + [ + scenarioSandbox.caddyDataDir, + scenarioSandbox.claudeDir, + scenarioSandbox.fakeRuntimeStateDir, + scenarioSandbox.lifecycleHighWaterDir, + scenarioSandbox.lifecycleLauncherDir, + scenarioSandbox.lifecycleRunDir, + scenarioSandbox.lifecycleTrustDir, + scenarioSandbox.runDir, + scenarioSandbox.workspaceDir, + ].map((path) => chownTree(path, appUid, appGid)) + ); + const baseComposeEnv: NodeJS.ProcessEnv = { + ...browserEnvironment, + CADDY_IMAGE_DIGEST: caddyDigest, + COMPOSE_FILE: composeFile, + COMPOSE_PROJECT_NAME: composeProject, + E2E_APP_GID: String(appGid), + E2E_APP_IMAGE: appImage, + E2E_APP_IP: network.app, + E2E_APP_UID: String(appUid), + E2E_CADDY_DATA_DIR: scenarioSandbox.caddyDataDir, + E2E_CADDY_IP: network.caddy, + E2E_CADDY_PUBLISHED_PORT: String(caddyPublishedPort), + E2E_CLAUDE_DIR: scenarioSandbox.claudeDir, + E2E_BOOT_ID: `boot_hosted-v1-e2e-${scenarioSandbox.marker}`, + E2E_FAKE_RUNTIME_STATE_DIR: scenarioSandbox.fakeRuntimeStateDir, + E2E_LIFECYCLE_BOOTSTRAP: scenarioSandbox.bootstrap, + E2E_LIFECYCLE_HIGH_WATER_DIR: scenarioSandbox.lifecycleHighWaterDir, + E2E_LIFECYCLE_LAUNCHER_DIR: scenarioSandbox.lifecycleLauncherDir, + E2E_LIFECYCLE_RUN_DIR: scenarioSandbox.lifecycleRunDir, + E2E_LIFECYCLE_TRUST_DIR: scenarioSandbox.lifecycleTrustDir, + E2E_NETWORK_SUBNET: network.subnet, + E2E_OIDC_IP: network.oidc, + E2E_OWNER_MARKER: scenarioSandbox.markerPath, + E2E_RUN_DIR: scenarioSandbox.runDir, + E2E_SOURCE_HEAD_COMMIT: runSourceDeclaration.headCommit, + E2E_SOURCE_PATCH_SHA256: runSourceDeclaration.patchSha256, + E2E_RUNTIME_WORKSPACE_ID, + E2E_TEAM_RUNTIME_WORKSPACE_ID, + E2E_TEAM_ID, + E2E_WORKSPACE_DIR: scenarioSandbox.workspaceDir, + HOSTED_E2E_RETENTION_INTERVAL_MS: browserCase.id === 'retention-resync' ? '100' : '60000', + HOSTED_E2E_RETENTION_MAX_EVENTS: + browserCase.id === 'retention-resync' + ? '1' + : browserCase.id === 'slow-consumer' + ? '20000' + : '10000', + HOSTED_DOMAIN: domain, + NODE_IMAGE_DIGEST: nodeDigest, + KEYCLOAK_IMAGE_DIGEST: keycloakDigest, + OIDC_DOMAIN: oidcDomain, + }; + const composeArgs = ['compose', '--project-name', composeProject, '--file', composeFile]; + const createScenarioEnvironment = (port: number): NodeJS.ProcessEnv => { + const origin = `https://${domain}:${port}`; + return { + ...baseComposeEnv, + E2E_APP_DATA_DIR: appDataDir, + HOSTED_E2E_AUTH_MODE: authMode === 'personal' ? 'personal' : 'oidc', + HOSTED_E2E_OIDC_ORIGIN: `https://${oidcDomain}:${port}`, + HOSTED_E2E_OIDC_ROLE: authMode === 'oidc-viewer' ? 'viewer' : 'owner', + HOSTED_E2E_ORIGIN: origin, + HOSTED_HTTPS_PORT: String(port), + }; + }; + let composeEnv = createScenarioEnvironment(caddyPublishedPort); + let pairingCode: string | null = null; + let caddyPublisherObservation: string | null = null; + let lifecycleOwnerObservation: string | null = null; + const controllerProjectObservationFile = join( + scenarioSandbox.runDir, + `controller-projects-${scenarioKey}.json` + ); + await writeFile( + controllerProjectObservationFile, + `${JSON.stringify({ status: 'unavailable' })}\n`, + { mode: 0o600 } + ); + let scenarioError: unknown = null; + let scenarioCompleted = false; + let composeAttempted = false; + try { + composeAttempted = true; + composeEnv = await runComposeUpWithExplicitPort({ + ...(index === 0 + ? { + buildImage: async () => { + sharedAppImageLifecycle.markBuildAttempted(); + await buildHostedV1AppImage({ + composeArgs, + environment: createScenarioEnvironment(caddyPublishedPort), + runDocker: (args, environment, timeoutMs) => + run('docker', [...args], { env: environment, timeoutMs }).then( + () => undefined + ), + }); + appImageEvidence = await inspectHostedV1AppImage( + appImage, + runSourceDeclaration, + createScenarioEnvironment(caddyPublishedPort) + ); + await writeEvidence( + join(artifactDirectory, 'app-image-evidence.json'), + JSON.stringify(appImageEvidence, null, 2) + ); + }, + } + : {}), + createEnvironment: (port) => { + composeEnv = createScenarioEnvironment(port); + return composeEnv; + }, + publishedPort: caddyPublishedPort, + startCaddy: (environment) => + run('docker', [...composeArgs, 'up', '--no-build', '--detach', '--wait', 'caddy'], { + env: environment, + capture: true, + }).then(() => undefined), + readCaddyPublishers: async (environment) => { + caddyPublisherObservation = await run( + 'docker', + [...composeArgs, 'ps', '--format', 'json', 'caddy'], + { env: environment, capture: true } + ); + return caddyPublisherObservation; + }, + startRemainingServices: (environment) => + run( + 'docker', + [...composeArgs, 'up', '--no-build', '--detach', '--wait', '--no-recreate'], + { env: environment, capture: true } + ).then(() => undefined), + }); + for (const service of ['hosted-controller', 'synthetic-oidc', 'fake-runtime'] as const) { + const observation = await run( + 'docker', + [...composeArgs, 'ps', '--format', 'json', service], + { + env: composeEnv, + capture: true, + } + ); + assertDockerComposeServiceNotPublished(observation, service); + if (service === 'fake-runtime') lifecycleOwnerObservation = observation; + } + if (lifecycleOwnerObservation === null) { + throw new Error('hosted_e2e_lifecycle_owner_observation_missing'); + } + const scenarioEvidenceDirectory = join(artifactDirectory, scenarioKey); + await mkdir(scenarioEvidenceDirectory, { recursive: true, mode: 0o700 }); + await writeEvidence( + join(scenarioEvidenceDirectory, 'lifecycle-owner-deployment.json'), + redactEvidence(lifecycleOwnerObservation, scenarioSandbox, pairingCode) + ); + if (authMode === 'personal') { + pairingCode = await run( + 'docker', + [ + ...composeArgs, + 'exec', + '-T', + 'hosted-controller', + 'node', + 'scripts/hosted-auth-cli.mjs', + 'pairing-code', + ], + { env: composeEnv, capture: true } + ); + if (!/^[A-Za-z0-9_-]{32,}$/.test(pairingCode)) { + throw new Error('hosted_e2e_pairing_code_invalid'); + } + } + + const runtimeFile = join(root, `runtime-${scenarioKey}.json`); + await writeFile( + runtimeFile, + `${JSON.stringify({ + authMode, + composeFile, + composeProject, + controllerProjectObservationFile, + claudeDir: scenarioSandbox.claudeDir, + eventCursor, + fakeRuntimeLifecycleTraceFile: join( + scenarioSandbox.fakeRuntimeStateDir, + 'lifecycle-trace.json' + ), + fakeRuntimeStateFile: join(scenarioSandbox.fakeRuntimeStateDir, 'runtime-state.json'), + fakeRuntimeStateDir: scenarioSandbox.fakeRuntimeStateDir, + appDataDir, + forbiddenWorkspaceId: E2E_FORBIDDEN_WORKSPACE_ID, + origin: composeEnv.HOSTED_E2E_ORIGIN, + pairingCode, + projectWorkspaceId: E2E_PROJECT_WORKSPACE_ID, + runtimeWorkspaceId: E2E_RUNTIME_WORKSPACE_ID, + teamId: E2E_TEAM_ID, + teamName: E2E_TEAM_NAME, + teamRuntimeWorkspaceId: E2E_TEAM_RUNTIME_WORKSPACE_ID, + workspaceId: E2E_WORKSPACE_ID, + workspaceDir: scenarioSandbox.workspaceDir, + })}\n`, + { mode: 0o600 } + ); + const outputDirectory = join(artifactDirectory, scenarioKey, 'playwright'); + await mkdir(outputDirectory, { recursive: true, mode: 0o700 }); + try { + await run( + 'pnpm', + [ + 'exec', + 'playwright', + 'test', + '--config', + playwrightConfig, + ...(browserCase.grep === null ? [] : ['--grep', browserCase.grep]), + ], + { + env: { + ...composeEnv, + HOSTED_E2E_RUNTIME_FILE: runtimeFile, + HOSTED_E2E_OUTPUT_DIR: outputDirectory, + HOSTED_E2E_SUITE: browserSuite, + }, + } + ); + } finally { + await sanitizePlaywrightEvidence(outputDirectory, scenarioSandbox, pairingCode); + } + scenarioCompleted = true; + } catch (error) { + scenarioError = error; + await captureFailureEvidence({ + artifactDirectory, + artifactKey: scenarioKey, + authMode, + caddyPublisherObservation, + composeArgs, + composeEnv, + controllerProjectObservationFile, + error, + expectedOidcIssuer, + pairingCode, + scannerEvidence, + sandbox: scenarioSandbox, + }); + } finally { + const scenarioCleanupSignalScope = beginHostedV1CleanupSignalScope({ + activeSignal: activeRunAbortSignal ?? interrupts.signal, + replaceActiveSignal: (signal) => { + activeRunAbortSignal = signal; + }, + }); + try { + if (composeAttempted) { + try { + await run( + 'docker', + [...composeArgs, 'down', '--timeout', '30', '--volumes', '--remove-orphans'], + { env: composeEnv } + ); + const [containers, networks, volumes] = await Promise.all([ + run('docker', [...composeArgs, 'ps', '--all', '--quiet'], { + env: composeEnv, + capture: true, + }), + run( + 'docker', + [ + 'network', + 'ls', + '--filter', + `label=com.docker.compose.project=${composeProject}`, + '--quiet', + ], + { env: composeEnv, capture: true } + ), + run( + 'docker', + [ + 'volume', + 'ls', + '--filter', + `label=com.docker.compose.project=${composeProject}`, + '--quiet', + ], + { env: composeEnv, capture: true } + ), + ]); + assertNoComposeResourcesRemain({ containers, networks, volumes }); + } catch (cleanupError) { + retainedScenarioRoots.add(scenarioSandbox.root); + scenarioError = new AggregateError( + [scenarioError, cleanupError].filter((value) => value !== null), + 'hosted_e2e_compose_cleanup_failed' + ); + await captureFailureEvidence({ + artifactDirectory, + artifactKey: scenarioKey, + authMode, + caddyPublisherObservation, + composeArgs, + composeEnv, + controllerProjectObservationFile, + error: scenarioError, + expectedOidcIssuer, + pairingCode, + scannerEvidence, + sandbox: scenarioSandbox, + }); + } + } + } finally { + if (scenarioCleanupSignalScope.interruptedSignal.aborted) { + scenarioError ??= + scenarioCleanupSignalScope.interruptedSignal.reason instanceof Error + ? scenarioCleanupSignalScope.interruptedSignal.reason + : new Error('hosted_e2e_interrupted'); + interruptedCleanupSignalScope = scenarioCleanupSignalScope; + } else { + scenarioCleanupSignalScope.restore(); + } + } + } + if (scenarioError !== null) { + throw new Error( + `hosted_e2e_${scenarioKey}_failed; evidence retained at ${artifactDirectory}`, + { cause: scenarioError } + ); + } + if (!scenarioCompleted || sourceDeclaration === null || appImageEvidence === null) { + throw new Error('hosted_e2e_scenario_evidence_incomplete'); + } + await writeEvidence( + join(artifactDirectory, scenarioKey, 'result.json'), + JSON.stringify( + { + schemaVersion: 1, + authMode, + browserCase: browserCase.id, + status: 'passed', + cleanup: 'verified', + lifecycleOwner: { + service: 'fake-runtime', + externalToController: true, + singleton: true, + published: false, + observationSha256: createHash('sha256') + .update(lifecycleOwnerObservation ?? '') + .digest('hex'), + }, + sourceDeclaration, + image: appImageEvidence, + }, + null, + 2 + ) + ); + } + } catch (error) { + runnerError = error; + await writeEvidence( + join(artifactDirectory, 'runner-failure.json'), + JSON.stringify( + { + schemaVersion: 1, + status: 'failed', + error: sandboxes.reduce( + (redacted, scenarioSandbox) => redactEvidence(redacted, scenarioSandbox, null), + safeError(error) + ), + }, + null, + 2 + ) + ).catch(() => undefined); + } finally { + const outerCleanupSignalScope = + interruptedCleanupSignalScope ?? + beginHostedV1CleanupSignalScope({ + activeSignal: activeRunAbortSignal ?? interrupts.signal, + replaceActiveSignal: (signal) => { + activeRunAbortSignal = signal; + }, + }); + runnerError = mergeHostedV1CleanupInterruption( + outerCleanupSignalScope.interruptedSignal, + runnerError + ); + try { + const imageCleanup = await sharedAppImageLifecycle.cleanup(runnerError); + runnerError = imageCleanup.runnerError; + if (runnerError === null) { + if (sourceDeclaration === null || appImageEvidence === null) { + runnerError = new Error('hosted_e2e_final_evidence_incomplete'); + } else { + try { + const finalSourceDeclaration = await collectHostedV1SourceDeclaration(); + if (JSON.stringify(finalSourceDeclaration) !== JSON.stringify(sourceDeclaration)) { + runnerError = new Error('hosted_e2e_source_declaration_changed_during_run'); + } + } catch (error) { + runnerError = error; + } + } + } + const sandboxCleanup = await cleanupHostedV1SandboxRoots({ + sandboxes, + retainedRoots: retainedScenarioRoots, + }); + if (sandboxCleanup.cleanupError !== null) { + runnerError = new AggregateError( + [runnerError, ...sandboxCleanup.cleanupError.errors].filter((value) => value !== null), + 'hosted_e2e_sandbox_cleanup_failed' + ); + } + if (sandboxCleanup.retainedMarkers.length > 0) { + await writeEvidence( + join(artifactDirectory, 'leftovers.json'), + JSON.stringify( + { + schemaVersion: 1, + status: 'cleanup_failed', + markers: sandboxCleanup.retainedMarkers, + sandboxRoots: sandboxCleanup.retainedMarkers.map(() => ''), + }, + null, + 2 + ) + ); + } + if (runnerError === null && sourceDeclaration !== null && appImageEvidence !== null) { + await writeEvidence( + join(artifactDirectory, 'result.json'), + JSON.stringify( + { + schemaVersion: 1, + status: 'passed', + browserSuite, + scenarios: scenarioDefinitions.map(({ authMode, browserCase, scenarioKey }) => ({ + authMode, + browserCase: browserCase.id, + scenarioKey, + })), + composeProjects, + cleanup: { composeResources: 'verified', sharedAppImageRemoved: true }, + sourceDeclaration, + image: appImageEvidence, + }, + null, + 2 + ) + ); + } else if (runnerError !== null) { + await writeEvidence( + join(artifactDirectory, 'runner-failure.json'), + JSON.stringify( + { + schemaVersion: 1, + status: 'failed', + error: sandboxes.reduce( + (redacted, scenarioSandbox) => redactEvidence(redacted, scenarioSandbox, null), + safeError(runnerError) + ), + }, + null, + 2 + ) + ).catch(() => undefined); + } + if (artifactOwner !== null) { + await chownTree(artifactDirectory, artifactOwner.uid, artifactOwner.gid); + } + runnerError = mergeHostedV1CleanupInterruption( + outerCleanupSignalScope.interruptedSignal, + runnerError + ); + if (runnerError !== null) { + await rm(join(artifactDirectory, 'result.json'), { force: true }); + await writeEvidence( + join(artifactDirectory, 'runner-failure.json'), + JSON.stringify( + { + schemaVersion: 1, + status: 'failed', + error: sandboxes.reduce( + (redacted, scenarioSandbox) => redactEvidence(redacted, scenarioSandbox, null), + safeError(runnerError) + ), + }, + null, + 2 + ) + ).catch(() => undefined); + } + } finally { + outerCleanupSignalScope.restore(); + } + } + if (runnerError !== null) throw runnerError; + process.stdout.write(`Hosted v1 E2E evidence: ${artifactDirectory}\n`); +} + +async function main(): Promise { + const interrupts = registerHostedV1InterruptHandlers({ + once: (signal, listener) => process.once(signal, listener), + remove: (signal, listener) => process.removeListener(signal, listener), + }); + activeRunAbortSignal = interrupts.signal; + try { + await runHostedV1Main(interrupts); + } finally { + activeRunAbortSignal = undefined; + interrupts.dispose(); + } +} + +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/hosted-auth-cli.mjs b/scripts/hosted-auth-cli.mjs new file mode 100644 index 0000000000..be3a0a97e0 --- /dev/null +++ b/scripts/hosted-auth-cli.mjs @@ -0,0 +1,309 @@ +#!/usr/bin/env node + +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { dirname, isAbsolute, normalize, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const usage = `Usage: + node scripts/hosted-auth-cli.mjs preflight + node scripts/hosted-auth-cli.mjs pairing-code + node scripts/hosted-auth-cli.mjs users list + node scripts/hosted-auth-cli.mjs users enable + node scripts/hosted-auth-cli.mjs users disable + node scripts/hosted-auth-cli.mjs roles set + node scripts/hosted-auth-cli.mjs roles clear + node scripts/hosted-auth-cli.mjs workspaces list + node scripts/hosted-auth-cli.mjs workspaces register [display-name] + node scripts/hosted-auth-cli.mjs workspaces disable + node scripts/hosted-auth-cli.mjs workspaces grant + node scripts/hosted-auth-cli.mjs workspaces revoke + node scripts/hosted-auth-cli.mjs personal-reset + node scripts/hosted-auth-cli.mjs auth-mode reset +`; + +function fail(message, exitCode = 1) { + process.stderr.write(`${message}\n`); + process.exitCode = exitCode; +} + +const MAXIMUM_PAIRING_DELIVERY_BYTES = 4 * 1024; +const PAIRING_CHALLENGE_PATTERN = /^[a-z][a-z0-9-]*_[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/u; +const PAIRING_CODE_PATTERN = /^[A-Za-z0-9_-]{32,512}$/u; +const REPOSITORY_BUILD_CONTEXT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function isEqualToOrWithin(parentPath, candidatePath) { + const pathFromParent = relative(parentPath, candidatePath); + return ( + pathFromParent === '' || + (pathFromParent !== '..' && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent)) + ); +} + +async function preflightHostedSecretsDirectory() { + const configuredPath = process.env.HOSTED_SECRETS_DIR; + if (configuredPath === undefined || !isAbsolute(configuredPath)) { + throw new Error('unavailable'); + } + const absoluteConfiguredPath = resolve(configuredPath); + const [repositoryBuildContext, resolvedSecretsDirectory] = await Promise.all([ + realpath(REPOSITORY_BUILD_CONTEXT), + realpath(absoluteConfiguredPath), + ]); + const secretsDirectory = await lstat(resolvedSecretsDirectory); + if ( + !secretsDirectory.isDirectory() || + isEqualToOrWithin(repositoryBuildContext, absoluteConfiguredPath) || + isEqualToOrWithin(repositoryBuildContext, resolvedSecretsDirectory) + ) { + throw new Error('unavailable'); + } +} + +function sameFileIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function assertPrivateOwned(stat, kind) { + const uid = process.getuid?.(); + const expectedKind = kind === 'directory' ? stat.isDirectory() : stat.isFile(); + if ( + !expectedKind || + stat.isSymbolicLink() || + (stat.mode & 0o077) !== 0 || + (uid !== undefined && stat.uid !== uid) + ) { + throw new Error('unavailable'); + } +} + +async function readPairingDelivery(path) { + if (!isAbsolute(path) || normalize(path) !== path) throw new Error('unavailable'); + const parentPath = dirname(path); + const parentBefore = await lstat(parentPath); + assertPrivateOwned(parentBefore, 'directory'); + const before = await lstat(path); + assertPrivateOwned(before, 'file'); + const portableConstants = fsConstants; + const noFollow = + typeof portableConstants.O_NOFOLLOW === 'number' ? portableConstants.O_NOFOLLOW : 0; + const closeOnExec = + typeof portableConstants.O_CLOEXEC === 'number' ? portableConstants.O_CLOEXEC : 0; + const handle = await open(path, fsConstants.O_RDONLY | noFollow | closeOnExec); + try { + const opened = await handle.stat(); + assertPrivateOwned(opened, 'file'); + if (!sameFileIdentity(before, opened)) throw new Error('unavailable'); + const after = await lstat(path); + assertPrivateOwned(after, 'file'); + if (!sameFileIdentity(opened, after)) throw new Error('unavailable'); + const parentAfter = await lstat(parentPath); + assertPrivateOwned(parentAfter, 'directory'); + if (!sameFileIdentity(parentBefore, parentAfter)) throw new Error('unavailable'); + const bytes = Buffer.alloc(MAXIMUM_PAIRING_DELIVERY_BYTES + 1); + let offset = 0; + for (;;) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset); + if (bytesRead === 0) break; + offset += bytesRead; + if (offset > MAXIMUM_PAIRING_DELIVERY_BYTES) throw new Error('unavailable'); + } + const value = JSON.parse(bytes.subarray(0, offset).toString('utf8')); + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Reflect.ownKeys(value).length !== 3 || + Reflect.ownKeys(value).some( + (key) => + typeof key !== 'string' || !['challengeId', 'pairingCode', 'expiresAt'].includes(key) + ) || + typeof value.challengeId !== 'string' || + !PAIRING_CHALLENGE_PATTERN.test(value.challengeId) || + typeof value.pairingCode !== 'string' || + !PAIRING_CODE_PATTERN.test(value.pairingCode) || + !Number.isSafeInteger(value.expiresAt) + ) { + throw new Error('unavailable'); + } + return value; + } finally { + await handle.close().catch(() => undefined); + } +} + +async function pairingCode() { + const path = process.env.PAIRING_CODE_FILE ?? '/run/agent-teams/pairing.json'; + try { + const value = await readPairingDelivery(path); + if (Date.now() >= value.expiresAt) { + throw new Error('unavailable'); + } + process.stdout.write(`${value.pairingCode}\n`); + } catch { + fail('Pairing challenge is unavailable or expired.'); + } +} + +function localControl(command, argumentsValue = {}) { + const socketPath = process.env.AUTH_CONTROL_SOCKET ?? '/run/agent-teams/control.sock'; + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + let body = ''; + let settled = false; + const finish = (error, value) => { + if (settled) return; + settled = true; + socket.destroy(); + if (error) reject(error); + else resolve(value); + }; + socket.setEncoding('utf8'); + socket.setTimeout(5_000, () => finish(new Error('local_control_timeout'))); + socket.on('connect', () => { + socket.write( + `${JSON.stringify({ version: 1, command, arguments: argumentsValue })}\n`, + 'utf8' + ); + }); + socket.on('data', (chunk) => { + body += chunk; + if (Buffer.byteLength(body) > 1024 * 1024) { + finish(new Error('local_control_response_invalid')); + return; + } + const newline = body.indexOf('\n'); + if (newline < 0) return; + try { + const response = JSON.parse(body.slice(0, newline)); + if (response?.ok === true) finish(null, response.value); + else if (response?.ok === false && typeof response.code === 'string') { + finish(new Error(response.code)); + } else { + finish(new Error('local_control_response_invalid')); + } + } catch { + finish(new Error('local_control_response_invalid')); + } + }); + socket.on('error', () => finish(new Error('local_control_unavailable'))); + socket.on('end', () => { + if (!settled) finish(new Error('local_control_response_invalid')); + }); + }); +} + +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +async function main() { + const [scope, action, first, ...remaining] = process.argv.slice(2); + if (scope === 'preflight' && action === undefined) { + try { + await preflightHostedSecretsDirectory(); + printJson({ ready: true }); + } catch { + fail( + 'Hosted authentication preflight failed: HOSTED_SECRETS_DIR must resolve to an existing absolute directory outside the repository Docker build context.' + ); + } + return; + } + if (scope === 'pairing-code' && action === undefined) { + await pairingCode(); + return; + } + + let command; + let argumentsValue = {}; + if (scope === 'users' && action === 'list' && first === undefined) { + command = 'users.list'; + } else if ( + scope === 'users' && + (action === 'enable' || action === 'disable') && + first !== undefined && + remaining.length === 0 + ) { + command = `users.${action}`; + argumentsValue = { userId: first }; + } else if ( + scope === 'roles' && + action === 'set' && + first !== undefined && + remaining.length === 1 + ) { + command = 'roles.set'; + argumentsValue = { userId: first, role: remaining[0] }; + } else if ( + scope === 'roles' && + action === 'clear' && + first !== undefined && + remaining.length === 0 + ) { + command = 'roles.clear'; + argumentsValue = { userId: first }; + } else if (scope === 'workspaces' && action === 'list' && first === undefined) { + command = 'workspaces.list'; + } else if ( + scope === 'workspaces' && + action === 'register' && + first !== undefined && + remaining.length <= 1 + ) { + command = 'workspaces.register'; + argumentsValue = { workspaceId: first, displayName: remaining[0] ?? first }; + } else if ( + scope === 'workspaces' && + action === 'disable' && + first !== undefined && + remaining.length === 0 + ) { + command = 'workspaces.disable'; + argumentsValue = { workspaceId: first }; + } else if ( + scope === 'workspaces' && + (action === 'grant' || action === 'revoke') && + first !== undefined && + remaining.length === 1 + ) { + command = `workspaces.${action}`; + argumentsValue = { userId: first, workspaceId: remaining[0] }; + } else if (scope === 'personal-reset' && action !== undefined && first === undefined) { + const resetGeneration = Number(action); + if (!Number.isSafeInteger(resetGeneration) || resetGeneration <= 0) { + fail(usage.trimEnd(), 2); + return; + } + command = 'personal.reset'; + argumentsValue = { resetGeneration }; + } else if ( + scope === 'auth-mode' && + action === 'reset' && + (first === 'personal' || first === 'oidc') && + remaining.length === 1 + ) { + const resetGeneration = Number(remaining[0]); + if (!Number.isSafeInteger(resetGeneration) || resetGeneration <= 0) { + fail(usage.trimEnd(), 2); + return; + } + command = 'auth-mode.reset'; + argumentsValue = { targetMode: first, resetGeneration }; + } else { + fail(usage.trimEnd(), 2); + return; + } + + try { + printJson(await localControl(command, argumentsValue)); + } catch (error) { + const code = error instanceof Error ? error.message : 'local_control_failure'; + fail(`Hosted authentication control failed: ${code}`); + } +} + +await main(); diff --git a/scripts/hosted-web/README.md b/scripts/hosted-web/README.md new file mode 100644 index 0000000000..89c1e7b55d --- /dev/null +++ b/scripts/hosted-web/README.md @@ -0,0 +1,14 @@ +# Hosted-web scripts + +Hosted-web scripts are evidence producers, validators, and test probes. They are not general-purpose +permission to run agent teams or runtime smoke against a user project. All live behavior must use a +new sandbox/test project or an explicitly test-only existing project. + +Existing phase scripts and their outputs are retained as agent evidence. Do not delete, move, +truncate, or rewrite them while organizing newer evidence. Record authority, hashes, regeneration, +review disposition, and supersession under +`docs/hosted-web-phases/EVIDENCE_LIFECYCLE.md`. + +Hosted-worker orchestration is not implemented in this repository. Admission and launch use +subscription-runtime's builtin `worker-start-v1` boundary; scripts retained here are product evidence +producers and probes only. diff --git a/scripts/hosted-web/build-instance-lock.mjs b/scripts/hosted-web/build-instance-lock.mjs new file mode 100644 index 0000000000..240b2e4d40 --- /dev/null +++ b/scripts/hosted-web/build-instance-lock.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); +const sourcePath = resolve( + repositoryRoot, + 'native/hosted/instance-lock/agent_teams_instance_lock.c' +); + +function usage() { + throw new Error('usage: build-instance-lock.mjs [--test-hooks] --output ABS_OR_RELATIVE_OUTPUT'); +} + +if (process.platform !== 'linux') { + throw new Error('agent-teams-instance-lock is a Linux-only artifact'); +} + +const args = process.argv.slice(2); +const testHooks = args[0] === '--test-hooks'; +const outputArgs = testHooks ? args.slice(1) : args; +if (outputArgs.length !== 2 || outputArgs[0] !== '--output' || outputArgs[1].length === 0) { + usage(); +} + +const outputPath = resolve(process.cwd(), outputArgs[1]); +const compiler = process.env.CC || 'cc'; +const testHookFlags = testHooks ? ['-DAGENT_TEAMS_INSTANCE_LOCK_TEST_HOOKS=1'] : []; +const result = spawnSync( + compiler, + [ + '-std=c17', + '-O2', + '-Wall', + '-Wextra', + '-Wconversion', + '-Werror', + ...testHookFlags, + '-o', + outputPath, + sourcePath, + ], + { stdio: 'inherit' } +); + +if (result.error) { + throw result.error; +} +if (result.status !== 0) { + throw new Error(`instance-lock compiler exited with status ${String(result.status)}`); +} diff --git a/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.d.mts b/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.d.mts new file mode 100644 index 0000000000..fedc5df0b8 --- /dev/null +++ b/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.d.mts @@ -0,0 +1,312 @@ +import type { DrainEvidenceEnvelope } from '../w4-w6-contract/drain-evidence-envelope.mjs'; + +export const repoRoot: string; + +export type AuthActionType = + | 'bootstrap' + | 'pair' + | 'restart' + | 'lose_keyring' + | 'expire_session' + | 'renew' + | 'logout' + | 'forget_device' + | 'begin_reset' + | 'record_drain_evidence' + | 'advance_reset'; + +export const AUTH_ACTION_TYPES: readonly AuthActionType[]; + +export interface AuthAction { + type: string; + [field: string]: unknown; +} + +export interface ProcessAnchorState { + status: 'ready' | 'drained'; + protocolVersion: number; + deploymentGeneration: string; + processAnchorGeneration: string; + processAnchorGenerationOrdinal: number; + anchorIdentity: string; + spawnNonceHash: string; +} + +export interface AuthSessionState { + generation: number; + active: boolean; + revokedReason?: string; + expiredBy?: unknown; +} + +export interface AuthDeviceState { + familyRef: string; + generation: number; + predecessor: { generation: number; remainingUses: number } | null; + revokedReason?: string; +} + +export interface ResetIntentState { + generation: number; + stage: string; + drainEvidence: DrainEvidenceEnvelope | null; +} + +export interface AuthState { + processEpoch: number; + processAnchor: ProcessAnchorState; + keyring: { status: string; keyId: string | null }; + resetGeneration: number; + resetIntent: ResetIntentState | null; + challenge: { ref: string; consumed: boolean } | null; + device: AuthDeviceState | null; + sessions: Record; + mutationAdmission: boolean; +} + +export interface CookieTransition { + cookie: string; + operation: string; + attributes: string[]; + domain: string | null; +} + +export interface AuthTransitionResult { + state: AuthState; + outcome: 'accepted' | 'rejected'; + code: string; + cookieTransitions?: CookieTransition[]; + response?: { + delivered: boolean; + sessionRef: string | null; + deviceGeneration: number | null; + }; +} + +export interface AuthTraceEntry { + action: string; + outcome: 'accepted' | 'rejected'; + code: string; +} + +export interface DrainEvidenceOverrides { + ready?: Record; + drained?: Record; + [field: string]: unknown; +} + +export function newAuthState(): AuthState; +export function validateDrainEvidence( + state: AuthState, + evidence: unknown, + purpose: string, + resetGeneration: number, + recorded?: boolean +): string | null; +export function drainEvidenceFor( + state: AuthState, + purpose: string, + resetGeneration: number, + overrides?: DrainEvidenceOverrides +): DrainEvidenceEnvelope; +export function authTransition(input: AuthState, action: AuthAction): AuthTransitionResult; +export function runAuthSchedule(actions: readonly AuthAction[]): { + state: AuthState; + trace: AuthTraceEntry[]; +}; + +export interface ProxyRequestInput { + peer?: string; + socketEncrypted?: boolean; + host?: string; + forwarded?: { proto?: unknown; host?: unknown }; + browserRequest?: boolean; + origin?: string; + surface?: string; +} + +export interface ProxyConfigInput { + publicOrigin: string; + corsOrigin: string; + trustedProxyPeers: string[]; +} + +export interface ProxyEvaluation { + accepted: boolean; + code: string; + stage: string; + cookieLookup: boolean; + bodyParsed: boolean; + idempotencyClaimed: boolean; +} + +export function evaluateProxyRequest( + request: ProxyRequestInput, + config: ProxyConfigInput +): ProxyEvaluation; + +export interface AuthorityCookieInput { + headerBytes: number; + maxHeaderBytes: number; + parseStatus: string; + cookieNames: string[]; +} + +export function evaluateAuthorityCookieInput(input: AuthorityCookieInput): { + accepted: boolean; + code: string; + cookieLookup: boolean; +}; + +export const STANDALONE_CHARACTERIZATION_PATH: string; +export const STANDALONE_CHARACTERIZATION_RECORD_TYPE: string; +export const STANDALONE_CANONICAL_SOURCE_COMMIT: string; +export const STANDALONE_INPUT_PATCH_SHA256: string; +export const ARTIFACT_EVOLUTION_ASSUMPTION: string; +export const ARTIFACT_PROOF_LEVELS: Readonly>; + +export interface ProjectionValidationResult { + ok: boolean; + violations: string[]; +} + +export function validateArtifactAuthorityProjections( + authority: unknown, + evidence: unknown, + estimate: unknown +): ProjectionValidationResult; + +export interface StandaloneCharacterizationSource { + standaloneInput: string; + rendererOutput: string; + externalPackages: string[]; + nativeCatchAllEmptyStub: boolean; + broadElectronStub: boolean; + standaloneServiceStubs: boolean; + terminalNodeInstallStub: boolean; + terminalRuntimeArtifactPresent: boolean; + standaloneWorkerEntry: boolean; + electronWorkerEntry: boolean; + internalWorkerRuntimeFilename: string; + defaultWildcardCors: boolean; + directHttpPublished: boolean; + productionNodeModulesCopiedWhole: boolean; + terminalPackages: string[]; + cookiePlugin: string | null; + versions: Record; + terminalHttpRegistration: boolean; + terminalMigration: boolean; +} + +export interface StandaloneCharacterizationEmitted { + observed: boolean; + files: { path: string; bytes: number; sha256: string }[]; + internalStorageWorkerPresent: boolean; + electronEmptyStubPresent: boolean; + terminalServiceMarkerPresent: boolean; + terminalPlatformMarkerPresent: boolean; +} + +export interface TerminalAbsenceReport { + passes: boolean; + violations: string[]; +} + +export interface StandaloneCharacterization { + schemaVersion: number; + recordType: string; + phaseStartSha: string; + canonicalSourceCommit: string; + inputPatchSha256: string; + proofLevel: string; + characterizationScope: string; + build: Record; + historicalProvenance: Record; + source: StandaloneCharacterizationSource; + emitted: StandaloneCharacterizationEmitted; + terminalAbsence?: TerminalAbsenceReport; +} + +export function standaloneCharacterizationSha256(characterization: unknown): string; + +export interface StandaloneCharacterizationProjection { + authorityPath: string; + authorityRecordType: string; + authoritySha256: string; + disposition: string; +} + +export function buildStandaloneCharacterizationProjection( + characterization: unknown +): StandaloneCharacterizationProjection; +export function validateStandaloneCharacterizationProjection( + characterization: StandaloneCharacterization, + projection: unknown +): ProjectionValidationResult & { expected: StandaloneCharacterizationProjection }; + +export function scanStandalone( + root?: string, + options?: { buildRoot?: string | null } +): StandaloneCharacterization; + +export function evaluateV1TerminalAbsence(scan: StandaloneCharacterization): TerminalAbsenceReport; + +export interface HostedArtifactContractRow { + artifactId: string; + finalImagePath?: string; + protocolSha256?: string; + spikeSourcePath?: string; + spikeSourceSha256?: string; + [field: string]: unknown; +} + +export interface HostedArtifactContract { + recordType?: string; + status?: string; + capabilityClaims?: Record; + artifacts?: HostedArtifactContractRow[]; + controllerContractPath?: string; + [field: string]: unknown; +} + +export function evaluateHostedArtifactContract(contract: HostedArtifactContract): { + contractPasses: boolean; + releasePasses: boolean; + hostedV1Admitted: boolean; + violations: string[]; + unresolvedArtifactIds: string[]; +}; + +export interface FinalImageSurfaceScan { + packages?: unknown; + files?: unknown; + routes?: unknown; + migrations?: unknown; + capabilities?: unknown; + processes?: unknown; + rendererChunks?: unknown; + ports?: unknown; + volumes?: unknown; +} + +export function evaluateFinalImageTerminalAbsence( + image: FinalImageSurfaceScan +): TerminalAbsenceReport; + +export interface SqliteAbiProbe { + packageName: string; + version: string; + sqliteVersion: string; + reopenedValue: string; +} + +export function runAbiSmokeProbe(): { + runtime: { + node: string; + nodeModuleAbi: number; + napi: number; + electron: string; + electronModuleAbi: number; + }; + sqlite: SqliteAbiProbe[]; +}; diff --git a/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs b/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs new file mode 100644 index 0000000000..d9f74b4b01 --- /dev/null +++ b/scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs @@ -0,0 +1,516 @@ +#!/usr/bin/env node + +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + drainEvidenceEnvelopeId, + validateDrainEvidenceEnvelope, +} from '../w4-w6-contract/drain-evidence-envelope.mjs'; +import { + evaluateV1TerminalAbsence, + repoRoot, + scanStandalone, +} from './standalone-artifact-evidence.mjs'; + +export * from './standalone-artifact-evidence.mjs'; + +export function newAuthState() { + return { + processEpoch: 1, + processAnchor: { + status: 'ready', + protocolVersion: 1, + deploymentGeneration: 'deployment-generation-1', + processAnchorGeneration: 'process-anchor-generation-1', + processAnchorGenerationOrdinal: 1, + anchorIdentity: 'anchor-identity-ref-1', + spawnNonceHash: 'spawn-nonce-hash-ref-1', + }, + keyring: { status: 'ready', keyId: 'key-ref-1' }, + resetGeneration: 0, + resetIntent: null, + challenge: null, + device: null, + sessions: {}, + mutationAdmission: false, + }; +} + +export const AUTH_ACTION_TYPES = Object.freeze([ + 'bootstrap', + 'pair', + 'restart', + 'lose_keyring', + 'expire_session', + 'renew', + 'logout', + 'forget_device', + 'begin_reset', + 'record_drain_evidence', + 'advance_reset', +]); + +const activeAuthorityExists = (state) => + state.keyring.status === 'ready' && + Boolean(state.device?.familyRef) && + !state.device?.revokedReason && + Object.values(state.sessions).some((session) => session.active && !session.revokedReason); + +export function validateDrainEvidence(state, evidence, purpose, resetGeneration, recorded = false) { + if (!evidence) return 'typed_drain_required'; + if (evidence.envelopeId !== drainEvidenceEnvelopeId) return 'controller_drain_envelope_mismatch'; + const ready = evidence.ready; + const drained = evidence.drained; + if (!ready || !drained) { + return 'runtime_state_unclassified'; + } + const controllerValidation = validateDrainEvidenceEnvelope(evidence); + if (!controllerValidation.ok) { + const violations = controllerValidation.violations; + if (violations.some((violation) => violation === 'const:ready:protocolVersion')) { + return 'drain_protocol_version_stale'; + } + if (violations.some((violation) => violation === 'const:drained:protocolVersion')) { + return 'drain_protocol_version_stale'; + } + if (violations.some((violation) => violation.startsWith('generation_binding:purpose'))) { + return 'drain_purpose_mismatch'; + } + if ( + violations.some((violation) => violation.startsWith('generation_binding:resetGeneration')) + ) { + return 'drain_reset_generation_stale'; + } + if ( + violations.some((violation) => + violation.startsWith('generation_binding:deploymentGeneration') + ) + ) { + return 'drain_deployment_generation_stale'; + } + if ( + violations.some((violation) => + violation.startsWith('generation_binding:processAnchorGeneration') + ) + ) { + return 'drain_process_anchor_generation_stale'; + } + if ( + violations.some( + (violation) => + violation === 'const:ready:mainPidfdReady' || + violation === 'const:ready:ownedProcessGroupReady' + ) + ) { + return 'drain_anchor_not_ready'; + } + if ( + violations.some( + (violation) => violation === 'const:drained:kind' || violation === 'const:drained:outcome' + ) + ) { + return 'runtime_state_unclassified'; + } + if (violations.some((violation) => violation === 'max_items:drained:residuals')) { + return 'runtime_residuals_present'; + } + if (violations.some((violation) => violation.includes(':drained:'))) { + return 'drain_response_shape_mismatch'; + } + return 'drain_evidence_shape_mismatch'; + } + const anchor = state.processAnchor; + if (anchor.status !== (recorded ? 'drained' : 'ready')) return 'drain_anchor_not_ready'; + if ( + ready.anchorIdentity !== anchor.anchorIdentity || + ready.spawnNonceHash !== anchor.spawnNonceHash + ) { + return 'drain_provenance_mismatch'; + } + if ( + ready.protocolVersion !== anchor.protocolVersion || + drained.protocolVersion !== anchor.protocolVersion + ) { + return 'drain_protocol_version_stale'; + } + if ( + ready.deploymentGeneration !== anchor.deploymentGeneration || + drained.deploymentGeneration !== anchor.deploymentGeneration + ) { + return 'drain_deployment_generation_stale'; + } + if ( + ready.processAnchorGeneration !== anchor.processAnchorGeneration || + drained.processAnchorGeneration !== anchor.processAnchorGeneration + ) { + return 'drain_process_anchor_generation_stale'; + } + if (ready.mainPidfdReady !== true || ready.ownedProcessGroupReady !== true) { + return 'drain_anchor_not_ready'; + } + if (ready.purpose !== purpose || drained.purpose !== purpose) return 'drain_purpose_mismatch'; + if (ready.resetGeneration !== resetGeneration || drained.resetGeneration !== resetGeneration) { + return 'drain_reset_generation_stale'; + } + if (drained.kind !== 'process_drain_outcome_v1' || drained.outcome !== 'drained') { + return 'runtime_state_unclassified'; + } + if (!drained.classificationId || !Array.isArray(drained.residuals)) { + return 'runtime_state_unclassified'; + } + if (drained.residuals.length !== 0) return 'runtime_residuals_present'; + return null; +} + +function advanceProcessAnchorGeneration(state) { + const processAnchorGenerationOrdinal = state.processAnchor.processAnchorGenerationOrdinal + 1; + const processAnchorGeneration = `process-anchor-generation-${processAnchorGenerationOrdinal}`; + state.processAnchor = { + status: 'ready', + protocolVersion: state.processAnchor.protocolVersion, + deploymentGeneration: state.processAnchor.deploymentGeneration, + processAnchorGeneration, + processAnchorGenerationOrdinal, + anchorIdentity: `anchor-identity-ref-${processAnchorGenerationOrdinal}`, + spawnNonceHash: `spawn-nonce-hash-ref-${processAnchorGenerationOrdinal}`, + }; +} + +export function drainEvidenceFor(state, purpose, resetGeneration, overrides = {}) { + const drainedOverrides = overrides.drained ?? {}; + const readyOverrides = overrides.ready ?? {}; + const envelopeOverrides = { ...overrides }; + delete envelopeOverrides.drained; + delete envelopeOverrides.ready; + return { + envelopeId: drainEvidenceEnvelopeId, + ready: { + protocolVersion: state.processAnchor.protocolVersion, + spawnNonceHash: state.processAnchor.spawnNonceHash, + deploymentGeneration: state.processAnchor.deploymentGeneration, + processAnchorGeneration: state.processAnchor.processAnchorGeneration, + purpose, + resetGeneration, + anchorIdentity: state.processAnchor.anchorIdentity, + mainPidfdReady: true, + ownedProcessGroupReady: true, + ...readyOverrides, + }, + drained: { + protocolVersion: state.processAnchor.protocolVersion, + kind: 'process_drain_outcome_v1', + outcome: 'drained', + purpose, + resetGeneration, + deploymentGeneration: state.processAnchor.deploymentGeneration, + processAnchorGeneration: state.processAnchor.processAnchorGeneration, + classificationId: `anchor-classification-${state.processAnchor.processAnchorGeneration}`, + residuals: [], + ...drainedOverrides, + }, + ...envelopeOverrides, + }; +} + +const deviceCookie = (operation) => ({ + cookie: '__Secure-atd', + operation, + attributes: ['Secure', 'HttpOnly', 'SameSite=Strict', 'Path=/api/hosted/v1/auth/renew'], + domain: null, +}); + +const sessionCookie = (operation) => ({ + cookie: '__Host-ats', + operation, + attributes: ['Secure', 'HttpOnly', 'SameSite=Strict', 'Path=/'], + domain: null, +}); + +function revokeAll(state, reason) { + if (state.device) state.device.revokedReason = reason; + for (const session of Object.values(state.sessions)) session.revokedReason = reason; + state.mutationAdmission = false; +} + +/** + * Executable Phase 0 model. References are deliberately symbolic: the model never creates or emits + * a credential value. Persistent records survive restart; process-local readiness does not. + */ +export function authTransition(input, action) { + const state = structuredClone(input); + if (!AUTH_ACTION_TYPES.includes(action.type)) { + throw new Error(`unknown auth action: ${action.type}`); + } + const preserveResetAdmissionFence = () => { + if (state.resetIntent) state.mutationAdmission = false; + }; + const reject = (code) => { + preserveResetAdmissionFence(); + return { state, outcome: 'rejected', code }; + }; + const accept = (code) => { + preserveResetAdmissionFence(); + return { state, outcome: 'accepted', code }; + }; + + if (state.resetIntent) { + state.mutationAdmission = false; + if (['bootstrap', 'pair', 'renew'].includes(action.type)) { + return reject('reset_in_progress'); + } + } + + switch (action.type) { + case 'bootstrap': + if (state.keyring.status !== 'ready') return reject('auth_not_ready_keyring'); + if (state.device && !state.device.revokedReason) return accept('existing_device_reused'); + { + const drainError = validateDrainEvidence(state, action.drainEvidence, 'pairing', 0); + if (drainError) return reject(drainError); + } + state.processAnchor.status = 'drained'; + state.challenge = { ref: `challenge-ref-${state.resetGeneration}`, consumed: false }; + return accept('challenge_issued'); + case 'pair': + if (state.keyring.status !== 'ready') return reject('auth_not_ready_keyring'); + if (!state.challenge || state.challenge.consumed) return reject('challenge_invalid'); + state.challenge.consumed = true; + state.device = { familyRef: 'device-family-ref-1', generation: 1, predecessor: null }; + state.sessions = { 'session-ref-1': { generation: 1, active: true } }; + state.mutationAdmission = true; + advanceProcessAnchorGeneration(state); + return { + ...accept('paired_device_and_session'), + cookieTransitions: [deviceCookie('set'), sessionCookie('set')], + }; + case 'restart': + state.processEpoch += 1; + state.mutationAdmission = !state.resetIntent && activeAuthorityExists(state); + return accept(state.mutationAdmission ? 'authority_reloaded' : 'auth_not_ready'); + case 'lose_keyring': + state.keyring = { status: 'missing', keyId: null }; + state.mutationAdmission = false; + return accept('keyring_marked_missing'); + case 'expire_session': { + const session = state.sessions[action.sessionRef]; + if (!session) return reject('session_unknown'); + session.active = false; + session.expiredBy = action.deadline; + state.mutationAdmission = false; + return accept('session_expired'); + } + case 'renew': { + if (state.keyring.status !== 'ready') return reject('auth_not_ready_keyring'); + if (!state.device || state.device.revokedReason) return reject('device_revoked'); + const current = state.device.generation; + const predecessor = state.device.predecessor; + const currentAccepted = action.presentedGeneration === current; + const graceAccepted = + predecessor?.generation === action.presentedGeneration && predecessor.remainingUses > 0; + if (!currentAccepted && !graceAccepted) { + revokeAll(state, 'predecessor_replay_outside_grace'); + return reject('device_family_revoked_replay'); + } + state.device.predecessor = { generation: current, remainingUses: 1 }; + state.device.generation = current + 1; + for (const session of Object.values(state.sessions)) { + if (session.active) { + session.active = false; + session.revokedReason = 'session_rotation'; + } + } + const sessionRef = `session-ref-${state.device.generation}`; + state.sessions[sessionRef] = { generation: state.device.generation, active: true }; + state.mutationAdmission = true; + return { + ...accept(graceAccepted ? 'predecessor_grace_rotated_forward' : 'device_rotated'), + cookieTransitions: [deviceCookie('rotate'), sessionCookie('rotate')], + response: action.responseLost + ? { delivered: false, sessionRef: null, deviceGeneration: null } + : { delivered: true, sessionRef, deviceGeneration: state.device.generation }, + }; + } + case 'logout': + if (state.sessions[action.sessionRef]) { + state.sessions[action.sessionRef].revokedReason = 'logout'; + state.sessions[action.sessionRef].active = false; + } + state.mutationAdmission = false; + return { ...accept('session_revoked'), cookieTransitions: [sessionCookie('clear')] }; + case 'forget_device': + revokeAll(state, 'forget_device'); + return { + ...accept('device_family_revoked'), + cookieTransitions: [deviceCookie('clear'), sessionCookie('clear')], + }; + case 'begin_reset': + if (state.resetIntent) return reject('reset_already_in_progress'); + if (action.generation <= state.resetGeneration) return reject('reset_generation_not_newer'); + state.mutationAdmission = false; + state.resetIntent = { + generation: action.generation, + stage: 'requested', + drainEvidence: null, + }; + return accept('reset_requested'); + case 'record_drain_evidence': { + const intent = state.resetIntent; + if (!intent) return reject('reset_not_requested'); + const drainError = validateDrainEvidence( + state, + action.evidence, + 'host_reset', + intent.generation + ); + if (drainError) { + intent.stage = 'draining'; + return reject(drainError); + } + intent.stage = 'drained'; + intent.drainEvidence = structuredClone(action.evidence); + state.processAnchor.status = 'drained'; + return accept('typed_drain_recorded'); + } + case 'advance_reset': { + const intent = state.resetIntent; + if (!intent) return reject('reset_not_requested'); + if (['requested', 'draining'].includes(intent.stage)) { + intent.stage = 'draining'; + return reject('typed_drain_required'); + } + if (intent.stage === 'drained') { + const drainError = validateDrainEvidence( + state, + intent.drainEvidence, + 'host_reset', + intent.generation, + true + ); + if (drainError) return reject(drainError); + intent.stage = 'new_key_staged'; + return accept('new_key_staged'); + } + if (intent.stage === 'new_key_staged') { + revokeAll(state, 'host_reset'); + intent.stage = 'authority_revoked'; + return accept('authority_revoked'); + } + if (intent.stage === 'authority_revoked') { + state.resetGeneration = intent.generation; + state.keyring = { status: 'ready', keyId: `key-ref-reset-${intent.generation}` }; + intent.stage = 'key_activated'; + return accept('key_activated'); + } + if (intent.stage === 'key_activated') { + state.challenge = { ref: `challenge-ref-${intent.generation}`, consumed: false }; + intent.stage = 'challenge_issued'; + return accept('challenge_issued'); + } + if (intent.stage === 'challenge_issued') { + state.resetIntent = null; + return accept('reset_completed'); + } + throw new Error(`unknown reset stage: ${intent.stage}`); + } + } +} + +export function runAuthSchedule(actions) { + let state = newAuthState(); + const trace = []; + for (const action of actions) { + const result = authTransition(state, action); + state = result.state; + trace.push({ action: action.type, outcome: result.outcome, code: result.code }); + } + return { state, trace }; +} + +const multiValue = (value) => + Array.isArray(value) || (typeof value === 'string' && value.includes(',')); + +/** Security-order spike: all rejection paths return before cookie/body/idempotency work. */ +export function evaluateProxyRequest(request, config) { + const rejected = (code, stage) => ({ + accepted: false, + code, + stage, + cookieLookup: false, + bodyParsed: false, + idempotencyClaimed: false, + }); + if (request.surface && request.surface !== 'browser') { + return rejected('browser_runtime_trust_surfaces_disjoint', 'surface'); + } + let publicOrigin; + try { + publicOrigin = new URL(config.publicOrigin); + } catch { + return rejected('public_origin_invalid', 'readiness'); + } + if ( + publicOrigin.protocol !== 'https:' || + publicOrigin.username || + publicOrigin.password || + publicOrigin.pathname !== '/' || + publicOrigin.search || + publicOrigin.hash + ) { + return rejected('public_origin_invalid', 'readiness'); + } + if (config.corsOrigin !== publicOrigin.origin) { + return rejected('cors_origin_must_equal_public_origin', 'readiness'); + } + + const forwarded = request.forwarded ?? {}; + const hasForwarded = Object.values(forwarded).some(Boolean); + const trustedProxy = config.trustedProxyPeers.includes(request.peer); + if (hasForwarded && !trustedProxy) return rejected('forwarded_header_spoof', 'transport'); + if (multiValue(forwarded.proto) || multiValue(forwarded.host)) { + return rejected('ambiguous_forwarded_authority', 'transport'); + } + const secure = request.socketEncrypted || (trustedProxy && forwarded.proto === 'https'); + if (!secure) return rejected('direct_http_forbidden', 'transport'); + const authority = trustedProxy ? forwarded.host : request.host; + if (authority !== publicOrigin.host) return rejected('unexpected_authority', 'authority'); + if (request.browserRequest && request.origin !== publicOrigin.origin) { + return rejected(request.origin ? 'unexpected_origin' : 'origin_required', 'origin'); + } + return { + accepted: true, + code: 'origin_and_authority_accepted', + stage: 'auth_next', + cookieLookup: false, + bodyParsed: false, + idempotencyClaimed: false, + }; +} + +export function evaluateAuthorityCookieInput(input) { + const rejected = (code) => ({ accepted: false, code, cookieLookup: false }); + if (input.headerBytes > input.maxHeaderBytes) return rejected('cookie_header_oversized'); + if (input.parseStatus !== 'parsed') return rejected('cookie_header_malformed'); + const authorityNames = new Set(['__Secure-atd', '__Host-ats']); + const seen = new Set(); + for (const name of input.cookieNames) { + if (!authorityNames.has(name)) continue; + if (seen.has(name)) return rejected('duplicate_authority_cookie'); + seen.add(name); + } + return { accepted: true, code: 'cookie_shape_accepted', cookieLookup: false }; +} + +function main() { + const outputArg = process.argv.indexOf('--output'); + const buildRootArg = process.argv.indexOf('--build-root'); + const scan = scanStandalone(repoRoot, { + buildRoot: buildRootArg >= 0 ? process.argv[buildRootArg + 1] : null, + }); + const output = `${JSON.stringify({ ...scan, terminalAbsence: evaluateV1TerminalAbsence(scan) }, null, 2)}\n`; + if (outputArg >= 0) writeFileSync(resolve(repoRoot, process.argv[outputArg + 1]), output); + else process.stdout.write(output); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/hosted-web/phase-0/auth-artifacts/prove-target-image-admission.mjs b/scripts/hosted-web/phase-0/auth-artifacts/prove-target-image-admission.mjs new file mode 100644 index 0000000000..34aa758d9d --- /dev/null +++ b/scripts/hosted-web/phase-0/auth-artifacts/prove-target-image-admission.mjs @@ -0,0 +1,532 @@ +#!/usr/bin/env node + +import { createHash, randomUUID } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { renderHostedContainerHardeningCompose } from '../../../ci/verify-hosted-container-hardening.mjs'; + +import { repoRoot } from './auth-artifacts-spike.mjs'; +import { + AUTHORITY_PATHS, + evaluateDisposableInstanceLockMigrationProof, + evaluateDockerInstanceLockStartup, + evaluateTargetImageAdmission, + HOSTED_ENTRYPOINT, + HOSTED_NODE, + HOSTED_PROFILES, + INSTANCE_LOCK_ANCHOR, + INSTANCE_LOCK_PARENT, + normalizeDecisionFacts, + PERSISTENT_APPLICATION_ROOT, + PERSISTENT_STATE_ROOT, + projectControllerArtifacts, + REQUIRED_CANONICAL_SOURCE_COMMIT, + REQUIRED_CANONICAL_SOURCE_TREE, + runProviderCanaryFixture, + TARGET_IMAGE_DECISION_PATH, + TERMINAL_SENSITIVE_SURFACES, +} from './target-image-admission-policy.mjs'; + +export { + evaluateDisposableInstanceLockMigrationProof, + evaluateDockerInstanceLockStartup, + evaluateTargetImageAdmission, + normalizeDecisionFacts, + REQUIRED_CANONICAL_SOURCE_COMMIT, + REQUIRED_CANONICAL_SOURCE_TREE, + runProviderCanaryFixture, + TARGET_IMAGE_DECISION_PATH, + TERMINAL_SENSITIVE_SURFACES, +}; + +function runDockerJson(dockerBinary, args) { + const result = spawnSync(dockerBinary, args, { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 5 * 1024 * 1024, + }); + if (result.status !== 0 || result.error) { + throw new Error(`docker ${args[0]} failed`); + } + return JSON.parse(result.stdout); +} + +function runDocker(dockerBinary, args, options = {}) { + const result = spawnSync(dockerBinary, args, { + cwd: options.cwd ?? repoRoot, + encoding: 'utf8', + env: options.environment ?? process.env, + maxBuffer: 5 * 1024 * 1024, + }); + if (result.status !== 0 || result.error) { + throw new Error(`docker ${args[0]} failed: ${(result.stderr ?? '').trim()}`); + } + return result.stdout; +} + +function disposableComposeEnvironment(projectName, sandboxRoot) { + const secretsDirectory = join(sandboxRoot, 'secrets'); + const claudeDirectory = join(sandboxRoot, 'claude'); + mkdirSync(secretsDirectory, { recursive: true, mode: 0o700 }); + mkdirSync(claudeDirectory, { recursive: true, mode: 0o700 }); + for (const secretName of [ + 'oidc_client_secret', + 'keycloak_admin_password', + 'keycloak_database_password', + ]) { + writeFileSync(join(secretsDirectory, secretName), randomUUID().replaceAll('-', ''), { + mode: 0o600, + }); + } + return { + ...process.env, + COMPOSE_PROJECT_NAME: projectName, + CLAUDE_DIR: claudeDirectory, + HOSTED_SECRETS_DIR: secretsDirectory, + NODE_IMAGE_DIGEST: `sha256:${'a'.repeat(64)}`, + KEYCLOAK_IMAGE_DIGEST: `sha256:${'b'.repeat(64)}`, + POSTGRES_IMAGE_DIGEST: `sha256:${'c'.repeat(64)}`, + CADDY_IMAGE_DIGEST: `sha256:${'d'.repeat(64)}`, + }; +} + +export function proveDisposableInstanceLockMigration(imageReference, options = {}) { + if (typeof imageReference !== 'string' || imageReference.length === 0) { + throw new Error('a built image reference is required'); + } + const dockerBinary = options.dockerBinary ?? '/usr/bin/docker'; + const projectName = + options.projectName ?? `agent-teams-lock-upgrade-${randomUUID().replaceAll('-', '')}`; + if (!/^agent-teams-lock-upgrade-[a-f0-9]{32}$/u.test(projectName)) { + throw new Error('disposable project name is invalid'); + } + + const sandboxRoot = mkdtempSync(join(tmpdir(), 'agent-teams-lock-upgrade-')); + const environment = disposableComposeEnvironment(projectName, sandboxRoot); + const applicationVolume = `${projectName}_agent-teams-data`; + const lockParentVolume = `${projectName}_agent-teams-instance-lock`; + const composeFile = join(repoRoot, 'docker/docker-compose.yml'); + const serviceTags = HOSTED_PROFILES.map((profile) => `${projectName}-agent-teams-${profile}`); + const createdVolumes = []; + const createdTags = []; + const markerBytes = Buffer.from(`legacy-marker:${projectName}\n`, 'utf8'); + const databaseBytes = Buffer.concat([ + Buffer.from('SQLite format 3\0', 'binary'), + createHash('sha256').update(`legacy-database:${projectName}`).digest(), + ]); + const seededSha256 = { + marker: sha256Text(markerBytes), + database: sha256Text(databaseBytes), + }; + const profiles = {}; + + try { + for (const resource of [applicationVolume, lockParentVolume]) { + const existing = spawnSync(dockerBinary, ['volume', 'inspect', resource], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (existing.status === 0) throw new Error(`disposable volume already exists: ${resource}`); + } + for (const tag of serviceTags) { + const existing = spawnSync(dockerBinary, ['image', 'inspect', tag], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (existing.status === 0) throw new Error(`disposable image tag already exists: ${tag}`); + } + + runDocker(dockerBinary, ['volume', 'create', applicationVolume], { environment }); + createdVolumes.push(applicationVolume); + const seedProgram = [ + "const { mkdirSync, writeFileSync } = require('node:fs');", + "mkdirSync('/legacy/hosted-auth-secrets', { recursive: true });", + `writeFileSync('/legacy/upgrade-marker.bin', Buffer.from('${markerBytes.toString('hex')}', 'hex'));`, + `writeFileSync('/legacy/internal-storage.sqlite', Buffer.from('${databaseBytes.toString('hex')}', 'hex'));`, + ].join(''); + runDocker( + dockerBinary, + [ + 'run', + '--rm', + '--entrypoint', + HOSTED_NODE, + '--mount', + `type=volume,source=${applicationVolume},target=/legacy`, + imageReference, + '-e', + seedProgram, + ], + { environment } + ); + + for (const [index, profile] of HOSTED_PROFILES.entries()) { + const serviceName = `agent-teams-${profile}`; + const serviceTag = serviceTags[index]; + runDocker(dockerBinary, ['tag', imageReference, serviceTag], { environment }); + createdTags.push(serviceTag); + const rendered = renderHostedContainerHardeningCompose({ + profile, + root: repoRoot, + dockerBinary, + environment, + }); + const renderedService = rendered.services?.[serviceName]; + if ( + rendered.volumes?.['agent-teams-application-data']?.name !== applicationVolume || + rendered.volumes?.['agent-teams-data']?.name !== lockParentVolume || + renderedService?.environment?.AUTH_DATA_DIR !== PERSISTENT_APPLICATION_ROOT + ) { + throw new Error(`rendered migration contract failed for ${profile}`); + } + + const probeProgram = [ + "const { createHash } = require('node:crypto');", + "const { readFileSync, statSync } = require('node:fs');", + "const digest = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');", + "const shape = (path) => { const value = statSync(path); return { uid: value.uid, gid: value.gid, mode: (value.mode & 0o7777).toString(8).padStart(4, '0'), isFile: value.isFile() }; };", + `process.stdout.write('MIGRATION_PROOF:' + JSON.stringify({ observedSha256: { marker: digest('${PERSISTENT_APPLICATION_ROOT}/upgrade-marker.bin'), database: digest('${PERSISTENT_APPLICATION_ROOT}/internal-storage.sqlite') }, applicationDataPath: '${PERSISTENT_APPLICATION_ROOT}', applicationVolume: '${applicationVolume}', lockParentVolume: '${lockParentVolume}', lockParent: shape('${INSTANCE_LOCK_PARENT}'), lockAnchor: shape('${INSTANCE_LOCK_ANCHOR}') }) + '\\n');`, + ].join(''); + const output = runDocker( + dockerBinary, + [ + 'compose', + '-f', + composeFile, + '--project-name', + projectName, + '--profile', + profile, + 'run', + '--rm', + '--no-deps', + '--no-build', + serviceName, + HOSTED_NODE, + '-e', + probeProgram, + ], + { environment } + ); + const proofLine = output.split(/\r?\n/u).find((line) => line.startsWith('MIGRATION_PROOF:')); + if (!proofLine) throw new Error(`migration observation missing for ${profile}`); + profiles[profile] = JSON.parse(proofLine.slice('MIGRATION_PROOF:'.length)); + } + + const proof = { + format: 'agent-teams-instance-lock-disposable-migration-proof/v1', + status: 'passed', + projectName, + seededSha256, + profiles, + }; + const evaluation = evaluateDisposableInstanceLockMigrationProof(proof); + if (!evaluation.ok) + throw new Error(`disposable migration proof failed: ${evaluation.violations}`); + return proof; + } finally { + spawnSync( + dockerBinary, + [ + 'compose', + '-f', + composeFile, + '--project-name', + projectName, + '--profile', + 'personal', + '--profile', + 'keycloak', + 'down', + '--volumes', + '--remove-orphans', + ], + { cwd: repoRoot, encoding: 'utf8', env: environment } + ); + for (const tag of createdTags) { + spawnSync(dockerBinary, ['image', 'rm', tag], { cwd: repoRoot, encoding: 'utf8' }); + } + for (const volume of [...createdVolumes, lockParentVolume]) { + spawnSync(dockerBinary, ['volume', 'rm', volume], { cwd: repoRoot, encoding: 'utf8' }); + } + rmSync(sandboxRoot, { recursive: true, force: true }); + } +} + +export function collectBuiltDockerInstanceLockStartupProof(imageReference, options = {}) { + if (typeof imageReference !== 'string' || imageReference.length === 0) { + throw new Error('a built image reference is required'); + } + const dockerBinary = options.dockerBinary ?? '/usr/bin/docker'; + const imageInspect = runDockerJson(dockerBinary, ['image', 'inspect', imageReference]); + if (!Array.isArray(imageInspect) || imageInspect.length !== 1) { + throw new Error('docker image inspect returned an unexpected image count'); + } + const files = {}; + for (const path of [ + PERSISTENT_STATE_ROOT, + HOSTED_ENTRYPOINT, + INSTANCE_LOCK_BINARY, + INSTANCE_LOCK_PARENT, + INSTANCE_LOCK_ANCHOR, + ]) { + const probe = spawnSync( + dockerBinary, + ['run', '--rm', '--entrypoint', '/usr/bin/stat', imageReference, '-c', '%u:%g:%a', path], + { cwd: repoRoot, encoding: 'utf8' } + ); + if (probe.status !== 0 || probe.error) throw new Error(`built image stat failed for ${path}`); + const match = /^(\d+):(\d+):([0-7]{3,4})$/u.exec(probe.stdout.trim()); + if (!match) throw new Error(`built image stat was invalid for ${path}`); + files[path] = { uid: Number(match[1]), gid: Number(match[2]), mode: match[3] }; + } + const entrypointBytesProbe = spawnSync( + dockerBinary, + ['run', '--rm', '--entrypoint', '/bin/cat', imageReference, HOSTED_ENTRYPOINT], + { cwd: repoRoot, encoding: null, maxBuffer: 1024 * 1024 } + ); + if ( + entrypointBytesProbe.status !== 0 || + entrypointBytesProbe.error || + !Buffer.isBuffer(entrypointBytesProbe.stdout) + ) { + throw new Error('built image entrypoint byte collection failed'); + } + files[HOSTED_ENTRYPOINT].sha256 = createHash('sha256') + .update(entrypointBytesProbe.stdout) + .digest('hex'); + return { + dockerfile: readFileSync(resolve(repoRoot, 'docker/Dockerfile'), 'utf8'), + entrypoint: readFileSync(resolve(repoRoot, 'docker/hosted-entrypoint.sh'), 'utf8'), + imageProbe: { ...imageInspect[0], Files: files }, + renderedComposes: Object.fromEntries( + HOSTED_PROFILES.map((profile) => [ + profile, + renderHostedContainerHardeningCompose({ + profile, + root: repoRoot, + dockerBinary, + environment: options.environment, + }), + ]) + ), + migrationProof: proveDisposableInstanceLockMigration(imageReference, { + dockerBinary, + }), + }; +} + +const sha256Text = (value) => createHash('sha256').update(value).digest('hex'); + +function runGit(args) { + const result = spawnSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + }); + if (result.status !== 0) { + throw new Error(`git ${args[0]} failed: ${(result.stderr ?? '').trim()}`); + } + return result.stdout.trimEnd(); +} + +function readCanonicalSource(path) { + return `${runGit(['show', `${REQUIRED_CANONICAL_SOURCE_COMMIT}:${path}`])}\n`; +} + +function readCanonicalJson(path) { + return JSON.parse(readCanonicalSource(path)); +} + +export function collectTargetImageDecision() { + const sourceTree = runGit(['rev-parse', `${REQUIRED_CANONICAL_SOURCE_COMMIT}^{tree}`]); + if (sourceTree !== REQUIRED_CANONICAL_SOURCE_TREE) { + throw new Error(`canonical source tree mismatch: ${sourceTree}`); + } + const controllerContract = readCanonicalJson( + 'docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json' + ); + const standalone = readCanonicalJson( + 'docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json' + ); + const dockerfile = readCanonicalSource('docker/Dockerfile'); + const providerFixture = runProviderCanaryFixture(); + const controllerArtifacts = projectControllerArtifacts(controllerContract); + const admission = evaluateTargetImageAdmission({ + image: null, + controllerArtifacts, + providerCanaries: providerFixture, + }); + + return { + schemaVersion: 2, + recordType: 'phase-0-target-image-capability-narrowing-decision', + decision: { + id: 'P0.D.TARGET_IMAGE', + state: 'accepted', + outcome: 'capability_narrowed', + phase0Gate: 'closed_by_accepted_narrowing', + exactImageEarliestOwner: 'phase-5', + rationale: + 'Phase 0 characterizes source and contracts but does not implement the production composition that Phase 5 must build; exact-image admission before Phase 5 would be circular.', + phase0Capability: + 'Preserve the complete fail-closed admission contract and canonical-source gaps without claiming an image exists.', + deferredCapability: + 'No hosted route, mutation, provider runtime, credential canary, production composition, or terminal-negative image readiness is admitted.', + }, + sourceIdentity: { + canonicalCommit: REQUIRED_CANONICAL_SOURCE_COMMIT, + canonicalTree: REQUIRED_CANONICAL_SOURCE_TREE, + verificationRelationship: 'repository_head_is_source_or_descendant', + evidenceIdentityPolicy: + 'The verifier reports repositoryHeadAtVerification separately; it never substitutes that mutable commit into this immutable source decision.', + }, + scope: { + sandboxAndSyntheticFixturesOnly: true, + realUserProjectsOpened: false, + dockerSocketRequiredForPhase0Decision: false, + liveContainerRuntimeObservationInDeterministicFacts: false, + phase1AuthorizedOrImplemented: false, + }, + authorities: AUTHORITY_PATHS.map((path) => ({ + path, + sourceCommit: REQUIRED_CANONICAL_SOURCE_COMMIT, + sha256: sha256Text(readCanonicalSource(path)), + })), + canonicalSourceFacts: { + currentCandidate: { + dockerfileBaseDigestPinned: /^FROM\s+[^\s]+@sha256:[a-f0-9]{64}/m.test(dockerfile), + finalImageDigestDeclared: false, + nonRootUserDeclared: /^USER\s+[^\s]+/m.test(dockerfile), + initEntrypointDeclared: /^ENTRYPOINT\s+/m.test(dockerfile), + controllerArtifactsCopied: controllerContract.artifacts.every((artifact) => + dockerfile.includes(artifact.finalImagePath) + ), + terminalAbsence: standalone.terminalAbsence, + }, + controllerArtifacts, + }, + providerRuntimeCanaryFixture: providerFixture, + phase5AdmissionGate: { + state: 'fail_closed', + admitted: false, + admissionRequiredBefore: [ + 'phase-5 route admission', + 'phase-5 capability advertisement', + 'phase-6 non-loopback mutation enablement', + ], + terminalSensitiveSurfaces: TERMINAL_SENSITIVE_SURFACES, + canonicalSourceGapCount: admission.violations.length, + canonicalSourceGaps: admission.violations, + terminalNegative: admission.terminalNegative, + requiredEvidence: + 'One reviewed immutable target-image manifest/profile and an instantiated digest with complete digest-bound inventory, native provenance/ownership/modes, startup-order proof, target-executed provider canaries, and terminal-negative scans over every named surface.', + }, + claims: { + exactImageInstantiated: false, + exactHostedCompositionProved: false, + providerRuntimeTargetProved: false, + credentialCanariesTargetProved: false, + terminalNegativeAdmission: false, + phase1AuthorizedOrImplemented: false, + }, + }; +} + +function normalizedDigest(value) { + return sha256Text(JSON.stringify(normalizeDecisionFacts(value))); +} + +export function collectEvidenceIdentity(repositoryHeadAtVerification) { + const repositoryHead = repositoryHeadAtVerification ?? runGit(['rev-parse', '--verify', 'HEAD']); + const relationshipResult = spawnSync( + 'git', + ['merge-base', '--is-ancestor', REQUIRED_CANONICAL_SOURCE_COMMIT, repositoryHead], + { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 } + ); + return { + repositoryHeadAtVerification: repositoryHead, + canonicalSourceCommit: REQUIRED_CANONICAL_SOURCE_COMMIT, + sourceRelationship: + relationshipResult.status === 0 ? 'source_or_descendant' : 'not_source_or_descendant', + }; +} + +export function verifyCommittedTargetImageDecision( + committed, + { repositoryHeadAtVerification, sourceRelationship } = {} +) { + const expected = collectTargetImageDecision(); + const evidenceIdentity = repositoryHeadAtVerification + ? { + repositoryHeadAtVerification, + canonicalSourceCommit: REQUIRED_CANONICAL_SOURCE_COMMIT, + sourceRelationship: sourceRelationship ?? 'not_source_or_descendant', + } + : collectEvidenceIdentity(); + const expectedFactDigest = normalizedDigest(expected); + const committedFactDigest = normalizedDigest(committed); + const normalizedFactsMatch = committedFactDigest === expectedFactDigest; + const sourceIdentityValid = + committed?.sourceIdentity?.canonicalCommit === REQUIRED_CANONICAL_SOURCE_COMMIT && + committed?.sourceIdentity?.canonicalTree === REQUIRED_CANONICAL_SOURCE_TREE; + const authorityProvenanceValid = expected.authorities.every((expectedAuthority) => { + const observed = committed?.authorities?.find( + ({ path, sourceCommit }) => + path === expectedAuthority.path && sourceCommit === REQUIRED_CANONICAL_SOURCE_COMMIT + ); + return observed?.sha256 === expectedAuthority.sha256; + }); + const sourceRelationshipValid = evidenceIdentity.sourceRelationship === 'source_or_descendant'; + return { + ok: + normalizedFactsMatch && + sourceIdentityValid && + authorityProvenanceValid && + sourceRelationshipValid, + normalizedFactsMatch, + sourceIdentityValid, + authorityProvenanceValid, + sourceRelationshipValid, + committedFactDigest, + expectedFactDigest, + evidenceIdentity, + expected, + }; +} + +function main() { + if (process.argv.includes('--verify-instance-lock-startup')) { + const valueAfter = (flag) => { + const index = process.argv.indexOf(flag); + if (index < 0 || !process.argv[index + 1]) { + throw new Error(`missing required ${flag} path`); + } + return process.argv[index + 1]; + }; + const result = evaluateDockerInstanceLockStartup( + collectBuiltDockerInstanceLockStartupProof(valueAfter('--image')) + ); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exitCode = 1; + return; + } + const decision = collectTargetImageDecision(); + const verification = verifyCommittedTargetImageDecision(decision); + process.stdout.write(`${JSON.stringify(decision, null, 2)}\n`); + // Independent gates: both flags are honored when passed together. Admission + // failure (exit 2) takes precedence over source-relationship failure (exit 1); + // with no flags the command stays a diagnostic dump that exits 0. + if (process.argv.includes('--verify-source-relationship') && !verification.ok) { + process.exitCode = 1; + } + if (process.argv.includes('--require-admission') && !decision.phase5AdmissionGate.admitted) { + process.exitCode = 2; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/hosted-web/phase-0/auth-artifacts/standalone-artifact-evidence.mjs b/scripts/hosted-web/phase-0/auth-artifacts/standalone-artifact-evidence.mjs new file mode 100644 index 0000000000..13df8247b0 --- /dev/null +++ b/scripts/hosted-web/phase-0/auth-artifacts/standalone-artifact-evidence.mjs @@ -0,0 +1,319 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + loadControllerArtifactContract, + validateControllerArtifactProjection, +} from '../w4-w6-contract/controller-artifact-contract.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +export const repoRoot = resolve(scriptDir, '../../../..'); +const localRequire = createRequire(import.meta.url); + +function read(root, path) { + return readFileSync(join(root, path), 'utf8'); +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +function walk(root, path = root) { + if (!existsSync(path)) return []; + return readdirSync(path, { withFileTypes: true }).flatMap((entry) => { + const child = join(path, entry.name); + return entry.isDirectory() ? walk(root, child) : [relative(root, child).replaceAll('\\', '/')]; + }); +} + +export const STANDALONE_CHARACTERIZATION_PATH = + 'docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json'; +export const STANDALONE_CHARACTERIZATION_RECORD_TYPE = + 'w6-current-canonical-plus-input-patch-artifact-scan'; +export const STANDALONE_CANONICAL_SOURCE_COMMIT = 'f09ad2af137c02bf4e660d5cce398a1acdbc73d3'; +export const STANDALONE_INPUT_PATCH_SHA256 = + 'd336045cf53e814d067280b5a32c9f55f07c7e19685ab6e16d692703b92aa673'; +export const ARTIFACT_EVOLUTION_ASSUMPTION = + 'The existing standalone source/build path may evolve in place, but the exact canonical artifact is rejected and evolution remains unproved; any resulting candidate requires a separately reviewed packet.'; +export const ARTIFACT_PROOF_LEVELS = Object.freeze({ + 'P0.W6.ARTIFACT_INVENTORY': 'targeted_current_canonical_plus_input_patch_build_observed', + 'P0.W6.TERMINAL_ABSENCE_REPORT': 'targeted_current_canonical_plus_input_patch_build_observed', +}); + +export function validateArtifactAuthorityProjections(authority, evidence, estimate) { + const violations = []; + if (authority?.artifactEvolutionAssumption !== ARTIFACT_EVOLUTION_ASSUMPTION) { + violations.push('artifact_authority:evolution_assumption'); + } + if (JSON.stringify(authority?.proofLevels) !== JSON.stringify(ARTIFACT_PROOF_LEVELS)) { + violations.push('artifact_authority:proof_levels'); + } + if (estimate?.artifactEvolutionAssumption !== authority?.artifactEvolutionAssumption) { + violations.push('estimate_input:artifact_evolution_assumption'); + } + const rows = new Map((evidence?.evidence ?? []).map((row) => [row.id, row])); + const estimateRow = rows.get('P0.W6.ESTIMATE'); + if (estimateRow?.facts?.artifactEvolutionAssumption !== authority?.artifactEvolutionAssumption) { + violations.push('P0.W6.ESTIMATE:artifact_evolution_assumption'); + } + for (const [evidenceId, proofLevel] of Object.entries(authority?.proofLevels ?? {})) { + if (rows.get(evidenceId)?.proofLevel !== proofLevel) { + violations.push(`${evidenceId}:proof_level`); + } + } + return { ok: violations.length === 0, violations }; +} + +export function standaloneCharacterizationSha256(characterization) { + return createHash('sha256').update(JSON.stringify(characterization)).digest('hex'); +} + +export function buildStandaloneCharacterizationProjection(characterization) { + return { + authorityPath: STANDALONE_CHARACTERIZATION_PATH, + authorityRecordType: STANDALONE_CHARACTERIZATION_RECORD_TYPE, + authoritySha256: standaloneCharacterizationSha256(characterization), + disposition: 'rejected_for_hosted_v1', + }; +} + +export function validateStandaloneCharacterizationProjection(characterization, projection) { + const expected = buildStandaloneCharacterizationProjection(characterization); + const violations = []; + if (JSON.stringify(projection) !== JSON.stringify(expected)) { + violations.push('standalone_characterization_projection_stale'); + } + if ( + JSON.stringify(characterization.terminalAbsence) !== + JSON.stringify(evaluateV1TerminalAbsence(characterization)) + ) { + violations.push('standalone_terminal_absence_projection_stale'); + } + return { ok: violations.length === 0, violations, expected }; +} + +export function scanStandalone(root = repoRoot, { buildRoot = null } = {}) { + const pkg = JSON.parse(read(root, 'package.json')); + const standaloneConfig = read(root, 'docker/vite.standalone.config.ts'); + const electronConfig = read(root, 'electron.vite.config.ts'); + const standaloneEntry = read(root, 'src/main/standalone.ts'); + const httpServer = read(root, 'src/main/services/infrastructure/HttpServer.ts'); + const dockerfile = read(root, 'docker/Dockerfile'); + const compose = read(root, 'docker/docker-compose.yml'); + const routeIndex = read(root, 'src/main/http/index.ts'); + const terminalNodePackage = read( + root, + 'vendor/terminal-platform/terminal-platform-node-stub/package.json' + ); + const migrations = read( + root, + 'src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts' + ); + const emittedRoot = buildRoot ? resolve(buildRoot) : null; + const buildFiles = emittedRoot ? walk(emittedRoot).filter((path) => path.endsWith('.cjs')) : []; + const buildText = buildFiles + .map((path) => readFileSync(join(emittedRoot, path), 'utf8')) + .join('\n'); + + return { + schemaVersion: 3, + recordType: STANDALONE_CHARACTERIZATION_RECORD_TYPE, + phaseStartSha: 'a32f509e6d9bd31ba2135940e336729bf90c3d93', + canonicalSourceCommit: STANDALONE_CANONICAL_SOURCE_COMMIT, + inputPatchSha256: STANDALONE_INPUT_PATCH_SHA256, + proofLevel: 'targeted_current_canonical_plus_input_patch_build_observed', + characterizationScope: 'exact_current_canonical_plus_input_patch_targeted_standalone_build', + build: { + command: + 'pnpm exec vite build --config docker/vite.standalone.config.ts --outDir --emptyOutDir', + config: 'docker/vite.standalone.config.ts', + input: 'src/main/standalone.ts', + output: 'ephemeral_target_directory', + sourceMaps: true, + comparison: 'exact_relative_path_byte_count_and_sha256', + }, + historicalProvenance: { + authorityPath: + 'docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json', + authorityRecordType: 'w6-historical-rejected-candidate-artifact-scan', + relationship: 'historical_only_not_current_artifact_authority', + }, + source: { + standaloneInput: 'src/main/standalone.ts', + rendererOutput: 'out/renderer', + externalPackages: ['fastify', '@fastify/cors', '@fastify/static', 'agent-teams-controller'], + nativeCatchAllEmptyStub: + standaloneConfig.includes("source.endsWith('.node')") && + standaloneConfig.includes('export default {}'), + broadElectronStub: standaloneConfig.includes('function electronStub()'), + standaloneServiceStubs: + standaloneEntry.includes('updaterServiceStub') && + standaloneEntry.includes('sshConnectionManagerStub'), + terminalNodeInstallStub: terminalNodePackage.includes('Install-time stub'), + terminalRuntimeArtifactPresent: walk(join(root, 'resources/terminal-platform')).some( + (path) => path !== '.gitkeep' + ), + standaloneWorkerEntry: standaloneConfig.includes("'internal-storage-worker':"), + electronWorkerEntry: electronConfig.includes("'internal-storage-worker':"), + internalWorkerRuntimeFilename: 'internal-storage-worker.cjs', + defaultWildcardCors: + standaloneEntry.includes("process.env.CORS_ORIGIN = '*'") && + httpServer.includes('origin: true, credentials: true'), + directHttpPublished: compose.includes('"3456:3456"') && dockerfile.includes('EXPOSE 3456'), + productionNodeModulesCopiedWhole: dockerfile.includes( + 'COPY --from=prod-deps /app/node_modules ./node_modules' + ), + terminalPackages: Object.keys(pkg.dependencies) + .filter( + (name) => name.startsWith('@terminal-platform/') || name === 'terminal-platform-node' + ) + .sort(), + cookiePlugin: pkg.dependencies['@fastify/cookie'] ?? null, + versions: { + fastify: pkg.dependencies.fastify, + fastifyCors: pkg.dependencies['@fastify/cors'], + betterSqlite3: pkg.dependencies['better-sqlite3'], + electron: pkg.devDependencies.electron, + node: pkg.engines?.node ?? '24.x (from Docker ARG and .node-version)', + }, + terminalHttpRegistration: /terminal/i.test(routeIndex), + terminalMigration: /terminal/i.test(migrations), + }, + emitted: { + observed: buildFiles.length > 0, + files: buildFiles.sort().map((path) => ({ + path: `dist-standalone/${path}`, + bytes: statSync(join(emittedRoot, path)).size, + sha256: sha256(join(emittedRoot, path)), + })), + internalStorageWorkerPresent: buildFiles.some((path) => + path.endsWith('internal-storage-worker.cjs') + ), + electronEmptyStubPresent: + buildText.includes('isEncryptionAvailable: () => false') && + buildText.includes('decryptString: () => ""'), + terminalServiceMarkerPresent: buildText.includes('class PtyTerminalService'), + terminalPlatformMarkerPresent: buildText.includes('terminal-platform-node'), + }, + }; +} + +export function evaluateV1TerminalAbsence(scan) { + const violations = []; + if (scan.source.terminalPackages.length) + violations.push('terminal_sdk_dependencies_in_production_manifest'); + if (scan.source.terminalNodeInstallStub) violations.push('terminal_node_install_stub'); + if (scan.source.productionNodeModulesCopiedWhole) + violations.push('unpruned_production_node_modules'); + if (scan.source.terminalHttpRegistration) violations.push('terminal_http_route_registered'); + if (scan.source.terminalMigration) violations.push('terminal_migration_present'); + if (scan.source.terminalRuntimeArtifactPresent) + violations.push('terminal_runtime_artifact_present'); + if (scan.emitted.terminalServiceMarkerPresent) + violations.push('terminal_service_in_server_bundle'); + if (scan.emitted.terminalPlatformMarkerPresent) + violations.push('terminal_platform_in_server_bundle'); + return { passes: violations.length === 0, violations }; +} + +export function evaluateHostedArtifactContract(contract) { + const violations = []; + const controllerContract = loadControllerArtifactContract(); + const projection = validateControllerArtifactProjection(controllerContract, contract); + if (contract.recordType !== 'w6-standalone-artifact-characterization') { + violations.push('record_type'); + } + if (contract.status !== 'rejected_for_hosted_v1') violations.push('status'); + violations.push(...projection.violations); + for (const [claim, value] of Object.entries(contract.capabilityClaims ?? {})) { + if (value !== false) violations.push(`capability_claim:${claim}`); + } + for (const claim of [ + 'remoteAuthReady', + 'remoteMutationReady', + 'productionCompositionReady', + 'terminalAbsenceAchieved', + ]) { + if (!Object.hasOwn(contract.capabilityClaims ?? {}, claim)) { + violations.push(`missing_capability_claim:${claim}`); + } + } + return { + contractPasses: violations.length === 0, + releasePasses: false, + hostedV1Admitted: false, + violations, + unresolvedArtifactIds: controllerContract.artifacts.map(({ artifactId }) => artifactId).sort(), + }; +} + +export function evaluateFinalImageTerminalAbsence(image) { + const violations = []; + const surfaces = [ + ['package', image.packages], + ['file', image.files], + ['route', image.routes], + ['migration', image.migrations], + ['capability', image.capabilities], + ['process', image.processes], + ['renderer_chunk', image.rendererChunks], + ['port', image.ports], + ['volume', image.volumes], + ]; + for (const [kind, values] of surfaces) { + if (!Array.isArray(values)) { + violations.push(`unscanned_surface:${kind}`); + continue; + } + for (const value of values) { + if (/terminal|pty|xterm/i.test(String(value))) violations.push(`${kind}:${value}`); + } + } + return { passes: violations.length === 0, violations }; +} + +function sqliteProbe(packageName, databasePath) { + const Database = localRequire(packageName); + let database = new Database(databasePath); + database.exec('CREATE TABLE abi_probe(value TEXT NOT NULL)'); + database.prepare('INSERT INTO abi_probe(value) VALUES (?)').run(packageName); + const sqliteVersion = database.prepare('SELECT sqlite_version() AS version').get().version; + database.close(); + database = new Database(databasePath, { readonly: true }); + const reopenedValue = database.prepare('SELECT value FROM abi_probe').get().value; + database.close(); + const packageJson = JSON.parse( + readFileSync(localRequire.resolve(`${packageName}/package.json`), 'utf8') + ); + return { packageName, version: packageJson.version, sqliteVersion, reopenedValue }; +} + +export function runAbiSmokeProbe() { + const directory = mkdtempSync(join(tmpdir(), 'w6-abi-probe-')); + try { + const rebuildRequire = createRequire(localRequire.resolve('@electron/rebuild')); + const nodeAbi = rebuildRequire('node-abi'); + const electronVersion = JSON.parse( + readFileSync(localRequire.resolve('electron/package.json'), 'utf8') + ).version; + return { + runtime: { + node: process.versions.node, + nodeModuleAbi: Number(process.versions.modules), + napi: Number(process.versions.napi), + electron: electronVersion, + electronModuleAbi: Number(nodeAbi.getAbi(electronVersion, 'electron')), + }, + sqlite: [ + sqliteProbe('better-sqlite3', join(directory, 'production.sqlite')), + sqliteProbe('better-sqlite3-node', join(directory, 'node-alias.sqlite')), + ], + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/scripts/hosted-web/phase-0/auth-artifacts/target-image-admission-policy.mjs b/scripts/hosted-web/phase-0/auth-artifacts/target-image-admission-policy.mjs new file mode 100644 index 0000000000..b94b68627f --- /dev/null +++ b/scripts/hosted-web/phase-0/auth-artifacts/target-image-admission-policy.mjs @@ -0,0 +1,581 @@ +import { createHash } from 'node:crypto'; + +import { evaluateFinalImageTerminalAbsence } from './auth-artifacts-spike.mjs'; + +export const REQUIRED_CANONICAL_SOURCE_COMMIT = '42ec333848e29e97c41699b9fed73ed199740e3f'; +export const REQUIRED_CANONICAL_SOURCE_TREE = '4bc04a743c20ea48e06ada55c761d03881117cac'; +export const TARGET_IMAGE_DECISION_PATH = + 'docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json'; + +export const AUTHORITY_PATHS = [ + 'docker/Dockerfile', + 'docker/docker-compose.yml', + 'docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json', + 'docs/research/hosted-web/phase-0/host-primitives/target-host-envelope.md', + 'docs/research/hosted-web/phase-0/provider-runtime/credential-exposure-matrix.json', + 'docs/research/hosted-web/phase-0/provider-runtime/execution-topology.json', + 'docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json', +]; + +const REQUIRED_ARTIFACT_PATHS = Object.freeze({ + 'agent-teams-instance-lock': '/app/bin/agent-teams-instance-lock', + 'agent-teams-process-anchor': '/app/bin/agent-teams-process-anchor', + 'agent-teams-workspace-guard': '/app/bin/agent-teams-workspace-guard', +}); + +const REQUIRED_PROVIDERS = ['anthropic', 'codex', 'gemini', 'opencode']; + +export const TERMINAL_SENSITIVE_SURFACES = Object.freeze([ + 'capabilities', + 'files', + 'migrations', + 'packages', + 'ports', + 'processes', + 'rendererChunks', + 'routes', + 'volumes', +]); + +export const INSTANCE_LOCK_BINARY = '/app/bin/agent-teams-instance-lock'; +export const INSTANCE_LOCK_PARENT = '/data/.agent-teams/instance-lock'; +export const INSTANCE_LOCK_ANCHOR = '/data/.agent-teams/instance-lock/instance.lock'; +export const PERSISTENT_STATE_ROOT = '/data/.agent-teams'; +export const PERSISTENT_APPLICATION_ROOT = '/data/.agent-teams/data'; +export const HOSTED_ENTRYPOINT = '/usr/local/bin/hosted-entrypoint'; +export const HOSTED_NODE = '/usr/local/bin/node'; +export const HOSTED_PROFILES = Object.freeze(['personal', 'keycloak']); + +function parseActiveDockerInstructions(dockerfile) { + const instructions = []; + let logical = ''; + for (const physical of dockerfile.split(/\r?\n/u)) { + const trimmed = physical.trim(); + if (!logical && (trimmed === '' || trimmed.startsWith('#'))) continue; + logical = logical ? `${logical} ${trimmed}` : trimmed; + if (logical.endsWith('\\')) { + logical = logical.slice(0, -1).trimEnd(); + continue; + } + const match = /^([A-Za-z]+)\s+(.+)$/u.exec(logical); + if (match) instructions.push({ opcode: match[1].toUpperCase(), value: match[2].trim() }); + logical = ''; + } + return instructions; +} + +function normalizeMode(mode) { + if (typeof mode === 'number') return (mode & 0o7777).toString(8).padStart(4, '0'); + if (typeof mode !== 'string') return null; + const match = /(?:^|[^0-7])([0-7]{3,4})$/u.exec(mode); + return match ? match[1].padStart(4, '0') : null; +} + +function effectiveImageValue(service, property, imageValue) { + const value = service?.[property]; + return value === undefined || value === null ? imageValue : value; +} + +function effectiveServiceEntrypoint(service, imageConfig) { + return effectiveImageValue(service, 'entrypoint', imageConfig?.Entrypoint); +} + +function persistentStateVolume(service) { + return service?.volumes?.find( + (mount) => + mount?.type === 'volume' && + mount?.target === PERSISTENT_STATE_ROOT && + mount?.read_only !== true && + mount?.volume?.nocopy !== true + ); +} + +function persistentApplicationVolume(service) { + return service?.volumes?.find( + (mount) => + mount?.type === 'volume' && + mount?.target === PERSISTENT_APPLICATION_ROOT && + mount?.read_only !== true && + mount?.volume?.nocopy !== true + ); +} + +function hasPreservedApplicationVolumeIdentity(renderedCompose) { + const volumes = renderedCompose?.volumes; + const lockVolumeName = volumes?.['agent-teams-data']?.name; + const applicationVolumeName = volumes?.['agent-teams-application-data']?.name; + if (typeof lockVolumeName !== 'string' || typeof applicationVolumeName !== 'string') return false; + const oldSuffix = '_agent-teams-data'; + const lockSuffix = '_agent-teams-instance-lock'; + return ( + applicationVolumeName.endsWith(oldSuffix) && + lockVolumeName.endsWith(lockSuffix) && + applicationVolumeName.slice(0, -oldSuffix.length) === + lockVolumeName.slice(0, -lockSuffix.length) + ); +} + +function usesHostedFinalImage(service) { + const build = service?.build; + if (!build || typeof build !== 'object' || build.target) return false; + const dockerfile = String(build.dockerfile ?? 'Dockerfile').replaceAll('\\', '/'); + return dockerfile === 'docker/Dockerfile' || dockerfile.endsWith('/docker/Dockerfile'); +} + +export function evaluateDockerInstanceLockStartup({ + dockerfile, + entrypoint, + imageProbe, + renderedComposes, + migrationProof, +}) { + const violations = []; + const instructions = parseActiveDockerInstructions(dockerfile); + const fromIndexes = instructions + .map((instruction, index) => (instruction.opcode === 'FROM' ? index : -1)) + .filter((index) => index >= 0); + const finalInstructions = instructions.slice(fromIndexes.at(-1)); + const activeValues = (opcode, collection = instructions) => + collection.filter((instruction) => instruction.opcode === opcode).map(({ value }) => value); + const builderCompile = + /node scripts\/hosted-web\/build-instance-lock\.mjs\s+--output \/app\/bin\/agent-teams-instance-lock/u; + const artifactCopy = + '--from=builder /app/bin/agent-teams-instance-lock ./bin/agent-teams-instance-lock'; + const entrypointCopy = 'COPY docker/hosted-entrypoint.sh /usr/local/bin/hosted-entrypoint'; + const entrypointDeclaration = `["${HOSTED_ENTRYPOINT}"]`; + const nodeCommand = `["${HOSTED_NODE}", "/app/dist-standalone/index.cjs"]`; + + if (!activeValues('RUN').some((value) => builderCompile.test(value))) { + violations.push('dockerfile:builder_compile_missing'); + } + if (!activeValues('COPY', finalInstructions).includes(artifactCopy)) { + violations.push('dockerfile:artifact_copy_missing'); + } + if (!activeValues('COPY', finalInstructions).includes(entrypointCopy.replace(/^COPY\s+/u, ''))) { + violations.push('dockerfile:entrypoint_copy_missing'); + } + if (activeValues('USER', finalInstructions).at(-1) !== 'node') { + violations.push('dockerfile:non_root_runtime_missing'); + } + if (activeValues('ENTRYPOINT', finalInstructions).at(-1) !== entrypointDeclaration) { + violations.push('dockerfile:instance_lock_entrypoint_missing'); + } + const finalCommand = activeValues('CMD', finalInstructions).at(-1); + if (finalCommand !== nodeCommand) violations.push('dockerfile:absolute_node_cmd_missing'); + if (/^\["node"/u.test(finalCommand ?? '')) violations.push('dockerfile:direct_node_bypass'); + + const finalOpcodes = finalInstructions.map(({ opcode }) => opcode); + if ( + !( + finalCommand === nodeCommand && + finalOpcodes.lastIndexOf('USER') < finalOpcodes.lastIndexOf('ENTRYPOINT') && + finalOpcodes.lastIndexOf('ENTRYPOINT') < finalOpcodes.lastIndexOf('CMD') + ) + ) { + violations.push('dockerfile:startup_order_invalid'); + } + + if (!entrypoint.includes(`exec ${INSTANCE_LOCK_BINARY}`)) { + violations.push('entrypoint:instance_lock_exec_missing'); + } + if (!entrypoint.includes('"$lock_parent" "$lock_name" "$lock_device" "$lock_inode" -- "$@"')) { + violations.push('entrypoint:argv_boundary_missing'); + } + if (/\beval\b|\b(?:ba)?sh\s+-c\b|\$\*/u.test(entrypoint)) { + violations.push('entrypoint:shell_injection_surface'); + } + const unqualifiedStat = /\$\(stat\s|[`;|&]\s*stat\s|^\s*stat\s/mu; + if (unqualifiedStat.test(entrypoint) || !entrypoint.includes('/usr/bin/stat')) { + violations.push('entrypoint:path_resolved_stat'); + } + if ( + !entrypoint.includes('/usr/bin/id -g') || + !entrypoint.includes('"$state_security" != "0:${runtime_gid}:1770"') || + !entrypoint.includes("!= '0:0:555'") || + !entrypoint.includes("!= '0:0:444'") + ) { + violations.push('entrypoint:mutable_lock_ancestor'); + } + + const imageConfig = imageProbe?.Config; + if (JSON.stringify(imageConfig?.Entrypoint) !== JSON.stringify([HOSTED_ENTRYPOINT])) { + violations.push('image:entrypoint_mismatch'); + } + if ( + JSON.stringify(imageConfig?.Cmd) !== + JSON.stringify([HOSTED_NODE, '/app/dist-standalone/index.cjs']) + ) { + violations.push('image:cmd_mismatch'); + } + if (imageConfig?.User !== 'node') violations.push('image:non_root_user_mismatch'); + if (!/^sha256:[a-f0-9]{64}$/u.test(imageProbe?.Id ?? '')) { + violations.push('image:identity_missing'); + } + if (imageProbe?.Files?.[HOSTED_ENTRYPOINT]?.sha256 !== sha256Text(entrypoint)) { + violations.push('image:entrypoint_content_mismatch'); + } + for (const [path, expectedUid, expectedGid, expectedMode] of [ + [PERSISTENT_STATE_ROOT, 0, 1000, '1770'], + [HOSTED_ENTRYPOINT, 0, 0, '0555'], + [INSTANCE_LOCK_BINARY, 0, 0, '0555'], + [INSTANCE_LOCK_PARENT, 0, 0, '0555'], + [INSTANCE_LOCK_ANCHOR, 0, 0, '0444'], + ]) { + const observed = imageProbe?.Files?.[path]; + if (observed?.uid !== expectedUid || observed?.gid !== expectedGid) { + violations.push(`image:file_owner_mismatch:${path}`); + } + if (normalizeMode(observed?.mode) !== expectedMode) { + violations.push(`image:file_mode_mismatch:${path}`); + } + } + for (const profile of HOSTED_PROFILES) { + const composeServices = renderedComposes?.[profile]?.services; + if (!composeServices || typeof composeServices !== 'object') { + violations.push(`compose:rendered_profile_missing:${profile}`); + continue; + } + if (!hasPreservedApplicationVolumeIdentity(renderedComposes[profile])) { + violations.push(`compose:existing_application_volume_identity_not_preserved:${profile}`); + } + const expectedApplication = `agent-teams-${profile}`; + if (!composeServices[expectedApplication]) { + violations.push(`compose:service_missing:${profile}:${expectedApplication}`); + } else if ( + persistentStateVolume(composeServices[expectedApplication])?.source !== 'agent-teams-data' + ) { + violations.push(`compose:shared_persistent_lock_missing:${profile}:${expectedApplication}`); + } else if ( + persistentApplicationVolume(composeServices[expectedApplication])?.source !== + 'agent-teams-application-data' + ) { + violations.push(`compose:nested_application_data_missing:${profile}:${expectedApplication}`); + } else if ( + composeServices[expectedApplication]?.environment?.AUTH_DATA_DIR !== + PERSISTENT_APPLICATION_ROOT + ) { + violations.push(`compose:application_data_path_mismatch:${profile}:${expectedApplication}`); + } + const startupServices = Object.entries(composeServices).filter(([, service]) => + usesHostedFinalImage(service) + ); + for (const [serviceName, service] of startupServices) { + if ( + JSON.stringify(effectiveServiceEntrypoint(service, imageConfig)) !== + JSON.stringify([HOSTED_ENTRYPOINT]) + ) { + violations.push(`compose:entrypoint_bypass:${profile}:${serviceName}`); + } + const effectiveCommand = effectiveImageValue(service, 'command', imageConfig?.Cmd); + if ( + !Array.isArray(effectiveCommand) || + typeof effectiveCommand[0] !== 'string' || + !effectiveCommand[0].startsWith('/') + ) { + violations.push(`compose:command_not_absolute_argv:${profile}:${serviceName}`); + } + } + } + const migrationEvaluation = evaluateDisposableInstanceLockMigrationProof(migrationProof); + for (const violation of migrationEvaluation.violations) { + violations.push(`upgrade:${violation}`); + } + + return { ok: violations.length === 0, violations }; +} + +export function evaluateDisposableInstanceLockMigrationProof(proof) { + const violations = []; + if ( + proof?.format !== 'agent-teams-instance-lock-disposable-migration-proof/v1' || + proof?.status !== 'passed' || + !/^agent-teams-lock-upgrade-[a-f0-9]{32}$/u.test(proof?.projectName ?? '') + ) { + violations.push('disposable_proof_identity_invalid'); + } + for (const artifact of ['marker', 'database']) { + const seeded = proof?.seededSha256?.[artifact]; + if (!/^[a-f0-9]{64}$/u.test(seeded ?? '')) { + violations.push(`seed_${artifact}_digest_invalid`); + continue; + } + for (const profile of HOSTED_PROFILES) { + if (proof?.profiles?.[profile]?.observedSha256?.[artifact] !== seeded) { + violations.push(`${profile}_${artifact}_bytes_not_preserved`); + } + } + } + for (const profile of HOSTED_PROFILES) { + const observation = proof?.profiles?.[profile]; + if ( + observation?.applicationDataPath !== PERSISTENT_APPLICATION_ROOT || + observation?.applicationVolume !== `${proof?.projectName}_agent-teams-data` || + observation?.lockParentVolume !== `${proof?.projectName}_agent-teams-instance-lock` + ) { + violations.push(`${profile}_nested_volume_identity_invalid`); + } + if ( + observation?.lockParent?.uid !== 0 || + observation?.lockParent?.gid !== 0 || + normalizeMode(observation?.lockParent?.mode) !== '0555' || + observation?.lockAnchor?.uid !== 0 || + observation?.lockAnchor?.gid !== 0 || + normalizeMode(observation?.lockAnchor?.mode) !== '0444' || + observation?.lockAnchor?.isFile !== true + ) { + violations.push(`${profile}_root_owned_lock_anchor_invalid`); + } + } + return { ok: violations.length === 0, violations }; +} + +const sha256Text = (value) => createHash('sha256').update(value).digest('hex'); +const isSha256 = (value) => typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); + +export function evaluateTargetImageAdmission({ image, controllerArtifacts, providerCanaries }) { + const violations = []; + if (!isSha256(image?.identity?.digest)) violations.push('image:immutable_digest_missing'); + if (!isSha256(image?.identity?.manifestDigest)) { + violations.push('image:immutable_manifest_digest_missing'); + } + if (!isSha256(image?.identity?.configDigest)) { + violations.push('image:immutable_config_digest_missing'); + } + if ( + !Array.isArray(image?.identity?.baseImageDigests) || + !image.identity.baseImageDigests.length + ) { + violations.push('image:pinned_base_image_missing'); + } else if (image.identity.baseImageDigests.some((digest) => !isSha256(digest))) { + violations.push('image:base_image_not_digest_pinned'); + } + + const profile = image?.profile; + if (profile?.os !== 'debian-slim') violations.push('profile:debian_slim_unproved'); + if (profile?.architecture !== 'linux-x64') violations.push('profile:linux_x64_unproved'); + if (profile?.nodeMajor !== 24) violations.push('profile:node_24_unproved'); + if (!Number.isInteger(profile?.uid) || profile.uid <= 0) { + violations.push('profile:non_root_uid_missing'); + } + if (!Number.isInteger(profile?.gid) || profile.gid <= 0) { + violations.push('profile:non_root_gid_missing'); + } + if (profile?.readOnlyRoot !== true) violations.push('profile:read_only_root_unproved'); + if (profile?.noNewPrivileges !== true) violations.push('profile:no_new_privileges_unproved'); + if (JSON.stringify(profile?.capabilityDrop) !== JSON.stringify(['ALL'])) { + violations.push('profile:capability_drop_all_unproved'); + } + if (!isSha256(profile?.seccompProfileDigest)) { + violations.push('profile:seccomp_digest_missing'); + } + if (profile?.init?.present !== true || !profile?.init?.path) { + violations.push('profile:minimal_init_unproved'); + } + if (profile?.launcherBeforeNode !== true) { + violations.push('profile:launcher_before_node_unproved'); + } + const startupOrder = profile?.startupOrder; + if (!Array.isArray(startupOrder)) { + violations.push('profile:startup_order_missing'); + } else { + const nodeIndex = startupOrder.indexOf('node'); + const initIndex = startupOrder.indexOf(profile?.init?.path); + const lockIndex = startupOrder.indexOf(REQUIRED_ARTIFACT_PATHS['agent-teams-instance-lock']); + const anchorIndex = startupOrder.indexOf(REQUIRED_ARTIFACT_PATHS['agent-teams-process-anchor']); + if ( + new Set(startupOrder).size !== startupOrder.length || + initIndex !== 0 || + lockIndex < 0 || + anchorIndex < 0 || + nodeIndex < 0 || + lockIndex >= nodeIndex || + anchorIndex >= nodeIndex + ) { + violations.push('profile:startup_order_invalid'); + } + } + + const inventory = image?.inventory; + if (inventory?.complete !== true) violations.push('inventory:completeness_unproved'); + if ( + !isSha256(inventory?.observedFromImageDigest) || + inventory.observedFromImageDigest !== image?.identity?.digest + ) { + violations.push('inventory:image_digest_binding_mismatch'); + } + if (!isSha256(inventory?.scannerDigest)) violations.push('inventory:scanner_digest_missing'); + + if (!Array.isArray(controllerArtifacts) || !controllerArtifacts.length) { + violations.push('composition:controller_artifacts_missing'); + } else { + const artifactIds = controllerArtifacts.map(({ artifactId }) => artifactId).sort(); + if ( + JSON.stringify(artifactIds) !== JSON.stringify(Object.keys(REQUIRED_ARTIFACT_PATHS).sort()) + ) { + violations.push('composition:artifact_set_mismatch'); + } + for (const artifact of controllerArtifacts) { + const prefix = `composition:${artifact?.artifactId ?? 'unknown'}`; + if (artifact?.finalImagePath !== REQUIRED_ARTIFACT_PATHS[artifact?.artifactId]) { + violations.push(`${prefix}:final_image_path_mismatch`); + } + if (!isSha256(artifact?.binaryDigest)) violations.push(`${prefix}:binary_digest_missing`); + if (!isSha256(artifact?.builderImageDigest)) { + violations.push(`${prefix}:builder_image_digest_missing`); + } + if (!artifact?.compilerIdentity) violations.push(`${prefix}:compiler_identity_missing`); + if (!Number.isInteger(artifact?.uid)) violations.push(`${prefix}:uid_missing`); + if (!Number.isInteger(artifact?.gid)) violations.push(`${prefix}:gid_missing`); + if (!Number.isInteger(artifact?.mode)) violations.push(`${prefix}:mode_missing`); + if ( + artifact?.finalImagePath && + (!Array.isArray(inventory?.files) || !inventory.files.includes(artifact.finalImagePath)) + ) { + violations.push(`${prefix}:not_in_file_inventory`); + } + } + } + + if ( + Array.isArray(startupOrder) && + Array.isArray(inventory?.processes) && + startupOrder.some((process) => !inventory.processes.includes(process)) + ) { + violations.push('inventory:startup_process_missing'); + } + + const terminal = evaluateFinalImageTerminalAbsence(inventory ?? {}); + violations.push(...terminal.violations.map((violation) => `terminal_negative:${violation}`)); + + if (providerCanaries?.status !== 'passed_target_image') { + violations.push('provider_runtime:target_image_canaries_unproved'); + } + if (providerCanaries?.rawCredentialValueRecorded !== false) { + violations.push('provider_runtime:credential_redaction_unproved'); + } + const providerRecords = providerCanaries?.records; + if ( + !Array.isArray(providerRecords) || + JSON.stringify(providerRecords.map(({ provider }) => provider).sort()) !== + JSON.stringify(REQUIRED_PROVIDERS) + ) { + violations.push('provider_runtime:provider_set_incomplete'); + } else if ( + providerRecords.some( + (record) => + record.executedInTargetImage !== true || + record.targetImageDigest !== image?.identity?.digest || + !isSha256(record.canaryEvidenceDigest) || + record.expectedCanaryPresent !== true || + record.rawCredentialValueRecorded !== false || + record.outputRedactionVerified !== true || + !Array.isArray(record.crossProviderCanaryKeys) || + record.crossProviderCanaryKeys.length !== 0 + ) + ) { + violations.push('provider_runtime:canary_record_invalid'); + } + + return { + admitted: violations.length === 0, + disposition: violations.length === 0 ? 'admitted' : 'fail_closed', + violations: [...new Set(violations)].sort(), + terminalNegative: + terminal.passes && violations.every((value) => !value.startsWith('terminal_negative:')), + }; +} + +const CANARY_KEYS = Object.freeze({ + anthropic: 'ANTHROPIC_API_KEY', + codex: 'OPENAI_API_KEY', + gemini: 'GEMINI_API_KEY', + opencode: 'OPENCODE_CONFIG_CONTENT', +}); + +export function runProviderCanaryFixture() { + const allKeys = Object.values(CANARY_KEYS); + const records = []; + const rawCanaries = []; + for (const [provider, expectedKey] of Object.entries(CANARY_KEYS)) { + const canary = ['phase0', provider, 'credential', 'canary'].join(':'); + rawCanaries.push(canary); + const environment = { + PATH: '/usr/local/bin:/usr/bin:/bin', + HOME: '/tmp/phase0-provider-fixture-home', + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1', + CLAUDE_CODE_ENTRY_PROVIDER: provider, + [expectedKey]: canary, + }; + const observed = allKeys.filter((key) => Object.hasOwn(environment, key)); + const record = { + provider, + expectedKey, + expectedCanaryPresent: observed.includes(expectedKey), + crossProviderCanaryKeys: observed.filter((key) => key !== expectedKey), + canaryRendering: Object.fromEntries(observed.map((key) => [key, '[REDACTED]'])), + rawCredentialValueRecorded: false, + fixtureEvaluationPassed: true, + }; + const serialized = JSON.stringify(record); + if (rawCanaries.some((value) => serialized.includes(value))) { + throw new Error(`provider canary fixture emitted a raw credential for ${provider}`); + } + records.push(record); + } + const passed = records.every( + (record) => + record.expectedCanaryPresent && + record.crossProviderCanaryKeys.length === 0 && + record.rawCredentialValueRecorded === false && + record.fixtureEvaluationPassed === true + ); + return { + status: passed ? 'passed_fixture_only' : 'failed_fixture', + executionBoundary: 'synthetic_environment_records_no_project_opened', + rawCredentialValueRecorded: false, + redactionToken: '[REDACTED]', + records, + limitation: + 'This fixture proves only deterministic admission-harness behavior; it is not target-image provider execution.', + }; +} + +export function projectControllerArtifacts(controllerContract) { + return controllerContract.artifacts.map((artifact) => ({ + artifactId: artifact.artifactId, + finalImagePath: artifact.finalImagePath, + binaryDigest: artifact.binarySha256 ? `sha256:${artifact.binarySha256}` : null, + builderImageDigest: artifact.builderImageDigest, + compilerIdentity: artifact.compilerIdentity, + uid: artifact.uid, + gid: artifact.gid, + mode: artifact.mode, + })); +} +function normalizationKey(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value.path ?? value.id ?? value.provider ?? value.artifactId ?? JSON.stringify(value); + } + return JSON.stringify(value); +} + +export function normalizeDecisionFacts(value) { + if (Array.isArray(value)) { + // Canonicalize arrays order-insensitively for the drift digest, using a + // locale-independent code-unit comparator so the digest is deterministic + // across ICU locales. + return value.map(normalizeDecisionFacts).sort((left, right) => { + const leftKey = normalizationKey(left); + const rightKey = normalizationKey(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, normalizeDecisionFacts(value[key])]) + ); + } + return value; +} diff --git a/scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs b/scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs new file mode 100644 index 0000000000..dd4aa6645a --- /dev/null +++ b/scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs @@ -0,0 +1,338 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; + +import { + evaluateHostedArtifactContract, + evaluateV1TerminalAbsence, + repoRoot, + runAbiSmokeProbe, + scanStandalone, + STANDALONE_CANONICAL_SOURCE_COMMIT, + STANDALONE_INPUT_PATCH_SHA256, + validateArtifactAuthorityProjections, + validateStandaloneCharacterizationProjection, +} from './auth-artifacts-spike.mjs'; +import { + REQUIRED_CANONICAL_SOURCE_COMMIT, + REQUIRED_CANONICAL_SOURCE_TREE, + TARGET_IMAGE_DECISION_PATH, + verifyCommittedTargetImageDecision, +} from './prove-target-image-admission.mjs'; +import { + controllerArtifactContractSha256, + loadControllerArtifactContract, + validateControllerArtifactProjection, +} from '../w4-w6-contract/controller-artifact-contract.mjs'; +import { + drainEvidenceEnvelopeId, + drainEvidenceEnvelopeSchemaPath, + drainEvidenceEnvelopeSchemaSha256, + validateW4DrainEvidenceProjection, +} from '../w4-w6-contract/drain-evidence-envelope.mjs'; + +const localRequire = createRequire(import.meta.url); +const requireFromFastify = createRequire(localRequire.resolve('fastify/package.json')); +const Ajv = requireFromFastify('ajv'); +const evidenceDir = resolve(repoRoot, 'docs/research/hosted-web/phase-0/auth-artifacts'); +const readJson = (path) => JSON.parse(readFileSync(resolve(repoRoot, path), 'utf8')); + +const schema = readJson('docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json'); +const validateEvidence = new Ajv({ allErrors: true }).compile(schema); +for (const file of [ + 'evidence.json', + 'estimate-input.json', + 'historical-rejected-candidate-artifact-scan.json', + 'observed-artifact-scan.json', + 'proposed-hosted-artifact-manifest.json', + 'finding-resolution.json', +]) { + const value = JSON.parse(readFileSync(resolve(evidenceDir, file), 'utf8')); + if (!validateEvidence(value)) { + throw new Error(`${file}: ${JSON.stringify(validateEvidence.errors)}`); + } +} + +const controllerSchema = readJson( + 'docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json' +); +const validateController = new Ajv({ allErrors: true }).compile(controllerSchema); +const controller = loadControllerArtifactContract(); +if (!validateController(controller)) { + throw new Error(`controller artifact contract: ${JSON.stringify(validateController.errors)}`); +} + +const evidence = readJson('docs/research/hosted-web/phase-0/auth-artifacts/evidence.json'); +const estimate = readJson('docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json'); +const expectedIds = [ + 'P0.W6.AUTH_TRANSITIONS', + 'P0.W6.PROXY_ORIGIN_THREAT_MATRIX', + 'P0.W6.COOKIE_VERSION_EVIDENCE', + 'P0.W6.ARTIFACT_INVENTORY', + 'P0.W6.ABI_STUB_REPORT', + 'P0.W6.TERMINAL_ABSENCE_REPORT', + 'P0.W6.ESTIMATE', +].sort(); +if (JSON.stringify(evidence.evidence.map(({ id }) => id).sort()) !== JSON.stringify(expectedIds)) { + throw new Error('W6 evidence IDs differ'); +} +if (evidence.packetRevision !== 'phase-00-r3') throw new Error('W6 evidence is not r3'); +for (const row of evidence.evidence) { + if ( + row.owner !== 'w6' || + !row.requirementIds?.length || + !row.assertions?.length || + !row.reproduction?.length + ) { + throw new Error(`${row.id}: incomplete evidence shape`); + } +} + +const nativeProtocolSchema = readJson( + 'docs/research/hosted-web/phase-0/host-primitives/native-protocol.schema.json' +); +const processAnchorProtocol = readJson( + 'docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json' +); +const drainProjection = validateW4DrainEvidenceProjection( + nativeProtocolSchema, + processAnchorProtocol +); +if (!drainProjection.ok) { + throw new Error( + `controller drain-envelope projection drift: ${drainProjection.violations.join(',')}` + ); +} + +const w4Projection = readJson( + 'docs/research/hosted-web/phase-0/host-primitives/native-artifact-contract.json' +); +const w6Projection = readJson( + 'docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json' +); +const controllerHash = controllerArtifactContractSha256(); +for (const [lane, projection] of [ + ['w4', w4Projection], + ['w6', w6Projection], +]) { + const result = validateControllerArtifactProjection(controller, projection); + if (!result.ok) throw new Error(`${lane} projection drift: ${result.violations.join(',')}`); +} +if (JSON.stringify(w4Projection.artifacts) !== JSON.stringify(w6Projection.artifacts)) { + throw new Error('W4/W6 artifact projections are not equal'); +} +const artifactGate = evaluateHostedArtifactContract(w6Projection); +if (!artifactGate.contractPasses || artifactGate.releasePasses || artifactGate.hostedV1Admitted) { + throw new Error(`r3 artifact disposition mismatch: ${JSON.stringify(artifactGate)}`); +} +if ( + w6Projection.canonicalBaseSha !== STANDALONE_CANONICAL_SOURCE_COMMIT || + w6Projection.inputPatchSha256 !== STANDALONE_INPUT_PATCH_SHA256 +) { + throw new Error('standalone manifest source identity is stale'); +} + +const committedScan = readJson( + 'docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json' +); +const targetedBuildRoot = mkdtempSync(resolve(tmpdir(), 'w6-current-standalone-build-')); +const targetedBuildEnv = { ...process.env }; +delete targetedBuildEnv.AGENT_TEAMS_DISABLE_SOURCEMAPS; +try { + const targetedBuild = spawnSync( + process.execPath, + [ + resolve(repoRoot, 'node_modules/vite/bin/vite.js'), + 'build', + '--config', + 'docker/vite.standalone.config.ts', + '--outDir', + targetedBuildRoot, + '--emptyOutDir', + ], + { + cwd: repoRoot, + encoding: 'utf8', + env: targetedBuildEnv, + } + ); + if (targetedBuild.status !== 0) { + throw new Error( + `targeted standalone build failed (${targetedBuild.status}): ${targetedBuild.stderr || targetedBuild.stdout}` + ); + } + const targetedBuildScanWithoutTerminal = scanStandalone(repoRoot, { + buildRoot: targetedBuildRoot, + }); + const targetedBuildScan = { + ...targetedBuildScanWithoutTerminal, + terminalAbsence: evaluateV1TerminalAbsence(targetedBuildScanWithoutTerminal), + }; + if (!isDeepStrictEqual(committedScan, targetedBuildScan)) { + const differingFields = Object.keys(committedScan).filter( + (field) => !isDeepStrictEqual(committedScan[field], targetedBuildScan[field]) + ); + throw new Error( + `canonical-plus-input-patch standalone characterization differs from targeted materialized build: ${differingFields.join(',')}` + ); + } +} finally { + rmSync(targetedBuildRoot, { recursive: true, force: true }); +} +const sourceScan = scanStandalone(repoRoot); +if (JSON.stringify(committedScan.source) !== JSON.stringify(sourceScan.source)) { + throw new Error('standalone source characterization is stale'); +} +if (sourceScan.emitted.observed || sourceScan.emitted.files.length !== 0) { + throw new Error('source characterization consulted mutable ambient standalone output'); +} +const standaloneProjection = validateStandaloneCharacterizationProjection( + committedScan, + w6Projection.currentStandalone +); +if (!standaloneProjection.ok) { + throw new Error( + `standalone characterization drift: ${standaloneProjection.violations.join(',')}` + ); +} +const inventoryEvidence = evidence.evidence.find(({ id }) => id === 'P0.W6.ARTIFACT_INVENTORY'); +if ( + JSON.stringify(inventoryEvidence?.facts?.characterizationAuthority) !== + JSON.stringify(standaloneProjection.expected) +) { + throw new Error('W6 artifact evidence disagrees with standalone characterization authority'); +} +if ( + committedScan.canonicalSourceCommit !== STANDALONE_CANONICAL_SOURCE_COMMIT || + committedScan.inputPatchSha256 !== STANDALONE_INPUT_PATCH_SHA256 || + committedScan.emitted.observed !== true || + committedScan.emitted.files.length !== 13 || + committedScan.emitted.internalStorageWorkerPresent !== true || + committedScan.emitted.electronEmptyStubPresent !== true || + committedScan.emitted.terminalServiceMarkerPresent !== true +) { + throw new Error('committed targeted standalone-build characterization is incomplete'); +} +if (evaluateV1TerminalAbsence(committedScan).passes) { + throw new Error('current standalone unexpectedly satisfies the terminal-absence rule'); +} + +const abiProbe = runAbiSmokeProbe(); +if ( + abiProbe.runtime.electron !== '41.10.5' || + abiProbe.runtime.nodeModuleAbi !== 137 || + abiProbe.runtime.electronModuleAbi !== 145 || + abiProbe.sqlite.some(({ packageName, reopenedValue }) => packageName !== reopenedValue) +) { + throw new Error(`ABI characterization mismatch: ${JSON.stringify(abiProbe)}`); +} + +const targetImageDecision = readJson(TARGET_IMAGE_DECISION_PATH); +const targetImageVerification = verifyCommittedTargetImageDecision(targetImageDecision); +if ( + !targetImageVerification.ok || + targetImageDecision.decision?.state !== 'accepted' || + targetImageDecision.decision?.outcome !== 'capability_narrowed' || + targetImageDecision.decision?.phase0Gate !== 'closed_by_accepted_narrowing' || + targetImageDecision.decision?.exactImageEarliestOwner !== 'phase-5' || + targetImageDecision.sourceIdentity?.canonicalCommit !== REQUIRED_CANONICAL_SOURCE_COMMIT || + targetImageDecision.sourceIdentity?.canonicalTree !== REQUIRED_CANONICAL_SOURCE_TREE || + targetImageDecision.scope?.realUserProjectsOpened !== false || + targetImageDecision.scope?.dockerSocketRequiredForPhase0Decision !== false || + targetImageDecision.scope?.liveContainerRuntimeObservationInDeterministicFacts !== false || + targetImageDecision.phase5AdmissionGate?.state !== 'fail_closed' || + targetImageDecision.phase5AdmissionGate?.admitted !== false || + targetImageDecision.phase5AdmissionGate?.canonicalSourceGapCount !== 51 || + targetImageDecision.phase5AdmissionGate?.canonicalSourceGaps?.filter((gap) => + gap.startsWith('terminal_negative:') + ).length !== 9 || + targetImageDecision.canonicalSourceFacts?.currentCandidate?.terminalAbsence?.passes !== false || + targetImageDecision.canonicalSourceFacts?.currentCandidate?.terminalAbsence?.violations + ?.length !== 4 || + Object.values(targetImageDecision.claims ?? {}).some((value) => value !== false) +) { + throw new Error( + `target-image narrowing or Phase 5 fail-closed gate drifted: ${JSON.stringify(targetImageVerification)}` + ); +} + +const handoff = readJson('.codex-handoff/target-image-decision-h2.json'); +if ( + handoff.schemaVersion !== 1 || + handoff.taskId !== 'target-image-decision-h2' || + handoff.canonicalSource?.commit !== REQUIRED_CANONICAL_SOURCE_COMMIT || + handoff.canonicalSource?.tree !== REQUIRED_CANONICAL_SOURCE_TREE || + handoff.status !== 'ready_for_integration' || + handoff.historicalProvenance?.relationship !== 'historical_only_not_current_commit_authority' || + handoff.drainEnvelopeConsumer?.envelopeId !== drainEvidenceEnvelopeId || + handoff.drainEnvelopeConsumer?.schemaPath !== drainEvidenceEnvelopeSchemaPath || + handoff.drainEnvelopeConsumer?.schemaSha256 !== drainEvidenceEnvelopeSchemaSha256() || + handoff.drainEnvelopeConsumer?.authority !== 'phase-00-controller' || + handoff.drainEnvelopeConsumer?.projection !== 'exact_required_fields_no_lane_owned_wrapper' || + handoff.reviewFindingResolutions?.some(({ status }) => status !== 'resolved') || + handoff.reviewFindingResolutions?.length !== 3 +) { + throw new Error('historical target-image decision handoff is stale'); +} +const authorityProjection = validateArtifactAuthorityProjections( + evidence.artifactAuthority, + evidence, + estimate +); +if (!authorityProjection.ok) { + throw new Error( + `W6 artifact-authority projection drift: ${authorityProjection.violations.join(',')}` + ); +} +if ( + Object.entries(handoff.scope).some( + ([key, value]) => key !== 'disposition' && typeof value === 'boolean' && value !== false + ) || + handoff.scope.disposition !== 'standalone_artifact_rejected_for_hosted_v1' +) { + throw new Error('W6 artifact-authority handoff overstates admission'); +} + +const checkedPaths = [ + '.codex-handoff/target-image-decision-h2.json', + '.codex-handoff/phase-00-freeze-fix-w6-artifact-f16.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/estimate-input.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/evidence.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/evidence.schema.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/finding-resolution.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/historical-rejected-candidate-artifact-scan.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/observed-artifact-scan.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/proposed-hosted-artifact-manifest.json', + 'docs/research/hosted-web/phase-0/auth-artifacts/report.md', + 'docs/research/hosted-web/phase-0/auth-artifacts/target-image-admission.json', + 'docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json', + 'docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.schema.json', + 'scripts/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.mjs', + 'scripts/hosted-web/phase-0/auth-artifacts/prove-target-image-admission.mjs', + 'scripts/hosted-web/phase-0/auth-artifacts/verify-evidence.mjs', + 'scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs', + 'scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.mjs', + 'test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts', + 'test/architecture/hosted-web/phase-0/auth-artifacts/target-image-admission.test.ts', + 'test/architecture/hosted-web/phase-0/w4-w6-contract/artifact-contract.test.ts', +]; +const serialized = checkedPaths + .map((path) => readFileSync(resolve(repoRoot, path), 'utf8')) + .join('\n'); +for (const pattern of [ + /-----BEGIN [A-Z ]+PRIVATE KEY-----/, + /\bBearer\s+[A-Za-z0-9._~-]+/, + /\b(?:sk|ghp)_[A-Za-z0-9]{12,}/, + /\/(?:Users|home)\/[A-Za-z0-9._-]+\//, +]) { + if (pattern.test(serialized)) throw new Error(`sensitive-looking value matched ${pattern}`); +} + +process.stdout.write( + `W6 r3 evidence plus accepted historical Phase 0 target-image narrowing, current-canonical-plus-input-patch standalone identity, descendant-stable normalized provenance, 51-obligation fail-closed Phase 5 admission, terminal rule, controller drain envelope, exact artifact-authority projections, reset admission, targeted standalone rejection, ABI characterization and split historical provenance passed (controller ${controllerHash})\n` +); diff --git a/scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.d.mts b/scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.d.mts new file mode 100644 index 0000000000..653bd84457 --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.d.mts @@ -0,0 +1,101 @@ +export interface TypeScriptDiagnostic { + file: string | null; + line: number | null; + column: number | null; + code: number; + message: string; +} + +export interface ParsedTypeScriptDiagnostics { + diagnostics: TypeScriptDiagnostic[]; + unparsed: string[]; +} + +export interface NormalizedTypeScriptDiagnostics { + inherited: TypeScriptDiagnostic[]; + resolved: TypeScriptDiagnostic[]; + unexpected: TypeScriptDiagnostic[]; +} + +export interface CompilerEvaluation { + passed: boolean; + rawExitCode: number | null; + observedDiagnosticCount: number; + normalizedInheritedCount: number; + resolvedInheritedCount: number; + effectiveDiagnosticCount: number; + inheritedDiagnostics: TypeScriptDiagnostic[]; + resolvedDiagnostics: TypeScriptDiagnostic[]; + unexpectedDiagnostics: TypeScriptDiagnostic[]; + unparsedOutput: string[]; +} + +export type ProcessDisposition = 'exited' | 'timeout' | 'signal' | 'runner-error'; + +export function classifyProcessDisposition(input: { + status: number | null; + signal: NodeJS.Signals | null; + errorCode?: string; +}): { + processDisposition: ProcessDisposition; + rawExitCode: number | null; + signal: NodeJS.Signals | null; +}; + +export const REPOSITORY_ROOT: string; + +export function parseTypeScriptDiagnostics( + output: string, + repositoryRoot?: string +): ParsedTypeScriptDiagnostics; + +export function normalizeDiagnostics( + observed: TypeScriptDiagnostic[], + expected: TypeScriptDiagnostic[] +): NormalizedTypeScriptDiagnostics; + +export function evaluateCompilerResult(input: { + exitCode: number | null; + output: string; + expected: TypeScriptDiagnostic[]; + repositoryRoot: string; +}): CompilerEvaluation; + +export function runGate(mode: 'targeted' | 'milestone'): CompilerEvaluation & { + schemaVersion: 1; + gate: string; + mode: 'targeted' | 'milestone'; + baseSha: string; + durationMs: number; + runnerError?: string; + runnerSignal?: NodeJS.Signals; +}; + +export function evaluateCapturedGate(input: { + mode: 'targeted' | 'milestone'; + exitCode: number | null; + output: string; + durationMs: number; + processDisposition?: ProcessDisposition; + signal?: NodeJS.Signals | null; + timeoutMs?: number | null; + compilerCommand?: string | null; +}): CompilerEvaluation & { + schemaVersion: 1; + gate: string; + mode: 'targeted' | 'milestone'; + baseSha: string; + durationMs: number; + compilerCommand: string; + processDisposition: ProcessDisposition; + signal: NodeJS.Signals | null; + timeoutMs: number | null; + compilerOutputBytes: number; + compilerOutputSha256: string; +}; + +export function evaluateCapturedCleanStage(input: Record): Record; + +export function assembleWorkspaceMilestone( + stages: Array & { id: string; passed: boolean; durationMs: number }> +): Record & { passed: boolean }; diff --git a/scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.mjs b/scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.mjs new file mode 100644 index 0000000000..5635e2d843 --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.mjs @@ -0,0 +1,378 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +export const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, '../../../../'); +const BASELINE_PATH = path.join( + REPOSITORY_ROOT, + 'docs/research/hosted-web/phase-0/final-gate/inherited-typescript-diagnostics.json' +); +const QUOTED_TYPE_MEMBER_SOURCE = String.raw`"(?:\\.|[^"\\])*"`; +const QUOTED_UNION_PATTERN = new RegExp( + String.raw`${QUOTED_TYPE_MEMBER_SOURCE}(?: \| ${QUOTED_TYPE_MEMBER_SOURCE})+`, + 'g' +); +const QUOTED_TYPE_MEMBER_PATTERN = new RegExp(QUOTED_TYPE_MEMBER_SOURCE, 'g'); + +function normalizeFilePath(file, repositoryRoot) { + const portable = file.replaceAll('\\', '/'); + const root = repositoryRoot.replaceAll('\\', '/').replace(/\/$/, ''); + if (portable === root) return '.'; + if (portable.startsWith(`${root}/`)) return portable.slice(root.length + 1); + return portable.replace(/^\.\//, ''); +} + +function normalizeMessage(message, repositoryRoot) { + const portable = message.replaceAll('\\', '/'); + const root = repositoryRoot.replaceAll('\\', '/').replace(/\/$/, ''); + return portable.replaceAll(root, ''); +} + +function canonicalizeQuotedUnionMemberOrder(message) { + return message.replace(QUOTED_UNION_PATTERN, (union) => + (union.match(QUOTED_TYPE_MEMBER_PATTERN) ?? []).sort().join(' | ') + ); +} + +export function parseTypeScriptDiagnostics(output, repositoryRoot = REPOSITORY_ROOT) { + const diagnostics = []; + const unparsed = []; + let current = null; + + const finishCurrent = () => { + if (!current) return; + current.message = normalizeMessage(current.messageLines.join('\n').trimEnd(), repositoryRoot); + delete current.messageLines; + diagnostics.push(current); + current = null; + }; + + for (const rawLine of output.replaceAll('\r\n', '\n').split('\n')) { + const line = rawLine.trimEnd(); + const located = /^(.*)\((\d+),(\d+)\): error TS(\d+): (.*)$/.exec(line); + const global = /^error TS(\d+): (.*)$/.exec(line); + if (located) { + finishCurrent(); + current = { + file: normalizeFilePath(located[1], repositoryRoot), + line: Number(located[2]), + column: Number(located[3]), + code: Number(located[4]), + messageLines: [located[5]], + }; + } else if (global) { + finishCurrent(); + current = { + file: null, + line: null, + column: null, + code: Number(global[1]), + messageLines: [global[2]], + }; + } else if (current && /^\s+/.test(rawLine)) { + current.messageLines.push(line); + } else if (line.length > 0) { + finishCurrent(); + unparsed.push(line); + } + } + finishCurrent(); + return { diagnostics, unparsed }; +} + +function diagnosticKey(diagnostic) { + return JSON.stringify([ + diagnostic.file, + diagnostic.line, + diagnostic.column, + diagnostic.code, + canonicalizeQuotedUnionMemberOrder(diagnostic.message), + ]); +} + +export function normalizeDiagnostics(observed, expected) { + const remaining = new Map(); + for (const diagnostic of expected) { + const key = diagnosticKey(diagnostic); + const entries = remaining.get(key) ?? []; + entries.push(diagnostic); + remaining.set(key, entries); + } + + const inherited = []; + const unexpected = []; + for (const diagnostic of observed) { + const key = diagnosticKey(diagnostic); + const entries = remaining.get(key); + if (!entries?.length) { + unexpected.push(diagnostic); + continue; + } + inherited.push(diagnostic); + entries.pop(); + if (entries.length === 0) remaining.delete(key); + } + + return { + inherited, + resolved: [...remaining.values()].flat(), + unexpected, + }; +} + +export function evaluateCompilerResult({ exitCode, output, expected, repositoryRoot }) { + const parsed = parseTypeScriptDiagnostics(output, repositoryRoot); + const normalized = normalizeDiagnostics(parsed.diagnostics, expected); + const supportedExitCode = exitCode === 0 || exitCode === 2; + const exitMatchesDiagnostics = + (exitCode === 0 && parsed.diagnostics.length === 0) || + (exitCode === 2 && parsed.diagnostics.length > 0); + const passed = + supportedExitCode && + exitMatchesDiagnostics && + parsed.unparsed.length === 0 && + normalized.unexpected.length === 0; + + return { + passed, + rawExitCode: exitCode, + observedDiagnosticCount: parsed.diagnostics.length, + normalizedInheritedCount: normalized.inherited.length, + resolvedInheritedCount: normalized.resolved.length, + effectiveDiagnosticCount: normalized.unexpected.length, + inheritedDiagnostics: normalized.inherited, + resolvedDiagnostics: normalized.resolved, + unexpectedDiagnostics: normalized.unexpected, + unparsedOutput: parsed.unparsed, + }; +} + +function loadBaseline() { + return JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); +} + +export function classifyProcessDisposition({ status, signal, errorCode }) { + if (errorCode === 'ETIMEDOUT') { + return { processDisposition: 'timeout', rawExitCode: null, signal: signal ?? null }; + } + if (signal) { + return { processDisposition: 'signal', rawExitCode: null, signal }; + } + if (errorCode) { + return { processDisposition: 'runner-error', rawExitCode: status, signal: null }; + } + return { processDisposition: 'exited', rawExitCode: status, signal: null }; +} + +export function evaluateCapturedGate({ + mode, + exitCode, + output, + durationMs, + processDisposition = 'exited', + signal = null, + timeoutMs = null, + compilerCommand = null, +}) { + const baseline = loadBaseline(); + const evaluation = evaluateCompilerResult({ + exitCode, + output, + expected: baseline.diagnostics, + repositoryRoot: REPOSITORY_ROOT, + }); + if (processDisposition !== 'exited') evaluation.passed = false; + return { + schemaVersion: 1, + gate: `phase-0-final-gate-typescript-${mode}`, + mode, + baseSha: baseline.baseSha, + durationMs, + compilerCommand: + compilerCommand ?? + (mode === 'milestone' + ? 'pnpm --silent typecheck' + : 'node node_modules/typescript/bin/tsc --noEmit --pretty false --project scripts/hosted-web/phase-0/final-gate/tsconfig.targeted.json'), + processDisposition, + signal, + timeoutMs, + compilerOutputBytes: Buffer.byteLength(output), + compilerOutputSha256: createHash('sha256').update(output).digest('hex'), + ...evaluation, + }; +} + +function compilerArguments(mode) { + if (mode === 'targeted') { + return [ + '--noEmit', + '--pretty', + 'false', + '--project', + path.join(SCRIPT_DIRECTORY, 'tsconfig.targeted.json'), + ]; + } + if (mode === 'milestone') { + return [ + '--noEmit', + '--pretty', + 'false', + '--project', + path.join(REPOSITORY_ROOT, 'tsconfig.json'), + ]; + } + throw new Error(`unsupported mode ${mode}`); +} + +export function runGate(mode) { + const baseline = loadBaseline(); + const compilerPath = path.join(REPOSITORY_ROOT, 'node_modules/typescript/bin/tsc'); + const startedAt = process.hrtime.bigint(); + const result = spawnSync(process.execPath, [compilerPath, ...compilerArguments(mode)], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + const evaluation = evaluateCompilerResult({ + exitCode: result.status, + output, + expected: baseline.diagnostics, + repositoryRoot: REPOSITORY_ROOT, + }); + if (result.error) { + evaluation.passed = false; + evaluation.runnerError = result.error.message; + } + if (result.signal) { + evaluation.passed = false; + evaluation.runnerSignal = result.signal; + } + return { + schemaVersion: 1, + gate: `phase-0-final-gate-typescript-${mode}`, + mode, + baseSha: baseline.baseSha, + durationMs: Math.round(durationMs * 100) / 100, + ...evaluation, + }; +} + +export function evaluateCapturedCleanStage({ + id, + exitCode, + output, + durationMs, + processDisposition, + signal, + timeoutMs, + compilerCommand, + rawCapturePath, +}) { + return { + id, + command: compilerCommand, + rawCapturePath, + durationMs, + processDisposition, + rawExitCode: exitCode, + signal, + timeoutMs, + compilerOutputBytes: Buffer.byteLength(output), + compilerOutputSha256: createHash('sha256').update(output).digest('hex'), + passed: processDisposition === 'exited' && exitCode === 0, + }; +} + +export function assembleWorkspaceMilestone(stages) { + const expectedIds = ['root', 'mcp-source', 'mcp-tests']; + if ( + stages.length !== expectedIds.length || + stages.some((stage, index) => stage.id !== expectedIds[index]) + ) { + throw new Error(`workspace milestone requires ordered stages: ${expectedIds.join(', ')}`); + } + return { + schemaVersion: 1, + gate: 'phase-0-final-gate-typescript-workspace-milestone', + mode: 'milestone', + baseSha: loadBaseline().baseSha, + workspaceScript: 'pnpm typecheck:workspace', + executionPolicy: + 'run the three canonical workspace stages in order; normalize only the inherited root diagnostics and require clean MCP exits', + timeoutMsPerStage: stages[0].timeoutMs, + durationMs: + Math.round(stages.reduce((total, stage) => total + stage.durationMs, 0) * 100) / 100, + passed: stages.every((stage) => stage.passed), + stages, + }; +} + +function main() { + const modeIndex = process.argv.indexOf('--mode'); + const mode = modeIndex === -1 ? 'targeted' : process.argv[modeIndex + 1]; + const inputIndex = process.argv.indexOf('--input'); + const assembleIndex = process.argv.indexOf('--assemble-workspace'); + let report; + if (assembleIndex !== -1) { + const reportPaths = process.argv.slice(assembleIndex + 1, assembleIndex + 4); + if (reportPaths.length !== 3) throw new Error('--assemble-workspace requires three reports'); + report = assembleWorkspaceMilestone( + reportPaths.map((reportPath) => JSON.parse(readFileSync(reportPath, 'utf8'))) + ); + } else if (inputIndex !== -1) { + const exitCodeIndex = process.argv.indexOf('--exit-code'); + const durationIndex = process.argv.indexOf('--duration-ms'); + const dispositionIndex = process.argv.indexOf('--process-disposition'); + const signalIndex = process.argv.indexOf('--signal'); + const timeoutIndex = process.argv.indexOf('--timeout-ms'); + const commandIndex = process.argv.indexOf('--compiler-command'); + const stageIndex = process.argv.indexOf('--stage-id'); + const rawCaptureIndex = process.argv.indexOf('--raw-capture-path'); + if ( + [exitCodeIndex, durationIndex, dispositionIndex, timeoutIndex, commandIndex, stageIndex].some( + (index) => index === -1 + ) + ) { + throw new Error('--input requires complete process and stage evidence'); + } + const rawExitCode = process.argv[exitCodeIndex + 1]; + const stageId = process.argv[stageIndex + 1]; + const input = { + id: stageId, + mode, + exitCode: rawExitCode === 'null' ? null : Number(rawExitCode), + output: readFileSync(process.argv[inputIndex + 1], 'utf8'), + durationMs: Number(process.argv[durationIndex + 1]), + processDisposition: process.argv[dispositionIndex + 1], + signal: + signalIndex === -1 || process.argv[signalIndex + 1] === 'null' + ? null + : process.argv[signalIndex + 1], + timeoutMs: Number(process.argv[timeoutIndex + 1]), + compilerCommand: process.argv[commandIndex + 1], + rawCapturePath: rawCaptureIndex === -1 ? null : process.argv[rawCaptureIndex + 1], + }; + if (stageId === 'root') { + report = { + id: stageId, + rawCapturePath: input.rawCapturePath, + ...evaluateCapturedGate(input), + }; + } else { + report = evaluateCapturedCleanStage(input); + } + } else { + report = runGate(mode); + } + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.passed ? 0 : 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main(); +} diff --git a/scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh b/scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh new file mode 100644 index 0000000000..2ef4ff7605 --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/run-normalized-typecheck.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash + +set -u + +mode="${1:-targeted}" +normalizer_mode="$mode" +case "$mode" in + targeted | milestone) ;; + test-stage) + if [ "${PHASE0_FINAL_GATE_TESTING:-}" != '1' ]; then + printf 'test-stage requires PHASE0_FINAL_GATE_TESTING=1\n' >&2 + exit 64 + fi + normalizer_mode='targeted' + ;; + *) + printf 'unsupported mode %s\n' "$mode" >&2 + exit 64 + ;; +esac + +timeout_ms="${PHASE0_TYPECHECK_TIMEOUT_MS:-300000}" +if ! [[ "$timeout_ms" =~ ^[1-9][0-9]*$ ]]; then + printf 'PHASE0_TYPECHECK_TIMEOUT_MS must be a positive integer\n' >&2 + exit 64 +fi +timeout_seconds=$(((timeout_ms + 999) / 1000)) +normalizer='scripts/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.mjs' +temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/phase-0-final-gate.XXXXXX")" +trap 'rm -rf "$temporary_directory"' EXIT + +run_stage() { + stage_id="$1" + output_file="$2" + report_file="$3" + shift 3 + compiler_command="$*" + control_file="$temporary_directory/$stage_id.control" + started_ns="$(date +%s%N)" + timeout --verbose --signal=TERM --kill-after=5s "${timeout_seconds}s" \ + bash -c 'output_file="$1"; shift; exec "$@" >"$output_file" 2>&1' \ + phase-0-final-gate "$output_file" "$@" 2>"$control_file" + stage_exit=$? + node - "$output_file" <<'NODE' +const fs = require('node:fs'); +const outputPath = process.argv[2]; +const repositoryRoot = process.cwd().replaceAll('\\', '/').replace(/\/$/, ''); +const output = fs.readFileSync(outputPath, 'utf8').replaceAll('\\', '/'); +fs.writeFileSync(outputPath, output.replaceAll(repositoryRoot, '')); +NODE + capture_normalization_exit=$? + if [ "$capture_normalization_exit" -ne 0 ]; then + printf 'evidence path normalization failed with exit %s\n' \ + "$capture_normalization_exit" >>"$control_file" + stage_exit=125 + fi + finished_ns="$(date +%s%N)" + duration_ms=$(((finished_ns - started_ns) / 1000000)) + + process_disposition='exited' + raw_exit_code="$stage_exit" + signal='null' + if grep -q '^timeout: sending signal ' "$control_file"; then + process_disposition='timeout' + raw_exit_code='null' + signal="SIG$(awk '/^timeout: sending signal / { final_signal=$4 } END { print final_signal }' "$control_file")" + elif [ "$stage_exit" -gt 128 ]; then + process_disposition='signal' + raw_exit_code='null' + signal="SIG$(kill -l "$((stage_exit - 128))")" + elif [ "$stage_exit" -ge 125 ]; then + process_disposition='runner-error' + fi + + node "$normalizer" \ + --mode "$normalizer_mode" \ + --stage-id "$stage_id" \ + --input "$output_file" \ + --exit-code "$raw_exit_code" \ + --duration-ms "$duration_ms" \ + --process-disposition "$process_disposition" \ + --signal "$signal" \ + --timeout-ms "$timeout_ms" \ + --compiler-command "$compiler_command" \ + --raw-capture-path "$output_file" >"$report_file" +} + +if [ "$mode" = 'test-stage' ]; then + scenario="${2:-}" + fixture_output="$temporary_directory/$scenario.raw.txt" + fixture_report="$temporary_directory/$scenario.report.json" + case "$scenario" in + timeout) + run_stage root "$fixture_output" "$fixture_report" bash -c 'sleep 5' + ;; + signal) + run_stage root "$fixture_output" "$fixture_report" bash -c 'kill -TERM $$' + ;; + runner-error) + run_stage root "$fixture_output" "$fixture_report" phase0-final-gate-command-does-not-exist + ;; + *) + printf 'unsupported test-stage scenario %s\n' "$scenario" >&2 + exit 64 + ;; + esac + cat "$fixture_report" + node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1])).passed ? 0 : 1)' \ + "$fixture_report" + exit $? +fi + +if [ "$mode" = 'targeted' ]; then + targeted_output="$temporary_directory/targeted.raw.txt" + targeted_report="$temporary_directory/targeted.report.json" + run_stage root "$targeted_output" "$targeted_report" \ + node node_modules/typescript/bin/tsc --noEmit --pretty false \ + --project scripts/hosted-web/phase-0/final-gate/tsconfig.targeted.json + cat "$targeted_report" + node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1])).passed ? 0 : 1)' \ + "$targeted_report" + exit $? +fi + +root_output='docs/research/hosted-web/phase-0/final-gate/milestone-typecheck.raw.txt' +mcp_source_output='docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-source.raw.txt' +mcp_tests_output='docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-mcp-tests.raw.txt' +root_report="$temporary_directory/root.report.json" +mcp_source_report="$temporary_directory/mcp-source.report.json" +mcp_tests_report="$temporary_directory/mcp-tests.report.json" + +run_stage root "$root_output" "$root_report" pnpm --silent typecheck +run_stage mcp-source "$mcp_source_output" "$mcp_source_report" \ + pnpm --silent --filter agent-teams-mcp typecheck +run_stage mcp-tests "$mcp_tests_output" "$mcp_tests_report" \ + pnpm --silent --filter agent-teams-mcp typecheck:test + +node "$normalizer" --mode milestone --assemble-workspace \ + "$root_report" "$mcp_source_report" "$mcp_tests_report" | \ + tee docs/research/hosted-web/phase-0/final-gate/milestone-typecheck-report.json +exit "${PIPESTATUS[0]}" diff --git a/scripts/hosted-web/phase-0/final-gate/run-owned-changes-check.sh b/scripts/hosted-web/phase-0/final-gate/run-owned-changes-check.sh new file mode 100644 index 0000000000..47090de53d --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/run-owned-changes-check.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -eu + +export PHASE0_OWNED_CHANGES_HEAD="$(git rev-parse HEAD)" +export PHASE0_OWNED_CHANGES_STATUS="$(git status --porcelain=v1 --untracked-files=all)" + +node scripts/hosted-web/phase-0/final-gate/verify-owned-changes.mjs --captured-git diff --git a/scripts/hosted-web/phase-0/final-gate/run-reconciliation-check.sh b/scripts/hosted-web/phase-0/final-gate/run-reconciliation-check.sh new file mode 100644 index 0000000000..c755adca9a --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/run-reconciliation-check.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -eu + +prior_base='42ec333848e29e97c41699b9fed73ed199740e3f' +paths=( + test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts + test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts + test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts + tsconfig.json + package.json + pnpm-lock.yaml +) + +export PHASE0_RECONCILIATION_HEAD="$(git rev-parse HEAD)" +for index in "${!paths[@]}"; do + path="${paths[$index]}" + export "PHASE0_RECONCILIATION_${index}_PRIOR=$(git rev-parse "$prior_base:$path")" + export "PHASE0_RECONCILIATION_${index}_HEAD=$(git rev-parse "HEAD:$path")" +done + +node scripts/hosted-web/phase-0/final-gate/verify-typecheck-reconciliation.mjs --captured-git diff --git a/scripts/hosted-web/phase-0/final-gate/tsconfig.targeted.json b/scripts/hosted-web/phase-0/final-gate/tsconfig.targeted.json new file mode 100644 index 0000000000..8e027bbc77 --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/tsconfig.targeted.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.json", + "files": [ + "../../../../test/architecture/hosted-web/phase-0/auth-artifacts/auth-artifacts-spike.test.ts", + "../../../../test/architecture/hosted-web/phase-0/final-gate/normalize-typescript-diagnostics.test.ts", + "../../../../test/architecture/hosted-web/phase-0/host-primitives/evidence-scanner.test.ts", + "../../../../test/architecture/hosted-web/phase-0/parity-renderer/scan-api-and-actions.test.ts", + "../../../../test/architecture/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.test.ts" + ], + "include": [] +} diff --git a/scripts/hosted-web/phase-0/final-gate/verify-owned-changes.mjs b/scripts/hosted-web/phase-0/final-gate/verify-owned-changes.mjs new file mode 100644 index 0000000000..e6254e03d2 --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/verify-owned-changes.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../'); +const canonicalHead = '3bc0dfa7c00261785c0c752270cb302a9294e751'; +const allowedPaths = [ + /^docs\/research\/hosted-web\/phase-0\/final-gate\//, + /^scripts\/hosted-web\/phase-0\/final-gate\//, + /^test\/architecture\/hosted-web\/phase-0\/final-gate\//, + /^\.codex-handoff\/final-gate-candidate-reconcile-h4\.json$/, +]; +const secretPatterns = [ + [ + 'private_key', + new RegExp(['-----BEGIN ', '(?:RSA |EC |OPENSSH )?', 'PRIVATE KEY-----'].join('')), + ], + ['openai_key', new RegExp(['s', 'k-[A-Za-z0-9_-]{20,}'].join(''))], + ['aws_access_key', new RegExp(['AK', 'IA[0-9A-Z]{16}'].join(''))], + ['github_token', new RegExp(['gh', '[pousr]_[A-Za-z0-9]{30,}'].join(''))], + ['slack_token', new RegExp(['xo', 'x[baprs]-[A-Za-z0-9-]{20,}'].join(''))], +]; +const useCapturedGit = process.argv.includes('--captured-git'); + +function gitChangedPaths() { + if (useCapturedGit) { + const output = process.env.PHASE0_OWNED_CHANGES_STATUS; + if (output === undefined) throw new Error('missing captured Git status'); + return parseChangedPaths(output.split('\n').filter(Boolean)); + } + const result = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + throw result.error ?? new Error(result.stderr.trim() || 'git status failed'); + } + return parseChangedPaths(result.stdout.split('\0').filter(Boolean)); +} + +function parseChangedPaths(records) { + return records.map((record) => { + const status = record.slice(0, 2); + const payload = record.slice(3); + const renamedPath = payload.includes(' -> ') ? payload.split(' -> ').at(-1) : payload; + return { path: renamedPath.replaceAll('\\', '/'), status }; + }); +} + +function gitHead() { + if (useCapturedGit) { + const head = process.env.PHASE0_OWNED_CHANGES_HEAD; + if (head === undefined) throw new Error('missing captured Git HEAD'); + return head.trim(); + } + const result = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + throw result.error ?? new Error(result.stderr.trim() || 'git rev-parse failed'); + } + return result.stdout.trim(); +} + +const startedAt = process.hrtime.bigint(); +const observedHead = gitHead(); +const changes = gitChangedPaths(); +const scopeViolations = changes.filter( + (change) => !allowedPaths.some((allowed) => allowed.test(change.path)) +); +const secretFindings = []; +for (const change of changes) { + if (change.status.includes('D')) continue; + const content = readFileSync(path.join(repositoryRoot, change.path), 'utf8'); + for (const [id, pattern] of secretPatterns) { + if (pattern.test(content)) secretFindings.push({ path: change.path, pattern: id }); + } +} +const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; +const report = { + schemaVersion: 1, + gate: 'phase-0-final-gate-owned-scope-and-secrets', + passed: + observedHead === canonicalHead && scopeViolations.length === 0 && secretFindings.length === 0, + durationMs: Math.round(durationMs * 100) / 100, + canonicalHead, + observedHead, + changedPaths: changes.map((change) => change.path).sort(), + scopeViolations, + secretFindings, +}; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +process.exitCode = report.passed ? 0 : 1; diff --git a/scripts/hosted-web/phase-0/final-gate/verify-typecheck-reconciliation.mjs b/scripts/hosted-web/phase-0/final-gate/verify-typecheck-reconciliation.mjs new file mode 100644 index 0000000000..0e0a294927 --- /dev/null +++ b/scripts/hosted-web/phase-0/final-gate/verify-typecheck-reconciliation.mjs @@ -0,0 +1,97 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../'); +const evidence = JSON.parse( + readFileSync( + path.join( + repositoryRoot, + 'docs/research/hosted-web/phase-0/final-gate/typecheck-evidence-reconciliation.json' + ), + 'utf8' + ) +); +const baseline = JSON.parse( + readFileSync( + path.join( + repositoryRoot, + 'docs/research/hosted-web/phase-0/final-gate/inherited-typescript-diagnostics.json' + ), + 'utf8' + ) +); +const violations = []; + +function assertEqual(id, actual, expected) { + if (actual !== expected) violations.push({ id, expected, actual }); +} + +assertEqual('canonical_head', process.env.PHASE0_RECONCILIATION_HEAD, evidence.canonicalHead); +assertEqual('baseline_base', baseline.baseSha, evidence.canonicalHead); +assertEqual( + 'source_classification', + baseline.sourceClassification.classification, + evidence.baselineSourceClassification.classification +); +assertEqual( + 'classification_observed_base', + baseline.sourceClassification.observedAtBaseSha, + evidence.canonicalHead +); + +const facts = [ + ...evidence.baselineSourceClassification.sourceBlobs, + ...evidence.unchangedRootTypecheckInputs, +]; +for (const [index, fact] of facts.entries()) { + assertEqual( + `prior_blob:${fact.path}`, + process.env[`PHASE0_RECONCILIATION_${index}_PRIOR`], + fact.priorBlob + ); + assertEqual( + `head_blob:${fact.path}`, + process.env[`PHASE0_RECONCILIATION_${index}_HEAD`], + fact.updatedBaseBlob + ); + assertEqual(`blob_continuity:${fact.path}`, fact.updatedBaseBlob, fact.priorBlob); +} + +const diagnosticCounts = new Map(); +for (const diagnostic of baseline.diagnostics) { + diagnosticCounts.set(diagnostic.file, (diagnosticCounts.get(diagnostic.file) ?? 0) + 1); +} +assertEqual('diagnostic_total', baseline.diagnostics.length, 7); +for (const source of evidence.baselineSourceClassification.sourceBlobs) { + assertEqual( + `diagnostic_count:${source.path}`, + diagnosticCounts.get(source.path), + source.diagnosticCount + ); +} +assertEqual('targeted_observed', evidence.freshTargetedObservation.observedDiagnosticCount, 7); +assertEqual('targeted_inherited', evidence.freshTargetedObservation.normalizedInheritedCount, 7); +// Older evidence carries unexpectedDiagnosticCount; the current normalizer emits the +// unexpectedDiagnostics array instead. Accept either so regenerated evidence reconciles. +assertEqual( + 'targeted_unexpected', + evidence.freshTargetedObservation.unexpectedDiagnosticCount ?? + (Array.isArray(evidence.freshTargetedObservation.unexpectedDiagnostics) + ? evidence.freshTargetedObservation.unexpectedDiagnostics.length + : undefined), + 0 +); +assertEqual('targeted_effective', evidence.freshTargetedObservation.effectiveDiagnosticCount, 0); + +const report = { + schemaVersion: 1, + gate: 'phase-0-final-gate-typecheck-reconciliation', + passed: violations.length === 0, + canonicalHead: evidence.canonicalHead, + classification: evidence.baselineSourceClassification.classification, + diagnosticCount: baseline.diagnostics.length, + violations, +}; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +process.exitCode = report.passed ? 0 : 1; diff --git a/scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c b/scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c new file mode 100644 index 0000000000..e1f32d869b --- /dev/null +++ b/scripts/hosted-web/phase-0/host-primitives/instance-lock/instance_lock_spike.c @@ -0,0 +1,201 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { EXIT_BUSY = 73, EXIT_ANCHOR = 74, EXIT_USAGE = 64 }; + +static int lock_fd = -1; +static int control_write_fd = -1; +static pid_t controller_pid = -1; + +static void fail(const char *message) { + perror(message); + exit(EXIT_ANCHOR); +} + +static uint64_t parse_u64(const char *value) { + char *end = NULL; + errno = 0; + unsigned long long parsed = strtoull(value, &end, 10); + if (errno != 0 || end == value || *end != '\0') { + fprintf(stderr, "invalid integer: %s\n", value); + exit(EXIT_USAGE); + } + return (uint64_t)parsed; +} + +static void sleep_ms(unsigned long milliseconds) { + struct timespec delay = { + .tv_sec = (time_t)(milliseconds / 1000), + .tv_nsec = (long)((milliseconds % 1000) * 1000000UL), + }; + while (nanosleep(&delay, &delay) == -1 && errno == EINTR) { + } +} + +static void forward_signal(int signal_number) { + if (controller_pid > 0) { + (void)kill(controller_pid, signal_number); + } +} + +static int open_verified_anchor(const char *parent_path, const char *anchor_name, + uint64_t expected_device, uint64_t expected_inode) { + int parent_fd = open(parent_path, O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (parent_fd == -1) { + fail("open deployment parent"); + } + + struct stat parent_stat; + if (fstat(parent_fd, &parent_stat) == -1) { + fail("fstat deployment parent"); + } + if (!S_ISDIR(parent_stat.st_mode) || (parent_stat.st_mode & 0022) != 0) { + fprintf(stderr, "unsafe deployment parent mode\n"); + exit(EXIT_ANCHOR); + } + + int anchor_fd = openat(parent_fd, anchor_name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (anchor_fd == -1) { + fail("open instance anchor"); + } + (void)close(parent_fd); + + struct stat anchor_stat; + if (fstat(anchor_fd, &anchor_stat) == -1) { + fail("fstat instance anchor"); + } + if (!S_ISREG(anchor_stat.st_mode) || anchor_stat.st_uid != 0 || + (anchor_stat.st_mode & 0022) != 0 || + (uint64_t)anchor_stat.st_dev != expected_device || + (uint64_t)anchor_stat.st_ino != expected_inode) { + fprintf(stderr, "instance anchor identity or ownership mismatch\n"); + exit(EXIT_ANCHOR); + } + return anchor_fd; +} + +static void controller_main(int inherited_lock_fd, int control_read_fd, int ready_write_fd, + const char *effect_path, const char *owner_id) { + if (dup2(inherited_lock_fd, 9) == -1) { + fail("reserve controller lease fd"); + } + if (inherited_lock_fd != 9) { + (void)close(inherited_lock_fd); + } + int flags = fcntl(9, F_GETFD); + if (flags == -1 || fcntl(9, F_SETFD, flags | FD_CLOEXEC) == -1) { + fail("set controller lease close-on-exec"); + } + + int effect_fd = open(effect_path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0600); + if (effect_fd == -1) { + fail("open effect marker"); + } + dprintf(effect_fd, "%s\n", owner_id); + (void)fsync(effect_fd); + (void)close(effect_fd); + if (write(ready_write_fd, "R", 1) != 1) { + fail("publish controller readiness"); + } + (void)close(ready_write_fd); + + char byte; + while (read(control_read_fd, &byte, 1) == -1 && errno == EINTR) { + } + (void)close(control_read_fd); + (void)close(9); + _exit(0); +} + +int main(int argc, char **argv) { + if (argc != 7) { + fprintf(stderr, + "usage: %s PARENT ANCHOR EXPECTED_DEV EXPECTED_INO EFFECT OWNER_ID\n", + argv[0]); + return EXIT_USAGE; + } + + uint64_t expected_device = parse_u64(argv[3]); + uint64_t expected_inode = parse_u64(argv[4]); + lock_fd = open_verified_anchor(argv[1], argv[2], expected_device, expected_inode); + if (flock(lock_fd, LOCK_EX | LOCK_NB) == -1) { + if (errno == EWOULDBLOCK || errno == EAGAIN) { + fprintf(stderr, "lease_busy\n"); + return EXIT_BUSY; + } + fail("flock instance anchor"); + } + + int controller_lock_fd = dup(lock_fd); + if (controller_lock_fd == -1) { + fail("duplicate lease fd"); + } + int control_pipe[2]; + if (pipe2(control_pipe, O_CLOEXEC) == -1) { + fail("create lifecycle control pipe"); + } + int ready_pipe[2]; + if (pipe2(ready_pipe, O_CLOEXEC) == -1) { + fail("create readiness pipe"); + } + + controller_pid = fork(); + if (controller_pid == -1) { + fail("fork controller fixture"); + } + if (controller_pid == 0) { + (void)close(control_pipe[1]); + (void)close(ready_pipe[0]); + (void)close(lock_fd); + controller_main(controller_lock_fd, control_pipe[0], ready_pipe[1], argv[5], argv[6]); + } + + (void)close(controller_lock_fd); + (void)close(control_pipe[0]); + (void)close(ready_pipe[1]); + control_write_fd = control_pipe[1]; + struct sigaction action = {.sa_handler = forward_signal}; + sigemptyset(&action.sa_mask); + (void)sigaction(SIGTERM, &action, NULL); + (void)sigaction(SIGINT, &action, NULL); + + char ready_byte = '\0'; + if (read(ready_pipe[0], &ready_byte, 1) != 1 || ready_byte != 'R') { + fail("await controller readiness"); + } + (void)close(ready_pipe[0]); + printf("ready launcher=%ld controller=%ld lock_fd=%d\n", (long)getpid(), + (long)controller_pid, lock_fd); + fflush(stdout); + + const char *close_launcher = getenv("ATG_CLOSE_LAUNCHER_LOCK_AFTER_READY"); + if (close_launcher != NULL && strcmp(close_launcher, "1") == 0) { + sleep_ms(100); + (void)close(lock_fd); + lock_fd = -1; + printf("launcher_lock_closed\n"); + fflush(stdout); + } + + int status = 0; + while (waitpid(controller_pid, &status, 0) == -1 && errno == EINTR) { + } + (void)close(control_write_fd); + if (lock_fd >= 0) { + (void)close(lock_fd); + } + return WIFEXITED(status) ? WEXITSTATUS(status) : 128; +} diff --git a/scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c b/scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c new file mode 100644 index 0000000000..c2377bf818 --- /dev/null +++ b/scripts/hosted-web/phase-0/host-primitives/process-anchor/process_anchor_spike.c @@ -0,0 +1,499 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { EXIT_USAGE = 64, EXIT_PROTOCOL = 79 }; + +static volatile sig_atomic_t provider_stop = 0; + +static void provider_term(int signal_number) { + (void)signal_number; + provider_stop = 1; +} + +static void sleep_ms(unsigned long milliseconds) { + struct timespec delay = { + .tv_sec = (time_t)(milliseconds / 1000), + .tv_nsec = (long)((milliseconds % 1000) * 1000000UL), + }; + while (nanosleep(&delay, &delay) == -1 && errno == EINTR) { + } +} + +static void record_fds(const char *marker_path, const char *role) { + int marker_fd = open(marker_path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0600); + if (marker_fd == -1) { + _exit(111); + } + DIR *directory = opendir("/proc/self/fd"); + if (directory == NULL) { + _exit(112); + } + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) { + char *end = NULL; + long fd = strtol(entry->d_name, &end, 10); + if (end == entry->d_name || *end != '\0') { + continue; + } + char link_path[64]; + char target[512]; + snprintf(link_path, sizeof(link_path), "/proc/self/fd/%ld", fd); + ssize_t count = readlink(link_path, target, sizeof(target) - 1); + if (count >= 0) { + target[count] = '\0'; + dprintf(marker_fd, "role=%s pid=%ld fd=%ld target=%s\n", role, (long)getpid(), fd, + target); + } + } + (void)closedir(directory); + (void)close(marker_fd); +} + +static int close_unintended_descriptors(void) { +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, ~0U, 0U) == 0) { + return 0; + } + if (errno != ENOSYS && errno != EPERM) { + return -1; + } +#endif + DIR *directory = opendir("/proc/self/fd"); + if (directory == NULL) { + return -1; + } + int directory_fd = dirfd(directory); + int descriptors[4096]; + size_t count = 0; + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) { + char *end = NULL; + long value = strtol(entry->d_name, &end, 10); + if (end != entry->d_name && *end == '\0' && value >= 3 && value != directory_fd) { + if (count >= sizeof(descriptors) / sizeof(descriptors[0])) { + (void)closedir(directory); + errno = EOVERFLOW; + return -1; + } + descriptors[count++] = (int)value; + } + } + (void)closedir(directory); + for (size_t index = 0; index < count; index++) { + (void)close(descriptors[index]); + } + return 0; +} + +static void provider_loop(const char *marker_path, const char *role, bool ignore_term, + unsigned long bounded_lifetime_ms) { + int null_fd = open("/dev/null", O_RDWR | O_CLOEXEC); + if (null_fd == -1) { + _exit(110); + } + (void)dup2(null_fd, STDIN_FILENO); + (void)dup2(null_fd, STDOUT_FILENO); + (void)dup2(null_fd, STDERR_FILENO); + if (null_fd > STDERR_FILENO) { + (void)close(null_fd); + } + if (close_unintended_descriptors() == -1) { + _exit(113); + } + record_fds(marker_path, role); + if (ignore_term) { + (void)signal(SIGTERM, SIG_IGN); + } else { + struct sigaction action = {.sa_handler = provider_term}; + sigemptyset(&action.sa_mask); + (void)sigaction(SIGTERM, &action, NULL); + } + if (bounded_lifetime_ms > 0) { + sleep_ms(bounded_lifetime_ms); + _exit(0); + } + while (!provider_stop) { + pause(); + } + _exit(0); +} + +static void spawn_provider_fixture(const char *mode, const char *marker_path) { + if (strcmp(mode, "double") == 0 || strcmp(mode, "escape") == 0) { + pid_t intermediate = fork(); + if (intermediate == -1) { + _exit(120); + } + if (intermediate == 0) { + pid_t grandchild = fork(); + if (grandchild == -1) { + _exit(121); + } + if (grandchild == 0) { + if (strcmp(mode, "escape") == 0 && setsid() == -1) { + _exit(122); + } + provider_loop(marker_path, strcmp(mode, "escape") == 0 ? "escaped" : "grandchild", + strcmp(mode, "escape") == 0, + strcmp(mode, "escape") == 0 ? 600UL : 0UL); + } + _exit(0); + } + } + provider_loop(marker_path, "main", strcmp(mode, "ignore") == 0, 0UL); +} + +static int open_pidfd(pid_t pid) { + int result = (int)syscall(SYS_pidfd_open, pid, 0); + return result; +} + +struct process_identity { + pid_t ppid; + pid_t pgrp; + unsigned long long start_time; +}; + +struct pidfd_target { + pid_t pid; + int pidfd; +}; + +static bool read_proc_identity(pid_t pid, struct process_identity *identity) { + char path[64]; + snprintf(path, sizeof(path), "/proc/%ld/stat", (long)pid); + FILE *file = fopen(path, "r"); + if (file == NULL) { + return false; + } + char record[4096]; + char *line = fgets(record, sizeof(record), file); + (void)fclose(file); + if (line == NULL) { + return false; + } + char *command_end = strrchr(record, ')'); + if (command_end == NULL || command_end[1] != ' ') { + return false; + } + char *save = NULL; + char *field = strtok_r(command_end + 2, " ", &save); + size_t field_number = 3; + bool have_ppid = false; + bool have_pgrp = false; + bool have_start_time = false; + while (field != NULL) { + if (field_number == 4) { + identity->ppid = (pid_t)strtol(field, NULL, 10); + have_ppid = true; + } else if (field_number == 5) { + identity->pgrp = (pid_t)strtol(field, NULL, 10); + have_pgrp = true; + } else if (field_number == 22) { + identity->start_time = strtoull(field, NULL, 10); + have_start_time = true; + break; + } + field = strtok_r(NULL, " ", &save); + field_number++; + } + return have_ppid && have_pgrp && have_start_time; +} + +static size_t collect_descendants(pid_t anchor_pid, pid_t *output, size_t capacity) { + pid_t known[1024]; + size_t known_count = 1; + known[0] = anchor_pid; + bool changed = true; + while (changed) { + changed = false; + DIR *proc = opendir("/proc"); + if (proc == NULL) { + return 0; + } + struct dirent *entry; + while ((entry = readdir(proc)) != NULL) { + char *end = NULL; + long candidate_long = strtol(entry->d_name, &end, 10); + if (end == entry->d_name || *end != '\0' || candidate_long <= 0) { + continue; + } + pid_t candidate = (pid_t)candidate_long; + bool already_known = false; + for (size_t index = 0; index < known_count; index++) { + if (known[index] == candidate) { + already_known = true; + break; + } + } + if (already_known || known_count >= 1024) { + continue; + } + struct process_identity identity; + if (!read_proc_identity(candidate, &identity)) { + continue; + } + for (size_t index = 0; index < known_count; index++) { + if (known[index] == identity.ppid) { + known[known_count++] = candidate; + changed = true; + break; + } + } + } + (void)closedir(proc); + } + size_t output_count = 0; + for (size_t index = 1; index < known_count && output_count < capacity; index++) { + output[output_count++] = known[index]; + } + return output_count; +} + +static int send_pidfd_signal(int pidfd, int signal_number) { +#ifdef SYS_pidfd_send_signal + return (int)syscall(SYS_pidfd_send_signal, pidfd, signal_number, NULL, 0); +#else + (void)pidfd; + (void)signal_number; + errno = ENOSYS; + return -1; +#endif +} + +static size_t snapshot_owned_targets(pid_t anchor_pid, pid_t owned_pgid, + struct pidfd_target *targets, size_t capacity, + bool *saw_escape, bool *saw_ambiguous) { + pid_t descendants[1024]; + size_t count = collect_descendants(anchor_pid, descendants, 1024); + size_t target_count = 0; + for (size_t index = 0; index < count; index++) { + struct process_identity before; + if (!read_proc_identity(descendants[index], &before)) { + continue; + } + int pidfd = open_pidfd(descendants[index]); + if (pidfd == -1) { + if (errno != ESRCH) { + *saw_ambiguous = true; + } + continue; + } + struct process_identity after; + if (!read_proc_identity(descendants[index], &after)) { + (void)close(pidfd); + continue; + } + if (before.start_time != after.start_time || before.ppid != after.ppid || + before.pgrp != after.pgrp) { + *saw_ambiguous = true; + (void)close(pidfd); + continue; + } + if (after.pgrp != owned_pgid) { + *saw_escape = true; + (void)close(pidfd); + continue; + } + if (target_count >= capacity) { + *saw_ambiguous = true; + (void)close(pidfd); + continue; + } + targets[target_count++] = (struct pidfd_target){.pid = descendants[index], .pidfd = pidfd}; + } + return target_count; +} + +static size_t signal_owned_descendants(pid_t anchor_pid, pid_t owned_pgid, int signal_number, + bool *saw_escape, bool *saw_ambiguous) { + struct pidfd_target targets[1024]; + size_t count = snapshot_owned_targets(anchor_pid, owned_pgid, targets, 1024, saw_escape, + saw_ambiguous); + for (size_t index = 0; index < count; index++) { + if (send_pidfd_signal(targets[index].pidfd, signal_number) == -1 && errno != ESRCH) { + *saw_ambiguous = true; + } + (void)close(targets[index].pidfd); + } + return count; +} + +static size_t reap_available(void) { + size_t reaped = 0; + int status = 0; + while (waitpid(-1, &status, WNOHANG) > 0) { + reaped++; + } + return reaped; +} + +int main(int argc, char **argv) { + if (argc != 9) { + fprintf(stderr, + "usage: %s NONCE MODE FD_MARKER GRACE_MS PURPOSE RESET_GENERATION " + "DEPLOYMENT_GENERATION PROCESS_ANCHOR_GENERATION\n", + argv[0]); + return EXIT_USAGE; + } + const char *nonce = argv[1]; + const char *mode = argv[2]; + unsigned long grace_ms = strtoul(argv[4], NULL, 10); + const char *purpose = argv[5]; + unsigned long reset_generation = strtoul(argv[6], NULL, 10); + const char *deployment_generation = argv[7]; + const char *process_anchor_generation = argv[8]; + if (nonce[0] == '\0' || + (strcmp(mode, "normal") != 0 && strcmp(mode, "ignore") != 0 && + strcmp(mode, "double") != 0 && strcmp(mode, "escape") != 0) || + (strcmp(purpose, "pairing") != 0 && strcmp(purpose, "host_reset") != 0 && + strcmp(purpose, "runtime_stop") != 0) || + deployment_generation[0] == '\0' || process_anchor_generation[0] == '\0') { + fprintf(stderr, "protocol_error\n"); + return EXIT_PROTOCOL; + } + if (prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == -1 || + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) { + perror("anchor setup"); + return EXIT_PROTOCOL; + } + (void)signal(SIGTERM, SIG_IGN); + if (close_unintended_descriptors() == -1) { + perror("close inherited descriptors"); + return EXIT_PROTOCOL; + } + + pid_t main_pid = fork(); + if (main_pid == -1) { + perror("fork provider fixture"); + return EXIT_PROTOCOL; + } + if (main_pid == 0) { + if (setpgid(0, 0) == -1) { + _exit(123); + } + spawn_provider_fixture(mode, argv[3]); + } + if (setpgid(main_pid, main_pid) == -1 && errno != EACCES) { + perror("allocate provider process group"); + return EXIT_PROTOCOL; + } + pid_t owned_pgid = main_pid; + int main_pidfd = open_pidfd(main_pid); + if (main_pidfd == -1) { + perror("pidfd_open"); + return EXIT_PROTOCOL; + } + int pidfd_flags = fcntl(main_pidfd, F_GETFD); + if (pidfd_flags == -1 || fcntl(main_pidfd, F_SETFD, pidfd_flags | FD_CLOEXEC) == -1) { + perror("pidfd cloexec"); + return EXIT_PROTOCOL; + } + printf("type=ready protocolVersion=1 spawnNonceHash=%s purpose=%s resetGeneration=%lu " + "deploymentGeneration=%s processAnchorGeneration=%s anchorIdentity=anchor-%ld " + "mainPidfdReady=true ownedProcessGroupReady=true\n", + nonce, purpose, reset_generation, deployment_generation, process_anchor_generation, + (long)getpid()); + fflush(stdout); + + char control[32]; + ssize_t control_count; + do { + control_count = read(STDIN_FILENO, control, sizeof(control)); + } while (control_count == -1 && errno == EINTR); + const char *reason = control_count == 0 ? "controller_eof" : "typed_stop"; + if (control_count > 0 && strncmp(control, "STOP", 4) != 0) { + printf("type=protocol_error code=invalid_control\n"); + fflush(stdout); + reason = "protocol_error"; + } + + bool saw_escape = false; + bool saw_ambiguous = false; + (void)signal_owned_descendants(getpid(), owned_pgid, SIGTERM, &saw_escape, + &saw_ambiguous); + printf("type=escalation phase=term reason=%s via=pidfd_snapshot pidfd_main=yes " + "numeric_pgid_signal=no\n", + reason); + fflush(stdout); + unsigned long elapsed = 0; + while (elapsed < grace_ms) { + (void)reap_available(); + pid_t descendants[1024]; + if (collect_descendants(getpid(), descendants, 1024) == 0) { + break; + } + (void)signal_owned_descendants(getpid(), owned_pgid, SIGTERM, &saw_escape, + &saw_ambiguous); + sleep_ms(10); + elapsed += 10; + } + pid_t residual[1024]; + size_t residual_count = collect_descendants(getpid(), residual, 1024); + if (residual_count > 0) { + printf("type=escalation phase=kill residual=%zu via=pidfd_snapshot " + "numeric_pgid_signal=no\n", + residual_count); + fflush(stdout); + } + for (int attempts = 0; attempts < 200; attempts++) { + (void)reap_available(); + if (collect_descendants(getpid(), residual, 1024) == 0) { + break; + } + (void)signal_owned_descendants(getpid(), owned_pgid, SIGKILL, &saw_escape, + &saw_ambiguous); + sleep_ms(10); + } + residual_count = collect_descendants(getpid(), residual, 1024); + (void)close(main_pidfd); + if (residual_count != 0 || saw_escape || saw_ambiguous) { + char residual_labels[96] = ""; + if (residual_count != 0) { + (void)strcat(residual_labels, "owned_residual"); + } + if (saw_escape) { + (void)strcat(residual_labels, residual_labels[0] == '\0' ? "escaped_group" : ",escaped_group"); + } + if (saw_ambiguous) { + (void)strcat(residual_labels, + residual_labels[0] == '\0' ? "ambiguous_identity" : ",ambiguous_identity"); + } + printf("type=drain_diagnostic residual=%zu escapedGroup=%s ambiguousIdentity=%s " + "numericPidSignal=false numericProcessGroupSignal=false\n", + residual_count, saw_escape ? "true" : "false", saw_ambiguous ? "true" : "false"); + printf("type=unclassified_residual protocolVersion=1 kind=process_drain_outcome_v1 outcome=unclassified " + "purpose=%s resetGeneration=%lu deploymentGeneration=%s " + "processAnchorGeneration=%s classificationId=anchor-%s-%ld residuals=[%s] " + "reason=unclassified_identity containerReplacementRequired=true\n", + purpose, reset_generation, deployment_generation, process_anchor_generation, + process_anchor_generation, (long)getpid(), residual_labels); + fflush(stdout); + return residual_count == 0 ? 0 : 2; + } + printf("type=drain_diagnostic residual=0 pidfd=true subreaper=true numericPidSignal=false " + "numericProcessGroupSignal=false\n"); + printf("type=drained protocolVersion=1 kind=process_drain_outcome_v1 outcome=drained purpose=%s " + "resetGeneration=%lu deploymentGeneration=%s processAnchorGeneration=%s " + "classificationId=anchor-%s-%ld residuals=[]\n", + purpose, reset_generation, deployment_generation, process_anchor_generation, + process_anchor_generation, (long)getpid()); + fflush(stdout); + return 0; +} diff --git a/scripts/hosted-web/phase-0/host-primitives/run-native-probes.py b/scripts/hosted-web/phase-0/host-primitives/run-native-probes.py new file mode 100644 index 0000000000..4dc6ec1307 --- /dev/null +++ b/scripts/hosted-web/phase-0/host-primitives/run-native-probes.py @@ -0,0 +1,1116 @@ +#!/usr/bin/env python3 +"""Marker-owned Linux feasibility runner for Phase 0 lane W4. + +The runner never discovers or signals arbitrary processes. Every PID, mount and path it touches is +created beneath its private temporary marker directory during this invocation. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import resource +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +SOURCE_ROOT = Path(__file__).resolve().parent +CC = os.environ.get("CC", "/usr/bin/cc") + + +class ProbeFailure(RuntimeError): + pass + + +def check(condition: bool, message: str) -> None: + if not condition: + raise ProbeFailure(message) + + +def process_identity(pid: int) -> tuple[int, int] | None: + try: + record = Path(f"/proc/{pid}/stat").read_text() + except FileNotFoundError: + return None + fields = record[record.rfind(")") + 2 :].split() + if len(fields) < 20: + raise ProbeFailure(f"incomplete /proc identity for owned PID {pid}") + return int(fields[2]), int(fields[19]) + + +class OwnedResources: + def __init__(self) -> None: + self.processes: list[subprocess.Popen[str]] = [] + self.identities: dict[tuple[int, int], str] = {} + self.process_groups: set[int] = set() + self.mounts: set[Path] = set() + + def track_process(self, process: subprocess.Popen[str], label: str) -> None: + self.processes.append(process) + self.track_pid(process.pid, label) + + def track_pid(self, pid: int, label: str) -> None: + identity = process_identity(pid) + if identity is None: + return + process_group, start_time = identity + self.identities[(pid, start_time)] = label + self.process_groups.add(process_group) + + def track_mount(self, path: Path) -> None: + self.mounts.add(path) + + def untrack_mount(self, path: Path) -> None: + self.mounts.discard(path) + + def identity_is_live(self, pid: int, start_time: int) -> bool: + identity = process_identity(pid) + return identity is not None and identity[1] == start_time + + def cleanup(self, marker: Path, residual_timeout: float = 3.0) -> dict[str, object]: + for process in reversed(self.processes): + terminate_owned(process) + for mount in sorted(self.mounts, reverse=True): + if mount_is_active(mount): + run_owned( + self, + ["/usr/bin/umount", str(mount)], + "cleanup-umount", + check=True, + capture_output=True, + ) + self.mounts.clear() + deadline = time.monotonic() + residual_timeout + residuals = list(self.identities) + while residuals and time.monotonic() < deadline: + residuals = [ + identity + for identity in residuals + if self.identity_is_live(identity[0], identity[1]) + ] + if residuals: + time.sleep(0.02) + if residuals: + details = ", ".join( + f"{pid}:{self.identities[(pid, start_time)]}" for pid, start_time in residuals + ) + raise ProbeFailure(f"owned process identities remained after cleanup: {details}") + shutil.rmtree(marker) + check(not marker.exists(), "marker directory remained after verified removal") + return { + "performedBeforeEmission": True, + "markerRemoved": True, + "ownedProcessIdentitiesTracked": len(self.identities), + "ownedProcessGroupsTracked": len(self.process_groups), + "ownedResidualProcesses": 0, + "ownedResidualMounts": 0, + } + + +def mount_is_active(path: Path) -> bool: + escaped = str(path).replace(" ", "\\040") + for line in Path("/proc/self/mountinfo").read_text().splitlines(): + fields = line.split() + if len(fields) > 4 and fields[4] == escaped: + return True + return False + + +def duplicate_high_descriptor(fd: int, offset: int = 0) -> int: + soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE) + candidate = 4096 + offset + check(soft_limit == resource.RLIM_INFINITY or candidate < soft_limit, "high-FD fixture unavailable") + os.dup2(fd, candidate, inheritable=True) + return candidate + + +def run_owned( + owned: OwnedResources, command: list[str], label: str, **options: object +) -> subprocess.CompletedProcess[str]: + check_result = bool(options.pop("check", False)) + capture_output = bool(options.pop("capture_output", False)) + timeout = options.pop("timeout", None) + input_value = options.pop("input", None) + if capture_output: + options["stdout"] = subprocess.PIPE + options["stderr"] = subprocess.PIPE + process = subprocess.Popen(command, **options) # type: ignore[arg-type] + owned.track_process(process, label) + stdout, stderr = process.communicate(input=input_value, timeout=timeout) + result = subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + if check_result and result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, command, output=result.stdout, stderr=result.stderr + ) + return result + + +def compile_spikes(build: Path, owned: OwnedResources) -> dict[str, Path]: + sources = { + "instance_lock": SOURCE_ROOT / "instance-lock" / "instance_lock_spike.c", + "workspace_guard": SOURCE_ROOT / "workspace-guard" / "workspace_guard_spike.c", + "process_anchor": SOURCE_ROOT / "process-anchor" / "process_anchor_spike.c", + } + binaries: dict[str, Path] = {} + for name, source in sources.items(): + output = build / name + command = [ + CC, + "-std=c17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-D_FORTIFY_SOURCE=2", + "-fstack-protector-strong", + str(source), + "-o", + str(output), + ] + compiler_environment = dict(os.environ) + compiler_environment["PATH"] = "/usr/local/bin:/usr/bin:/bin" + run_owned( + owned, + command, + f"compiler:{name}", + check=True, + text=True, + capture_output=True, + env=compiler_environment, + ) + binaries[name] = output + return binaries + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def native_artifact_feasibility(binaries: dict[str, Path]) -> dict[str, object]: + records = [] + definitions = [ + ( + "agent-teams-instance-lock", + SOURCE_ROOT / "instance-lock" / "instance_lock_spike.c", + ROOT / "docs/research/hosted-web/phase-0/host-primitives/instance-lock.protocol.json", + binaries["instance_lock"], + ), + ( + "agent-teams-process-anchor", + SOURCE_ROOT / "process-anchor" / "process_anchor_spike.c", + ROOT / "docs/research/hosted-web/phase-0/host-primitives/process-anchor.protocol.json", + binaries["process_anchor"], + ), + ( + "agent-teams-workspace-guard", + SOURCE_ROOT / "workspace-guard" / "workspace_guard_spike.c", + ROOT / "docs/research/hosted-web/phase-0/host-primitives/workspace-guard.protocol.json", + binaries["workspace_guard"], + ), + ] + for artifact_id, source, protocol, binary in definitions: + records.append( + { + "artifactId": artifact_id, + "sourceSha256": sha256_file(source), + "protocolSha256": sha256_file(protocol), + "currentHostUnstrippedBinarySha256": sha256_file(binary), + } + ) + return { + "status": "passed_current_host_target_unverified", + "buildRecipeId": "w4-native-c17-v1", + "artifacts": records, + } + + +def terminate_owned(process: subprocess.Popen[str], timeout: float = 3.0) -> None: + if process.poll() is not None: + return + before = process_identity(process.pid) + check(before is not None, f"live owned child {process.pid} had no process identity") + pidfd = os.pidfd_open(process.pid) + try: + after = process_identity(process.pid) + check(after == before, f"owned child {process.pid} changed identity before pidfd signal") + signal.pidfd_send_signal(pidfd, signal.SIGTERM) + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + signal.pidfd_send_signal(pidfd, signal.SIGKILL) + process.wait(timeout=timeout) + finally: + os.close(pidfd) + + +def run_cleanup_probes() -> dict[str, object]: + positive_marker = Path(tempfile.mkdtemp(prefix="atg-phase0-w4-cleanup-positive-", dir="/tmp")) + positive = OwnedResources() + positive_process = subprocess.Popen(["/usr/bin/sleep", "10"], text=True) + positive.track_process(positive_process, "cleanup-positive-live-process") + positive_result = positive.cleanup(positive_marker) + check(positive_process.poll() is not None, "positive cleanup did not terminate its live child") + + residual_marker = Path(tempfile.mkdtemp(prefix="atg-phase0-w4-cleanup-residual-", dir="/tmp")) + residual = OwnedResources() + residual_process = subprocess.Popen(["/usr/bin/sleep", "10"], text=True) + residual.track_process(residual_process, "cleanup-negative-live-residual") + residual.processes.clear() + residual_rejected = False + try: + residual.cleanup(residual_marker, residual_timeout=0.05) + except ProbeFailure as error: + residual_rejected = "owned process identities remained" in str(error) + finally: + terminate_owned(residual_process) + if residual_marker.exists(): + shutil.rmtree(residual_marker) + check(residual_rejected, "cleanup accepted an intentionally live owned residual") + + absent_marker = Path(tempfile.mkdtemp(prefix="atg-phase0-w4-cleanup-absent-", dir="/tmp")) + shutil.rmtree(absent_marker) + removal_error_rejected = False + try: + OwnedResources().cleanup(absent_marker, residual_timeout=0.05) + except FileNotFoundError: + removal_error_rejected = True + check(removal_error_rejected, "cleanup ignored a marker removal error") + return { + "actualOwnedResourcesCleanupExecutions": 3, + "liveOwnedProcessTerminated": True, + "negativeResidualsObserved": 1, + "negativeResidualProcessRejected": residual_rejected, + "negativeMarkerRemovalRejected": removal_error_rejected, + "positiveCleanup": positive_result, + } + + +def wait_pid_gone(pid: int, timeout: float = 3.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not Path(f"/proc/{pid}").exists(): + return + time.sleep(0.02) + raise ProbeFailure(f"owned PID {pid} did not exit") + + +def instance_command(binary: Path, deployment: Path, effect: Path, owner: str) -> list[str]: + anchor = deployment / "instance.lock" + identity = anchor.stat() + return [ + str(binary), + str(deployment), + anchor.name, + str(identity.st_dev), + str(identity.st_ino), + str(effect), + owner, + ] + + +def start_instance( + binary: Path, + deployment: Path, + effect: Path, + owner: str, + owned: OwnedResources, + env: dict[str, str] | None = None, +) -> tuple[subprocess.Popen[str], int]: + process = subprocess.Popen( + instance_command(binary, deployment, effect, owner), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + owned.track_process(process, f"instance-launcher:{owner}") + check(process.stdout is not None, "instance stdout unavailable") + line = process.stdout.readline().strip() + check(line.startswith("ready "), f"instance did not become ready: {line}") + match = re.search(r"controller=(\d+)", line) + check(match is not None, "controller PID missing from ready record") + controller_pid = int(match.group(1)) + owned.track_pid(controller_pid, f"instance-controller:{owner}") + return process, controller_pid + + +def run_instance_lock(binary: Path, marker: Path, owned: OwnedResources) -> dict[str, object]: + deployment = marker / "lease" / "deployment" + deployment.mkdir(parents=True, mode=0o700) + os.chmod(deployment, 0o700) + anchor = deployment / "instance.lock" + anchor.write_bytes(b"") + os.chmod(anchor, 0o600) + original_identity = anchor.stat() + + owner_effect = marker / "lease" / "owner.effect" + owner, controller_pid = start_instance(binary, deployment, owner_effect, "owner-a", owned) + try: + check(owner_effect.read_text() == "owner-a\n", "owner effect was not committed once") + contender_effect = marker / "lease" / "contender.effect" + contender = run_owned( + owned, + instance_command(binary, deployment, contender_effect, "contender"), + "instance-contender", + text=True, + capture_output=True, + ) + check(contender.returncode == 73, "concurrent contender did not return lease_busy") + check(not contender_effect.exists(), "busy contender reached effect code") + + owner.send_signal(signal.SIGSTOP) + paused = run_owned( + owned, + instance_command(binary, deployment, contender_effect, "paused-contender"), + "instance-paused-contender", + text=True, + capture_output=True, + ) + check(paused.returncode == 73, "paused owner was incorrectly stealable") + check(not contender_effect.exists(), "paused-owner contender reached effect code") + owner.send_signal(signal.SIGCONT) + + replaced = deployment / "instance.lock.original" + anchor.rename(replaced) + anchor.write_bytes(b"replacement") + os.chmod(anchor, 0o600) + replacement = run_owned( + owned, + [ + str(binary), + str(deployment), + "instance.lock", + str(original_identity.st_dev), + str(original_identity.st_ino), + str(contender_effect), + "replacement-contender", + ], + "instance-replacement-contender", + text=True, + capture_output=True, + ) + check(replacement.returncode == 74, "replacement anchor identity was accepted") + check(not contender_effect.exists(), "replacement contender reached effect code") + anchor.unlink() + replaced.rename(anchor) + + owner.kill() + owner.wait(timeout=3) + wait_pid_gone(controller_pid) + handoff_effect = marker / "lease" / "handoff.effect" + handoff, _ = start_instance(binary, deployment, handoff_effect, "owner-b", owned) + terminate_owned(handoff) + check(handoff_effect.read_text() == "owner-b\n", "clean post-failure handoff failed") + finally: + if owner.poll() is None: + owner.send_signal(signal.SIGCONT) + terminate_owned(owner) + + duplicate_env = dict(os.environ) + duplicate_env["ATG_CLOSE_LAUNCHER_LOCK_AFTER_READY"] = "1" + duplicate_effect = marker / "lease" / "duplicate.effect" + duplicate, duplicate_controller = start_instance( + binary, deployment, duplicate_effect, "duplicate-owner", owned, duplicate_env + ) + try: + check(duplicate.stdout is not None, "duplicate stdout unavailable") + closed_line = duplicate.stdout.readline().strip() + check(closed_line == "launcher_lock_closed", "launcher duplicate did not close") + contender = run_owned( + owned, + instance_command(binary, deployment, marker / "lease" / "duplicate-contender.effect", "x"), + "instance-duplicate-contender", + text=True, + capture_output=True, + ) + check(contender.returncode == 73, "controller duplicate did not retain the lease") + finally: + terminate_owned(duplicate) + wait_pid_gone(duplicate_controller) + + return { + "status": "passed_current_host", + "mutualExclusion": True, + "pausedOwnerNotStealable": True, + "killedOwnerCleanHandoff": True, + "duplicateCloseOrdering": True, + "pathReplacementRejectedByIdentity": True, + "outsideEffects": 0, + } + + +def guard_identity( + binary: Path, root: Path, generation: str, owned: OwnedResources +) -> tuple[int, int, int]: + identity = root.stat() + result = run_owned( + owned, + [ + str(binary), + str(root), + str(identity.st_dev), + str(identity.st_ino), + "0", + generation, + "probe", + "unused", + ], + "workspace-identity-probe", + text=True, + capture_output=True, + ) + check(result.returncode == 0, f"workspace syscall probe failed: {result.stderr}") + match = re.search(r"dev=(\d+) ino=(\d+) mnt=(\d+)", result.stdout) + check(match is not None, "workspace probe identity was incomplete") + return tuple(int(value) for value in match.groups()) + + +def guard_command( + binary: Path, + root: Path, + identity: tuple[int, int, int], + generation: str, + operation: list[str], +) -> list[str]: + return [ + str(binary), + str(root), + *(str(value) for value in identity), + generation, + *operation, + ] + + +def run_workspace_guard(binary: Path, marker: Path, owned: OwnedResources) -> dict[str, object]: + workspace_parent = marker / "guard" + root = workspace_parent / "workspace" + outside = workspace_parent / "outside-marker-owned-negative-control" + root.mkdir(parents=True) + outside.mkdir() + (root / ".atg-mount-generation").write_text("generation-1\n") + (root / "safe").mkdir() + identity = guard_identity(binary, root, "generation-1", owned) + + created = run_owned( + owned, + guard_command(binary, root, identity, "generation-1", ["create", "safe/created", "safe"]), + "workspace-create", + text=True, + capture_output=True, + ) + check(created.returncode == 0 and (root / "safe" / "created").read_text() == "safe", "guarded create failed") + + (root / "parent-link").symlink_to(outside, target_is_directory=True) + parent_escape = run_owned( + owned, + guard_command(binary, root, identity, "generation-1", ["create", "parent-link/escape", "bad"]), + "workspace-parent-symlink-negative", + text=True, + capture_output=True, + ) + check(parent_escape.returncode == 77, "parent symlink escape was not rejected") + check(not (outside / "escape").exists(), "parent symlink created an outside effect") + + outside_target = outside / "final-target" + outside_target.write_text("unchanged") + (root / "safe" / "final-link").symlink_to(outside_target) + final_escape = run_owned( + owned, + guard_command(binary, root, identity, "generation-1", ["create", "safe/final-link", "bad"]), + "workspace-final-symlink-negative", + text=True, + capture_output=True, + ) + check(final_escape.returncode == 77, "final symlink mutation was not rejected") + check(outside_target.read_text() == "unchanged", "final symlink changed outside marker") + + stale = run_owned( + owned, + guard_command(binary, root, identity, "stale-generation", ["create", "safe/stale", "bad"]), + "workspace-stale-generation-negative", + text=True, + capture_output=True, + ) + check(stale.returncode == 77 and not (root / "safe" / "stale").exists(), "stale generation produced an effect") + + race_command = guard_command( + binary, root, identity, "generation-1", ["create", "safe/race", "descriptor-bound"] + ) + race_env = dict(os.environ) + race_env["ATG_GUARD_PAUSE_MS"] = "350" + race = subprocess.Popen(race_command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=race_env) + owned.track_process(race, "workspace-root-rename-race") + time.sleep(0.12) + old_root = workspace_parent / "workspace-opened-inode" + root.rename(old_root) + root.symlink_to(outside, target_is_directory=True) + race_stdout, race_stderr = race.communicate(timeout=3) + check(race.returncode == 0, f"descriptor-bound rename race failed: {race_stdout} {race_stderr}") + check((old_root / "safe" / "race").read_text() == "descriptor-bound", "effect did not stay on opened inode") + check(not (outside / "safe" / "race").exists(), "root rename race produced outside effect") + root.unlink() + old_root.rename(root) + + raw_script = """ +const fs = require('node:fs'); +const root = process.argv[1]; +fs.realpathSync(root); +process.stdout.write('checked\\n'); +setTimeout(() => fs.writeFileSync(root + '/raw-node-effect', 'escaped'), 250); +""" + raw = subprocess.Popen( + ["/usr/local/bin/node", "-e", raw_script, str(root)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + owned.track_process(raw, "raw-node-toctou-negative") + check(raw.stdout is not None and raw.stdout.readline().strip() == "checked", "raw Node control did not reach check point") + raw_old = workspace_parent / "workspace-raw-old" + root.rename(raw_old) + root.symlink_to(outside, target_is_directory=True) + raw.communicate(timeout=3) + check((outside / "raw-node-effect").read_text() == "escaped", "raw Node negative control did not reproduce TOCTOU") + (outside / "raw-node-effect").unlink() + root.unlink() + raw_old.rename(root) + + descriptor_canary = workspace_parent / "instance.lock" + descriptor_canary.write_text("descriptor canary") + canary_fd = os.open(descriptor_canary, os.O_RDONLY) + control_read_fd, control_write_fd = os.pipe() + high_canary_fd = duplicate_high_descriptor(canary_fd) + high_control_fd = duplicate_high_descriptor(control_read_fd, 1) + forbidden_targets = { + os.readlink(f"/proc/self/fd/{high_canary_fd}"), + os.readlink(f"/proc/self/fd/{high_control_fd}"), + } + exec_fixture_code = r""" +import os, sys +role = sys.argv[1] +targets = [] +for name in os.listdir('/proc/self/fd'): + try: targets.append(os.readlink('/proc/self/fd/' + name)) + except OSError: pass +open(role + '.fds', 'w').write('\n'.join(targets)) +open(role + '.marker', 'w').write(os.getcwd()) +""" + try: + for role in ("provider", "git-helper", "helper"): + child = run_owned( + owned, + guard_command( + binary, + root, + identity, + "generation-1", + ["exec", "safe", "/usr/bin/python3", "-c", exec_fixture_code, role], + ), + f"workspace-exec:{role}", + text=True, + capture_output=True, + pass_fds=(high_canary_fd, high_control_fd), + ) + child_marker = root / "safe" / f"{role}.marker" + fd_marker = root / "safe" / f"{role}.fds" + check(child.returncode == 0 and child_marker.exists(), f"guarded {role} failed: {child.stderr}") + check(Path(child_marker.read_text()).samefile(root / "safe"), f"{role} cwd escaped descriptor root") + observed_targets = set(fd_marker.read_text().splitlines()) + check(forbidden_targets.isdisjoint(observed_targets), f"lease/control descriptor leaked to {role}") + finally: + os.close(canary_fd) + os.close(control_read_fd) + os.close(control_write_fd) + os.close(high_canary_fd) + os.close(high_control_fd) + + repo = root / "repo" + run_owned(owned, ["/usr/bin/git", "init", "-q", str(repo)], "git-init", check=True) + run_owned( + owned, + ["/usr/bin/git", "-C", str(repo), "config", "user.email", "fixture@example.invalid"], + "git-config-email", + check=True, + ) + run_owned( + owned, + ["/usr/bin/git", "-C", str(repo), "config", "user.name", "W4 Fixture"], + "git-config-name", + check=True, + ) + (repo / "tracked").write_text("fixture") + run_owned(owned, ["/usr/bin/git", "-C", str(repo), "add", "tracked"], "git-add", check=True) + run_owned( + owned, + ["/usr/bin/git", "-C", str(repo), "commit", "-qm", "fixture"], + "git-commit", + check=True, + ) + hook_marker = outside / "git-hook.effect" + hooks = repo / ".git" / "hooks" + hook = hooks / "post-checkout" + hook.write_text(f"#!/bin/sh\n/usr/bin/touch '{hook_marker}'\n") + hook.chmod(0o700) + run_owned( + owned, + ["/usr/bin/git", "-C", str(repo), "worktree", "add", "-q", "../raw-wt", "-b", "raw"], + "raw-git-hook-negative", + check=True, + ) + check(hook_marker.exists(), "raw Git negative control did not run post-checkout hook") + hook_marker.unlink() + guarded_git = run_owned( + owned, + guard_command( + binary, + root, + identity, + "generation-1", + [ + "exec", + "repo", + "/usr/bin/git", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "credential.helper=", + "worktree", + "add", + "-q", + "../guarded-wt", + "-b", + "guarded", + ], + ), + "guarded-git-worktree", + text=True, + capture_output=True, + ) + check(guarded_git.returncode == 0, f"guarded Git fixture failed: {guarded_git.stderr}") + check(not hook_marker.exists(), "guarded Git ran repository hook") + + bind_mount_result = "unavailable" + bind_target = root / "bound" + bind_target.mkdir() + bind_source = outside / "bind-source" + bind_source.mkdir() + mount = run_owned( + owned, + ["/usr/bin/mount", "--bind", str(bind_source), str(bind_target)], + "workspace-bind-mount", + text=True, + capture_output=True, + ) + if mount.returncode == 0: + owned.track_mount(bind_target) + try: + bound = run_owned( + owned, + guard_command(binary, root, identity, "generation-1", ["create", "bound/escape", "bad"]), + "workspace-bind-mount-negative", + text=True, + capture_output=True, + ) + check(bound.returncode == 77 and not (bind_source / "escape").exists(), "bind submount crossing was not rejected") + bind_mount_result = "rejected_zero_effect" + finally: + run_owned( + owned, + ["/usr/bin/umount", str(bind_target)], + "workspace-bind-umount", + check=True, + capture_output=True, + ) + owned.untrack_mount(bind_target) + + return { + "status": "passed_current_host", + "openat2": True, + "statxMountId": identity[2], + "parentSymlinkOutsideEffects": 0, + "finalSymlinkOutsideEffects": 0, + "rootRenameOutsideEffects": 0, + "staleGenerationOutsideEffects": 0, + "fakeProviderCwdBound": True, + "gitHookDisabled": True, + "rawNodeNegativeControlOutsideEffects": 1, + "bindMount": bind_mount_result, + "execDescriptorLeaks": 0, + "highFdCanariesClosed": True, + } + + +def read_ready(process: subprocess.Popen[str]) -> str: + check(process.stdout is not None, "anchor stdout unavailable") + ready = process.stdout.readline().strip() + check(ready.startswith("type=ready "), f"anchor ready record missing: {ready}") + return ready + + +def run_anchor_case( + binary: Path, marker: Path, mode: str, close_control: bool, owned: OwnedResources +) -> tuple[str, str]: + nonce = f"spawn-nonce-hash-{mode}-{'eof' if close_control else 'stop'}" + fd_marker = marker / "anchor" / f"{mode}.fds" + inherited_canary = marker / "anchor" / "instance.lock" + inherited_canary.touch(exist_ok=True) + canary_fd = os.open(inherited_canary, os.O_RDONLY) + extra_control_read, extra_control_write = os.pipe() + high_canary_fd = duplicate_high_descriptor(canary_fd) + high_control_fd = duplicate_high_descriptor(extra_control_read, 1) + try: + process = subprocess.Popen( + [ + str(binary), + nonce, + mode, + str(fd_marker), + "120", + "host_reset", + "7", + "deployment-generation-fixture", + f"process-anchor-generation-{mode}", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + pass_fds=(high_canary_fd, high_control_fd), + ) + owned.track_process(process, f"process-anchor:{mode}") + finally: + os.close(canary_fd) + os.close(extra_control_read) + os.close(extra_control_write) + os.close(high_canary_fd) + os.close(high_control_fd) + ready = read_ready(process) + parse_ready_dto(ready, nonce, f"process-anchor-generation-{mode}") + expected_role = "escaped" if mode == "escape" else "grandchild" if mode == "double" else "main" + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + if fd_marker.exists() and f"role={expected_role}" in fd_marker.read_text(): + break + time.sleep(0.02) + check( + fd_marker.exists() and f"role={expected_role}" in fd_marker.read_text(), + f"{mode} provider did not record descriptor evidence", + ) + for provider_pid in re.findall(r"pid=(\d+)", fd_marker.read_text()): + owned.track_pid(int(provider_pid), f"process-anchor-provider:{mode}") + unrelated = subprocess.Popen(["/usr/bin/sleep", "10"], start_new_session=True) + owned.track_process(unrelated, f"unrelated-canary:{mode}") + reuse_pressure = [ + subprocess.Popen(["/usr/bin/sleep", "0.2"], start_new_session=True) + for _ in range(24) + ] + for index, canary in enumerate(reuse_pressure): + owned.track_process(canary, f"pid-pgid-reuse-pressure:{mode}:{index}") + try: + check(process.stdin is not None, "anchor control unavailable") + if close_control: + process.stdin.close() + process.stdin = None + else: + process.stdin.write("STOP\n") + process.stdin.flush() + process.stdin.close() + process.stdin = None + stdout, stderr = process.communicate(timeout=5) + check(process.returncode == 0, f"anchor {mode} failed: {stdout} {stderr}") + check(unrelated.poll() is None, "anchor signaled an unrelated marker-owned process") + for canary in reuse_pressure: + canary.wait(timeout=2) + check(canary.returncode == 0, "PID/PGID reuse-pressure canary was signaled") + finally: + terminate_owned(unrelated) + if process.poll() is None: + process.kill() + process.wait(timeout=3) + fd_evidence = fd_marker.read_text() + check("pipe:[" not in fd_evidence and "instance.lock" not in fd_evidence, f"lease/control/status descriptor leaked to {mode} provider") + return ready, stdout + + +def parse_ready_dto( + ready: str, spawn_nonce_hash: str, process_anchor_generation: str +) -> dict[str, object]: + fields = dict(part.split("=", 1) for part in ready.split() if "=" in part) + expected_fields = { + "type", + "protocolVersion", + "spawnNonceHash", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "anchorIdentity", + "mainPidfdReady", + "ownedProcessGroupReady", + } + check(set(fields) == expected_fields, "ready DTO fields differ from the protocol") + dto: dict[str, object] = { + "protocolVersion": int(fields.get("protocolVersion", "-1")), + "spawnNonceHash": fields.get("spawnNonceHash"), + "purpose": fields.get("purpose"), + "resetGeneration": int(fields.get("resetGeneration", "-1")), + "deploymentGeneration": fields.get("deploymentGeneration"), + "processAnchorGeneration": fields.get("processAnchorGeneration"), + "anchorIdentity": fields.get("anchorIdentity"), + "mainPidfdReady": fields.get("mainPidfdReady") == "true", + "ownedProcessGroupReady": fields.get("ownedProcessGroupReady") == "true", + } + check(dto["protocolVersion"] == 1, "ready DTO protocol mismatch") + check(dto["spawnNonceHash"] == spawn_nonce_hash, "ready DTO nonce hash mismatch") + check(dto["purpose"] == "host_reset", "ready DTO purpose mismatch") + check(dto["resetGeneration"] == 7, "ready DTO reset generation mismatch") + check( + dto["deploymentGeneration"] == "deployment-generation-fixture", + "ready DTO deployment generation mismatch", + ) + check( + dto["processAnchorGeneration"] == process_anchor_generation, + "ready DTO process-anchor generation mismatch", + ) + check(bool(dto["anchorIdentity"]), "ready DTO anchor identity missing") + check(dto["mainPidfdReady"] is True, "ready DTO pidfd readiness missing") + check(dto["ownedProcessGroupReady"] is True, "ready DTO process-group readiness missing") + return dto + + +def parse_drain_dto(output: str, record_type: str) -> dict[str, object]: + line = next( + (candidate for candidate in output.splitlines() if candidate.startswith(f"type={record_type} ")), + None, + ) + check(line is not None, f"missing {record_type} drain record") + fields = dict(part.split("=", 1) for part in line.split() if "=" in part) + expected_fields = { + "type", + "protocolVersion", + "kind", + "outcome", + "purpose", + "resetGeneration", + "deploymentGeneration", + "processAnchorGeneration", + "classificationId", + "residuals", + } + if record_type == "unclassified_residual": + expected_fields.update({"reason", "containerReplacementRequired"}) + check(set(fields) == expected_fields, f"{record_type} DTO fields differ from the protocol") + residual_text = fields.get("residuals", "") + check(residual_text.startswith("[") and residual_text.endswith("]"), "malformed residual list") + residuals = [item for item in residual_text[1:-1].split(",") if item] + dto: dict[str, object] = { + "protocolVersion": int(fields.get("protocolVersion", "-1")), + "kind": fields.get("kind"), + "outcome": fields.get("outcome"), + "purpose": fields.get("purpose"), + "resetGeneration": int(fields.get("resetGeneration", "-1")), + "deploymentGeneration": fields.get("deploymentGeneration"), + "processAnchorGeneration": fields.get("processAnchorGeneration"), + "classificationId": fields.get("classificationId"), + "residuals": residuals, + } + if record_type == "unclassified_residual": + dto["reason"] = fields.get("reason") + dto["containerReplacementRequired"] = ( + fields.get("containerReplacementRequired") == "true" + ) + check(dto["protocolVersion"] == 1, "drain DTO protocol mismatch") + check(dto["kind"] == "process_drain_outcome_v1", "drain DTO kind mismatch") + check(dto["purpose"] == "host_reset", "drain DTO purpose mismatch") + check(dto["resetGeneration"] == 7, "drain DTO reset generation mismatch") + check( + dto["deploymentGeneration"] == "deployment-generation-fixture", + "drain DTO deployment generation mismatch", + ) + check(bool(dto["classificationId"]), "drain DTO classification identity missing") + return dto + + +def run_process_anchor(binary: Path, marker: Path, owned: OwnedResources) -> dict[str, object]: + (marker / "anchor").mkdir() + normal_ready, normal = run_anchor_case(binary, marker, "normal", False, owned) + ignore_ready, ignored = run_anchor_case(binary, marker, "ignore", False, owned) + double_ready, double = run_anchor_case(binary, marker, "double", True, owned) + escape_ready, escaped = run_anchor_case(binary, marker, "escape", True, owned) + ready_dto = parse_ready_dto( + normal_ready, "spawn-nonce-hash-normal-stop", "process-anchor-generation-normal" + ) + del ignore_ready, double_ready, escape_ready + check( + "type=drained" in normal and "residual=0" in normal, + f"normal stop lacked drained outcome: {normal}", + ) + check( + "phase=kill" in ignored and "type=drained" in ignored and "residual=0" in ignored, + "TERM-ignore case lacked KILL/drained evidence", + ) + check( + "reason=controller_eof" in double + and "type=drained" in double + and "residual=0" in double, + "double-fork EOF case did not drain", + ) + check("role=grandchild" in (marker / "anchor" / "double.fds").read_text(), "double-fork fixture did not execute") + check( + "type=unclassified_residual" in escaped + and "kind=process_drain_outcome_v1" in escaped + and "outcome=unclassified" in escaped + and "numericPidSignal=false" in escaped + and "numericProcessGroupSignal=false" in escaped + and "containerReplacementRequired=true" in escaped, + "escaped process did not produce typed fail-closed outcome", + ) + drained_dto = parse_drain_dto(normal, "drained") + unclassified_dto = parse_drain_dto(escaped, "unclassified_residual") + check(drained_dto["outcome"] == "drained" and drained_dto["residuals"] == [], "drained DTO malformed") + check( + unclassified_dto["outcome"] == "unclassified" + and unclassified_dto["residuals"] == ["escaped_group"], + "unclassified DTO malformed", + ) + return { + "status": "passed_current_host", + "nonceReadyBound": True, + "pidfd": True, + "subreaperDoubleFork": True, + "typedStopDrained": True, + "controllerEofDrained": True, + "termKillEscalation": True, + "typedUnclassified": True, + "drainDtoKind": "process_drain_outcome_v1", + "drainDtoGenerationBound": True, + "drainDtoSamples": { + "ready": ready_dto, + "drained": drained_dto, + "unclassified": unclassified_dto, + }, + "numericPidSignals": 0, + "numericPgidSignals": 0, + "pidfdDescendantSignals": True, + "ownedProcessGroupSignals": False, + "rapidPidPgidReuseNegativeSchedule": True, + "pidReuseDeterministicallyForced": False, + "unrelatedProcessesSignaled": 0, + "controlDescriptorLeaks": 0, + "highFdCanariesClosed": True, + } + + +def host_envelope(owned: OwnedResources) -> dict[str, object]: + status = Path("/proc/self/status").read_text() + seccomp = re.search(r"^Seccomp:\s+(\d+)$", status, re.MULTILINE) + no_new_privs = re.search(r"^NoNewPrivs:\s+(\d+)$", status, re.MULTILINE) + filesystem = run_owned( + owned, + ["/usr/bin/findmnt", "-T", "/tmp", "-o", "FSTYPE", "-n"], + "host-envelope-findmnt", + check=True, + text=True, + capture_output=True, + ).stdout.strip() + docker = run_owned( + owned, + ["/usr/bin/docker", "version", "--format", "{{.Server.Version}}"], + "host-envelope-docker", + text=True, + capture_output=True, + ) + return { + "osRelease": Path("/etc/os-release").read_text().splitlines()[0], + "kernel": os.uname().release, + "architecture": os.uname().machine, + "uid": os.getuid(), + "filesystem": filesystem, + "seccompMode": int(seccomp.group(1)) if seccomp else None, + "noNewPrivs": int(no_new_privs.group(1)) if no_new_privs else None, + "node": run_owned( + owned, + ["/usr/local/bin/node", "--version"], + "host-envelope-node", + check=True, + text=True, + capture_output=True, + ).stdout.strip(), + "dockerDaemonReachable": docker.returncode == 0, + "finalShapeContainer": False, + } + + +def main() -> int: + marker = Path(tempfile.mkdtemp(prefix="atg-phase0-w4-marker-", dir="/tmp")) + os.chmod(marker, stat.S_IRWXU) + owned = OwnedResources() + result: dict[str, object] | None = None + probe_error: Exception | None = None + try: + build = marker / "build" + build.mkdir() + binaries = compile_spikes(build, owned) + result = { + "$schema": "./probe-results.schema.json", + "schemaVersion": 1, + "recordId": "P0.W4.CURRENT_HOST_PROBE_RESULTS", + "status": "characterized", + "probeCommand": "/usr/bin/python3 scripts/hosted-web/phase-0/host-primitives/run-native-probes.py", + "markerOwnership": "all fixture paths, mounts, process identities, and process groups are invocation-owned and tracked before use", + "host": host_envelope(owned), + "nativeArtifactFeasibility": native_artifact_feasibility(binaries), + "cleanupProbes": run_cleanup_probes(), + "instanceLease": run_instance_lock(binaries["instance_lock"], marker, owned), + "workspaceGuard": run_workspace_guard(binaries["workspace_guard"], marker, owned), + "processAnchor": run_process_anchor(binaries["process_anchor"], marker, owned), + } + except (ProbeFailure, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + probe_error = error + try: + cleanup = owned.cleanup(marker) + except (ProbeFailure, subprocess.CalledProcessError, OSError) as cleanup_error: + print( + json.dumps( + { + "schemaVersion": 1, + "error": str(probe_error) if probe_error else None, + "cleanupError": str(cleanup_error), + "marker": str(marker), + }, + indent=2, + ), + file=sys.stderr, + ) + return 1 + if probe_error is not None or result is None: + print( + json.dumps( + {"schemaVersion": 1, "error": str(probe_error), "cleanup": cleanup}, indent=2 + ), + file=sys.stderr, + ) + return 1 + result["cleanup"] = cleanup + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hosted-web/phase-0/host-primitives/scan-evidence.d.mts b/scripts/hosted-web/phase-0/host-primitives/scan-evidence.d.mts new file mode 100644 index 0000000000..6fb0dd5dd0 --- /dev/null +++ b/scripts/hosted-web/phase-0/host-primitives/scan-evidence.d.mts @@ -0,0 +1,7 @@ +export interface EvidenceScanResult { + failures: string[]; + ok: boolean; +} + +export function verifyW4Handoff(root?: string): Promise; +export function scanEvidence(directory: string): Promise; diff --git a/scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs b/scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs new file mode 100644 index 0000000000..ea613669e4 --- /dev/null +++ b/scripts/hosted-web/phase-0/host-primitives/scan-evidence.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + controllerArtifactContractPath, + loadControllerArtifactContract, + validateControllerArtifactProjection, +} from '../w4-w6-contract/controller-artifact-contract.mjs'; +import { validateW4DrainEvidenceProjection } from '../w4-w6-contract/drain-evidence-envelope.mjs'; + +const requiredMarkdown = new Map([ + ['target-host-envelope.md', 'P0.W4.TARGET_HOST_ENVELOPE'], + ['instance-lease-spike.md', 'P0.W4.INSTANCE_LEASE_SPIKE'], + ['workspace-guard-spike.md', 'P0.W4.WORKSPACE_GUARD_SPIKE'], + ['process-anchor-spike.md', 'P0.W4.PROCESS_ANCHOR_SPIKE'], + ['native-artifact-proposal.md', 'P0.W4.NATIVE_ARTIFACT_PROPOSAL'], +]); + +const requiredJson = new Map([ + ['current-host-probe-results.json', 'P0.W4.CURRENT_HOST_PROBE_RESULTS'], + ['estimate-input.json', 'P0.W4.ESTIMATE'], + ['native-protocol.schema.json', 'P0.W4.NATIVE_PROTOCOL_SCHEMA.V1'], + ['probe-results.schema.json', 'P0.W4.PROBE_RESULTS_SCHEMA.V1'], + ['instance-lock.protocol.json', 'agent-teams-instance-lock'], + ['workspace-guard.protocol.json', 'agent-teams-workspace-guard'], + ['process-anchor.protocol.json', 'agent-teams-process-anchor'], + ['native-artifact-contract.json', 'P0.W4.NATIVE_ARTIFACT_PROJECTION.V1'], +]); + +const sha256 = (text) => createHash('sha256').update(text).digest('hex'); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + +export async function verifyW4Handoff(root = repositoryRoot) { + const handoff = JSON.parse(await readFile(path.join(root, '.codex-handoff/phase-00-w4.json'))); + const expectedTaskId = 'agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v7'; + const expectedBase = 'f7d98790eb868714e536f77bd796072ea706911a'; + const expectedWorktree = + '/var/data/agent-teams-hosted-web-refactor/worktrees/phase-00-remediation-w4-w6-v7'; + const expectedPatchHash = '183069adf05cb254c846cbd37a7c39ac930b2cb5dd6994f6b5b96dc5d4304d79'; + const failures = []; + if (handoff.schemaVersion !== 2) failures.push('schemaVersion'); + if (handoff.taskId !== expectedTaskId || handoff.jobId !== expectedTaskId) { + failures.push('taskId/jobId'); + } + if (handoff.baseSha !== expectedBase || handoff.canonicalBaseSha !== expectedBase) { + failures.push('baseSha/canonicalBaseSha'); + } + if (handoff.sourceWorktree !== expectedWorktree) failures.push('sourceWorktree'); + if ( + handoff.remediationProvenance?.approvedV6ReviewSha256 !== + '5c4c0ed2792df575dfd74c3a197ff00af6ed2abcc001dd815c39e70a87f7ed7a' || + handoff.remediationProvenance?.supersedingReviewRecordSha256 !== + 'b68ad9f064e622edc64e96194bd00bea42b5c31467a0503b58b8e826911eaa8b' || + handoff.remediationProvenance?.rejectedIntegrationArchiveSha256 !== + '1b49a4f0745b5e67fe8d56c97174ae55af4d9c5edb006112440b467bc9cea1dc' || + handoff.remediationProvenance?.v6PreservedPatchSha256 !== + '479f78a3a89a7e132899ede39a7606c59ce9b201ebe04d97df281e3a4825f690' + ) { + failures.push('remediationProvenance'); + } + if ( + handoff.salvage?.sourceTaskId !== + 'agent-teams-hosted-web-refactor-phase-00-remediation-w4-w6-v5' || + handoff.salvage?.preservedPatchSha256 !== expectedPatchHash || + handoff.salvage?.independentlyVerified !== true + ) { + failures.push('salvage'); + } + if ( + handoff.packetRevision !== 'phase-00-r3' || + handoff.status !== 'characterized' || + handoff.scope?.disposition !== 'current_host_characterization_and_read_only_projection_only' || + Object.values(handoff.scope ?? {}).some( + (value) => typeof value === 'boolean' && value !== false + ) + ) { + failures.push('r3Disposition'); + } + return { failures, ok: failures.length === 0 }; +} + +export async function scanEvidence(directory) { + const failures = []; + const files = new Set(await readdir(directory)); + const records = new Map(); + for (const [file, evidenceId] of requiredMarkdown) { + if (!files.has(file)) { + failures.push(`missing ${file}`); + continue; + } + const text = await readFile(path.join(directory, file), 'utf8'); + if (!text.includes(evidenceId)) failures.push(`${file} missing ${evidenceId}`); + if (!text.includes('Status: `characterized`')) + failures.push(`${file} overstates topology status`); + } + for (const [file, identity] of requiredJson) { + if (!files.has(file)) { + failures.push(`missing ${file}`); + continue; + } + let record; + try { + record = JSON.parse(await readFile(path.join(directory, file), 'utf8')); + } catch { + failures.push(`${file} is not valid JSON`); + continue; + } + records.set(file, record); + if (!file.endsWith('.schema.json') && record.schemaVersion !== 1) { + failures.push(`${file} schemaVersion must be 1`); + } + if ( + ![ + record.$id, + record.recordId, + record.evidenceId, + record.artifactId, + record.contractId, + ].includes(identity) + ) { + failures.push(`${file} missing identity ${identity}`); + } + } + for (const file of ['current-host-probe-results.json', 'estimate-input.json']) { + const record = records.get(file); + if (record && record.status !== 'characterized') { + failures.push(`${file} must remain characterized`); + } + } + + const processProtocol = records.get('process-anchor.protocol.json'); + if (processProtocol) { + const projection = validateW4DrainEvidenceProjection( + records.get('native-protocol.schema.json'), + processProtocol, + repositoryRoot + ); + if ( + processProtocol.request?.numericPidTargetsAllowed !== false || + processProtocol.signalSemantics?.numericPidSignalsAllowed !== false || + processProtocol.signalSemantics?.numericProcessGroupSignalsAllowed !== false + ) { + failures.push('process-anchor.protocol.json must forbid numeric PID and PGID signaling'); + } + if (!projection.ok) { + failures.push( + `process-anchor.protocol.json differs from controller drain authority: ${projection.violations.join(',')}` + ); + } + } + + const results = records.get('current-host-probe-results.json'); + if ( + results && + (results.cleanup?.performedBeforeEmission !== true || + results.cleanup?.markerRemoved !== true || + results.cleanup?.ownedResidualProcesses !== 0 || + results.cleanup?.ownedResidualMounts !== 0 || + results.cleanupProbes?.actualOwnedResourcesCleanupExecutions !== 3 || + results.cleanupProbes?.negativeResidualsObserved !== 1 || + results.cleanupProbes?.negativeResidualProcessRejected !== true || + results.cleanupProbes?.negativeMarkerRemovalRejected !== true || + results.processAnchor?.numericPgidSignals !== 0 || + results.processAnchor?.pidfdDescendantSignals !== true || + results.processAnchor?.drainDtoSamples?.ready?.purpose !== 'host_reset' || + results.processAnchor?.drainDtoSamples?.ready?.resetGeneration !== 7 || + results.processAnchor?.drainDtoSamples?.ready?.deploymentGeneration !== + 'deployment-generation-fixture' || + results.processAnchor?.drainDtoSamples?.ready?.processAnchorGeneration !== + 'process-anchor-generation-normal' || + results.processAnchor?.drainDtoSamples?.drained?.kind !== 'process_drain_outcome_v1' || + results.processAnchor?.drainDtoSamples?.drained?.outcome !== 'drained' || + results.processAnchor?.drainDtoSamples?.drained?.residuals?.length !== 0 || + results.processAnchor?.drainDtoSamples?.unclassified?.outcome !== 'unclassified' || + results.processAnchor?.drainDtoSamples?.unclassified?.residuals?.length === 0) + ) { + failures.push('current-host-probe-results.json lacks measured cleanup or pidfd-only signaling'); + } + + const estimate = records.get('estimate-input.json'); + if ( + estimate && + (estimate.canonicalBucketId !== 'EST-LIFECYCLE-RUNTIME' || + estimate.w2Reconciliation?.sharedCanonicalBucket !== 'EST-LIFECYCLE-RUNTIME' || + !estimate.w2Reconciliation?.overlapRule?.includes('never add')) + ) { + failures.push('estimate-input.json is not reconciled to the W2 canonical bucket'); + } + + const artifactContract = records.get('native-artifact-contract.json'); + if (artifactContract) { + const controllerContract = loadControllerArtifactContract(); + const projection = validateControllerArtifactProjection(controllerContract, artifactContract); + const artifactById = new Map( + artifactContract.artifacts?.map((artifact) => [artifact.artifactId, artifact]) + ); + for (const [artifactId, protocolFile] of [ + ['agent-teams-instance-lock', 'instance-lock.protocol.json'], + ['agent-teams-process-anchor', 'process-anchor.protocol.json'], + ['agent-teams-workspace-guard', 'workspace-guard.protocol.json'], + ]) { + const artifact = artifactById.get(artifactId); + const protocolText = files.has(protocolFile) + ? await readFile(path.join(directory, protocolFile), 'utf8') + : null; + let sourceText = null; + if (artifact?.spikeSourcePath) { + try { + sourceText = await readFile( + path.resolve(repositoryRoot, artifact.spikeSourcePath), + 'utf8' + ); + } catch { + sourceText = null; + } + } + if ( + !artifact || + artifact.finalImagePath !== `/app/bin/${artifactId}` || + !protocolText || + artifact.protocolSha256 !== sha256(protocolText) || + !sourceText || + artifact.spikeSourceSha256 !== sha256(sourceText) + ) { + failures.push(`native artifact contract mismatch for ${artifactId}`); + } + } + if ( + artifactContract.controllerContractPath !== controllerArtifactContractPath || + !projection.ok + ) { + failures.push( + `native artifact projection differs from controller authority: ${projection.violations.join(',')}` + ); + } + if ( + artifactContract.status !== 'read_only_projection_current_host_characterized' || + Object.values(artifactContract.capabilityClaims ?? {}).some((value) => value !== false) + ) { + failures.push('native artifact projection overstates W4 r3 capability admission'); + } + } + const allText = await Promise.all( + [...files] + .filter((file) => /\.(?:md|json)$/.test(file)) + .map((file) => readFile(path.join(directory, file), 'utf8')) + ); + if ( + allText.some((text) => /\/Users\/|~\/\.claude|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY/.test(text)) + ) { + failures.push('evidence contains a real-project/home path or private-key marker'); + } + return { failures, ok: failures.length === 0 }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const directory = path.resolve( + process.argv[2] ?? 'docs/research/hosted-web/phase-0/host-primitives' + ); + const result = await scanEvidence(directory); + const handoffResult = await verifyW4Handoff(); + result.failures.push(...handoffResult.failures.map((failure) => `W4 handoff ${failure}`)); + result.ok = result.failures.length === 0; + if (!result.ok) { + process.stderr.write(`${result.failures.join('\n')}\n`); + process.exitCode = 1; + } else { + process.stdout.write('host-primitives evidence scan passed\n'); + } +} diff --git a/scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c b/scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c new file mode 100644 index 0000000000..74d289b969 --- /dev/null +++ b/scripts/hosted-web/phase-0/host-primitives/workspace-guard/workspace_guard_spike.c @@ -0,0 +1,267 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { EXIT_REJECTED = 77, EXIT_UNSUPPORTED = 78, EXIT_USAGE = 64 }; + +static void fail_rejected(const char *code) { + fprintf(stderr, "%s errno=%d\n", code, errno); + exit(EXIT_REJECTED); +} + +static uint64_t parse_u64(const char *value) { + char *end = NULL; + errno = 0; + unsigned long long parsed = strtoull(value, &end, 10); + if (errno != 0 || end == value || *end != '\0') { + fprintf(stderr, "invalid integer: %s\n", value); + exit(EXIT_USAGE); + } + return (uint64_t)parsed; +} + +static int guarded_openat2(int directory_fd, const char *path, int flags, mode_t mode, + uint64_t resolve) { + struct open_how how = { + .flags = (uint64_t)flags, + .mode = (uint64_t)mode, + .resolve = resolve, + }; + int result = (int)syscall(SYS_openat2, directory_fd, path, &how, sizeof(how)); + if (result == -1 && (errno == ENOSYS || errno == EPERM)) { + fprintf(stderr, "openat2_unsupported errno=%d\n", errno); + exit(EXIT_UNSUPPORTED); + } + return result; +} + +static struct statx statx_fd(int fd) { + struct statx result; + memset(&result, 0, sizeof(result)); + if (statx(fd, "", AT_EMPTY_PATH | AT_STATX_SYNC_AS_STAT, + STATX_TYPE | STATX_MODE | STATX_INO | STATX_MNT_ID, &result) == -1) { + if (errno == ENOSYS || errno == EPERM) { + fprintf(stderr, "statx_unsupported errno=%d\n", errno); + exit(EXIT_UNSUPPORTED); + } + fail_rejected("root_statx_failed"); + } + if ((result.stx_mask & (STATX_TYPE | STATX_INO | STATX_MNT_ID)) != + (STATX_TYPE | STATX_INO | STATX_MNT_ID)) { + errno = ENOTSUP; + fail_rejected("root_statx_incomplete"); + } + return result; +} + +static void verify_generation(int root_fd, const char *expected_generation) { + int generation_fd = guarded_openat2( + root_fd, ".atg-mount-generation", O_RDONLY | O_CLOEXEC | O_NOFOLLOW, 0, + RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV); + if (generation_fd == -1) { + fail_rejected("generation_open_failed"); + } + char buffer[129] = {0}; + ssize_t count = read(generation_fd, buffer, sizeof(buffer) - 1); + (void)close(generation_fd); + if (count < 0) { + fail_rejected("generation_read_failed"); + } + buffer[strcspn(buffer, "\r\n")] = '\0'; + if (strcmp(buffer, expected_generation) != 0) { + errno = ESTALE; + fail_rejected("stale_generation"); + } +} + +static void sleep_ms(unsigned long milliseconds) { + struct timespec delay = { + .tv_sec = (time_t)(milliseconds / 1000), + .tv_nsec = (long)((milliseconds % 1000) * 1000000UL), + }; + while (nanosleep(&delay, &delay) == -1 && errno == EINTR) { + } +} + +static void split_relative_path(char *path, char **parent, char **base) { + if (path[0] == '/' || strcmp(path, ".") == 0 || strcmp(path, "..") == 0) { + errno = EINVAL; + fail_rejected("invalid_relative_path"); + } + char *slash = strrchr(path, '/'); + if (slash == NULL) { + *parent = "."; + *base = path; + return; + } + *slash = '\0'; + *parent = path; + *base = slash + 1; + if ((*base)[0] == '\0' || strstr(path, "..") != NULL) { + errno = EINVAL; + fail_rejected("invalid_relative_path"); + } +} + +static void create_file(int root_fd, const char *relative_path, const char *content) { + char *path_copy = strdup(relative_path); + if (path_copy == NULL) { + fail_rejected("allocation_failed"); + } + char *parent = NULL; + char *base = NULL; + split_relative_path(path_copy, &parent, &base); + int parent_fd = guarded_openat2( + root_fd, parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC, 0, + RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV); + if (parent_fd == -1) { + free(path_copy); + fail_rejected("parent_resolution_failed"); + } + int output_fd = openat(parent_fd, base, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0600); + if (output_fd == -1) { + (void)close(parent_fd); + free(path_copy); + fail_rejected("create_failed"); + } + size_t length = strlen(content); + if (write(output_fd, content, length) != (ssize_t)length || fsync(output_fd) == -1 || + fsync(parent_fd) == -1) { + fail_rejected("durable_create_failed"); + } + (void)close(output_fd); + (void)close(parent_fd); + free(path_copy); +} + +static int close_unintended_descriptors(void) { +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, ~0U, 0U) == 0) { + return 0; + } + if (errno != ENOSYS && errno != EPERM) { + return -1; + } +#endif + int directory_fd = open("/proc/self/fd", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (directory_fd == -1) { + return -1; + } + DIR *directory = fdopendir(directory_fd); + if (directory == NULL) { + (void)close(directory_fd); + return -1; + } + int descriptors[4096]; + size_t count = 0; + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) { + char *end = NULL; + long value = strtol(entry->d_name, &end, 10); + if (end != entry->d_name && *end == '\0' && value >= 3 && value != directory_fd) { + if (count >= sizeof(descriptors) / sizeof(descriptors[0])) { + (void)closedir(directory); + errno = EOVERFLOW; + return -1; + } + descriptors[count++] = (int)value; + } + } + (void)closedir(directory); + for (size_t index = 0; index < count; index++) { + (void)close(descriptors[index]); + } + return 0; +} + +int main(int argc, char **argv) { + if (argc < 8) { + fprintf(stderr, + "usage: %s ROOT EXPECTED_DEV EXPECTED_INO EXPECTED_MNT GENERATION OP ...\n", + argv[0]); + return EXIT_USAGE; + } + + uint64_t expected_device = parse_u64(argv[2]); + uint64_t expected_inode = parse_u64(argv[3]); + uint64_t expected_mount_id = parse_u64(argv[4]); + int root_fd = open(argv[1], O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (root_fd == -1) { + fail_rejected("root_open_failed"); + } + struct stat root_stat; + if (fstat(root_fd, &root_stat) == -1 || !S_ISDIR(root_stat.st_mode)) { + fail_rejected("root_fstat_failed"); + } + struct statx root_statx = statx_fd(root_fd); + if ((uint64_t)root_stat.st_dev != expected_device || + (uint64_t)root_stat.st_ino != expected_inode || root_statx.stx_ino != expected_inode || + (expected_mount_id != 0 && root_statx.stx_mnt_id != expected_mount_id)) { + errno = ESTALE; + fail_rejected("root_identity_drift"); + } + verify_generation(root_fd, argv[5]); + + const char *pause_value = getenv("ATG_GUARD_PAUSE_MS"); + if (pause_value != NULL) { + sleep_ms((unsigned long)parse_u64(pause_value)); + } + + if (strcmp(argv[6], "probe") == 0) { + int probe_fd = guarded_openat2( + root_fd, ".", O_PATH | O_DIRECTORY | O_CLOEXEC, 0, + RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV); + if (probe_fd == -1) { + fail_rejected("openat2_probe_failed"); + } + (void)close(probe_fd); + printf("probe_ok dev=%llu ino=%llu mnt=%llu\n", (unsigned long long)expected_device, + (unsigned long long)expected_inode, (unsigned long long)root_statx.stx_mnt_id); + return 0; + } + if (strcmp(argv[6], "create") == 0 && argc == 9) { + create_file(root_fd, argv[7], argv[8]); + printf("create_ok generation=%s\n", argv[5]); + return 0; + } + if (strcmp(argv[6], "exec") == 0 && argc >= 9) { + int cwd_fd = guarded_openat2( + root_fd, argv[7], O_PATH | O_DIRECTORY | O_CLOEXEC, 0, + RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV); + if (cwd_fd == -1 || fchdir(cwd_fd) == -1) { + fail_rejected("cwd_entry_failed"); + } + (void)close(cwd_fd); + (void)close(root_fd); + if (close_unintended_descriptors() == -1) { + fail_rejected("descriptor_close_failed"); + } + char **exec_argv = &argv[8]; + char *clean_environment[] = { + "PATH=/usr/bin:/bin", + "HOME=/nonexistent", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_TERMINAL_PROMPT=0", + NULL, + }; + execve(exec_argv[0], exec_argv, clean_environment); + fail_rejected("exec_failed"); + } + fprintf(stderr, "unsupported operation\n"); + return EXIT_USAGE; +} diff --git a/scripts/hosted-web/phase-0/parity-renderer/parity-catalog-seeds.ts b/scripts/hosted-web/phase-0/parity-renderer/parity-catalog-seeds.ts new file mode 100644 index 0000000000..ff96150d39 --- /dev/null +++ b/scripts/hosted-web/phase-0/parity-renderer/parity-catalog-seeds.ts @@ -0,0 +1,635 @@ +import { + type AbsenceRow, + type ApiSurface, + REVIEWED_CONTROL_FILES, + type SemanticRow, +} from './parity-scan-contracts'; + +export const apiGroups: Array<{ + surface: ApiSurface; + owner: string; + namespace: string; + disposition: SemanticRow['disposition']; + securityClass: string; + target: string; + members: string[]; +}> = [ + { + surface: 'TeamsAPI', + owner: 'team-lifecycle', + namespace: 'team.lifecycle', + disposition: 'decomposed', + securityClass: 'team-resource', + target: 'WP-7-TEAM-LIFECYCLE', + members: [ + 'list', + 'getData', + 'deleteTeam', + 'restoreTeam', + 'permanentlyDeleteTeam', + 'deleteDraft', + 'prepareProvisioning', + 'createTeam', + 'getProvisioningStatus', + 'getLaunchFailureDiagnostics', + 'cancelProvisioning', + 'stop', + 'createConfig', + 'launchTeam', + 'updateConfig', + 'addMember', + 'replaceMembers', + 'removeMember', + 'restoreMember', + 'updateMemberRole', + 'getMemberSpawnStatuses', + 'restartMember', + 'skipMemberForLaunch', + ], + }, + { + surface: 'TeamsAPI', + owner: 'workspace-registry', + namespace: 'workspace.registry', + disposition: 'decomposed', + securityClass: 'workspace-authorization', + target: 'WP-3-WORKSPACE-REGISTRY', + members: [ + 'getWorktreeGitStatus', + 'initializeGitRepository', + 'createInitialGitCommit', + 'getProjectBranch', + 'setProjectBranchTracking', + 'onProjectBranchChange', + ], + }, + { + surface: 'TeamsAPI', + owner: 'team-task-board', + namespace: 'team.task', + disposition: 'decomposed', + securityClass: 'team-resource', + target: 'WP-8-TASK-BOARD', + members: [ + 'getTaskChangePresence', + 'setChangePresenceTracking', + 'createTask', + 'getTask', + 'requestReview', + 'updateKanban', + 'updateKanbanColumnOrder', + 'updateTaskStatus', + 'updateTaskOwner', + 'updateTaskFields', + 'startTask', + 'startTaskByUser', + 'getAllTasks', + 'addTaskComment', + 'setTaskClarification', + 'softDeleteTask', + 'restoreTask', + 'getDeletedTasks', + 'addTaskRelationship', + 'removeTaskRelationship', + ], + }, + { + surface: 'TeamsAPI', + owner: 'team-messaging', + namespace: 'team.message', + disposition: 'decomposed', + securityClass: 'team-resource', + target: 'WP-8-MESSAGING', + members: [ + 'getSavedRequest', + 'sendMessage', + 'getOpenCodeRuntimeDeliveryStatus', + 'getMessagesPage', + 'processSend', + ], + }, + { + surface: 'TeamsAPI', + owner: 'team-runtime-control', + namespace: 'team.runtime', + disposition: 'decomposed', + securityClass: 'runtime-operator', + target: 'WP-5-RUNTIME-CONTROL', + members: [ + 'setToolActivityTracking', + 'setTaskLogStreamTracking', + 'getClaudeLogs', + 'getMemberActivityMeta', + 'processAlive', + 'aliveList', + 'getMemberLogs', + 'getLogsForTask', + 'getTaskActivity', + 'getTaskActivityDetail', + 'getTaskLogStreamSummary', + 'getTaskLogStream', + 'getTaskExactLogSummaries', + 'getTaskExactLogDetail', + 'getMemberStats', + 'killProcess', + 'getLeadActivity', + 'getLeadContext', + 'getTeamAgentRuntime', + 'retryFailedOpenCodeSecondaryLanes', + ], + }, + { + surface: 'TeamsAPI', + owner: 'agent-attachments', + namespace: 'agent.attachment', + disposition: 'decomposed', + securityClass: 'bounded-content', + target: 'WP-9-ATTACHMENTS', + members: ['getAttachments', 'saveTaskAttachment', 'getTaskAttachment', 'deleteTaskAttachment'], + }, + { + surface: 'TeamsAPI', + owner: 'team-approvals', + namespace: 'team.approval', + disposition: 'decomposed', + securityClass: 'approval-policy', + target: 'WP-9-APPROVALS', + members: ['validateCliArgs'], + }, + { + surface: 'TeamsAPI', + owner: 'team-console', + namespace: 'team.console', + disposition: 'direct', + securityClass: 'renderer-local', + target: 'WP-7-TEAM-CONSOLE', + members: ['showMessageNotification', 'onTeamChange', 'onProvisioningProgress'], + }, + { + surface: 'ReviewAPI', + owner: 'team-review', + namespace: 'team.review', + disposition: 'decomposed', + securityClass: 'review-change-set', + target: 'WP-9-REVIEW', + members: [ + 'getAgentChanges', + 'getTaskChanges', + 'getTeamTaskChangeSummaries', + 'invalidateTaskChangeSummaries', + 'getChangeStats', + 'getFileContent', + 'applyDecisions', + 'executeMutation', + 'retryMutationRecovery', + 'restoreHistory', + 'checkConflict', + 'rejectHunks', + 'rejectFile', + 'previewReject', + 'saveEditedFile', + 'deleteEditedFile', + 'restoreRejectedRename', + 'reapplyRejectedRename', + 'watchFiles', + 'unwatchFiles', + 'onExternalFileChange', + 'loadDecisions', + 'saveDecisions', + 'clearDecisions', + 'loadDecisionConflictCandidates', + 'resolveDecisionConflictCandidate', + 'loadDraftHistory', + 'saveDraftHistoryEntry', + 'clearDraftHistory', + 'loadDraftHistoryConflictCandidates', + 'resolveDraftHistoryConflictCandidate', + 'replaceDraftHistoryConflictCandidate', + ], + }, + { + surface: 'ReviewAPI', + owner: 'team-review', + namespace: 'team.review', + disposition: 'desktop-only', + securityClass: 'desktop-shell', + target: 'DEFERRED-DESKTOP-ONLY', + members: ['onCmdN', 'getGitFileLog'], + }, + { + surface: 'CrossTeamAPI', + owner: 'team-messaging', + namespace: 'team.message.cross-team', + disposition: 'decomposed', + securityClass: 'cross-team-resource', + target: 'WP-8-MESSAGING', + members: ['send', 'listTargets', 'getOutbox'], + }, +]; + +// These are reviewed semantic assignments. Source hashes are refreshable evidence, never identity. +export const actionSeeds: Array< + Omit & { + refs: Array<[keyof typeof REVIEWED_CONTROL_FILES, string]>; + } +> = [ + { + id: 'team.console.select', + owner: 'team-console', + disposition: 'direct', + securityClass: 'team-read', + target: 'WP-7-TEAM-CONSOLE', + evidence: ['keyboard/click equivalence', 'selection generation'], + refs: [ + ['list', '7ae3e9868fb0d4a2'], + ['list', '3d61968c3b27192f'], + ['create', 'a41ca0eb05d0e44d'], + ], + }, + { + id: 'team.lifecycle.launch', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'team-operator', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['idempotent accepted run', 'runtime outcome'], + refs: [ + ['list', '6316b98384b52106'], + ['detail', '57315f4848354f04'], + ['detail', 'b0f60eaa09e35acc'], + ], + }, + { + id: 'team.lifecycle.stop', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'team-operator', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['lifecycle generation', 'runtime stop outcome'], + refs: [ + ['list', '9e5a0346eee1dc22'], + ['detail', 'f24d2defb60f97d3'], + ], + }, + { + id: 'team.lifecycle.copy-draft', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'team-mutation', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['new stable team identity'], + refs: [['list', 'caca7b02e0fe8e0c']], + }, + { + id: 'team.lifecycle.delete', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'destructive-team-operator', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['tombstone generation', 'partial cleanup outcome'], + refs: [ + ['list', '31aff054fdea44fc'], + ['detail', '7d3361a6fdf74099'], + ['detail', 'b8f7b322927174be'], + ], + }, + { + id: 'team.lifecycle.restore', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'team-operator', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['tombstone generation check'], + refs: [['list', '4aed9e873872f4c9']], + }, + { + id: 'team.lifecycle.permanently-delete', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'destructive-team-operator', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['explicit irreversible confirmation'], + refs: [['list', 'f94e171aad27f966']], + }, + { + id: 'team.lifecycle.list.refresh', + owner: 'team-lifecycle', + disposition: 'direct', + securityClass: 'team-read', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['bounded freshness generation'], + refs: [['list', 'e51dbba28734b4f3']], + }, + { + id: 'team.lifecycle.create-draft', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'team-mutation', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['draft identity before provisioning'], + refs: [ + ['list', '51e06c68cf47389f'], + ['create', '4fb257c290cbb747'], + ], + }, + { + id: 'team.lifecycle.delete-draft', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'destructive-team-operator', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['draft-only deletion'], + refs: [['detail', '19f7011fed6875b2']], + }, + { + id: 'team.lifecycle.edit-config', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'team-mutation', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['config revision conflict'], + refs: [['detail', 'd0df23591a37fda1']], + }, + { + id: 'team.lifecycle.add-member', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'team-mutation', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['roster generation'], + refs: [['detail', '2822e8d54b308717']], + }, + { + id: 'team.lifecycle.remove-member', + owner: 'team-lifecycle', + disposition: 'decomposed', + securityClass: 'destructive-team-operator', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['roster generation and runtime outcome'], + refs: [['detail', 'e39a599d1bf8f481']], + }, + { + id: 'team.task.create', + owner: 'team-task-board', + disposition: 'decomposed', + securityClass: 'team-mutation', + target: 'WP-8-TASK-BOARD', + evidence: ['task revision and delivery outcome'], + refs: [ + ['detail', 'f6be75adbc83db1b'], + ['detail', 'f2ce29562f9b62f6'], + ], + }, + { + id: 'team.task.request-changes', + owner: 'team-task-board', + disposition: 'decomposed', + securityClass: 'team-mutation', + target: 'WP-8-TASK-BOARD', + evidence: ['task revision and review reference'], + refs: [['detail', '9e2ce9a330923369']], + }, + { + id: 'team.console.local-view', + owner: 'team-console', + disposition: 'direct', + securityClass: 'renderer-local', + target: 'WP-7-TEAM-CONSOLE', + evidence: ['no server authority'], + refs: [ + ['list', 'c40241bfedec76c5'], + ['list', 'bfb04384019ff8a2'], + ['list', '4fd19e07a723a058'], + ['list', '7253b728045c5be7'], + ['list', '495711757cb25a3d'], + ['detail', '93356b94c86b9a31'], + ['detail', '60524c5c20d1e755'], + ['detail', '082021ccdae5c5a7'], + ['detail', '5391ea0fc7047eb7'], + ['create', '8ae85785caeac217'], + ['create', 'bbd620637e74d07a'], + ], + }, + { + id: 'team.console.desktop-editor', + owner: 'team-console', + disposition: 'desktop-only', + securityClass: 'desktop-shell', + target: 'ABSENT-BEFORE-HOSTED-MOUNT', + evidence: ['hosted chunk import exclusion'], + refs: [['detail', '4d51206ae5cc9e46']], + }, + { + id: 'team.lifecycle.draft.configure', + owner: 'team-lifecycle', + disposition: 'direct', + securityClass: 'renderer-local-draft', + target: 'WP-7-TEAM-LIFECYCLE', + evidence: ['server revalidates accepted draft'], + refs: [ + ['create', 'd5fe5d760046156d'], + ['create', '6d8853fe55f75739'], + ['create', 'fed55a4d724f8753'], + ['create', '6eb6d60bd8a2b4f9'], + ['create', '460a6a2c6167d111'], + ['create', 'b595fc47dbad28f4'], + ['create', 'cdb8b3b29e5bd5dc'], + ['create', 'f57186dc2c15e899'], + ['create', '2def27cf5dcb15c3'], + ['create', '1a7ffd59f07eefa3'], + ['create', '4f195bffd0ffd15b'], + ['create', 'f021d7f085c99bfa'], + ['create', 'd07a1c16e844146d'], + ['create', 'f4d5123b4759ab0a'], + ['create', 'efe1b616c01dac38'], + ['create', '9c184401f5913a9b'], + ], + }, + { + id: 'provider.management.credentials.edit', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'secret-local-input', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['credential never enters renderer evidence'], + refs: [ + ['providers', '475f2078f99114db'], + ['providers', '3816eb21807a0310'], + ['providers', '9bfec2d3b0f3b701'], + ['providers', 'c6ce0f9d97c1db56'], + ['providers', '8e6244151887f85f'], + ['providers', '0866a69dd465cea2'], + ['providers', '32a0b93f9100520a'], + ['providers', '90b5386bbc48f0ef'], + ['providers', 'e61e6699ac805f3d'], + ['providers', '2e96c75568c654f7'], + ], + }, + { + id: 'provider.management.connect', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'provider-credential', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['provider availability/auth result'], + refs: [ + ['providers', '69d499064c5acd68'], + ['providers', '6bc3452b02286516'], + ['providers', '0762178282badb9e'], + ['providers', '29c4895caee155e7'], + ['providers', 'e05b1f72353d30ba'], + ['providers', 'd168672245c63043'], + ['providers', '3ad8822f863003a5'], + ['providers', '1495ee29bd7c3e90'], + ['providers', 'ac9b52278c788f42'], + ], + }, + { + id: 'provider.management.connect.cancel', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'renderer-local', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['no team lifecycle effect'], + refs: [['providers', 'ad293317df8ded48']], + }, + { + id: 'provider.management.refresh', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'provider-read', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['fresh availability projection'], + refs: [ + ['providers', 'fe93b4de9d313bdb'], + ['providers', '4db1c0b6fb7b9ffb'], + ['providers', '08bc5dc7c332dd08'], + ], + }, + { + id: 'provider.management.forget', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'destructive-provider-operator', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['credential deletion outcome'], + refs: [ + ['providers', '022abe36d0ffbc03'], + ['providers', '2a7c06563fa1b014'], + ], + }, + { + id: 'provider.management.provider.select', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'renderer-local', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['keyboard/click equivalence'], + refs: [ + ['providers', 'ac11e711a5ad0984'], + ['providers', '70a068044f5b9d78'], + ['providers', '884c2ef7b9d904ef'], + ], + }, + { + id: 'provider.management.model.select', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'renderer-local', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['keyboard/click equivalence'], + refs: [], + }, + { + id: 'provider.management.model.test', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'provider-execution-probe', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['bounded test result'], + refs: [['providers', 'a64b1f2f3678ae66']], + }, + { + id: 'provider.management.model.set-default', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'provider-setting', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['setting scope'], + refs: [], + }, + { + id: 'provider.management.model.page', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'provider-read', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['bounded cursor and pagination'], + refs: [['providers', 'c5c2275db5ceba29']], + }, + { + id: 'provider.management.directory.search', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'provider-read', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['bounded query and pagination'], + refs: [ + ['providers', '4e64864b4b16cd70'], + ['providers', '6b69132fab97a1a0'], + ['providers', 'b22672f4d62f3453'], + ], + }, + { + id: 'provider.management.local-view', + owner: 'runtime-provider-management', + disposition: 'direct', + securityClass: 'renderer-local', + target: 'WP-4-PROVIDER-AVAILABILITY', + evidence: ['no team lifecycle effect'], + refs: [ + ['providers', '1cecb00a557a2370'], + ['providers', '1e310b0bae09ff97'], + ['providers', 'ea696b218060f2e5'], + ['providers', 'abb468deaa0c78f4'], + ['providers', '4e45b33a345a189b'], + ['providers', 'f9923ff85d4eee62'], + ], + }, +]; + +export const absenceSeeds: Array< + Omit & { + refs: Array<[keyof typeof REVIEWED_CONTROL_FILES, string]>; + } +> = [ + { + id: 'P0.W1.ABSENCE.dialog-state', + reason: 'Dialog open/cancel state is local and creates no hosted command.', + refs: [ + ['list', '6f151970571293da'], + ['detail', '257597c3ce8bca4e'], + ['detail', 'a30b08f387e731a0'], + ['detail', '00e75ccb184739af'], + ['detail', '85535c3fe2325671'], + ['detail', '613400ca3d7039e8'], + ['create', '089f3839d882cd3f'], + ], + }, + { + id: 'P0.W1.ABSENCE.event-containment', + reason: 'Event containment is not a semantic action.', + refs: [ + ['providers', '49a1bb745fad7f34'], + ['providers', '54f6c7a4459a5ffa'], + ['providers', '60e136772eb5c4bc'], + ['providers', '8b8e8db68dec5b0a'], + ['providers', '66f02e5325663a12'], + ], + }, + { + id: 'P0.W1.ABSENCE.disabled-wrapper', + reason: 'A disabled presentation wrapper without a handler is not an action.', + refs: [['providers', '726e1410bc9bb643']], + }, +]; diff --git a/scripts/hosted-web/phase-0/parity-renderer/parity-scan-contracts.ts b/scripts/hosted-web/phase-0/parity-renderer/parity-scan-contracts.ts new file mode 100644 index 0000000000..b65d7e0080 --- /dev/null +++ b/scripts/hosted-web/phase-0/parity-renderer/parity-scan-contracts.ts @@ -0,0 +1,232 @@ +import { createHash } from 'node:crypto'; + +export const PINNED_BASE_SHA = 'cbe501ad0f1fa0e51a038e832ad35fce4120321b'; +export const PHASE_START_SHA = 'a32f509e6d9bd31ba2135940e336729bf90c3d93'; +export const API_SURFACES = ['TeamsAPI', 'ReviewAPI', 'CrossTeamAPI'] as const; +export const PHASE_START_API_COUNTS = { + TeamsAPI: 86, + ReviewAPI: 20, + CrossTeamAPI: 3, +} as const; +export const CLIENT_SURFACES = { + TeamsAPI: 'teams', + ReviewAPI: 'review', + CrossTeamAPI: 'crossTeam', +} as const; +export const CLIENT_ACCESSORS = { + TeamsAPI: 'getTeamsApi', + ReviewAPI: 'getReviewApi', + CrossTeamAPI: 'getCrossTeamApi', +} as const; +export const REVIEWED_CONTROL_FILES = { + list: 'src/renderer/components/team/TeamListView.tsx', + detail: 'src/renderer/components/team/TeamDetailView.tsx', + create: 'src/renderer/components/team/dialogs/CreateTeamDialog.tsx', + providers: + 'src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx', +} as const; +export const CONTROL_ROOTS = [ + REVIEWED_CONTROL_FILES.list, + REVIEWED_CONTROL_FILES.detail, + 'src/renderer/components/team/ToolApprovalSheet.tsx', + 'src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx', + REVIEWED_CONTROL_FILES.providers, +] as const; +export const CONTROL_SCOPE_PREFIXES = [ + 'src/renderer/components/team/', + 'src/features/change-review/renderer/', + 'src/features/runtime-provider-management/renderer/', +] as const; +// Complete React interaction families plus the Radix value/open callbacks used by mounted controls. +// Keeping whole families together prevents the previous allowlist bug where onBlur, onPaste, +// onContextMenu, and onDragStart silently disappeared while sibling events were scanned. Additional +// on* callbacks on components imported from external packages are discovered from each source file +// rather than maintained as another selective allowlist. +const EVENT_PROPS = new Set([ + 'onAnimationEnd', + 'onAnimationIteration', + 'onAnimationStart', + 'onAbort', + 'onAuxClick', + 'onBeforeInput', + 'onBeforeToggle', + 'onBlur', + 'onCanPlay', + 'onCanPlayThrough', + 'onChange', + 'onCheckedChange', + 'onClick', + 'onCompositionEnd', + 'onCompositionStart', + 'onCompositionUpdate', + 'onContextMenu', + 'onCopy', + 'onCut', + 'onDoubleClick', + 'onDrag', + 'onDragEnd', + 'onDragEnter', + 'onDragExit', + 'onDragLeave', + 'onDragOver', + 'onDragStart', + 'onDrop', + 'onDurationChange', + 'onEmptied', + 'onEncrypted', + 'onEnded', + 'onError', + 'onFocus', + 'onInput', + 'onInvalid', + 'onKeyDown', + 'onKeyPress', + 'onKeyUp', + 'onLoad', + 'onLoadedData', + 'onLoadedMetadata', + 'onLoadStart', + 'onMouseDown', + 'onMouseEnter', + 'onMouseLeave', + 'onMouseMove', + 'onMouseOut', + 'onMouseOver', + 'onMouseUp', + 'onOpenChange', + 'onPaste', + 'onPointerCancel', + 'onPointerDown', + 'onPointerEnter', + 'onPointerLeave', + 'onPointerMove', + 'onPointerOut', + 'onPointerOver', + 'onPointerUp', + 'onGotPointerCapture', + 'onLostPointerCapture', + 'onPause', + 'onPlay', + 'onPlaying', + 'onProgress', + 'onRateChange', + 'onReset', + 'onScroll', + 'onScrollEnd', + 'onSeeked', + 'onSeeking', + 'onSelect', + 'onSubmit', + 'onStalled', + 'onSuspend', + 'onTimeUpdate', + 'onTouchCancel', + 'onTouchEnd', + 'onTouchMove', + 'onTouchStart', + 'onToggle', + 'onTransitionCancel', + 'onTransitionEnd', + 'onTransitionRun', + 'onTransitionStart', + 'onValueChange', + 'onVolumeChange', + 'onWaiting', + 'onWheel', +]); +export const isEventProp = (name: string): boolean => + EVENT_PROPS.has(name) || + (name.endsWith('Capture') && EVENT_PROPS.has(name.slice(0, -'Capture'.length))); +export const INTERNAL_IMPORT_PREFIXES = [ + '.', + '@features/', + '@main/', + '@preload/', + '@renderer/', + '@shared/', +] as const; +export const IMPLICIT_CONTROLS = new Set([ + 'Button', + 'SelectItem', + 'SelectTrigger', + 'TabsTrigger', + 'a', + 'button', +]); + +export const LEGACY_CHILD_API_ACTION_IDS = { + 'team.legacy-control.dialogs.add.member.dialog.handle-submit': 'team.lifecycle.add-member', + 'team.legacy-control.members.member.card.handle-restart-member': 'team.lifecycle.restart-member', + 'team.legacy-control.members.member.card.handle-restore-member': 'team.lifecycle.restore-member', +} as const; + +export const MOUNT_CHAINS = [ + { + root: 'src/renderer/components/team/ToolApprovalSheet.tsx', + edges: [ + { + from: 'src/renderer/App.tsx', + to: 'src/renderer/components/team/ToolApprovalSheet.tsx', + component: 'ToolApprovalSheet', + }, + ], + }, + { + root: 'src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx', + edges: [ + { + from: 'src/renderer/components/layout/TabbedLayout.tsx', + to: 'src/renderer/components/layout/GlobalTaskDetailDialogSlot.tsx', + component: 'GlobalTaskDetailDialogSlot', + }, + { + from: 'src/renderer/components/layout/GlobalTaskDetailDialogSlot.tsx', + to: 'src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx', + component: 'GlobalTaskDetailDialog', + }, + ], + }, +] as const; + +export type ApiSurface = (typeof API_SURFACES)[number]; +export type SourceRef = { file: string; sourceHash: string; siteCount: number }; +export type SemanticRow = { + id: string; + owner: string; + disposition: 'direct' | 'decomposed' | 'desktop-only' | 'deferred'; + securityClass: string; + target: string; + evidence: string[]; + sourceRefs: SourceRef[]; +}; +export type AbsenceRow = { id: string; reason: string; sourceRefs: SourceRef[] }; +export type ControlSite = { + file: string; + sourceHash: string; + element: string; + prop: string; + text: string; + effects: Array<'containment' | 'semantic'>; +}; +export type ChildControlCatalog = { + schemaId: string; + schemaVersion: number; + evidenceId: string; + packetRevision: string; + pinnedBaseSha: string; + phaseStartSha: string; + roots: string[]; + sourceFiles: string[]; + actions: Record; + absences: Record; + mappings: Record; +}; + +export const sha = (text: string, length?: number): string => + createHash('sha256').update(text).digest('hex').slice(0, length); +export const normalized = (text: string): string => text.replace(/\s+/g, ' ').trim(); +export const kebab = (text: string): string => + text + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[^a-zA-Z0-9]+/g, '-') + .toLowerCase(); diff --git a/scripts/hosted-web/phase-0/parity-renderer/parity-source-scanner.ts b/scripts/hosted-web/phase-0/parity-renderer/parity-source-scanner.ts new file mode 100644 index 0000000000..7a0c460467 --- /dev/null +++ b/scripts/hosted-web/phase-0/parity-renderer/parity-source-scanner.ts @@ -0,0 +1,619 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; + +import ts from 'typescript'; + +import { + API_SURFACES, + type AbsenceRow, + type ApiSurface, + CLIENT_ACCESSORS, + CLIENT_SURFACES, + CONTROL_SCOPE_PREFIXES, + type ChildControlCatalog, + type ControlSite, + IMPLICIT_CONTROLS, + INTERNAL_IMPORT_PREFIXES, + isEventProp, + LEGACY_CHILD_API_ACTION_IDS, + MOUNT_CHAINS, + normalized, + REVIEWED_CONTROL_FILES, + type SemanticRow, + sha, + type SourceRef, +} from './parity-scan-contracts'; + +export function scanApiInterfaces( + sourceText: string +): Array<{ surface: ApiSurface; member: string; signature: string; signatureHash: string }> { + const source = ts.createSourceFile( + 'api.ts', + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const printer = ts.createPrinter({ removeComments: true }); + const rows: Array<{ + surface: ApiSurface; + member: string; + signature: string; + signatureHash: string; + }> = []; + for (const statement of source.statements) { + if ( + !ts.isInterfaceDeclaration(statement) || + !API_SURFACES.includes(statement.name.text as ApiSurface) + ) + continue; + const surface = statement.name.text as ApiSurface; + for (const member of statement.members) { + if ((!ts.isPropertySignature(member) && !ts.isMethodSignature(member)) || !member.name) + continue; + const name = member.name.getText(source).replace(/^['"]|['"]$/g, ''); + const signature = normalized(printer.printNode(ts.EmitHint.Unspecified, member, source)); + rows.push({ surface, member: name, signature, signatureHash: `sha256:${sha(signature)}` }); + } + } + return rows; +} + +export function jsxElement(node: ts.JsxAttribute): string { + const parent = node.parent.parent; + return ts.isJsxOpeningElement(parent) || ts.isJsxSelfClosingElement(parent) + ? parent.tagName.getText() + : 'unknown'; +} + +export function scanControls(sourceText: string, file: string): ControlSite[] { + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const rows: ControlSite[] = []; + const externalComponentRoots = new Set(); + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const moduleSpecifier = statement.moduleSpecifier.text; + if (INTERNAL_IMPORT_PREFIXES.some((prefix) => moduleSpecifier.startsWith(prefix))) continue; + const clause = statement.importClause; + if (!clause?.isTypeOnly && clause?.name) externalComponentRoots.add(clause.name.text); + if (!clause?.isTypeOnly && clause?.namedBindings) { + if (ts.isNamespaceImport(clause.namedBindings)) { + externalComponentRoots.add(clause.namedBindings.name.text); + } else { + for (const specifier of clause.namedBindings.elements) { + if (!specifier.isTypeOnly) externalComponentRoots.add(specifier.name.text); + } + } + } + } + const isInteractionProp = (element: string, prop: string): boolean => + isEventProp(prop) || + (/^on[A-Z]/.test(prop) && externalComponentRoots.has(element.split('.')[0])); + const classifyEffects = ( + initializer: ts.JsxAttributeValue | undefined + ): ControlSite['effects'] => { + if (!initializer) return ['semantic']; + let containment = false; + let semantic = false; + const visitEffect = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const expression = node.expression; + const method = + ts.isPropertyAccessExpression(expression) || ts.isPropertyAccessChain(expression) + ? expression.name.text + : undefined; + if ( + method && + ['preventDefault', 'stopImmediatePropagation', 'stopPropagation'].includes(method) + ) { + containment = true; + } else { + semantic = true; + } + } else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + semantic = true; + } else if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + semantic = true; + } + ts.forEachChild(node, visitEffect); + }; + visitEffect(initializer); + // A referenced callback is an opaque semantic effect at the JSX boundary. Inline handlers that + // only contain propagation/default suppression remain deliberate absences. + if (!containment && !semantic) semantic = true; + return [containment ? 'containment' : undefined, semantic ? 'semantic' : undefined].filter( + (effect): effect is 'containment' | 'semantic' => effect !== undefined + ); + }; + const add = ( + element: string, + prop: string, + text: string, + effects: ControlSite['effects'] = ['semantic'] + ): void => { + const clean = normalized(text); + rows.push({ + file, + element, + prop, + text: clean, + sourceHash: sha(`${element}|${prop}|${clean}`, 16), + effects, + }); + }; + const visit = (node: ts.Node): void => { + if (ts.isJsxAttribute(node) && isInteractionProp(jsxElement(node), node.name.getText(source))) { + add( + jsxElement(node), + node.name.getText(source), + node.initializer?.getText(source) ?? '', + classifyEffects(node.initializer) + ); + } + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + IMPLICIT_CONTROLS.has(node.tagName.getText(source)) + ) { + const attributes = node.attributes.properties.filter(ts.isJsxAttribute); + const names = new Set(attributes.map((attribute) => attribute.name.getText(source))); + const element = node.tagName.getText(source); + if (![...names].some((name) => isInteractionProp(element, name)) && !names.has('asChild')) { + add(element, element === 'a' ? 'navigate' : 'implicitAction', node.getText(source)); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return rows; +} + +export function importedModuleSpecifiers(sourceText: string, file: string): string[] { + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ); + const specifiers = new Set(); + const visit = (node: ts.Node): void => { + const followsChangeReviewPublicExport = + file.startsWith('src/features/change-review/renderer/') && ts.isExportDeclaration(node); + if ( + (ts.isImportDeclaration(node) || followsChangeReviewPublicExport) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.add(node.moduleSpecifier.text); + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) + ) { + specifiers.add(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...specifiers].sort(); +} + +export function resolveImportedModule( + from: string, + specifier: string, + readSource: (path: string) => string | undefined +): string | undefined { + const base = ( + specifier === '@renderer' + ? 'src/renderer' + : specifier.startsWith('@renderer/') + ? `src/renderer/${specifier.slice('@renderer/'.length)}` + : specifier === '@features' + ? 'src/features' + : specifier.startsWith('@features/') + ? `src/features/${specifier.slice('@features/'.length)}` + : specifier.startsWith('.') + ? join(dirname(from), specifier) + : '' + ).replaceAll('\\', '/'); + if (!base) return undefined; + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + join(base, 'index.ts').replaceAll('\\', '/'), + join(base, 'index.tsx').replaceAll('\\', '/'), + ]; + return candidates.find((candidate) => readSource(candidate) !== undefined); +} + +export function discoverControlClosure( + roots: readonly string[], + readSource: (path: string) => string | undefined +): string[] { + const pending = [...roots]; + const visited = new Set(); + while (pending.length) { + const file = pending.shift()!; + if (visited.has(file)) continue; + const source = readSource(file); + if (source === undefined) throw new Error(`Missing reachable control module: ${file}`); + visited.add(file); + for (const specifier of importedModuleSpecifiers(source, file)) { + const imported = resolveImportedModule(file, specifier, readSource); + if (!imported || !CONTROL_SCOPE_PREFIXES.some((prefix) => imported.startsWith(prefix))) + continue; + if (!visited.has(imported)) pending.push(imported); + } + } + return [...visited].filter((file) => file.endsWith('.tsx')).sort(); +} + +export function validateMountedControlRoots( + readSource: (path: string) => string | undefined +): Array<{ root: string; mountChain: string[] }> { + return MOUNT_CHAINS.map(({ root, edges }) => { + for (const edge of edges) { + const source = readSource(edge.from); + if (source === undefined) throw new Error(`Missing mount module: ${edge.from}`); + const importsTarget = importedModuleSpecifiers(source, edge.from).some( + (specifier) => resolveImportedModule(edge.from, specifier, readSource) === edge.to + ); + if (!importsTarget || !source.includes(`<${edge.component}`)) { + throw new Error( + `Mounted control root is not reachable through ${edge.from} -> ${edge.to} (${edge.component})` + ); + } + } + return { root, mountChain: edges.map((edge) => `${edge.from}#${edge.component}`) }; + }); +} + +export function validateControlClosure(discovered: string[], declared: string[]): void { + const expected = [...new Set(discovered)].sort(); + const actual = [...new Set(declared)].sort(); + if (actual.length !== declared.length) + throw new Error('Control closure contains duplicate files'); + const missing = expected.filter((file) => !actual.includes(file)); + const extra = actual.filter((file) => !expected.includes(file)); + if (missing.length || extra.length) { + throw new Error( + `Control closure mismatch; missing=[${missing.join(',')}]; extra=[${extra.join(',')}]` + ); + } +} + +export function validateChildControlCatalog( + sites: ControlSite[], + catalog: ChildControlCatalog, + knownActionIds: ReadonlySet = new Set(), + knownAbsenceIds: ReadonlySet = new Set() +): void { + const actualByKey = new Map(); + for (const site of sites) { + const key = `${site.file}#sha256:${site.sourceHash}`; + actualByKey.set(key, (actualByKey.get(key) ?? 0) + 1); + } + for (const [key, encoded] of Object.entries(catalog.mappings)) { + const separator = encoded.indexOf('|'); + const siteCount = Number(encoded.slice(0, separator)); + const actionId = encoded.slice(separator + 1); + if (separator < 1 || !Number.isInteger(siteCount) || siteCount < 1 || !actionId) { + throw new Error(`Invalid child control mapping encoding: ${key}`); + } + if ( + !catalog.actions[actionId] && + !catalog.absences[actionId] && + !knownActionIds.has(actionId) && + !knownAbsenceIds.has(actionId) + ) { + throw new Error(`Child control mapping references an unknown disposition: ${actionId}`); + } + if (actualByKey.get(key) !== siteCount) { + throw new Error(`Child control reference is stale: ${key}`); + } + } + const reviewedFiles = new Set(Object.values(REVIEWED_CONTROL_FILES)); + for (const key of actualByKey.keys()) { + const hashSeparator = key.lastIndexOf('#sha256:'); + const file = key.slice(0, hashSeparator); + if (!reviewedFiles.has(file) && !(key in catalog.mappings)) { + throw new Error(`Missing child control mapping: ${key}`); + } + } +} + +export function childCatalogRefs(catalog: ChildControlCatalog, dispositionId: string): SourceRef[] { + return Object.entries(catalog.mappings) + .filter(([, encoded]) => encoded.slice(encoded.indexOf('|') + 1) === dispositionId) + .map(([key, encoded]) => { + const hashSeparator = key.lastIndexOf('#sha256:'); + return { + file: key.slice(0, hashSeparator), + sourceHash: key.slice(hashSeparator + 1), + siteCount: Number(encoded.slice(0, encoded.indexOf('|'))), + }; + }) + .sort((left, right) => + `${left.file}#${left.sourceHash}`.localeCompare(`${right.file}#${right.sourceHash}`) + ); +} + +export function childCatalogActions(catalog: ChildControlCatalog): SemanticRow[] { + const refsByAction = new Map(); + for (const [key, encoded] of Object.entries(catalog.mappings)) { + const separator = encoded.indexOf('|'); + const siteCount = Number(encoded.slice(0, separator)); + const actionId = encoded.slice(separator + 1); + const hashSeparator = key.lastIndexOf('#sha256:'); + const file = key.slice(0, hashSeparator); + const sourceHash = key.slice(hashSeparator + 1); + const current = refsByAction.get(actionId) ?? []; + current.push({ file, sourceHash, siteCount }); + refsByAction.set(actionId, current); + } + return Object.entries(catalog.actions) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, encoded]) => { + const [owner, disposition, securityClass, target, ...evidence] = encoded.split('|'); + if ( + !owner || + !['direct', 'decomposed', 'desktop-only', 'deferred'].includes(disposition) || + !securityClass || + !target || + !evidence.length + ) { + throw new Error(`Invalid child action encoding for ${id}`); + } + const sourceRefs = refsByAction.get(id) ?? []; + if (!sourceRefs.length) throw new Error(`Unused child action: ${id}`); + return { + id, + owner, + disposition: disposition as SemanticRow['disposition'], + securityClass, + target, + evidence, + sourceRefs: sourceRefs.sort((left, right) => + `${left.file}#${left.sourceHash}`.localeCompare(`${right.file}#${right.sourceHash}`) + ), + }; + }); +} + +export function childCatalogAbsences(catalog: ChildControlCatalog): AbsenceRow[] { + const refsByAbsence = new Map(); + for (const [key, encoded] of Object.entries(catalog.mappings)) { + const separator = encoded.indexOf('|'); + const siteCount = Number(encoded.slice(0, separator)); + const absenceId = encoded.slice(separator + 1); + if (!catalog.absences[absenceId]) continue; + const hashSeparator = key.lastIndexOf('#sha256:'); + const file = key.slice(0, hashSeparator); + const sourceHash = key.slice(hashSeparator + 1); + const current = refsByAbsence.get(absenceId) ?? []; + current.push({ file, sourceHash, siteCount }); + refsByAbsence.set(absenceId, current); + } + return Object.entries(catalog.absences) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, reason]) => { + const sourceRefs = refsByAbsence.get(id) ?? []; + if (!sourceRefs.length) throw new Error(`Unused child absence: ${id}`); + return { + id, + reason, + sourceRefs: sourceRefs.sort((left, right) => + `${left.file}#${left.sourceHash}`.localeCompare(`${right.file}#${right.sourceHash}`) + ), + }; + }); +} + +export function validateLegacyChildApiActionMappings( + actions: SemanticRow[], + apiActions: Array<{ actionId: string; owningFeature: string }> +): Array<{ childActionId: string; apiActionId: string; owner: string }> { + const childActions = new Map(actions.map((action) => [action.id, action])); + const apiOwners = new Map(apiActions.map((action) => [action.actionId, action.owningFeature])); + + return Object.entries(LEGACY_CHILD_API_ACTION_IDS).map(([childActionId, apiActionId]) => { + const childAction = childActions.get(childActionId); + if (!childAction) throw new Error(`Missing API-linked child action: ${childActionId}`); + const apiOwner = apiOwners.get(apiActionId); + if (!apiOwner) throw new Error(`Missing API action for child mapping: ${apiActionId}`); + if (childAction.owner !== apiOwner) { + throw new Error( + `Child/API ownership conflict: ${childActionId} (${childAction.owner}) -> ${apiActionId} (${apiOwner})` + ); + } + return { childActionId, apiActionId, owner: apiOwner }; + }); +} + +export function refsFor( + repoRoot: string, + refs: Array<[keyof typeof REVIEWED_CONTROL_FILES, string]>, + sites: ControlSite[] +): SourceRef[] { + return refs.map(([key, sourceHash]) => { + const file = REVIEWED_CONTROL_FILES[key]; + const siteCount = sites.filter( + (site) => site.file === file && site.sourceHash === sourceHash + ).length; + if (!siteCount) throw new Error(`Reviewed source reference disappeared: ${file}#${sourceHash}`); + if (!existsSync(join(repoRoot, file))) throw new Error(`Missing reviewed source file: ${file}`); + return { file, sourceHash: `sha256:${sourceHash}`, siteCount }; + }); +} + +export function validateSemanticCatalog( + sites: ControlSite[], + actions: SemanticRow[], + absences: AbsenceRow[] +): void { + const ids = [...actions.map((row) => row.id), ...absences.map((row) => row.id)]; + if (new Set(ids).size !== ids.length) throw new Error('Duplicate semantic action/absence ID'); + for (const action of actions) { + if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/.test(action.id)) + throw new Error(`Non-canonical action ID: ${action.id}`); + } + const mapped = new Map(); + const mappedDisposition = new Map(); + for (const row of [...actions, ...absences]) + for (const ref of row.sourceRefs) { + const key = `${ref.file}#${ref.sourceHash.replace('sha256:', '')}`; + if (mapped.has(key)) throw new Error(`Source reference assigned twice: ${key}`); + mapped.set(key, ref.siteCount); + mappedDisposition.set(key, 'reason' in row ? 'absence' : 'action'); + } + const actual = new Map(); + for (const site of sites) { + const key = `${site.file}#${site.sourceHash}`; + actual.set(key, (actual.get(key) ?? 0) + 1); + } + for (const [key, count] of actual) + if (mapped.get(key) !== count) throw new Error(`Missing or stale semantic mapping: ${key}`); + for (const site of sites) { + const key = `${site.file}#${site.sourceHash}`; + if ( + site.effects.includes('containment') && + site.effects.includes('semantic') && + mappedDisposition.get(key) === 'absence' + ) { + throw new Error(`Mixed containment/semantic handler must map to a semantic action: ${key}`); + } + } + for (const key of mapped.keys()) + if (!actual.has(key)) throw new Error(`Catalog reference has no source site: ${key}`); +} + +export function validateApiDispositions( + apiRows: ReturnType, + dispositions: Array<{ source: ApiSurface; sourceMember: string }> +): void { + const expected = apiRows.map((row) => `${row.surface}.${row.member}`).sort(); + const actual = dispositions.map((row) => `${row.source}.${row.sourceMember}`).sort(); + if (expected.length !== actual.length || expected.some((key, index) => key !== actual[index])) { + throw new Error('API dispositions must contain every pinned member exactly once'); + } +} + +export function findDynamicDispatch(sourceText: string): string[] { + const dynamic = [ + ...sourceText.matchAll(/api\.(?:teams|review|crossTeam)\s*\[\s*([^'"\]]+?)\s*\]/g), + ]; + return dynamic + .filter( + (match) => + !sourceText + .slice(Math.max(0, match.index! - 160), match.index) + .includes('@hosted-web-dynamic-action') + ) + .map((match) => match[0]); +} + +export function walk(root: string): string[] { + if (!existsSync(root)) return []; + return readdirSync(root).flatMap((name) => { + const path = join(root, name); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); +} + +export function scanRendererApiCallers( + sourceText: string +): { surface: ApiSurface; member: string }[] { + const callers: { surface: ApiSurface; member: string }[] = []; + for (const surface of API_SURFACES) { + const client = CLIENT_SURFACES[surface]; + const accessor = CLIENT_ACCESSORS[surface]; + const expression = new RegExp( + `(?:(?:api|window\\.electronAPI)\\.${client}|${accessor}\\(\\))\\.([A-Za-z_$][\\w$]*)`, + 'g' + ); + for (const match of sourceText.matchAll(expression)) { + callers.push({ surface, member: match[1] }); + } + } + return callers; +} + +export function rendererCallers(repoRoot: string): Map> { + const result = new Map>(); + for (const absolute of [ + ...walk(join(repoRoot, 'src/renderer')), + ...walk(join(repoRoot, 'src/features')), + ].filter((file) => /\.tsx?$/.test(file))) { + const text = readFileSync(absolute, 'utf8'); + for (const { surface, member } of scanRendererApiCallers(text)) { + const key = `${surface}.${member}`; + if (!result.has(key)) result.set(key, new Set()); + result.get(key)!.add(relative(repoRoot, absolute)); + } + if (findDynamicDispatch(text).length) + throw new Error(`Unannotated dynamic API dispatch: ${relative(repoRoot, absolute)}`); + } + return result; +} + +export function bypassEvidence(repoRoot: string): { + summary: Record; + rows: Array<{ id: string; kind: string; path: string; sourceHash: string }>; +} { + const rules = [ + ['direct-electron-global', /window\.electronAPI\.teams/g], + ['global-mega-client-call', /\bapi\.(?:teams|review|crossTeam)\./g], + [ + 'structural-capability-check', + /(?:typeof\s+[^\n]+===\s*['"]function|\?\.(?:teams|review|crossTeam))/g, + ], + [ + 'fabricated-browser-success', + /(?:not available in browser mode|return\s+\[\]|return\s+\{\}|no-op)/gi, + ], + ] as const; + const rows: Array<{ id: string; kind: string; path: string; sourceHash: string }> = []; + for (const absolute of [ + ...walk(join(repoRoot, 'src/renderer')), + ...walk(join(repoRoot, 'src/features')), + ].filter((file) => /\.tsx?$/.test(file))) { + const text = readFileSync(absolute, 'utf8'); + const path = relative(repoRoot, absolute); + for (const [kind, pattern] of rules) + for (const match of text.matchAll(pattern)) { + const context = normalized( + text.slice( + Math.max(0, match.index! - 80), + Math.min(text.length, match.index! + match[0].length + 80) + ) + ); + const sourceHash = `sha256:${sha(context)}`; + rows.push({ + id: `P0.W1.BYPASS.${sha(`${kind}:${path}:${sourceHash}`, 16)}`, + kind, + path, + sourceHash, + }); + } + } + rows.sort((a, b) => a.id.localeCompare(b.id)); + const summary = Object.fromEntries( + rules.map(([kind]) => [kind, rows.filter((row) => row.kind === kind).length]) + ); + return { summary, rows }; +} diff --git a/scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts b/scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts new file mode 100644 index 0000000000..191ea1c7e9 --- /dev/null +++ b/scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts @@ -0,0 +1,587 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, relative, resolve } from 'node:path'; + +import { absenceSeeds, actionSeeds, apiGroups } from './parity-catalog-seeds'; +import { + API_SURFACES, + type AbsenceRow, + type ApiSurface, + type ChildControlCatalog, + CONTROL_ROOTS, + kebab, + PHASE_START_API_COUNTS, + PHASE_START_SHA, + PINNED_BASE_SHA, + type SemanticRow, + sha, +} from './parity-scan-contracts'; +import { + bypassEvidence, + childCatalogAbsences, + childCatalogActions, + childCatalogRefs, + discoverControlClosure, + refsFor, + rendererCallers, + scanApiInterfaces, + scanControls, + validateApiDispositions, + validateChildControlCatalog, + validateControlClosure, + validateLegacyChildApiActionMappings, + validateMountedControlRoots, + validateSemanticCatalog, + walk, +} from './parity-source-scanner'; + +export { + CONTROL_ROOTS, + isEventProp, + LEGACY_CHILD_API_ACTION_IDS, + PHASE_START_SHA, + PINNED_BASE_SHA, + type AbsenceRow, + type ChildControlCatalog, + type ControlSite, + type SemanticRow, +} from './parity-scan-contracts'; +export { + discoverControlClosure, + findDynamicDispatch, + refsFor, + resolveImportedModule, + scanApiInterfaces, + scanControls, + scanRendererApiCallers, + validateApiDispositions, + validateChildControlCatalog, + validateControlClosure, + validateLegacyChildApiActionMappings, + validateMountedControlRoots, + validateSemanticCatalog, +} from './parity-source-scanner'; + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function writeCompactJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value)}\n`); +} + +type SchemaNode = { + type?: string; + required?: string[]; + properties?: Record; + items?: SchemaNode; +}; + +export function validateJsonSchema(value: unknown, schema: SchemaNode, label = '$'): void { + if (schema.type === 'object' && (!value || typeof value !== 'object' || Array.isArray(value))) { + throw new Error(`${label} must be an object`); + } + if (schema.type === 'array' && !Array.isArray(value)) + throw new Error(`${label} must be an array`); + if (schema.type === 'string' && typeof value !== 'string') + throw new Error(`${label} must be a string`); + if (schema.type === 'number' && typeof value !== 'number') + throw new Error(`${label} must be a number`); + if (schema.type === 'boolean' && typeof value !== 'boolean') + throw new Error(`${label} must be a boolean`); + if (schema.type === 'object' && value && typeof value === 'object' && !Array.isArray(value)) { + const record = value as Record; + for (const key of schema.required ?? []) + if (!(key in record)) throw new Error(`${label}.${key} is required`); + for (const [key, child] of Object.entries(schema.properties ?? {})) { + if (key in record) validateJsonSchema(record[key], child, `${label}.${key}`); + } + } + if (schema.type === 'array' && Array.isArray(value) && schema.items) { + value.forEach((item, index) => validateJsonSchema(item, schema.items!, `${label}[${index}]`)); + } +} + +function evidenceSchemas(outputRoot: string): void { + const base = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + required: [ + 'schemaId', + 'schemaVersion', + 'evidenceId', + 'packetRevision', + 'pinnedBaseSha', + 'phaseStartSha', + ], + }; + const schemas = { + 'api-parity-ledger.schema.json': { + ...base, + required: [...base.required, 'counts', 'members'], + properties: { + members: { + type: 'array', + items: { + type: 'object', + required: [ + 'stableId', + 'source', + 'sourceMember', + 'legacySignature', + 'sourceSignatureHash', + 'rendererCallers', + 'owningFeature', + 'disposition', + 'securityClass', + 'requiredSemanticEvidence', + 'actionId', + 'targetWorkPackage', + ], + }, + }, + }, + }, + 'renderer-action-inventory.schema.json': { + ...base, + required: [ + ...base.required, + 'roots', + 'mountProofs', + 'sourceFiles', + 'excludedSourceFiles', + 'actions', + 'apiActionBindings', + 'legacyChildApiActionBindings', + 'deliberateAbsences', + ], + properties: { + roots: { type: 'array', items: { type: 'string' } }, + mountProofs: { + type: 'array', + items: { type: 'object', required: ['root', 'mountChain'] }, + }, + sourceFiles: { + type: 'array', + items: { type: 'object', required: ['path', 'sha256', 'interactionSiteCount'] }, + }, + excludedSourceFiles: { + type: 'array', + items: { type: 'object', required: ['path', 'reason', 'interactionSiteCount'] }, + }, + actions: { + type: 'array', + items: { + type: 'object', + required: [ + 'id', + 'owner', + 'disposition', + 'securityClass', + 'target', + 'evidence', + 'sourceRefs', + ], + }, + }, + apiActionBindings: { + type: 'array', + items: { + type: 'object', + required: ['actionId', 'owner', 'source', 'sourceMember', 'rendererCallers'], + }, + }, + legacyChildApiActionBindings: { + type: 'array', + items: { + type: 'object', + required: ['childActionId', 'apiActionId', 'owner'], + }, + }, + deliberateAbsences: { + type: 'array', + items: { type: 'object', required: ['id', 'reason', 'sourceRefs'] }, + }, + }, + }, + 'renderer-child-control-catalog.schema.json': { + ...base, + required: [...base.required, 'roots', 'sourceFiles', 'actions', 'absences', 'mappings'], + properties: { + roots: { type: 'array', items: { type: 'string' } }, + sourceFiles: { type: 'array', items: { type: 'string' } }, + actions: { type: 'object' }, + absences: { type: 'object' }, + mappings: { type: 'object' }, + }, + }, + 'legacy-bypass-inventory.schema.json': { + ...base, + required: [...base.required, 'summary', 'rawArtifact'], + properties: { + rawArtifact: { + type: 'object', + required: [ + 'format', + 'recordCount', + 'sha256', + 'externalPath', + 'pathScope', + 'reproductionCommand', + ], + properties: { + externalPath: { const: 'legacy-bypass-raw.json' }, + pathScope: { const: 'artifact-pack-relative' }, + reproductionCommand: { + const: + 'W1_RAW_EVIDENCE_ROOT= node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts', + }, + }, + }, + }, + }, + 'estimate-input.schema.json': { + ...base, + required: [...base.required, 'unit', 'buckets', 'varianceAssessment'], + properties: { + buckets: { + type: 'array', + items: { + type: 'object', + required: [ + 'bucketId', + 'packages', + 'productionLines', + 'testLines', + 'deletedLines', + 'netLines', + 'excludedGeneratedVendorLines', + 'overlap', + 'confidence', + 'assumptions', + 'evidenceRefs', + ], + }, + }, + varianceAssessment: { + type: 'object', + required: [ + 'parentRangeStillSupported', + 'uniqueBucketOverTwentyPercent', + 'scopeReviewRequired', + 'changes', + ], + }, + }, + }, + }; + for (const [name, schema] of Object.entries(schemas)) + writeJson(join(outputRoot, 'schemas', name), schema); +} + +export function generateEvidence( + repoRoot: string, + rawRoot = '/tmp/agent-teams-hosted-web-refactor-phase-00-remediation-w1-v9-artifacts' +): { rawPath: string; rawHash: string; apiCount: number; controlCount: number } { + const outputRoot = join(repoRoot, 'docs/research/hosted-web/phase-0/parity-renderer'); + const apiRows = scanApiInterfaces( + readFileSync(join(repoRoot, 'src/shared/types/api.ts'), 'utf8') + ); + const callers = rendererCallers(repoRoot); + const dispositions = apiGroups.flatMap((group) => + group.members.map((sourceMember) => { + const source = group.surface; + const scanned = apiRows.find((row) => row.surface === source && row.member === sourceMember); + if (!scanned) throw new Error(`Explicit API disposition is stale: ${source}.${sourceMember}`); + return { + stableId: `P0.W1.API.${source}.${sourceMember}`, + source, + sourceMember, + legacySignature: scanned.signature, + sourceSignatureHash: scanned.signatureHash, + rendererCallers: [...(callers.get(`${source}.${sourceMember}`) ?? [])].sort(), + owningFeature: group.owner, + disposition: group.disposition, + securityClass: group.securityClass, + requiredSemanticEvidence: [ + 'normalized success/error contract', + 'support distinct from resource allowance', + 'revision/idempotency/event obligation', + ], + actionId: `${group.namespace}.${kebab(sourceMember)}`, + targetWorkPackage: group.target, + }; + }) + ); + validateApiDispositions(apiRows, dispositions); + + const readRepoSource = (file: string): string | undefined => { + const absolute = join(repoRoot, file); + return existsSync(absolute) && statSync(absolute).isFile() + ? readFileSync(absolute, 'utf8') + : undefined; + }; + const mountProofs = validateMountedControlRoots(readRepoSource); + const controlFiles = discoverControlClosure(CONTROL_ROOTS, readRepoSource); + const teamControlCandidates = walk(join(repoRoot, 'src/renderer/components/team')) + .filter((absolute) => absolute.endsWith('.tsx') && !/\.(?:test|stories)\.tsx$/.test(absolute)) + .map((absolute) => relative(repoRoot, absolute)) + .sort(); + const excludedControlFiles = teamControlCandidates.filter((file) => !controlFiles.includes(file)); + const catalogPath = join(outputRoot, 'renderer-child-control-catalog.json'); + if (!existsSync(catalogPath)) + throw new Error(`Missing reviewed child-control catalog: ${catalogPath}`); + const childCatalog = JSON.parse(readFileSync(catalogPath, 'utf8')) as ChildControlCatalog; + if ( + childCatalog.pinnedBaseSha !== PINNED_BASE_SHA || + childCatalog.phaseStartSha !== PHASE_START_SHA + ) { + throw new Error('Child-control catalog provenance does not match the pinned W1 source'); + } + if (JSON.stringify(childCatalog.roots) !== JSON.stringify(CONTROL_ROOTS)) { + throw new Error('Child-control catalog roots do not match the mounted team roots'); + } + validateControlClosure(controlFiles, childCatalog.sourceFiles); + const sites = controlFiles.flatMap((file) => scanControls(readRepoSource(file)!, file)); + validateChildControlCatalog( + sites, + childCatalog, + new Set(actionSeeds.map((seed) => seed.id)), + new Set(absenceSeeds.map((seed) => seed.id)) + ); + const actions: SemanticRow[] = [ + ...actionSeeds.map(({ refs, ...row }) => ({ + ...row, + sourceRefs: [...refsFor(repoRoot, refs, sites), ...childCatalogRefs(childCatalog, row.id)], + })), + ...childCatalogActions(childCatalog), + ]; + const deliberateAbsences: AbsenceRow[] = [ + ...absenceSeeds.map(({ refs, ...row }) => ({ + ...row, + sourceRefs: [...refsFor(repoRoot, refs, sites), ...childCatalogRefs(childCatalog, row.id)], + })), + ...childCatalogAbsences(childCatalog), + ]; + validateSemanticCatalog(sites, actions, deliberateAbsences); + const ownerByAction = new Map(dispositions.map((row) => [row.actionId, row.owningFeature])); + for (const action of actions) { + const apiOwner = ownerByAction.get(action.id); + if (apiOwner && apiOwner !== action.owner) + throw new Error(`Cross-lane ownership conflict for ${action.id}`); + } + if (ownerByAction.get('team.lifecycle.stop') !== 'team-lifecycle') + throw new Error('Team stop must remain team-lifecycle owned'); + if ( + actions.find((row) => row.id === 'provider.management.credentials.edit')?.owner !== + 'runtime-provider-management' + ) + throw new Error('Provider credential controls must remain provider-management owned'); + const legacyChildApiActionBindings = validateLegacyChildApiActionMappings(actions, dispositions); + const apiActionBindings = dispositions + .filter((row) => row.rendererCallers.length) + .map((row) => ({ + actionId: row.actionId, + owner: row.owningFeature, + source: row.source, + sourceMember: row.sourceMember, + rendererCallers: row.rendererCallers, + })); + + const bypasses = bypassEvidence(repoRoot); + const rawPath = join(rawRoot, 'legacy-bypass-raw.json'); + writeCompactJson(rawPath, bypasses.rows); + const rawText = readFileSync(rawPath, 'utf8'); + const rawHash = `sha256:${sha(rawText)}`; + + const envelope = { + schemaVersion: 2, + packetRevision: 'phase-00-r2', + pinnedBaseSha: PINNED_BASE_SHA, + phaseStartSha: PHASE_START_SHA, + }; + const apiCounts = Object.fromEntries( + API_SURFACES.map((surface) => [ + surface, + apiRows.filter((row) => row.surface === surface).length, + ]) + ) as Record; + writeJson(join(outputRoot, 'api-parity-ledger.json'), { + schemaId: 'p0-w1-api-parity-ledger', + evidenceId: 'P0.W1.API_PARITY_LEDGER', + ...envelope, + counts: apiCounts, + historicalCountDifference: `ReviewAPI +${apiCounts.ReviewAPI - PHASE_START_API_COUNTS.ReviewAPI} since the phase-start AST: current ${API_SURFACES.map((surface) => apiCounts[surface]).join('/')} versus historical ${API_SURFACES.map((surface) => PHASE_START_API_COUNTS[surface]).join('/')}`, + members: dispositions, + }); + writeJson(join(outputRoot, 'renderer-action-inventory.json'), { + schemaId: 'p0-w1-renderer-action-inventory', + evidenceId: 'P0.W1.RENDERER_ACTIONS', + ...envelope, + identityRule: + 'Semantic IDs are reviewed contract identifiers; source hashes, handler text, counts, file paths, and line positions are refreshable references and never enter identity.', + roots: [...CONTROL_ROOTS], + mountProofs, + sourceFiles: controlFiles.map((file) => ({ + path: file, + sha256: `sha256:${sha(readRepoSource(file)!)}`, + interactionSiteCount: sites.filter((site) => site.file === file).length, + })), + excludedSourceFiles: excludedControlFiles.map((file) => ({ + path: file, + reason: + 'No relative or renderer-alias static/dynamic import path exists from the mounted W1 team roots; the file is absent from this mount closure.', + interactionSiteCount: scanControls(readRepoSource(file)!, file).length, + })), + transitiveActionCoverage: `The checked-in child-control catalog exactly matches the recursively discovered team/change-review/provider renderer closure. Every scanner-visible site maps once to a reviewed semantic action or deliberate absence; direct renderer IPC callers remain bound to the ${apiRows.length}-member parity ledger. Every other production team TSX file is listed as excluded and rechecked as unreachable from these roots.`, + actions, + apiActionBindings, + legacyChildApiActionBindings, + deliberateAbsences, + dynamicDispatch: { + unannotatedCount: 0, + annotation: '@hosted-web-dynamic-action ', + }, + }); + writeJson(join(outputRoot, 'legacy-bypass-inventory.json'), { + schemaId: 'p0-w1-legacy-bypass-inventory', + evidenceId: 'P0.W1.LEGACY_BYPASSES', + ...envelope, + summary: bypasses.summary, + rawArtifact: { + format: 'deterministically sorted compact JSON', + recordCount: bypasses.rows.length, + sha256: rawHash, + externalPath: basename(rawPath), + pathScope: 'artifact-pack-relative', + reproductionCommand: + 'W1_RAW_EVIDENCE_ROOT= node --import tsx scripts/hosted-web/phase-0/parity-renderer/scan-api-and-actions.ts', + }, + requiredDisposition: + 'Supported hosted actions use a real feature facet; unavailable and desktop-only controls are absent before mount. No optional-method check or fabricated success is capability proof.', + }); + const buckets = [ + { + bucketId: 'EST-CONTRACTS', + packages: ['shared capability/action contracts', 'ADR-19 parity gate'], + productionLines: { low: 1200, high: 1800 }, + testLines: { low: 800, high: 1200 }, + deletedLines: { low: 0, high: 0 }, + netLines: { low: 2000, high: 3000 }, + excludedGeneratedVendorLines: ['Phase 0 evidence', 'lockfiles', 'vendor'], + overlap: 'W1 parity contracts only.', + confidence: 'high', + assumptions: ['No replacement mega-interface.'], + evidenceRefs: ['P0.W1.API_PARITY_LEDGER', 'P0.W1.SCANNER'], + }, + { + bucketId: 'EST-RENDERER-LIFECYCLE', + packages: ['team-console', 'team lifecycle renderer composition'], + productionLines: { low: 1800, high: 2800 }, + testLines: { low: 1200, high: 2000 }, + deletedLines: { low: 900, high: 1600 }, + netLines: { low: 2100, high: 3200 }, + excludedGeneratedVendorLines: ['Phase 0 evidence', 'format churn'], + overlap: 'Task/message/review/provider actions stay with their canonical owners.', + confidence: 'medium', + assumptions: ['Desktop-only controls are absent before hosted mount.'], + evidenceRefs: ['P0.W1.RENDERER_ACTIONS', 'P0.W1.SELECTION_INVARIANTS'], + }, + { + bucketId: 'EST-REMAINING-PARITY', + packages: [ + 'team-task-board', + 'team-messaging', + 'team-review', + 'team-approvals', + 'agent-attachments', + ], + productionLines: { low: 2500, high: 3900 }, + testLines: { low: 1500, high: 2600 }, + deletedLines: { low: 1200, high: 2300 }, + netLines: { low: 2800, high: 4200 }, + excludedGeneratedVendorLines: ['Phase 0 evidence', 'post-v1 terminal'], + overlap: 'Server/runtime/auth work remains in its owning non-W1 bucket.', + confidence: 'medium-low', + assumptions: ['One owning feature per semantic action.'], + evidenceRefs: ['P0.W1.API_PARITY_LEDGER', 'P0.W1.RENDERER_ACTIONS', 'P0.W1.LEGACY_BYPASSES'], + }, + ]; + writeJson(join(outputRoot, 'estimate-input.json'), { + schemaId: 'p0-w1-estimate-input', + evidenceId: 'P0.W1.ESTIMATE', + ...envelope, + unit: 'net integrated source lines; aligned low/high = production + test - deleted', + buckets, + varianceAssessment: { + parentRangeStillSupported: false, + uniqueBucketOverTwentyPercent: true, + scopeReviewRequired: true, + changes: [ + { + bucketId: 'EST-RENDERER-LIFECYCLE', + baseline: { low: 3000, high: 5000 }, + recomputed: { low: 2100, high: 3200 }, + variancePercent: { low: -30, high: -36 }, + }, + { + bucketId: 'EST-REMAINING-PARITY', + baseline: { low: 4000, high: 6500 }, + recomputed: { low: 2800, high: 4200 }, + variancePercent: { low: -30, high: -35.38 }, + }, + ], + controllerDisposition: + 'scope review required before estimate freeze; W1 does not suppress or self-approve either variance', + }, + }); + evidenceSchemas(outputRoot); + const generatedJsonPaths = [ + 'api-parity-ledger', + 'renderer-action-inventory', + 'legacy-bypass-inventory', + 'estimate-input', + ].flatMap((stem) => [ + join(outputRoot, `${stem}.json`), + join(outputRoot, 'schemas', `${stem}.schema.json`), + ]); + generatedJsonPaths.push( + join(outputRoot, 'schemas', 'renderer-child-control-catalog.schema.json') + ); + execFileSync( + process.execPath, + [join(repoRoot, 'node_modules/prettier/bin/prettier.cjs'), '--write', ...generatedJsonPaths], + { cwd: repoRoot, stdio: 'ignore' } + ); + for (const stem of [ + 'api-parity-ledger', + 'renderer-action-inventory', + 'renderer-child-control-catalog', + 'legacy-bypass-inventory', + 'estimate-input', + ]) { + const document = JSON.parse(readFileSync(join(outputRoot, `${stem}.json`), 'utf8')) as unknown; + const schema = JSON.parse( + readFileSync(join(outputRoot, 'schemas', `${stem}.schema.json`), 'utf8') + ) as SchemaNode; + validateJsonSchema(document, schema, stem); + } + return { rawPath, rawHash, apiCount: apiRows.length, controlCount: sites.length }; +} + +function findRoot(start: string): string { + let current = resolve(start); + while (!existsSync(join(current, 'package.json'))) { + const parent = dirname(current); + if (parent === current) throw new Error('Repository root not found'); + current = parent; + } + return current; +} + +if (process.argv[1]?.endsWith('scan-api-and-actions.ts')) { + const result = generateEvidence(findRoot(process.cwd()), process.env.W1_RAW_EVIDENCE_ROOT); + console.log(JSON.stringify({ status: 'ok', ...result })); +} diff --git a/scripts/hosted-web/phase-0/provider-runtime/provider-runtime-fixtures.ts b/scripts/hosted-web/phase-0/provider-runtime/provider-runtime-fixtures.ts new file mode 100644 index 0000000000..0d3b7001b4 --- /dev/null +++ b/scripts/hosted-web/phase-0/provider-runtime/provider-runtime-fixtures.ts @@ -0,0 +1,483 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + compareSet, + compareUnique, + EVIDENCE_ROOT, + EXPECTED_FAKE_RUNTIME_SEAMS, + EXPECTED_MATRIX_CASES, + PHASE_START_SHA, + PROVIDER_RUNTIME_ROUTING_KEYS, + readJson, + type EnvironmentSemanticsFixture, + type JsonRecord, + type ProviderModeIngressFixture, + type ProviderRuntimeRoutingObservation, +} from './provider-runtime-shared'; + +export function validatePerKeyEnvironmentEvidenceCoverage(document: JsonRecord): string[] { + const classified = (document.records as JsonRecord[]).flatMap( + (row) => (row.keys as string[]) ?? [] + ); + const keyEvidence = resolvePerKeyEnvironmentEvidence(document); + return [ + ...compareSet( + 'per-key environment evidence', + keyEvidence.map((entry) => String(entry.key)), + classified + ), + ...compareUnique( + 'per-key environment evidence ids', + keyEvidence.map((entry) => String(entry.id)) + ), + ]; +} + +export function resolvePerKeyEnvironmentEvidence(document: JsonRecord): JsonRecord[] { + const profiles = document.keyPolicyProfiles as JsonRecord[]; + const profileById = new Map(profiles.map((profile) => [String(profile.id), profile])); + const groupById = new Map( + (document.records as JsonRecord[]).map((group) => [String(group.id), group]) + ); + const table = document.keyEvidence as JsonRecord; + const paths = table.probePaths as JsonRecord[]; + const pathById = new Map(paths.map((entry) => [String(entry.id), String(entry.path)])); + return (table.rows as unknown[][]).map((row) => { + const [key, groupId, policyProfileId, probePathId] = row.map(String); + const profile = profileById.get(policyProfileId) ?? {}; + const group = groupById.get(groupId) ?? {}; + const sourceClass = String(profile.sourceClass); + const exactKeyProbe = ['production_source_census', 'checked_fixture_binding'].includes( + sourceClass + ); + const targetProbe = sourceClass === 'target_contract_prohibition'; + return { + ...profile, + id: `env-key:${key}`, + key, + groupId, + policyProfileId, + probe: { + kind: exactKeyProbe + ? sourceClass === 'production_source_census' + ? 'source_census' + : 'fixture_binding' + : targetProbe + ? 'target_contract' + : 'source_anchor', + path: pathById.get(probePathId) ?? '', + token: exactKeyProbe + ? key.endsWith('*') + ? key.slice(0, -1) + : key + : String(group.sourceToken ?? ''), + assertion: exactKeyProbe + ? 'path_contains_exact_key' + : targetProbe + ? 'contract_contains_prohibition' + : 'source_contains_anchor', + }, + }; + }); +} + +export function validateCredentialExposureLinks( + root: string, + environment: JsonRecord, + credentialMatrix: JsonRecord +): string[] { + const errors: string[] = []; + const keys = resolvePerKeyEnvironmentEvidence(environment); + const sets = credentialMatrix.exposureSets as JsonRecord[]; + errors.push( + ...compareUnique( + 'credential exposure set ids', + sets.map((set) => String(set.id)) + ) + ); + const memberships = new Map(); + for (const set of sets) { + const setId = String(set.id); + readFileSync(resolve(root, String(set.provenanceArtifact)), 'utf8'); + readFileSync(resolve(root, String(set.probeTest).split('#')[0]), 'utf8'); + for (const keyId of set.memberKeyEvidenceIds as string[]) { + const current = memberships.get(keyId) ?? []; + current.push(setId); + memberships.set(keyId, current); + } + } + errors.push( + ...compareSet( + 'credential exposure key membership', + [...memberships.keys()], + keys.map((entry) => String(entry.id)) + ) + ); + for (const key of keys) { + const keyId = String(key.id); + const declared = key.credentialExposureSetIds as string[]; + const linked = memberships.get(keyId) ?? []; + errors.push(...compareSet(`credential exposure membership ${keyId}`, linked, declared)); + } + const units = credentialMatrix.records as JsonRecord[]; + const knownSets = new Set(sets.map((set) => String(set.id))); + for (const unit of units) { + readFileSync(resolve(root, String(unit.source)), 'utf8'); + readFileSync(resolve(root, String(unit.probeTest)), 'utf8'); + for (const setId of unit.exposureSetIds as string[]) + if (!knownSets.has(setId)) + errors.push(`credential-exposure-matrix.json: unknown set ${setId} on ${String(unit.id)}`); + } + return errors; +} + +export function validateFakeRuntimeMatrix(root: string, matrix: JsonRecord): string[] { + const errors: string[] = []; + errors.push( + ...compareSet( + 'fake-runtime cases', + (matrix.records as JsonRecord[]).map((row) => String(row.case)), + EXPECTED_MATRIX_CASES + ) + ); + for (const row of matrix.records as JsonRecord[]) { + const caseName = String(row.case); + const expectedSeam = + EXPECTED_FAKE_RUNTIME_SEAMS[caseName as (typeof EXPECTED_MATRIX_CASES)[number]]; + const proof = row.executableProof as JsonRecord; + const authority = proof.authority as JsonRecord; + const authorityPath = String(authority.path); + const authorityToken = String(authority.token); + const authoritySource = readFileSync(resolve(root, authorityPath), 'utf8'); + if (!authoritySource.includes(authorityToken)) { + errors.push( + `fake-runtime ${caseName}: stale canonical seam ${authorityPath}#${authorityToken}` + ); + } + if ( + !expectedSeam || + proof.seam !== expectedSeam.seam || + authorityPath !== expectedSeam.path || + authorityToken !== expectedSeam.token + ) { + errors.push(`fake-runtime ${caseName}: wrong canonical seam binding`); + } + if (proof.runner !== 'vitest_canonical_runtime_seams_v1') { + errors.push(`fake-runtime ${caseName}: wrong runner`); + } + const expectedPositive = `w2.fake-runtime.${caseName}.positive`; + const expectedNegative = `w2.fake-runtime.${caseName}.failing-negative`; + if (proof.positiveTestId !== expectedPositive) { + errors.push(`fake-runtime ${caseName}: wrong positive test id`); + } + if (proof.failingNegativeTestId !== expectedNegative) { + errors.push(`fake-runtime ${caseName}: wrong failing-negative test id`); + } + const testFile = String(proof.testFile); + const testSource = readFileSync(resolve(root, testFile), 'utf8'); + for (const testId of [expectedPositive, expectedNegative]) { + if (!testSource.includes(`it('${testId}'`)) { + errors.push(`fake-runtime ${caseName}: missing independently addressable test ${testId}`); + } + } + if (row.proofLevel !== 'fixture_characterized') { + errors.push(`fake-runtime ${caseName}: unproved rows must remain explicit_gap`); + } + } + return errors; +} + +export function validateEnvironmentSemanticsFixture( + root: string, + environment: JsonRecord, + fixture: EnvironmentSemanticsFixture +): string[] { + const errors: string[] = []; + if (fixture.schemaVersion !== 2) + errors.push('environment semantics fixture: wrong schemaVersion'); + if (fixture.canonicalBaseSha !== PHASE_START_SHA) { + errors.push('environment semantics fixture: wrong canonicalBaseSha'); + } + const resolved = resolvePerKeyEnvironmentEvidence(environment); + errors.push( + ...compareSet( + 'environment semantics delegated executable keys', + fixture.delegatedExecutableSemantics.keys, + PROVIDER_RUNTIME_ROUTING_KEYS + ), + ...compareSet( + 'environment semantics delegated authority paths', + fixture.delegatedExecutableSemantics.authorityPaths, + [ + 'src/main/services/runtime/providerRuntimeEnv.ts', + 'src/main/services/runtime/buildRuntimeBaseEnv.ts', + 'src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts', + ] + ) + ); + if ( + fixture.delegatedExecutableSemantics.proofTestId !== + 'w2.environment.provider-routing.source-seam' + ) { + errors.push('environment semantics fixture: wrong delegated executable proof test'); + } + const fixtureResolved = resolved.filter( + (entry) => !PROVIDER_RUNTIME_ROUTING_KEYS.includes(String(entry.key) as never) + ); + errors.push( + ...compareSet( + 'environment semantics keys', + fixture.entries.map((entry) => entry.key), + fixtureResolved.map((entry) => String(entry.key)) + ) + ); + const expectedByKey = new Map(fixture.entries.map((entry) => [entry.key, entry])); + for (const actual of fixtureResolved) { + const key = String(actual.key); + const expected = expectedByKey.get(key); + if (!expected) continue; + const actualBindings = (actual.providerBindings as JsonRecord[]).map((binding) => ({ + providerId: String(binding.providerId), + backendFamily: String(binding.backendFamily), + targetDisposition: String(binding.targetDisposition), + })); + const dimensions: Array<[string, unknown, unknown]> = [ + ['policy profile', actual.policyProfileId, expected.policyProfileId], + ['semantic role', actual.semanticRole, expected.semanticRole], + ['provider/backend/disposition bindings', actualBindings, expected.providerBindings], + ['platform', actual.platformScope, expected.platformScope], + ['child visibility', actual.childVisibility, expected.childVisibility], + [ + 'providerless prohibition', + actual.providerlessProhibition, + expected.providerlessProhibition, + ], + [ + 'credential exposure', + (actual.credentialExposureSetIds as string[])[0], + expected.credentialExposureSetId, + ], + ['authority path', (actual.probe as JsonRecord).path, expected.authority.path], + ['authority token', (actual.probe as JsonRecord).token, expected.authority.token], + ]; + for (const [label, actualValue, expectedValue] of dimensions) { + if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) { + errors.push(`environment semantics ${key}: wrong ${label}`); + } + } + const source = readFileSync(resolve(root, expected.authority.path), 'utf8'); + if (!source.includes(expected.authority.token)) { + errors.push( + `environment semantics ${key}: stale source authority ${expected.authority.path}#${expected.authority.token}` + ); + } + } + return errors; +} + +export function validateProviderRuntimeRoutingSemantics( + environment: JsonRecord, + observations: ProviderRuntimeRoutingObservation[] +): string[] { + const errors: string[] = []; + const scenarioIds = observations.map( + (row) => `${row.key}:${row.providerId}:${row.runtimeBackend}` + ); + const expectedScenarioIds = PROVIDER_RUNTIME_ROUTING_KEYS.flatMap((key) => + [ + ['anthropic', 'anthropic_default'], + ['anthropic', 'anthropic_bedrock'], + ['anthropic', 'anthropic_vertex'], + ['anthropic', 'anthropic_foundry'], + ['anthropic', 'anthropic_claude_platform_aws'], + ['codex', 'codex_configured'], + ['gemini', 'gemini_configured'], + ].map(([providerId, runtimeBackend]) => `${key}:${providerId}:${runtimeBackend}`) + ); + errors.push( + ...compareSet('provider routing source observations', scenarioIds, expectedScenarioIds) + ); + + const resolved = resolvePerKeyEnvironmentEvidence(environment); + const byKey = new Map(resolved.map((entry) => [String(entry.key), entry])); + const authorityPathByKey = new Map([ + ['CLAUDE_CONFIG_DIR', 'src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts'], + ['CLAUDE_CODE_CODEX_BACKEND', 'src/main/services/runtime/buildRuntimeBaseEnv.ts'], + ['CLAUDE_CODE_GEMINI_BACKEND', 'src/main/services/runtime/buildRuntimeBaseEnv.ts'], + ]); + const expectedRoleByKey = new Map([ + ['CLAUDE_CONFIG_DIR', 'selected_child_input'], + ['CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'emitted_child_key'], + ['CLAUDE_CODE_ENTRY_PROVIDER', 'emitted_child_key'], + ['CLAUDE_CODE_USE_OPENAI', 'removed_child_key'], + ['CLAUDE_CODE_USE_BEDROCK', 'host_policy_input_and_emitted_child_key'], + ['CLAUDE_CODE_USE_VERTEX', 'host_policy_input_and_emitted_child_key'], + ['CLAUDE_CODE_USE_FOUNDRY', 'host_policy_input_and_emitted_child_key'], + ['CLAUDE_CODE_USE_GEMINI', 'removed_child_key'], + ['CLAUDE_CODE_CODEX_BACKEND', 'emitted_child_key'], + ['CLAUDE_CODE_GEMINI_BACKEND', 'emitted_child_key'], + ]); + + for (const key of PROVIDER_RUNTIME_ROUTING_KEYS) { + const actual = byKey.get(key); + if (!actual) { + errors.push(`provider routing semantics ${key}: missing per-key evidence`); + continue; + } + const expectedBindings = observations + .filter((row) => row.key === key) + .map(({ key: _key, ...binding }) => binding); + const actualBindings = (actual.providerBindings as JsonRecord[]).map((binding) => ({ + providerId: binding.providerId, + backendFamily: binding.backendFamily, + runtimeBackend: binding.runtimeBackend, + targetDisposition: binding.targetDisposition, + emissionDisposition: binding.emissionDisposition, + })); + if (JSON.stringify(actualBindings) !== JSON.stringify(expectedBindings)) { + errors.push(`provider routing semantics ${key}: wrong source-derived bindings`); + } + const expectedRole = expectedRoleByKey.get(key); + if (actual.semanticRole !== expectedRole) { + errors.push(`provider routing semantics ${key}: wrong semantic role`); + } + const onlyRemoved = expectedBindings.every( + (binding) => binding.emissionDisposition === 'removed_before_spawn' + ); + const expectedVisibility = onlyRemoved + ? 'absent_current_and_target' + : 'provider_child_visible_when_selected'; + if (actual.childVisibility !== expectedVisibility) { + errors.push(`provider routing semantics ${key}: wrong child visibility`); + } + const expectedAuthority = + authorityPathByKey.get(key) ?? 'src/main/services/runtime/providerRuntimeEnv.ts'; + if ((actual.probe as JsonRecord).path !== expectedAuthority) { + errors.push(`provider routing semantics ${key}: wrong source branch authority`); + } + } + + const profileGroups = [ + ['CLAUDE_CONFIG_DIR'], + ['CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'CLAUDE_CODE_ENTRY_PROVIDER'], + ['CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI'], + ['CLAUDE_CODE_USE_BEDROCK'], + ['CLAUDE_CODE_USE_VERTEX'], + ['CLAUDE_CODE_USE_FOUNDRY'], + ['CLAUDE_CODE_CODEX_BACKEND', 'CLAUDE_CODE_GEMINI_BACKEND'], + ]; + const groupProfileIds = profileGroups.map((keys) => { + const ids = new Set(keys.map((key) => String(byKey.get(key)?.policyProfileId ?? ''))); + if (ids.size !== 1) errors.push(`provider routing profile group ${keys.join(',')}: split`); + return [...ids][0]; + }); + errors.push(...compareUnique('provider routing behavior profile ids', groupProfileIds)); + return errors; +} + +export function verifyFakeRuntimeProofExecution(root: string, matrix: JsonRecord): string[] { + const records = matrix.records as JsonRecord[]; + const testFiles = [ + ...new Set(records.map((row) => String((row.executableProof as JsonRecord).testFile))), + ]; + if (testFiles.length !== 1) + return ['fake-runtime execution: matrix must bind one focused proof file']; + const expectedTestIds = records.flatMap((row) => { + const proof = row.executableProof as JsonRecord; + return [String(proof.positiveTestId), String(proof.failingNegativeTestId)]; + }); + const outputDir = mkdtempSync(join(tmpdir(), 'phase-00-w2-proof-')); + const outputFile = join(outputDir, 'vitest-results.json'); + try { + const result = spawnSync( + 'pnpm', + [ + 'exec', + 'vitest', + 'run', + testFiles[0], + '--reporter=json', + `--outputFile=${outputFile}`, + '--testNamePattern', + 'w2\\.fake-runtime\\.', + ], + { + cwd: root, + encoding: 'utf8', + env: { ...process.env, W2_FAKE_RUNTIME_PROOF_CHILD: '1' }, + } + ); + if (result.status !== 0) { + return [ + `fake-runtime execution failed (exit ${String(result.status)}): ${(result.stderr || result.stdout).trim()}`, + ]; + } + const report = readJson(outputFile); + const assertions = ((report.testResults as JsonRecord[]) ?? []).flatMap( + (suite) => (suite.assertionResults as JsonRecord[]) ?? [] + ); + const passed = new Set( + assertions + .filter((assertion) => assertion.status === 'passed') + .map((assertion) => String(assertion.title ?? assertion.fullName)) + ); + return compareSet('executed fake-runtime proof ids', [...passed], expectedTestIds); + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } +} + +export function validateProviderModeIngressFixture( + root: string, + fixture: ProviderModeIngressFixture +): string[] { + const topology = readJson(resolve(root, EVIDENCE_ROOT, 'execution-topology.json')); + const ingress = readJson(resolve(root, EVIDENCE_ROOT, 'runtime-ingress-inventory.json')); + const topologyRows = topology.records as JsonRecord[]; + const providers = topologyRows + .filter((row) => typeof row.providerIdentity === 'string') + .map((row) => String(row.providerIdentity)); + const opencodeModes = topologyRows + .filter((row) => typeof row.mode === 'string' && String(row.mode).includes('opencode')) + .flatMap((row) => String(row.mode).split('|')) + .filter((mode) => mode !== 'unsupported_opencode_led_mixed_team'); + const expectedPairs = [ + ...providers + .filter((provider) => provider !== 'opencode') + .map((provider) => `${provider}:primary_only`), + ...opencodeModes.map((mode) => `opencode:${mode}`), + ]; + const actualPairs = fixture.dispositions.map((row) => `${row.provider}:${row.mode}`); + const errors = compareSet('provider/mode dispositions', actualPairs, expectedPairs); + const ingressOperations = (ingress.records as JsonRecord[]).map((row) => String(row.commandKind)); + for (const row of fixture.dispositions) { + const isOpenCode = row.provider === 'opencode'; + const expectedDisposition = isOpenCode + ? 'current_source_observed_runtime_ingress' + : 'current_source_observed_no_runtime_ingress'; + if (row.disposition !== expectedDisposition) + errors.push(`${row.provider}:${row.mode}: wrong disposition ${row.disposition}`); + errors.push( + ...compareSet( + `${row.provider}:${row.mode}: operations`, + row.operations, + isOpenCode ? ingressOperations : [] + ) + ); + if (!row.targetStatus.includes('target-unverified')) + errors.push(`${row.provider}:${row.mode}: target-unverified status missing`); + if (row.authorityRefs.length === 0) + errors.push(`${row.provider}:${row.mode}: no independent authority references`); + for (const authority of row.authorityRefs) { + const source = readFileSync(resolve(root, authority.path), 'utf8'); + if (!source.includes(authority.token)) + errors.push( + `${row.provider}:${row.mode}: stale authority ${authority.path}#${authority.token}` + ); + } + } + return errors; +} diff --git a/scripts/hosted-web/phase-0/provider-runtime/provider-runtime-shared.ts b/scripts/hosted-web/phase-0/provider-runtime/provider-runtime-shared.ts new file mode 100644 index 0000000000..cbb4992acd --- /dev/null +++ b/scripts/hosted-web/phase-0/provider-runtime/provider-runtime-shared.ts @@ -0,0 +1,257 @@ +import { readFileSync } from 'node:fs'; + +export const PHASE_START_SHA = 'c72fd201867b9bcd1ef77d5e0f95ba379adb4fca'; +export const EVIDENCE_ROOT = 'docs/research/hosted-web/phase-0/provider-runtime'; + +export const ARTIFACTS = [ + 'execution-topology.json', + 'runtime-ingress-inventory.json', + 'environment-provenance.json', + 'credential-exposure-matrix.json', + 'fake-runtime-fixture-matrix.json', + 'estimate-input.json', +] as const; + +export const EXPECTED_ROUTES = [ + '/api/teams/:teamName/opencode/runtime/bootstrap-checkin', + '/api/teams/:teamName/opencode/runtime/deliver-message', + '/api/teams/:teamName/opencode/runtime/task-event', + '/api/teams/:teamName/opencode/runtime/heartbeat', +] as const; +export const EXPECTED_COMMANDS = [ + 'runtime.bootstrap-checkin', + 'runtime.deliver-message', + 'runtime.task-event', + 'runtime.heartbeat', + 'runtime.permission-answer', +] as const; +export const EXPECTED_PROVIDERS = ['anthropic', 'codex', 'gemini', 'opencode'] as const; +export const EXPECTED_MODES = [ + 'primary_only', + 'pure_opencode', + 'pure_opencode_solo', + 'pure_opencode_member_lanes', + 'mixed_opencode_side_lanes', + 'unsupported_opencode_led_mixed_team', +] as const; +export const EXPECTED_MATRIX_CASES = [ + 'homogeneous_anthropic', + 'homogeneous_codex', + 'homogeneous_gemini', + 'homogeneous_opencode', + 'mixed_provider_team', + 'missing_runtime', + 'missing_auth', + 'unsupported_backend', + 'malformed_capability_response', + 'process_timeout', + 'partial_launch', + 'restart_adoption', + 'opencode_secondary_lane_recovery', +] as const; +export const EXPECTED_FAKE_RUNTIME_SEAMS: Record< + (typeof EXPECTED_MATRIX_CASES)[number], + { seam: string; path: string; token: string } +> = { + homogeneous_anthropic: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + homogeneous_codex: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + homogeneous_gemini: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + homogeneous_opencode: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + mixed_provider_team: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + missing_runtime: { + seam: 'adapter', + path: 'src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts', + token: 'export class OpenCodeTeamRuntimeAdapter', + }, + missing_auth: { + seam: 'preflight', + path: 'src/main/services/team/provisioning/TeamProvisioningProviderPreflight.ts', + token: 'export function extractAuthStatusReadiness', + }, + unsupported_backend: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + malformed_capability_response: { + seam: 'capability_response_parser', + path: 'src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts', + token: 'export async function detectOpenCodeApiCapabilities', + }, + process_timeout: { + seam: 'adapter', + path: 'src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts', + token: 'export class OpenCodeTeamRuntimeAdapter', + }, + partial_launch: { + seam: 'adapter', + path: 'src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts', + token: 'export class OpenCodeTeamRuntimeAdapter', + }, + restart_adoption: { + seam: 'recovery', + path: 'src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts', + token: 'export async function recoverStaleMixedSecondaryLaunchSnapshotWithPorts', + }, + opencode_secondary_lane_recovery: { + seam: 'recovery', + path: 'src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts', + token: 'export async function recoverStaleMixedSecondaryLaunchSnapshotWithPorts', + }, +}; +export const ENVIRONMENT_DISCOVERY_ROOTS = [ + 'src/main', + 'src/features/codex-account', + 'src/features/member-work-sync', + 'src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts', +] as const; +export const ENVIRONMENT_DISCOVERY_EXCLUDED_SEGMENTS = ['/__tests__/', '/renderer/']; +export const PROVIDER_RUNTIME_ROUTING_KEYS = [ + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', + 'CLAUDE_CODE_ENTRY_PROVIDER', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_CODEX_BACKEND', + 'CLAUDE_CODE_GEMINI_BACKEND', +] as const; +export const NON_ENVIRONMENT_LITERALS = new Set([ + 'AgentStudio', + 'AppData', + 'Local', + 'EEXIST', + 'ENOENT', + 'NFKD', + 'Atomic', + 'Details', +]); + +export type JsonRecord = Record; + +export interface SurfaceFixture { + routes: string[]; + commands: string[]; + providers: string[]; + modes: string[]; +} + +export interface ProviderModeIngressFixture { + authorityModel: string; + dispositions: Array<{ + provider: string; + mode: string; + disposition: + | 'current_source_observed_runtime_ingress' + | 'current_source_observed_no_runtime_ingress'; + operations: string[]; + authorityRefs: Array<{ path: string; token: string }>; + targetStatus: string; + }>; +} + +export interface EnvironmentSemanticsFixture { + schemaVersion: number; + canonicalBaseSha: string; + derivation: string; + delegatedExecutableSemantics: { + keys: string[]; + authorityPaths: string[]; + proofTestId: string; + }; + entries: Array<{ + key: string; + policyProfileId: string; + semanticRole: string; + providerBindings: Array<{ + providerId: string; + backendFamily: string; + targetDisposition: string; + }>; + platformScope: string; + childVisibility: string; + credentialExposureSetId: string; + providerlessProhibition?: { + scope: string; + targetDisposition: string; + reason: string; + }; + authority: { path: string; token: string }; + }>; +} + +export interface ProviderRuntimeRoutingObservation { + key: (typeof PROVIDER_RUNTIME_ROUTING_KEYS)[number]; + providerId: 'anthropic' | 'codex' | 'gemini'; + backendFamily: 'provisioning_cli_primary'; + runtimeBackend: + | 'anthropic_default' + | 'anthropic_bedrock' + | 'anthropic_vertex' + | 'anthropic_foundry' + | 'anthropic_claude_platform_aws' + | 'codex_configured' + | 'gemini_configured'; + targetDisposition: 'required' | 'optional' | 'forbidden'; + emissionDisposition: + | 'emitted_always' + | 'emitted_when_backend_selected' + | 'emitted_configured_backend' + | 'preserved_when_custom_configuration' + | 'removed_before_spawn'; +} + +export function readJson(path: string): JsonRecord { + return JSON.parse(readFileSync(path, 'utf8')) as JsonRecord; +} + +export function extractQuoted(source: string, pattern: RegExp): string[] { + return [...source.matchAll(pattern)].map((match) => match[1]); +} + +export function compareSet( + label: string, + actual: readonly string[], + expected: readonly string[] +): string[] { + const errors = compareUnique(label, [...actual]); + const actualSet = new Set(actual); + for (const value of expected) + if (!actualSet.has(value)) errors.push(`${label}: missing ${value}`); + for (const value of actualSet) + if (!expected.includes(value)) errors.push(`${label}: unexpected ${value}`); + return errors; +} + +export function compareUnique(label: string, values: string[]): string[] { + const seen = new Set(); + const errors: string[] = []; + for (const value of values) { + if (!value) errors.push(`${label}: empty value`); + else if (seen.has(value)) errors.push(`${label}: duplicate ${value}`); + seen.add(value); + } + return errors; +} diff --git a/scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts b/scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts new file mode 100644 index 0000000000..d102a94cf5 --- /dev/null +++ b/scripts/hosted-web/phase-0/provider-runtime/scan-runtime-surfaces.ts @@ -0,0 +1,1221 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { extname, join, relative, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const PHASE_START_SHA = 'c72fd201867b9bcd1ef77d5e0f95ba379adb4fca'; +const EVIDENCE_ROOT = 'docs/research/hosted-web/phase-0/provider-runtime'; + +const ARTIFACTS = [ + 'execution-topology.json', + 'runtime-ingress-inventory.json', + 'environment-provenance.json', + 'credential-exposure-matrix.json', + 'fake-runtime-fixture-matrix.json', + 'estimate-input.json', +] as const; + +const EXPECTED_ROUTES = [ + '/api/teams/:teamName/opencode/runtime/bootstrap-checkin', + '/api/teams/:teamName/opencode/runtime/deliver-message', + '/api/teams/:teamName/opencode/runtime/task-event', + '/api/teams/:teamName/opencode/runtime/heartbeat', + '/api/teams/:teamName/opencode/runtime/permission-answer', +] as const; +const EXPECTED_COMMANDS = [ + 'runtime.bootstrap-checkin', + 'runtime.deliver-message', + 'runtime.task-event', + 'runtime.heartbeat', + 'runtime.permission-answer', +] as const; +const EXPECTED_PROVIDERS = ['anthropic', 'codex', 'gemini', 'opencode'] as const; +const EXPECTED_MODES = [ + 'primary_only', + 'pure_opencode', + 'pure_opencode_solo', + 'pure_opencode_worktree_root_lanes', + 'mixed_opencode_side_lanes', + 'unsupported_opencode_led_mixed_team', +] as const; +const EXPECTED_MATRIX_CASES = [ + 'homogeneous_anthropic', + 'homogeneous_codex', + 'homogeneous_gemini', + 'homogeneous_opencode', + 'mixed_provider_team', + 'missing_runtime', + 'missing_auth', + 'unsupported_backend', + 'malformed_capability_response', + 'process_timeout', + 'partial_launch', + 'restart_adoption', + 'opencode_secondary_lane_recovery', +] as const; +const EXPECTED_FAKE_RUNTIME_SEAMS: Record< + (typeof EXPECTED_MATRIX_CASES)[number], + { seam: string; path: string; token: string } +> = { + homogeneous_anthropic: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + homogeneous_codex: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + homogeneous_gemini: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + homogeneous_opencode: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + mixed_provider_team: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + missing_runtime: { + seam: 'adapter', + path: 'src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts', + token: 'export class OpenCodeTeamRuntimeAdapter', + }, + missing_auth: { + seam: 'preflight', + path: 'src/main/services/team/provisioning/TeamProvisioningProviderPreflight.ts', + token: 'export function extractAuthStatusReadiness', + }, + unsupported_backend: { + seam: 'planner', + path: 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts', + token: 'export function planTeamRuntimeLanes', + }, + malformed_capability_response: { + seam: 'capability_response_parser', + path: 'src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts', + token: 'export async function detectOpenCodeApiCapabilities', + }, + process_timeout: { + seam: 'adapter', + path: 'src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts', + token: 'export class OpenCodeTeamRuntimeAdapter', + }, + partial_launch: { + seam: 'adapter', + path: 'src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts', + token: 'export class OpenCodeTeamRuntimeAdapter', + }, + restart_adoption: { + seam: 'recovery', + path: 'src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts', + token: 'export async function recoverStaleMixedSecondaryLaunchSnapshotWithPorts', + }, + opencode_secondary_lane_recovery: { + seam: 'recovery', + path: 'src/main/services/team/provisioning/TeamProvisioningStaleMixedSecondaryRecovery.ts', + token: 'export async function recoverStaleMixedSecondaryLaunchSnapshotWithPorts', + }, +}; +const ENVIRONMENT_DISCOVERY_ROOTS = [ + 'src/main', + 'src/features/codex-account', + 'src/features/member-work-sync', + 'src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts', +] as const; +const ENVIRONMENT_DISCOVERY_EXCLUDED_SEGMENTS = ['/__tests__/', '/renderer/']; +const PROVIDER_RUNTIME_ROUTING_KEYS = [ + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', + 'CLAUDE_CODE_ENTRY_PROVIDER', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_CODEX_BACKEND', + 'CLAUDE_CODE_GEMINI_BACKEND', +] as const; +const NON_ENVIRONMENT_LITERALS = new Set([ + 'AgentStudio', + 'AppData', + 'Local', + 'EEXIST', + 'ENOENT', + 'NFKD', + 'Atomic', + 'Details', +]); + +type JsonRecord = Record; + +export interface SurfaceFixture { + routes: string[]; + commands: string[]; + providers: string[]; + modes: string[]; +} + +export interface ProviderModeIngressFixture { + authorityModel: string; + dispositions: Array<{ + provider: string; + mode: string; + disposition: + | 'current_source_observed_runtime_ingress' + | 'current_source_observed_no_runtime_ingress'; + operations: string[]; + authorityRefs: Array<{ path: string; token: string }>; + targetStatus: string; + }>; +} + +export interface EnvironmentSemanticsFixture { + schemaVersion: number; + canonicalBaseSha: string; + derivation: string; + delegatedExecutableSemantics: { + keys: string[]; + authorityPaths: string[]; + proofTestId: string; + }; + entries: Array<{ + key: string; + policyProfileId: string; + semanticRole: string; + providerBindings: Array<{ + providerId: string; + backendFamily: string; + targetDisposition: string; + }>; + platformScope: string; + childVisibility: string; + credentialExposureSetId: string; + providerlessProhibition?: { + scope: string; + targetDisposition: string; + reason: string; + }; + authority: { path: string; token: string }; + }>; +} + +export interface ProviderRuntimeRoutingObservation { + key: (typeof PROVIDER_RUNTIME_ROUTING_KEYS)[number]; + providerId: 'anthropic' | 'codex' | 'gemini'; + backendFamily: 'provisioning_cli_primary'; + runtimeBackend: + | 'anthropic_default' + | 'anthropic_bedrock' + | 'anthropic_vertex' + | 'anthropic_foundry' + | 'anthropic_claude_platform_aws' + | 'codex_configured' + | 'gemini_configured'; + targetDisposition: 'required' | 'optional' | 'forbidden'; + emissionDisposition: + | 'emitted_always' + | 'emitted_when_backend_selected' + | 'emitted_configured_backend' + | 'preserved_when_custom_configuration' + | 'removed_before_spawn'; +} + +function readJson(path: string): JsonRecord { + return JSON.parse(readFileSync(path, 'utf8')) as JsonRecord; +} + +function extractQuoted(source: string, pattern: RegExp): string[] { + return [...source.matchAll(pattern)].map((match) => match[1]); +} + +function compareSet( + label: string, + actual: readonly string[], + expected: readonly string[] +): string[] { + const errors = compareUnique(label, [...actual]); + const actualSet = new Set(actual); + for (const value of expected) + if (!actualSet.has(value)) errors.push(`${label}: missing ${value}`); + for (const value of actualSet) + if (!expected.includes(value)) errors.push(`${label}: unexpected ${value}`); + return errors; +} + +function compareUnique(label: string, values: string[]): string[] { + const seen = new Set(); + const errors: string[] = []; + for (const value of values) { + if (!value) errors.push(`${label}: empty value`); + else if (seen.has(value)) errors.push(`${label}: duplicate ${value}`); + seen.add(value); + } + return errors; +} + +export function validateSurfaceFixture(fixture: SurfaceFixture): string[] { + return [ + ...compareSet('routes', fixture.routes, EXPECTED_ROUTES), + ...compareSet('commands', fixture.commands, EXPECTED_COMMANDS), + ...compareSet('providers', fixture.providers, EXPECTED_PROVIDERS), + ...compareSet('modes', fixture.modes, EXPECTED_MODES), + ]; +} + +function scanSource(root: string): SurfaceFixture { + const routesSource = readFileSync(resolve(root, 'src/main/http/teams.ts'), 'utf8'); + const commandSource = readFileSync( + resolve(root, 'src/main/services/team/runtime-control/domain/RuntimeControlCommand.ts'), + 'utf8' + ); + const providerSource = readFileSync( + resolve(root, 'src/main/services/team/runtime/TeamRuntimeAdapter.ts'), + 'utf8' + ); + const plannerSource = readFileSync( + resolve(root, 'src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts'), + 'utf8' + ); + return { + routes: extractQuoted( + routesSource, + /['"](\/api\/teams\/:teamName\/opencode\/runtime\/[^'"]+)['"]/g + ), + commands: extractQuoted(commandSource, /\|\s*['"](runtime\.[a-z-]+)['"]/g), + providers: extractQuoted( + providerSource, + /TEAM_RUNTIME_PROVIDER_IDS\s*=\s*\[([^\]]+)\]/gs + ).flatMap((body) => extractQuoted(body, /['"]([a-z]+)['"]/g)), + modes: [ + ...new Set([ + ...extractQuoted(plannerSource, /mode:\s*['"]([a-z_]+)['"]/g), + ...extractQuoted(plannerSource, /reason:\s*['"]([a-z_]+)['"]/g), + ]), + ], + }; +} + +function matchesType(value: unknown, declared: unknown): boolean { + if (Array.isArray(declared)) return declared.some((type) => matchesType(value, type)); + if (declared === 'null') return value === null; + if (declared === 'array') return Array.isArray(value); + if (declared === 'object') + return value !== null && typeof value === 'object' && !Array.isArray(value); + if (declared === 'integer') return Number.isInteger(value); + return typeof value === declared; +} + +function validateSchema(value: unknown, schema: JsonRecord, path: string): string[] { + const errors: string[] = []; + if ('const' in schema && value !== schema.const) errors.push(`${path}: violates const`); + if (Array.isArray(schema.enum) && !schema.enum.some((entry) => Object.is(entry, value))) + errors.push(`${path}: violates enum`); + if (schema.type !== undefined && !matchesType(value, schema.type)) + return [...errors, `${path}: expected ${String(schema.type)}`]; + if ( + typeof value === 'string' && + typeof schema.minLength === 'number' && + value.length < schema.minLength + ) + errors.push(`${path}: below minLength`); + if (typeof value === 'number' && typeof schema.minimum === 'number' && value < schema.minimum) + errors.push(`${path}: below minimum`); + if (typeof value === 'number' && typeof schema.maximum === 'number' && value > schema.maximum) + errors.push(`${path}: above maximum`); + if (Array.isArray(value)) { + if (typeof schema.minItems === 'number' && value.length < schema.minItems) + errors.push(`${path}: below minItems`); + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) + errors.push(`${path}: above maxItems`); + if ( + schema.uniqueItems && + new Set(value.map((entry) => JSON.stringify(entry))).size !== value.length + ) + errors.push(`${path}: items not unique`); + if (Array.isArray(schema.prefixItems)) + schema.prefixItems.forEach((rule, index) => { + if (index < value.length) + errors.push(...validateSchema(value[index], rule as JsonRecord, `${path}[${index}]`)); + }); + if ( + schema.items === false && + value.length > ((schema.prefixItems as unknown[] | undefined)?.length ?? 0) + ) + errors.push(`${path}: unexpected tuple item`); + if (schema.items && typeof schema.items === 'object') + value.forEach((entry, index) => + errors.push(...validateSchema(entry, schema.items as JsonRecord, `${path}[${index}]`)) + ); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + const object = value as JsonRecord; + for (const key of (schema.required as string[] | undefined) ?? []) + if (!(key in object)) errors.push(`${path}: missing required ${key}`); + const properties = (schema.properties as Record | undefined) ?? {}; + if (schema.additionalProperties === false) { + for (const key of Object.keys(object)) + if (!(key in properties)) errors.push(`${path}: unknown property ${key}`); + } + for (const [key, rule] of Object.entries(properties)) + if (key in object) errors.push(...validateSchema(object[key], rule, `${path}.${key}`)); + } + if (Array.isArray(schema.anyOf)) { + const valid = schema.anyOf.some( + (branch) => validateSchema(value, branch as JsonRecord, path).length === 0 + ); + if (!valid) errors.push(`${path}: violates anyOf`); + } + if (Array.isArray(schema.oneOf)) { + const branchErrors = schema.oneOf.map((branch) => + validateSchema(value, branch as JsonRecord, path) + ); + const validCount = branchErrors.filter((branch) => branch.length === 0).length; + if (validCount !== 1) { + errors.push(`${path}: violates oneOf`); + if (validCount === 0) errors.push(...branchErrors[0]); + } + } + if (schema.not && typeof schema.not === 'object') { + if (validateSchema(value, schema.not as JsonRecord, path).length === 0) { + errors.push(`${path}: violates not`); + } + } + return errors; +} + +export function validateArtifactDocument( + root: string, + file: string, + document: JsonRecord +): string[] { + const schemaRef = document.$schema; + if (typeof schemaRef !== 'string' || !schemaRef.startsWith('./schemas/')) + return [`${file}: invalid schema reference`]; + const schema = readJson(resolve(root, EVIDENCE_ROOT, schemaRef.slice(2))); + return validateSchema(document, schema, file); +} + +function extractEnvironmentTokens(source: string): string[] { + const environmentObject = '(?:process\\.env|[A-Za-z][A-Za-z0-9_]*(?:Env|Environment)|env)'; + const candidates = [ + ...extractQuoted(source, new RegExp(`${environmentObject}\\.([A-Z][A-Za-z0-9_]*)`, 'g')), + ...extractQuoted( + source, + new RegExp(`${environmentObject}\\[['"]([A-Z][A-Za-z0-9_]*|npm_config_[a-z_]+)['"]\\]`, 'g') + ), + ...extractQuoted( + source, + /\b[A-Z][A-Z0-9_]*(?:ENV|ENV_VAR|ENV_KEY)\s*=\s*['"]([A-Z][A-Za-z0-9_]*|npm_config_[a-z_]+)['"]/g + ), + ...extractQuoted( + source, + /\b[A-Za-z][A-Za-z0-9_]*Env[A-Za-z0-9_]*\([^;]{0,300}?['"]([A-Z][A-Za-z0-9_]*|npm_config_[a-z_]+)['"]/g + ), + ...extractQuoted( + source, + /(?:[A-Za-z][A-Za-z0-9_]*Env[A-Za-z0-9_]*|env|assignments)\.set\(\s*['"]([A-Z][A-Za-z0-9_]*|npm_config_[a-z_]+)['"]/g + ), + ]; + for (const match of source.matchAll( + /\b[A-Z][A-Z0-9_]*ENV[A-Z0-9_]*(?:KEYS|VARS|MARKERS)\s*=\s*\[([^\]]+)\]/gs + )) { + candidates.push( + ...extractQuoted(match[1], /['"]([A-Z][A-Za-z0-9_]*|npm_config_[a-z_]+)=?['"]/g) + ); + } + for (const match of source.matchAll( + /\b[A-Z][A-Z0-9_]*ENV_KEYS\s*=\s*new Set\(\s*\[([\s\S]*?)\]\s*\)/g + )) { + candidates.push(...extractQuoted(match[1], /['"]([A-Z][A-Za-z0-9_]*|npm_config_[a-z_]+)['"]/g)); + } + for (const match of source.matchAll( + /\b[A-Z][A-Z0-9_]*ENV_PREFIXES\s*=\s*\[([\s\S]*?)\]\s*(?:as const)?;/g + )) { + candidates.push( + ...extractQuoted(match[1], /['"]([A-Z][A-Za-z0-9_]*)['"]/g).map((prefix) => `${prefix}*`) + ); + } + for (const match of source.matchAll( + /\b(?:const|let)\s+(?:[A-Za-z][A-Za-z0-9_]*(?:Env|Environment)[A-Za-z0-9_]*|[A-Z][A-Z0-9_]*ENV[A-Z0-9_]*|env)(?:\s*:[^=]+)?\s*=\s*{([\s\S]{0,20000}?)\n\s*};/g + )) { + candidates.push(...extractQuoted(match[1], /\b([A-Z][A-Za-z0-9_]{2,})\s*:/g)); + } + for (const match of source.matchAll(/\benv\s*:\s*{([\s\S]{0,10000}?)\n\s*}/g)) { + candidates.push(...extractQuoted(match[1], /\b([A-Z][A-Za-z0-9_]{2,})\s*:/g)); + } + for (const match of source.matchAll( + /\b[A-Za-z][A-Za-z0-9_]*(?:Env|Environment)[A-Za-z0-9_]*\(\s*{([\s\S]{0,10000}?)\n\s*}\s*\)/g + )) { + candidates.push(...extractQuoted(match[1], /\b([A-Z][A-Za-z0-9_]{2,})\s*:/g)); + } + return candidates.filter((key) => !NON_ENVIRONMENT_LITERALS.has(key)); +} + +function walkProductionSources(root: string, relativeRoot: string): string[] { + const absoluteRoot = resolve(root, relativeRoot); + const files: string[] = []; + if (statSync(absoluteRoot).isFile()) return [relativeRoot]; + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolutePath = join(directory, entry.name); + if (entry.isDirectory()) visit(absolutePath); + else if (entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name))) { + const path = `/${relative(root, absolutePath).replaceAll('\\', '/')}`; + if ( + !path.endsWith('.test.ts') && + !path.endsWith('.test.tsx') && + !ENVIRONMENT_DISCOVERY_EXCLUDED_SEGMENTS.some((segment) => path.includes(segment)) + ) + files.push(path.slice(1)); + } + } + }; + visit(absoluteRoot); + return files; +} + +export function discoverEnvironmentKeys(root: string): Map { + const occurrences = new Map(); + const sources = ENVIRONMENT_DISCOVERY_ROOTS.flatMap((sourceRoot) => + walkProductionSources(root, sourceRoot) + ); + for (const path of sources) { + const source = readFileSync(resolve(root, path), 'utf8'); + for (const key of extractEnvironmentTokens(source)) { + const paths = occurrences.get(key) ?? []; + if (!paths.includes(path)) paths.push(path); + occurrences.set(key, paths); + } + } + return occurrences; +} + +export function validateEnvironmentCompleteness( + root: string, + document: JsonRecord, + knownOccurrences?: Map +): string[] { + const rows = document.records as JsonRecord[]; + const classified = rows.flatMap((row) => (row.keys as string[]) ?? []); + const errors = compareUnique('environment keys', classified); + const discovery = document.sourceDiscovery as JsonRecord; + errors.push( + ...compareSet( + 'environment discovery roots', + discovery.roots as string[], + ENVIRONMENT_DISCOVERY_ROOTS + ), + ...compareSet( + 'environment discovery exclusions', + discovery.excludedSegments as string[], + ENVIRONMENT_DISCOVERY_EXCLUDED_SEGMENTS + ), + ...compareSet('environment discovery extensions', discovery.extensions as string[], [ + '.ts', + '.tsx', + ]) + ); + const discovered = knownOccurrences ?? discoverEnvironmentKeys(root); + const sourceClassified = rows + .filter((row) => row.discoveryDisposition === 'source_discovered') + .flatMap((row) => (row.keys as string[]) ?? []); + const sourceClassifiedSet = new Set(sourceClassified); + for (const key of discovered.keys()) + if (!sourceClassifiedSet.has(key)) + errors.push(`environment-provenance.json: discovered unclassified key ${key}`); + for (const key of sourceClassified) + if (!discovered.has(key)) + errors.push(`environment-provenance.json: classified key has no source occurrence ${key}`); + for (const row of rows.filter( + (candidate) => candidate.discoveryDisposition === 'fixture_bound' + )) { + const keys = row.keys as string[]; + const bindings = row.keyBindings as JsonRecord[]; + const bindingKeys = bindings.map((binding) => String(binding.key)); + errors.push(...compareSet(`environment fixture bindings ${String(row.id)}`, bindingKeys, keys)); + for (const binding of bindings) { + const key = String(binding.key); + const path = String(binding.path); + const source = readFileSync(resolve(root, path), 'utf8'); + if (!source.includes(key)) + errors.push(`environment-provenance.json: stale fixture binding ${key} in ${path}`); + } + } + for (const row of rows) { + const source = readFileSync(resolve(root, String(row.source)), 'utf8'); + if (!source.includes(String(row.sourceToken))) + errors.push(`environment-provenance.json: stale source token for ${String(row.id)}`); + } + const keyEvidence = resolvePerKeyEnvironmentEvidence(document); + errors.push(...validatePerKeyEnvironmentEvidenceCoverage(document)); + const keyTable = document.keyEvidence as JsonRecord; + errors.push( + ...compareSet('per-key evidence fields', keyTable.fields as string[], [ + 'key', + 'groupId', + 'policyProfileId', + 'probePathId', + ]), + ...compareUnique( + 'per-key policy profile ids', + (document.keyPolicyProfiles as JsonRecord[]).map((profile) => String(profile.id)) + ), + ...compareUnique( + 'per-key probe path ids', + (keyTable.probePaths as JsonRecord[]).map((path) => String(path.id)) + ) + ); + const groupById = new Map(rows.map((row) => [String(row.id), row])); + const policyProfileIds = new Set( + (document.keyPolicyProfiles as JsonRecord[]).map((profile) => String(profile.id)) + ); + for (const entry of keyEvidence) { + const key = String(entry.key); + const group = groupById.get(String(entry.groupId)); + if (!group || !(group.keys as string[]).includes(key)) + errors.push(`environment-provenance.json: ${key} has invalid group binding`); + if (!policyProfileIds.has(String(entry.policyProfileId))) { + errors.push(`environment-provenance.json: ${key} has invalid policy profile`); + continue; + } + const probe = entry.probe as JsonRecord; + const path = String(probe.path); + const token = String(probe.token); + if (!path) { + errors.push(`environment-provenance.json: ${key} has invalid probe path`); + continue; + } + const source = readFileSync(resolve(root, path), 'utf8'); + if (!source.includes(token)) + errors.push(`environment-provenance.json: ${key} has stale exact probe ${path}#${token}`); + if (probe.kind === 'source_census' && !(discovered.get(key) ?? []).includes(path)) + errors.push( + `environment-provenance.json: ${key} probe is not a discovered source occurrence` + ); + if ((entry.executionUnitIds as string[]).length === 0) + errors.push(`environment-provenance.json: ${key} has no execution unit`); + if ((entry.credentialExposureSetIds as string[]).length !== 1) + errors.push(`environment-provenance.json: ${key} must bind exactly one exposure set`); + } + return errors; +} + +export function validatePerKeyEnvironmentEvidenceCoverage(document: JsonRecord): string[] { + const classified = (document.records as JsonRecord[]).flatMap( + (row) => (row.keys as string[]) ?? [] + ); + const keyEvidence = resolvePerKeyEnvironmentEvidence(document); + return [ + ...compareSet( + 'per-key environment evidence', + keyEvidence.map((entry) => String(entry.key)), + classified + ), + ...compareUnique( + 'per-key environment evidence ids', + keyEvidence.map((entry) => String(entry.id)) + ), + ]; +} + +export function resolvePerKeyEnvironmentEvidence(document: JsonRecord): JsonRecord[] { + const profiles = document.keyPolicyProfiles as JsonRecord[]; + const profileById = new Map(profiles.map((profile) => [String(profile.id), profile])); + const groupById = new Map( + (document.records as JsonRecord[]).map((group) => [String(group.id), group]) + ); + const table = document.keyEvidence as JsonRecord; + const paths = table.probePaths as JsonRecord[]; + const pathById = new Map(paths.map((entry) => [String(entry.id), String(entry.path)])); + return (table.rows as unknown[][]).map((row) => { + const [key, groupId, policyProfileId, probePathId] = row.map(String); + const profile = profileById.get(policyProfileId) ?? {}; + const group = groupById.get(groupId) ?? {}; + const sourceClass = String(profile.sourceClass); + const exactKeyProbe = ['production_source_census', 'checked_fixture_binding'].includes( + sourceClass + ); + const targetProbe = sourceClass === 'target_contract_prohibition'; + return { + ...profile, + id: `env-key:${key}`, + key, + groupId, + policyProfileId, + probe: { + kind: exactKeyProbe + ? sourceClass === 'production_source_census' + ? 'source_census' + : 'fixture_binding' + : targetProbe + ? 'target_contract' + : 'source_anchor', + path: pathById.get(probePathId) ?? '', + token: exactKeyProbe + ? key.endsWith('*') + ? key.slice(0, -1) + : key + : String(group.sourceToken ?? ''), + assertion: exactKeyProbe + ? 'path_contains_exact_key' + : targetProbe + ? 'contract_contains_prohibition' + : 'source_contains_anchor', + }, + }; + }); +} + +export function validateCredentialExposureLinks( + root: string, + environment: JsonRecord, + credentialMatrix: JsonRecord +): string[] { + const errors: string[] = []; + const keys = resolvePerKeyEnvironmentEvidence(environment); + const sets = credentialMatrix.exposureSets as JsonRecord[]; + errors.push( + ...compareUnique( + 'credential exposure set ids', + sets.map((set) => String(set.id)) + ) + ); + const memberships = new Map(); + for (const set of sets) { + const setId = String(set.id); + readFileSync(resolve(root, String(set.provenanceArtifact)), 'utf8'); + readFileSync(resolve(root, String(set.probeTest).split('#')[0]), 'utf8'); + for (const keyId of set.memberKeyEvidenceIds as string[]) { + const current = memberships.get(keyId) ?? []; + current.push(setId); + memberships.set(keyId, current); + } + } + errors.push( + ...compareSet( + 'credential exposure key membership', + [...memberships.keys()], + keys.map((entry) => String(entry.id)) + ) + ); + for (const key of keys) { + const keyId = String(key.id); + const declared = key.credentialExposureSetIds as string[]; + const linked = memberships.get(keyId) ?? []; + errors.push(...compareSet(`credential exposure membership ${keyId}`, linked, declared)); + } + const units = credentialMatrix.records as JsonRecord[]; + const knownSets = new Set(sets.map((set) => String(set.id))); + for (const unit of units) { + readFileSync(resolve(root, String(unit.source)), 'utf8'); + readFileSync(resolve(root, String(unit.probeTest)), 'utf8'); + for (const setId of unit.exposureSetIds as string[]) + if (!knownSets.has(setId)) + errors.push(`credential-exposure-matrix.json: unknown set ${setId} on ${String(unit.id)}`); + } + return errors; +} + +export function validateFakeRuntimeMatrix(root: string, matrix: JsonRecord): string[] { + const errors: string[] = []; + errors.push( + ...compareSet( + 'fake-runtime cases', + (matrix.records as JsonRecord[]).map((row) => String(row.case)), + EXPECTED_MATRIX_CASES + ) + ); + for (const row of matrix.records as JsonRecord[]) { + const caseName = String(row.case); + const expectedSeam = + EXPECTED_FAKE_RUNTIME_SEAMS[caseName as (typeof EXPECTED_MATRIX_CASES)[number]]; + const proof = row.executableProof as JsonRecord; + const authority = proof.authority as JsonRecord; + const authorityPath = String(authority.path); + const authorityToken = String(authority.token); + const authoritySource = readFileSync(resolve(root, authorityPath), 'utf8'); + if (!authoritySource.includes(authorityToken)) { + errors.push( + `fake-runtime ${caseName}: stale canonical seam ${authorityPath}#${authorityToken}` + ); + } + if ( + !expectedSeam || + proof.seam !== expectedSeam.seam || + authorityPath !== expectedSeam.path || + authorityToken !== expectedSeam.token + ) { + errors.push(`fake-runtime ${caseName}: wrong canonical seam binding`); + } + if (proof.runner !== 'vitest_canonical_runtime_seams_v1') { + errors.push(`fake-runtime ${caseName}: wrong runner`); + } + const expectedPositive = `w2.fake-runtime.${caseName}.positive`; + const expectedNegative = `w2.fake-runtime.${caseName}.failing-negative`; + if (proof.positiveTestId !== expectedPositive) { + errors.push(`fake-runtime ${caseName}: wrong positive test id`); + } + if (proof.failingNegativeTestId !== expectedNegative) { + errors.push(`fake-runtime ${caseName}: wrong failing-negative test id`); + } + const testFile = String(proof.testFile); + const testSource = readFileSync(resolve(root, testFile), 'utf8'); + for (const testId of [expectedPositive, expectedNegative]) { + if (!testSource.includes(`it('${testId}'`)) { + errors.push(`fake-runtime ${caseName}: missing independently addressable test ${testId}`); + } + } + if (row.proofLevel !== 'fixture_characterized') { + errors.push(`fake-runtime ${caseName}: unproved rows must remain explicit_gap`); + } + } + return errors; +} + +export function validateEnvironmentSemanticsFixture( + root: string, + environment: JsonRecord, + fixture: EnvironmentSemanticsFixture +): string[] { + const errors: string[] = []; + if (fixture.schemaVersion !== 2) + errors.push('environment semantics fixture: wrong schemaVersion'); + if (fixture.canonicalBaseSha !== PHASE_START_SHA) { + errors.push('environment semantics fixture: wrong canonicalBaseSha'); + } + const resolved = resolvePerKeyEnvironmentEvidence(environment); + errors.push( + ...compareSet( + 'environment semantics delegated executable keys', + fixture.delegatedExecutableSemantics.keys, + PROVIDER_RUNTIME_ROUTING_KEYS + ), + ...compareSet( + 'environment semantics delegated authority paths', + fixture.delegatedExecutableSemantics.authorityPaths, + [ + 'src/main/services/runtime/providerRuntimeEnv.ts', + 'src/main/services/runtime/buildRuntimeBaseEnv.ts', + 'src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts', + ] + ) + ); + if ( + fixture.delegatedExecutableSemantics.proofTestId !== + 'w2.environment.provider-routing.source-seam' + ) { + errors.push('environment semantics fixture: wrong delegated executable proof test'); + } + const fixtureResolved = resolved.filter( + (entry) => !PROVIDER_RUNTIME_ROUTING_KEYS.includes(String(entry.key) as never) + ); + errors.push( + ...compareSet( + 'environment semantics keys', + fixture.entries.map((entry) => entry.key), + fixtureResolved.map((entry) => String(entry.key)) + ) + ); + const expectedByKey = new Map(fixture.entries.map((entry) => [entry.key, entry])); + for (const actual of fixtureResolved) { + const key = String(actual.key); + const expected = expectedByKey.get(key); + if (!expected) continue; + const actualBindings = (actual.providerBindings as JsonRecord[]).map((binding) => ({ + providerId: String(binding.providerId), + backendFamily: String(binding.backendFamily), + targetDisposition: String(binding.targetDisposition), + })); + const dimensions: Array<[string, unknown, unknown]> = [ + ['policy profile', actual.policyProfileId, expected.policyProfileId], + ['semantic role', actual.semanticRole, expected.semanticRole], + ['provider/backend/disposition bindings', actualBindings, expected.providerBindings], + ['platform', actual.platformScope, expected.platformScope], + ['child visibility', actual.childVisibility, expected.childVisibility], + [ + 'providerless prohibition', + actual.providerlessProhibition, + expected.providerlessProhibition, + ], + [ + 'credential exposure', + (actual.credentialExposureSetIds as string[])[0], + expected.credentialExposureSetId, + ], + ['authority path', (actual.probe as JsonRecord).path, expected.authority.path], + ['authority token', (actual.probe as JsonRecord).token, expected.authority.token], + ]; + for (const [label, actualValue, expectedValue] of dimensions) { + if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) { + errors.push(`environment semantics ${key}: wrong ${label}`); + } + } + const source = readFileSync(resolve(root, expected.authority.path), 'utf8'); + if (!source.includes(expected.authority.token)) { + errors.push( + `environment semantics ${key}: stale source authority ${expected.authority.path}#${expected.authority.token}` + ); + } + } + return errors; +} + +export function validateProviderRuntimeRoutingSemantics( + environment: JsonRecord, + observations: ProviderRuntimeRoutingObservation[] +): string[] { + const errors: string[] = []; + const scenarioIds = observations.map( + (row) => `${row.key}:${row.providerId}:${row.runtimeBackend}` + ); + const expectedScenarioIds = PROVIDER_RUNTIME_ROUTING_KEYS.flatMap((key) => + [ + ['anthropic', 'anthropic_default'], + ['anthropic', 'anthropic_bedrock'], + ['anthropic', 'anthropic_vertex'], + ['anthropic', 'anthropic_foundry'], + ['anthropic', 'anthropic_claude_platform_aws'], + ['codex', 'codex_configured'], + ['gemini', 'gemini_configured'], + ].map(([providerId, runtimeBackend]) => `${key}:${providerId}:${runtimeBackend}`) + ); + errors.push( + ...compareSet('provider routing source observations', scenarioIds, expectedScenarioIds) + ); + + const resolved = resolvePerKeyEnvironmentEvidence(environment); + const byKey = new Map(resolved.map((entry) => [String(entry.key), entry])); + const authorityPathByKey = new Map([ + ['CLAUDE_CONFIG_DIR', 'src/main/services/team/provisioning/TeamProvisioningEnvBuilder.ts'], + ['CLAUDE_CODE_CODEX_BACKEND', 'src/main/services/runtime/buildRuntimeBaseEnv.ts'], + ['CLAUDE_CODE_GEMINI_BACKEND', 'src/main/services/runtime/buildRuntimeBaseEnv.ts'], + ]); + const expectedRoleByKey = new Map([ + ['CLAUDE_CONFIG_DIR', 'selected_child_input'], + ['CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'emitted_child_key'], + ['CLAUDE_CODE_ENTRY_PROVIDER', 'emitted_child_key'], + ['CLAUDE_CODE_USE_OPENAI', 'removed_child_key'], + ['CLAUDE_CODE_USE_BEDROCK', 'host_policy_input_and_emitted_child_key'], + ['CLAUDE_CODE_USE_VERTEX', 'host_policy_input_and_emitted_child_key'], + ['CLAUDE_CODE_USE_FOUNDRY', 'host_policy_input_and_emitted_child_key'], + ['CLAUDE_CODE_USE_GEMINI', 'removed_child_key'], + ['CLAUDE_CODE_CODEX_BACKEND', 'emitted_child_key'], + ['CLAUDE_CODE_GEMINI_BACKEND', 'emitted_child_key'], + ]); + + for (const key of PROVIDER_RUNTIME_ROUTING_KEYS) { + const actual = byKey.get(key); + if (!actual) { + errors.push(`provider routing semantics ${key}: missing per-key evidence`); + continue; + } + const expectedBindings = observations + .filter((row) => row.key === key) + .map(({ key: _key, ...binding }) => binding); + const actualBindings = (actual.providerBindings as JsonRecord[]).map((binding) => ({ + providerId: binding.providerId, + backendFamily: binding.backendFamily, + runtimeBackend: binding.runtimeBackend, + targetDisposition: binding.targetDisposition, + emissionDisposition: binding.emissionDisposition, + })); + if (JSON.stringify(actualBindings) !== JSON.stringify(expectedBindings)) { + errors.push(`provider routing semantics ${key}: wrong source-derived bindings`); + } + const expectedRole = expectedRoleByKey.get(key); + if (actual.semanticRole !== expectedRole) { + errors.push(`provider routing semantics ${key}: wrong semantic role`); + } + const onlyRemoved = expectedBindings.every( + (binding) => binding.emissionDisposition === 'removed_before_spawn' + ); + const expectedVisibility = onlyRemoved + ? 'absent_current_and_target' + : 'provider_child_visible_when_selected'; + if (actual.childVisibility !== expectedVisibility) { + errors.push(`provider routing semantics ${key}: wrong child visibility`); + } + const expectedAuthority = + authorityPathByKey.get(key) ?? 'src/main/services/runtime/providerRuntimeEnv.ts'; + if ((actual.probe as JsonRecord).path !== expectedAuthority) { + errors.push(`provider routing semantics ${key}: wrong source branch authority`); + } + } + + const profileGroups = [ + ['CLAUDE_CONFIG_DIR'], + ['CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'CLAUDE_CODE_ENTRY_PROVIDER'], + ['CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI'], + ['CLAUDE_CODE_USE_BEDROCK'], + ['CLAUDE_CODE_USE_VERTEX'], + ['CLAUDE_CODE_USE_FOUNDRY'], + ['CLAUDE_CODE_CODEX_BACKEND', 'CLAUDE_CODE_GEMINI_BACKEND'], + ]; + const groupProfileIds = profileGroups.map((keys) => { + const ids = new Set(keys.map((key) => String(byKey.get(key)?.policyProfileId ?? ''))); + if (ids.size !== 1) errors.push(`provider routing profile group ${keys.join(',')}: split`); + return [...ids][0]; + }); + errors.push(...compareUnique('provider routing behavior profile ids', groupProfileIds)); + return errors; +} + +export function verifyFakeRuntimeProofExecution(root: string, matrix: JsonRecord): string[] { + const records = matrix.records as JsonRecord[]; + const testFiles = [ + ...new Set(records.map((row) => String((row.executableProof as JsonRecord).testFile))), + ]; + if (testFiles.length !== 1) + return ['fake-runtime execution: matrix must bind one focused proof file']; + const expectedTestIds = records.flatMap((row) => { + const proof = row.executableProof as JsonRecord; + return [String(proof.positiveTestId), String(proof.failingNegativeTestId)]; + }); + const outputDir = mkdtempSync(join(tmpdir(), 'phase-00-w2-proof-')); + const outputFile = join(outputDir, 'vitest-results.json'); + try { + const result = spawnSync( + 'pnpm', + [ + 'exec', + 'vitest', + 'run', + testFiles[0], + '--reporter=json', + `--outputFile=${outputFile}`, + '--testNamePattern', + 'w2\\.fake-runtime\\.', + ], + { + cwd: root, + encoding: 'utf8', + env: { ...process.env, W2_FAKE_RUNTIME_PROOF_CHILD: '1' }, + } + ); + if (result.status !== 0) { + return [ + `fake-runtime execution failed (exit ${String(result.status)}): ${(result.stderr || result.stdout).trim()}`, + ]; + } + const report = readJson(outputFile); + const assertions = ((report.testResults as JsonRecord[]) ?? []).flatMap( + (suite) => (suite.assertionResults as JsonRecord[]) ?? [] + ); + const passed = new Set( + assertions + .filter((assertion) => assertion.status === 'passed') + .map((assertion) => String(assertion.title ?? assertion.fullName)) + ); + return compareSet('executed fake-runtime proof ids', [...passed], expectedTestIds); + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } +} + +export function validateProviderModeIngressFixture( + root: string, + fixture: ProviderModeIngressFixture +): string[] { + const topology = readJson(resolve(root, EVIDENCE_ROOT, 'execution-topology.json')); + const ingress = readJson(resolve(root, EVIDENCE_ROOT, 'runtime-ingress-inventory.json')); + const topologyRows = topology.records as JsonRecord[]; + const providers = topologyRows + .filter((row) => typeof row.providerIdentity === 'string') + .map((row) => String(row.providerIdentity)); + const opencodeModes = topologyRows + .filter((row) => typeof row.mode === 'string' && String(row.mode).includes('opencode')) + .flatMap((row) => String(row.mode).split('|')) + .filter((mode) => mode !== 'unsupported_opencode_led_mixed_team'); + const expectedPairs = [ + ...providers + .filter((provider) => provider !== 'opencode') + .map((provider) => `${provider}:primary_only`), + ...opencodeModes.map((mode) => `opencode:${mode}`), + ]; + const actualPairs = fixture.dispositions.map((row) => `${row.provider}:${row.mode}`); + const errors = compareSet('provider/mode dispositions', actualPairs, expectedPairs); + const ingressOperations = (ingress.records as JsonRecord[]).map((row) => String(row.commandKind)); + for (const row of fixture.dispositions) { + const isOpenCode = row.provider === 'opencode'; + const expectedDisposition = isOpenCode + ? 'current_source_observed_runtime_ingress' + : 'current_source_observed_no_runtime_ingress'; + if (row.disposition !== expectedDisposition) + errors.push(`${row.provider}:${row.mode}: wrong disposition ${row.disposition}`); + errors.push( + ...compareSet( + `${row.provider}:${row.mode}: operations`, + row.operations, + isOpenCode ? ingressOperations : [] + ) + ); + if (!row.targetStatus.includes('target-unverified')) + errors.push(`${row.provider}:${row.mode}: target-unverified status missing`); + if (row.authorityRefs.length === 0) + errors.push(`${row.provider}:${row.mode}: no independent authority references`); + for (const authority of row.authorityRefs) { + const source = readFileSync(resolve(root, authority.path), 'utf8'); + if (!source.includes(authority.token)) + errors.push( + `${row.provider}:${row.mode}: stale authority ${authority.path}#${authority.token}` + ); + } + } + return errors; +} + +function findSecretValues(value: unknown, path: string): string[] { + const errors: string[] = []; + if ( + typeof value === 'string' && + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|\bBearer\s+[A-Za-z0-9._-]{12,}|\bsk-[A-Za-z0-9]{12,}/i.test( + value + ) + ) + errors.push(`${path}: possible secret value`); + else if (Array.isArray(value)) + value.forEach((entry, index) => errors.push(...findSecretValues(entry, `${path}[${index}]`))); + else if (value && typeof value === 'object') { + for (const [key, entry] of Object.entries(value)) { + if (/^(secretValue|tokenValue|authPayload|rawProviderPayload)$/i.test(key)) + errors.push(`${path}.${key}: forbidden evidence field`); + errors.push(...findSecretValues(entry, `${path}.${key}`)); + } + } + return errors; +} + +function validateEstimate(document: JsonRecord): string[] { + const ranges = document.ranges as Record; + const errors: string[] = []; + for (const [name, range] of Object.entries(ranges)) + if (range.low > range.high) errors.push(`estimate-input.json: ${name} low exceeds high`); + const expectedLow = ranges.productionLines.low + ranges.testLines.low - ranges.deletedLines.high; + const expectedHigh = + ranges.productionLines.high + ranges.testLines.high - ranges.deletedLines.low; + if (ranges.netChangedLines.low !== expectedLow || ranges.netChangedLines.high !== expectedHigh) + errors.push('estimate-input.json: net range arithmetic mismatch'); + const w4 = document.w4Reconciliation as JsonRecord; + if (w4.sharedCanonicalBucket !== document.canonicalBucketId) + errors.push('estimate-input.json: W4 bucket mismatch'); + return errors; +} + +function validateEvidence(root: string): string[] { + const evidenceRoot = resolve(root, EVIDENCE_ROOT); + const errors: string[] = []; + for (const file of ARTIFACTS) { + const document = readJson(resolve(evidenceRoot, file)); + errors.push( + ...validateArtifactDocument(root, file, document), + ...findSecretValues(document, file) + ); + if (document.phaseStartSha !== PHASE_START_SHA) errors.push(`${file}: wrong phaseStartSha`); + if (Array.isArray(document.records)) + errors.push( + ...compareUnique( + `${file} record ids`, + (document.records as JsonRecord[]).map((row) => String(row.id ?? '')) + ) + ); + } + + const ingress = readJson(resolve(evidenceRoot, 'runtime-ingress-inventory.json')); + const ingressRows = ingress.records as JsonRecord[]; + errors.push( + ...compareSet( + 'inventory routes', + ingressRows.map((row) => String(row.currentRoute)), + EXPECTED_ROUTES + ) + ); + errors.push( + ...compareSet( + 'inventory commands', + ingressRows.map((row) => String(row.commandKind)), + EXPECTED_COMMANDS + ) + ); + const trust = ingress.trustSurfaceProof as JsonRecord; + if ((trust.authorityIntersection as unknown[]).length !== 0) + errors.push('runtime-ingress-inventory.json: browser/runtime authority overlaps'); + const operatorActions = new Set(trust.operatorOnlyActions as string[]); + const computedIntersection = (trust.runtimeOnlyActions as string[]).filter((action) => + operatorActions.has(action) + ); + if (computedIntersection.length > 0) + errors.push( + `runtime-ingress-inventory.json: computed authority overlap ${computedIntersection.join(', ')}` + ); + if ( + !String(trust.targetBrowserAuthority).includes('/api/hosted/v1') || + !String(trust.targetRuntimeAuthority).includes('/api/runtime/v1') || + !String(trust.targetRuntimeAuthority).includes('cannot invoke operator verbs') + ) { + errors.push('runtime-ingress-inventory.json: proposed trust split is incomplete'); + } + + const environment = readJson(resolve(evidenceRoot, 'environment-provenance.json')); + errors.push(...validateEnvironmentCompleteness(root, environment)); + const environmentSemantics = readJson( + resolve( + root, + 'test/architecture/hosted-web/phase-0/provider-runtime/fixtures/environment-semantics.json' + ) + ) as unknown as EnvironmentSemanticsFixture; + errors.push(...validateEnvironmentSemanticsFixture(root, environment, environmentSemantics)); + + const credentialMatrix = readJson(resolve(evidenceRoot, 'credential-exposure-matrix.json')); + errors.push(...validateCredentialExposureLinks(root, environment, credentialMatrix)); + + const matrix = readJson(resolve(evidenceRoot, 'fake-runtime-fixture-matrix.json')); + errors.push(...validateFakeRuntimeMatrix(root, matrix)); + + const positiveProviderModeFixture = readJson( + resolve( + root, + 'test/architecture/hosted-web/phase-0/provider-runtime/fixtures/provider-mode-ingress-positive.json' + ) + ) as unknown as ProviderModeIngressFixture; + const negativeProviderModeFixture = readJson( + resolve( + root, + 'test/architecture/hosted-web/phase-0/provider-runtime/fixtures/provider-mode-ingress-negative.json' + ) + ) as unknown as ProviderModeIngressFixture; + errors.push(...validateProviderModeIngressFixture(root, positiveProviderModeFixture)); + const negativeErrors = validateProviderModeIngressFixture(root, negativeProviderModeFixture); + if (negativeErrors.length === 0) + errors.push('provider/mode ingress negative fixture unexpectedly passed'); + + errors.push(...validateEstimate(readJson(resolve(evidenceRoot, 'estimate-input.json')))); + return errors; +} + +export function scanRepository(root: string): string[] { + return [...validateSurfaceFixture(scanSource(root)), ...validateEvidence(root)]; +} + +function main(): void { + const root = resolve(fileURLToPath(new URL('../../../../', import.meta.url))); + const matrix = readJson(resolve(root, EVIDENCE_ROOT, 'fake-runtime-fixture-matrix.json')); + const errors = [...scanRepository(root), ...verifyFakeRuntimeProofExecution(root, matrix)]; + if (errors.length > 0) { + errors.forEach((error) => process.stderr.write(`ERROR ${error}\n`)); + process.exitCode = 1; + return; + } + process.stdout.write( + `P0.W2.RUNTIME_SCANNER ok: 4 providers, 2 backend families, 5 operations, ${EXPECTED_MATRIX_CASES.length} independently executed positive/failing-negative provider cases, 7 independently sourced provider/mode dispositions; per-key provenance/exposure, strict nested schemas, omission-sensitive environment census, trust split and canonical estimate valid\n` + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) main(); diff --git a/scripts/hosted-web/phase-0/recovery-events/effect-recovery-worker.mjs b/scripts/hosted-web/phase-0/recovery-events/effect-recovery-worker.mjs new file mode 100644 index 0000000000..9fde796683 --- /dev/null +++ b/scripts/hosted-web/phase-0/recovery-events/effect-recovery-worker.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node +import { appendFileSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; + +const [storePath, externalPath, tracePath, recoveryClass, crashPause, mode] = process.argv.slice(2); + +if (!storePath || !externalPath || !tracePath || !recoveryClass || !crashPause || !mode) { + throw new Error('effect recovery worker requires store, external, trace, class, pause, and mode'); +} + +const load = (path) => JSON.parse(readFileSync(path, 'utf8')); +const save = (path, value) => { + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value)}\n`, 'utf8'); + renameSync(temporary, path); +}; +const trace = (message) => + appendFileSync(tracePath, `${JSON.stringify({ pid: process.pid, mode, message })}\n`, 'utf8'); + +const record = load(storePath); +const external = load(externalPath); +const compensationSchedule = crashPause.includes('compensation'); +let outcome = record.commandOutcome ?? 'running'; + +const persistRecord = () => save(storePath, record); +const persistExternal = () => save(externalPath, external); +const persist = (field, value) => { + record[field] = value; + persistRecord(); + trace(`persist:${field}=${value}`); +}; +const checkpoint = (name) => { + trace(`boundary:${name}`); + if (mode !== 'attempt' || name !== crashPause) return; + trace(`crash:${name}`); + process.exit(86); +}; +const invokeExternal = () => { + external.externalCallAttempts += 1; + if (external.externalOperationIds.includes('operation-1')) { + trace('adapter_deduplicated:operation-1'); + } else { + external.externalOperationIds.push('operation-1'); + external.externalEffects += 1; + trace('external_effect:operation-1'); + } + persistExternal(); +}; +const compensate = () => { + external.compensationAttempts += 1; + if (!external.compensationOperationIds.includes('compensation-1')) { + external.compensationOperationIds.push('compensation-1'); + external.compensationEffects += 1; + trace('compensation:compensation-1'); + } else { + trace('adapter_deduplicated:compensation-1'); + } + persistExternal(); + persist('compensationState', 'compensated'); + persist('state', 'compensated'); +}; +const publish = () => { + external.publicationAttempts += 1; + persistExternal(); + trace('publish:wakeup-for-durable-journal'); +}; +const commitCommand = (value = 'committed') => { + if (recoveryClass === 'transactional_local' && external.externalEffects === 0) { + external.externalEffects = 1; + external.externalOperationIds.push('transaction-operation-1'); + persistExternal(); + } + record.commandOutcome = value; + record.journalCommitted = true; + persistRecord(); + trace(`commit:command+outbox:${value}`); +}; +const proveAttempt = () => { + if (recoveryClass === 'transactional_local') { + persist('evidenceDisposition', 'same_transaction'); + return 'safe_to_continue'; + } + if (recoveryClass === 'non_reconcilable') { + if (!record.callMayHaveStarted) return 'safe_to_continue'; + persist('state', 'ambiguous'); + persist('evidenceDisposition', 'unproved'); + persist('commandOutcome', 'operator_required'); + outcome = 'operator_required'; + return 'stop'; + } + const exists = external.externalOperationIds.includes('operation-1'); + trace( + `${recoveryClass === 'idempotent_by_operation_id' ? 'lookup:operation-1' : 'lookup:operation-bound-evidence'}:${exists ? 'succeeded' : 'absent'}` + ); + if (!exists) invokeExternal(); + persist( + 'evidenceDisposition', + recoveryClass === 'idempotent_by_operation_id' + ? 'durable_operation_lookup' + : 'operation_bound_unique_evidence' + ); + persist('state', 'observed_succeeded'); + return 'safe_to_continue'; +}; + +function executeFromStart() { + checkpoint('before_attempting'); + persist('state', 'attempting'); + persist('callMayHaveStarted', false); + checkpoint('after_attempting'); + persist('callMayHaveStarted', true); + checkpoint('before_external_call'); + if (recoveryClass === 'transactional_local') { + trace('stage:transactional_local_effect'); + } else { + invokeExternal(); + } + checkpoint('after_external_call'); + checkpoint('before_evidence_query'); + persist( + 'evidenceDisposition', + recoveryClass === 'transactional_local' + ? 'same_transaction' + : recoveryClass === 'idempotent_by_operation_id' + ? 'durable_operation_lookup' + : recoveryClass === 'non_reconcilable' + ? 'explicit_in_call_ack' + : 'operation_bound_unique_evidence' + ); + persist('state', 'observed_succeeded'); + checkpoint('after_evidence_query'); + completeAfterEvidence(); +} + +function completeAfterEvidence() { + if (compensationSchedule) { + if (record.state !== 'compensated') { + persist('state', 'compensating'); + persist('compensationState', 'compensating'); + checkpoint('before_compensation'); + compensate(); + checkpoint('after_compensation'); + } + if (!record.commandOutcome) commitCommand('compensated'); + } else { + checkpoint('before_command_commit'); + if (!record.commandOutcome) commitCommand(); + checkpoint('after_command_commit'); + } + completePublication(); +} + +function completePublication() { + checkpoint('before_event_publication'); + publish(); + checkpoint('after_event_publication'); + outcome = record.commandOutcome ?? outcome; +} + +function recover() { + trace(`restart:load:${record.state}`); + if (record.commandOutcome === 'operator_required') { + outcome = 'operator_required'; + return; + } + if (record.commandOutcome) { + completePublication(); + return; + } + if (record.state === 'not_started') { + trace('recover:not_started:safe_to_begin'); + executeFromStart(); + return; + } + if (record.state === 'attempting') { + if (proveAttempt() === 'stop') return; + if (record.state === 'attempting') { + if (recoveryClass !== 'transactional_local') { + invokeExternal(); + if (recoveryClass === 'non_reconcilable') { + persist('evidenceDisposition', 'explicit_in_call_ack'); + } + } + persist('state', 'observed_succeeded'); + } + completeAfterEvidence(); + return; + } + if (record.state === 'observed_succeeded') { + trace(`recover:observed_succeeded:${record.evidenceDisposition}`); + completeAfterEvidence(); + return; + } + if (record.state === 'compensating') { + trace('recover:compensating'); + compensate(); + if (!record.commandOutcome) commitCommand('compensated'); + completePublication(); + return; + } + if (record.state === 'compensated') { + if (!record.commandOutcome) commitCommand('compensated'); + completePublication(); + } +} + +trace('process:start'); +if (mode === 'attempt') executeFromStart(); +else if (mode === 'recovery') recover(); +else throw new Error(`unknown effect recovery worker mode: ${mode}`); + +writeFileSync( + process.stdout.fd, + `${JSON.stringify({ pid: process.pid, outcome: record.commandOutcome ?? outcome })}\n`, + 'utf8' +); diff --git a/scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs b/scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs new file mode 100644 index 0000000000..34d1bd3eef --- /dev/null +++ b/scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs @@ -0,0 +1,650 @@ +#!/usr/bin/env node + +import { runRecoveryEvidenceGenerator } from './render-recovery-evidence.mjs'; + +const CHECK = process.argv.includes('--check'); + +const tx = (effectId = 'commit_state_and_event') => ({ + effectId, + recoveryClass: 'transactional_local', + candidateRecoveryClass: 'transactional_local', + proofRequired: 'command outcome and bounded journal row commit in the same internal transaction', + currentEvidence: 'missing_hosted_internal_storage', + ambiguousOutcome: 'recover_from_transaction', + automaticRecoveryAdmitted: false, + currentRecoveryDisposition: 'operator_required_until_transaction_exists', + writerAuthority: 'app-exclusive internal-storage worker', + writerEvidenceRef: 'P0.W3.WRITER_COORDINATION:sqlite.mutate', +}); +const op = (effectId, evidence) => ({ + effectId, + recoveryClass: 'non_reconcilable', + candidateRecoveryClass: 'idempotent_by_operation_id', + proofRequired: evidence, + currentEvidence: 'unproved_durable_lookup_or_writer_coordination', + ambiguousOutcome: 'operator_required', + automaticRecoveryAdmitted: false, + currentRecoveryDisposition: 'operator_required', + writerAuthority: 'external or compatibility writer; operation lookup unproved', + writerEvidenceRef: 'P0.W3.WRITER_COORDINATION', +}); +const unique = (effectId, evidence) => ({ + effectId, + recoveryClass: 'reconcilable_by_unique_evidence', + candidateRecoveryClass: 'reconcilable_by_unique_evidence', + proofRequired: evidence, + currentEvidence: 'missing_operation_bound_before_after_evidence', + ambiguousOutcome: 'prove_absent_or_succeeded_before_retry', + automaticRecoveryAdmitted: false, + currentRecoveryDisposition: 'operator_required', + writerAuthority: 'effect-specific external writer coordination required', + writerEvidenceRef: 'P0.W3.WRITER_COORDINATION', +}); +const nonrec = (effectId, reason) => ({ + effectId, + recoveryClass: 'non_reconcilable', + candidateRecoveryClass: 'non_reconcilable', + proofRequired: reason, + currentEvidence: 'boundary_can_be_ambiguous', + ambiguousOutcome: 'operator_required', + automaticRecoveryAdmitted: false, + currentRecoveryDisposition: 'operator_required', + writerAuthority: 'uncoordinated or acknowledgement-free external writer', + writerEvidenceRef: 'P0.W3.WRITER_COORDINATION', +}); + +function descriptor(commandKind, featureOwner, sourceMethods, normalizedIntentFields, effects) { + return { + commandKind, + featureOwner, + sourceMethods, + inputSchemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + idempotencyScope: 'deployment_actor_command_kind_key', + retentionClass: effects.some((effect) => effect.recoveryClass === 'non_reconcilable') + ? 'operator_resolution_plus_receipt_ttl' + : 'command_outcome_plus_receipt_ttl', + normalizedIntentFields, + fingerprintRecordFields: [ + 'descriptorId', + 'inputSchemaVersion', + 'fingerprintVersion', + 'keyVersion', + 'digest', + ], + effects: effects.map((effect, index) => ({ + effectOwner: featureOwner, + effectRole: index === 0 ? 'coordinator_effect' : 'secondary_effect', + ...effect, + })), + }; +} + +function buildCommandCatalog(mutationManifest) { + const commands = [ + descriptor( + 'team.soft_delete', + 'team-lifecycle', + ['deleteTeam'], + ['teamId', 'teamGeneration'], + [ + tx(), + unique( + 'move_team_to_tombstone', + 'operationId plus exact source/destination identity and generation' + ), + ] + ), + descriptor( + 'team.restore', + 'team-lifecycle', + ['restoreTeam'], + ['teamId', 'tombstoneGeneration'], + [ + tx(), + unique('restore_team_files', 'operationId plus tombstone and restored identity evidence'), + ] + ), + descriptor( + 'team.permanent_delete', + 'team-lifecycle', + ['permanentlyDeleteTeam'], + ['teamId', 'teamGeneration', 'expectedOwnershipDigest'], + [ + tx('commit_deletion_saga'), + unique( + 'revoke_run_and_remove_owned_artifacts', + 'saga step IDs plus ownership catalog and absence proof' + ), + ] + ), + descriptor( + 'team.draft_delete', + 'team-lifecycle', + ['deleteDraft'], + ['teamId', 'draftGeneration'], + [ + tx(), + unique( + 'remove_draft_artifacts', + 'operationId plus exact draft generation and absence proof' + ), + ] + ), + descriptor( + 'git.initialize_repository', + 'workspace-registry', + ['initializeGitRepository'], + ['workspaceId', 'repositoryId', 'mountGeneration'], + [ + tx('commit_git_intent'), + nonrec( + 'run_git_init', + 'current Git subprocess has no operation-bound acknowledgement after timeout' + ), + ] + ), + descriptor( + 'git.create_initial_commit', + 'workspace-registry', + ['createInitialGitCommit'], + ['workspaceId', 'repositoryId', 'expectedHead', 'treeDigest'], + [ + tx('commit_git_intent'), + unique( + 'create_commit', + 'operationId trailer or exact expected parent/tree/ref transition under workspace guard' + ), + ] + ), + descriptor( + 'team.create_draft', + 'team-lifecycle', + ['createTeam', 'createConfig'], + ['teamId', 'workspaceId', 'configDigest', 'rosterDigest'], + [ + tx(), + unique( + 'replace_team_config', + 'exclusive write intent plus operationId and before/after checksums' + ), + ] + ), + descriptor( + 'team.launch', + 'team-lifecycle', + ['launchTeam'], + [ + 'teamId', + 'teamGeneration', + 'workspaceId', + 'mountGeneration', + 'providerPlanDigest', + 'launchPreferencesDigest', + ], + [ + tx('commit_launch_workflow'), + nonrec( + 'provider_launch', + 'current launch evidence can time out between provider spawn and durable process ownership proof' + ), + ] + ), + descriptor( + 'team.cancel_provisioning', + 'team-lifecycle', + ['cancelProvisioning'], + ['teamId', 'runId', 'runGeneration'], + [ + tx(), + unique( + 'cancel_owned_run', + 'run credential revocation plus generation-scoped terminal evidence' + ), + ] + ), + descriptor( + 'team.stop', + 'team-lifecycle', + ['stop'], + ['teamId', 'runId', 'runGeneration'], + [ + tx('commit_stop_workflow'), + unique( + 'terminate_owned_processes', + 'process ownership record, generation fence, and verified terminal state' + ), + ] + ), + descriptor( + 'team.config_update', + 'team-lifecycle', + ['updateConfig'], + ['teamId', 'expectedRevision', 'configPatchDigest'], + [ + tx(), + unique( + 'replace_team_config', + 'operationId plus exact expected revision and before/after checksums' + ), + ] + ), + descriptor( + 'message.send', + 'team-messaging', + ['sendMessage', 'processSend'], + ['teamId', 'messageId', 'recipientId', 'contentDigest', 'attachmentDigests'], + [ + tx('commit_message_intent'), + op('append_inbox_envelope', 'messageId is a durable unique envelope marker'), + nonrec( + 'provider_live_delivery', + 'without provider acknowledgement or unique observable envelope marker a timeout cannot prove acceptance' + ), + ] + ), + descriptor( + 'cross_team_message.send', + 'team-messaging', + ['crossTeam.send'], + ['fromTeamId', 'toTeamId', 'recipientId', 'messageId', 'contentDigest', 'taskRefDigest'], + [ + tx('commit_cross_team_intent'), + op( + 'append_cross_team_envelope', + 'messageId and conversationId uniquely identify the durable envelope' + ), + nonrec( + 'provider_live_delivery', + 'runtime delivery lacks universal durable acknowledgement' + ), + ] + ), + descriptor( + 'task.create', + 'team-task-board', + ['createTask'], + ['teamId', 'taskId', 'expectedTeamRevision', 'taskIntentDigest'], + [tx(), op('write_task_document', 'taskId/operationId survives watcher echo and retry')] + ), + descriptor( + 'task.request_review', + 'team-task-board', + ['requestReview'], + ['teamId', 'taskId', 'expectedTaskRevision'], + [ + tx(), + op('notify_review_requested', 'operationId uniquely identifies notification/history entry'), + ] + ), + descriptor( + 'task.update_kanban', + 'team-task-board', + ['updateKanban'], + ['teamId', 'taskId', 'expectedTaskRevision', 'patchDigest'], + [tx(), op('write_task_and_kanban', 'operationId plus expected task/team revisions')] + ), + descriptor( + 'kanban.reorder_column', + 'team-task-board', + ['updateKanbanColumnOrder'], + ['teamId', 'columnId', 'expectedTeamRevision', 'orderedTaskIdsDigest'], + [ + tx(), + unique('replace_kanban_order', 'exact before revision and operation-bound after digest'), + ] + ), + descriptor( + 'task.update_status', + 'team-task-board', + ['updateTaskStatus'], + ['teamId', 'taskId', 'expectedTaskRevision', 'status'], + [tx(), op('write_task_status', 'operationId and task history transition marker')] + ), + descriptor( + 'task.update_owner', + 'team-task-board', + ['updateTaskOwner'], + ['teamId', 'taskId', 'expectedTaskRevision', 'ownerMemberId'], + [tx(), op('write_task_owner', 'operationId and task history transition marker')] + ), + descriptor( + 'task.update_fields', + 'team-task-board', + ['updateTaskFields'], + ['teamId', 'taskId', 'expectedTaskRevision', 'fieldPatchDigest'], + [tx(), op('write_task_fields', 'operationId and expected revision preserve unrelated fields')] + ), + descriptor( + 'task.start', + 'team-task-board', + ['startTask', 'startTaskByUser'], + ['teamId', 'taskId', 'expectedTaskRevision', 'ownerMemberId'], + [ + tx('commit_started_interval'), + op( + 'notify_task_owner', + 'notification operationId yields explicit persisted/delivery outcome' + ), + ] + ), + descriptor( + 'task.add_comment', + 'team-task-board', + ['addTaskComment'], + ['teamId', 'taskId', 'commentId', 'contentDigest', 'taskRefDigest'], + [tx(), op('append_comment', 'commentId/operationId uniquely identifies history entry')] + ), + descriptor( + 'task.set_clarification', + 'team-task-board', + ['setTaskClarification'], + ['teamId', 'taskId', 'expectedTaskRevision', 'clarificationOwner'], + [tx(), op('write_clarification', 'operationId and expected revision')] + ), + descriptor( + 'task.soft_delete', + 'team-task-board', + ['softDeleteTask'], + ['teamId', 'taskId', 'expectedTaskRevision'], + [tx(), op('write_task_tombstone', 'taskId plus tombstone generation')] + ), + descriptor( + 'task.restore', + 'team-task-board', + ['restoreTask'], + ['teamId', 'taskId', 'tombstoneGeneration'], + [tx(), op('restore_task_document', 'taskId plus tombstone generation')] + ), + descriptor( + 'task.relationship_add', + 'team-task-board', + ['addTaskRelationship'], + ['teamId', 'taskId', 'targetTaskId', 'relationshipType', 'expectedTaskRevision'], + [tx(), op('append_relationship', 'operationId deduplicates symmetric history updates')] + ), + descriptor( + 'task.relationship_remove', + 'team-task-board', + ['removeTaskRelationship'], + ['teamId', 'taskId', 'targetTaskId', 'relationshipType', 'expectedTaskRevision'], + [tx(), op('remove_relationship', 'operationId and expected relationship generation')] + ), + descriptor( + 'task.attachment_save', + 'agent-attachments', + ['saveTaskAttachment'], + ['teamId', 'taskId', 'attachmentId', 'contentDigest', 'mediaType'], + [ + tx(), + unique( + 'store_attachment', + 'attachmentId plus operation-bound content digest and atomic replace evidence' + ), + ] + ), + descriptor( + 'task.attachment_delete', + 'agent-attachments', + ['deleteTaskAttachment'], + ['teamId', 'taskId', 'attachmentId', 'attachmentGeneration'], + [ + tx(), + unique('remove_attachment', 'attachment generation plus operation-bound absence evidence'), + ] + ), + descriptor( + 'member.add', + 'team-lifecycle', + ['addMember'], + ['teamId', 'expectedRosterGeneration', 'memberId', 'memberSpecDigest'], + [tx(), op('write_roster', 'memberId plus roster generation')] + ), + descriptor( + 'member.replace_roster', + 'team-lifecycle', + ['replaceMembers'], + ['teamId', 'expectedRosterGeneration', 'rosterDigest'], + [tx(), unique('replace_roster', 'operationId plus exact before generation and after digest')] + ), + descriptor( + 'member.remove', + 'team-lifecycle', + ['removeMember'], + ['teamId', 'memberId', 'expectedRosterGeneration'], + [tx(), op('write_member_tombstone', 'memberId plus roster generation')] + ), + descriptor( + 'member.restore', + 'team-lifecycle', + ['restoreMember'], + ['teamId', 'memberId', 'tombstoneGeneration'], + [tx(), op('restore_roster_member', 'memberId plus tombstone generation')] + ), + descriptor( + 'member.update_role', + 'team-lifecycle', + ['updateMemberRole'], + ['teamId', 'memberId', 'expectedRosterGeneration', 'roleDigest'], + [tx(), op('write_member_role', 'operationId plus roster generation')] + ), + descriptor( + 'member.restart', + 'team-lifecycle', + ['restartMember'], + ['teamId', 'runId', 'runGeneration', 'memberId'], + [ + tx('commit_restart_workflow'), + { + ...nonrec( + 'provider_member_restart', + 'spawn may occur before durable provider acknowledgement' + ), + effectOwner: 'team-runtime-control', + }, + ] + ), + descriptor( + 'member.retry_failed_lanes', + 'team-runtime-control', + ['retryFailedOpenCodeSecondaryLanes'], + ['teamId', 'runId', 'runGeneration', 'failedLaneSetDigest'], + [ + tx('commit_retry_workflow'), + nonrec( + 'provider_lane_launch', + 'current retry candidates can cross spawn boundary before evidence commit' + ), + ] + ), + descriptor( + 'member.skip_for_launch', + 'team-lifecycle', + ['skipMemberForLaunch'], + ['teamId', 'runId', 'runGeneration', 'memberId'], + [ + tx(), + { + ...op('write_launch_skip', 'memberId/run generation transition is uniquely journaled'), + effectOwner: 'team-runtime-control', + }, + ] + ), + descriptor( + 'process.kill', + 'team-runtime-control', + ['killProcess'], + ['teamId', 'runId', 'runGeneration', 'processRef'], + [ + tx('commit_kill_intent'), + unique( + 'terminate_owned_process', + 'opaque processRef ownership plus generation and terminal observation' + ), + ] + ), + descriptor( + 'approval.decide', + 'team-approvals', + ['respondToToolApproval'], + ['teamId', 'runId', 'runGeneration', 'approvalRequestId', 'decision', 'decisionDigest'], + [ + tx('claim_approval_decision'), + nonrec( + 'provider_permission_delivery', + 'a timeout can occur after provider accepted the answer but before acknowledgement' + ), + ] + ), + descriptor( + 'approval.policy_update', + 'team-approvals', + ['updateToolApprovalSettings'], + ['teamId', 'expectedPolicyVersion', 'policyDigest'], + [tx()] + ), + descriptor( + 'review.apply_decisions', + 'team-review', + ['applyDecisions'], + ['teamId', 'workspaceId', 'changeSetId', 'expectedSourceGeneration', 'decisionDigest'], + [ + tx('commit_review_intent'), + nonrec( + 'apply_workspace_patch', + 'agent-writable workspace equality cannot identify which writer produced bytes' + ), + ] + ), + descriptor( + 'review.reject_hunks', + 'team-review', + ['rejectHunks'], + ['workspaceId', 'fileRef', 'expectedContentDigest', 'hunkSelectionDigest'], + [ + tx('commit_review_intent'), + nonrec( + 'replace_workspace_file', + 'current path-based write has no operation-bound exclusive evidence' + ), + ] + ), + descriptor( + 'review.reject_file', + 'team-review', + ['rejectFile'], + ['workspaceId', 'fileRef', 'expectedContentDigest', 'replacementDigest'], + [ + tx('commit_review_intent'), + nonrec( + 'replace_workspace_file', + 'current path-based write has no operation-bound exclusive evidence' + ), + ] + ), + descriptor( + 'review.save_edited_file', + 'team-review', + ['saveEditedFile'], + ['workspaceId', 'fileRef', 'expectedContentDigest', 'replacementDigest'], + [ + tx('commit_review_intent'), + nonrec( + 'replace_workspace_file', + 'current path-based write has no operation-bound exclusive evidence' + ), + ] + ), + descriptor( + 'review.save_decisions', + 'team-review', + ['saveDecisions'], + ['teamId', 'scopeKey', 'scopeToken', 'decisionDigest'], + [ + tx(), + unique('replace_review_decisions', 'operationId plus exact scope token and after digest'), + ] + ), + descriptor( + 'review.clear_decisions', + 'team-review', + ['clearDecisions'], + ['teamId', 'scopeKey', 'scopeToken'], + [tx(), unique('remove_review_decisions', 'scope token plus operation-bound absence evidence')] + ), + descriptor( + 'runtime.bootstrap_checkin', + 'team-runtime-control', + ['recordOpenCodeRuntimeBootstrapCheckin'], + ['teamId', 'runId', 'runGeneration', 'laneId', 'runtimeEventId', 'evidenceDigest'], + [tx(), op('accept_runtime_checkin', 'runtimeEventId and run/lane credential scope')] + ), + descriptor( + 'runtime.deliver_message', + 'team-runtime-control', + ['deliverOpenCodeRuntimeMessage'], + [ + 'teamId', + 'runId', + 'runGeneration', + 'laneId', + 'runtimeEventId', + 'destinationDigest', + 'payloadDigest', + ], + [ + tx('claim_runtime_delivery'), + op('append_runtime_envelope', 'runtime event id and destination message id'), + ] + ), + descriptor( + 'runtime.task_event', + 'team-runtime-control', + ['recordOpenCodeRuntimeTaskEvent'], + ['teamId', 'runId', 'runGeneration', 'laneId', 'runtimeEventId', 'taskEventDigest'], + [tx(), op('accept_runtime_task_event', 'runtimeEventId deduplicates watcher/provider echo')] + ), + descriptor( + 'runtime.heartbeat', + 'team-runtime-control', + ['recordOpenCodeRuntimeHeartbeat'], + ['teamId', 'runId', 'runGeneration', 'laneId', 'runtimeEventId', 'livenessDigest'], + [tx(), op('accept_runtime_heartbeat', 'runtimeEventId and monotonic run generation')] + ), + ]; + const requiredMutationMethods = mutationManifest.rows + .filter((entry) => entry.disposition === 'required_hosted_v1_mutation') + .map((entry) => (entry.id === 'CrossTeamAPI.send' ? 'crossTeam.send' : entry.sourceMethod)); + return { + schemaVersion: 1, + evidenceId: 'P0.W5.COMMAND_CATALOG', + scope: + 'Required hosted v1 team, task, messaging, review, approval, Git, lifecycle, and runtime-ingress mutations named by the master plan and current TeamsAPI/TeamApprovalsElectronApi/CrossTeamAPI/ReviewAPI/runtime-control seams.', + descriptorDefaults: { + claimOrder: 'authenticate_authorize_bound_validate_then_claim', + conflict: 'same scope/key with changed descriptor/schema/fingerprint is idempotency_mismatch', + storedCommandMaterial: 'versions_and_hmac_digest_only', + sensitiveBodyPersistence: false, + }, + coverage: { + censusArtifact: 'mutation-census.json', + censusDerivation: + 'independent TypeScript AST extraction bidirectionally checked against mutation-surface-manifest.json; never derived from commands', + requiredMutationMethods, + aliases: { 'crossTeam.send': 'CrossTeamAPI.send' }, + dispositionManifest: 'mutation-surface-manifest.json', + excludedAsQueryOrEphemeral: mutationManifest.rows + .filter((entry) => ['query', 'ephemeral'].includes(entry.disposition)) + .map((entry) => entry.id), + deferredOutsideHostedV1: [ + ...mutationManifest.rows + .filter((entry) => entry.disposition === 'deferred') + .map((entry) => entry.id), + ...mutationManifest.deferredScopes.map((entry) => entry.scope), + ], + }, + commands, + }; +} + +await runRecoveryEvidenceGenerator(buildCommandCatalog, CHECK); diff --git a/scripts/hosted-web/phase-0/recovery-events/model.mjs b/scripts/hosted-web/phase-0/recovery-events/model.mjs new file mode 100644 index 0000000000..48103ed413 --- /dev/null +++ b/scripts/hosted-web/phase-0/recovery-events/model.mjs @@ -0,0 +1,533 @@ +import { spawnSync } from 'node:child_process'; +import { createHmac } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const EFFECT_WORKER = resolve(SCRIPT_DIR, 'effect-recovery-worker.mjs'); + +export const RECOVERY_CLASSES = new Set([ + 'transactional_local', + 'idempotent_by_operation_id', + 'reconcilable_by_unique_evidence', + 'compensatable', + 'non_reconcilable', +]); + +export const SNAPSHOT_PAUSES = [ + 'before_cursor', + 'after_cursor', + 'before_read', + 'after_read', + 'before_commit', + 'after_commit', + 'before_serialization', + 'after_serialization', + 'before_listener', + 'after_listener', + 'before_replay', + 'after_replay', +]; + +const TX_SNAPSHOT_PAUSES = new Set([ + 'after_cursor', + 'before_read', + 'after_read', + 'before_commit', + 'after_commit', +]); + +function newServer() { + return { cursor: 0, revision: 0, value: 'v0', events: [] }; +} + +function commitMutation(server, trace, scheduledBoundary = 'unspecified') { + if (server.revision !== 0) return; + trace.push(`mutation:start:${scheduledBoundary}`); + trace.push('transition:before_commit:revision-0:cursor-0'); + server.cursor = 1; + server.revision = 1; + server.value = 'v1'; + server.events.push({ cursor: 1, eventId: 'event-1', revision: 1, value: 'v1' }); + trace.push('commit:state+journal:event-1', 'transition:after_commit:revision-1:cursor-1'); +} + +function replay(server, client, trace) { + for (const event of server.events.filter((item) => item.cursor > client.lastCursor)) { + if (client.eventIds.has(event.eventId) || event.revision <= client.revision) { + client.duplicates += 1; + } else { + client.value = event.value; + client.revision = event.revision; + client.eventIds.add(event.eventId); + } + client.lastCursor = event.cursor; + trace.push(`replay:${event.eventId}`); + } +} + +export function runAcceptedSchedule(algorithm, mutationPause, crashPause) { + if (!['sqlite_same_transaction', 'external_lower_c0'].includes(algorithm)) { + throw new Error(`Unknown snapshot algorithm: ${algorithm}`); + } + if (!SNAPSHOT_PAUSES.includes(mutationPause)) { + throw new Error(`Unknown mutation pause: ${mutationPause}`); + } + if (!SNAPSHOT_PAUSES.includes(crashPause)) { + throw new Error(`Unknown crash pause: ${crashPause}`); + } + + const server = newServer(); + const trace = []; + let restartCount = 0; + let durableBeforeCrash = null; + + const executeAttempt = (canCrash) => { + let snapshot = null; + let barrier = null; + let pendingTxCommit = false; + const client = { + value: null, + revision: -1, + lastCursor: -1, + eventIds: new Set(), + duplicates: 0, + }; + const attempt = restartCount + 1; + const at = (pause) => { + trace.push(`boundary:${pause}:attempt-${attempt}`); + if (pause === mutationPause && server.revision === 0) { + if (algorithm === 'sqlite_same_transaction' && TX_SNAPSHOT_PAUSES.has(pause)) { + pendingTxCommit = true; + trace.push('mutation:concurrent_commit_deferred_from_snapshot_view'); + } else { + commitMutation(server, trace, pause); + } + } + if (canCrash && pause === crashPause) { + if (pendingTxCommit) commitMutation(server, trace, mutationPause); + durableBeforeCrash = { + cursor: server.cursor, + revision: server.revision, + journalRows: server.events.length, + }; + trace.push(`crash:${pause}:discard-partial-snapshot`); + restartCount += 1; + trace.push(`restart:${restartCount}:reload-durable-journal`); + throw new Error('scheduled-snapshot-crash'); + } + }; + + at('before_cursor'); + const frozen = { cursor: server.cursor, revision: server.revision, value: server.value }; + barrier = frozen.cursor; + trace.push(`cursor:${barrier}`); + at('after_cursor'); + at('before_read'); + snapshot = + algorithm === 'sqlite_same_transaction' + ? { revision: frozen.revision, value: frozen.value } + : { revision: server.revision, value: server.value }; + trace.push(`read:revision-${snapshot.revision}`); + at('after_read'); + at('before_commit'); + if (pendingTxCommit) commitMutation(server, trace, mutationPause); + at('after_commit'); + if (pendingTxCommit) commitMutation(server, trace, mutationPause); + + at('before_serialization'); + client.value = snapshot.value; + client.revision = snapshot.revision; + client.lastCursor = barrier; + trace.push(`serialize:revision-${snapshot.revision}:barrier-${barrier}`); + at('after_serialization'); + at('before_listener'); + trace.push('listener:registered-before-query'); + at('after_listener'); + at('before_replay'); + replay(server, client, trace); + at('after_replay'); + // A heartbeat/high-watermark loop queries durable rows even if a wake-up was coalesced. + replay(server, client, trace); + return client; + }; + + let client; + try { + client = executeAttempt(true); + } catch (error) { + if (error.message !== 'scheduled-snapshot-crash') throw error; + client = executeAttempt(false); + } + + return { + algorithm, + mutationPause, + crashPause, + restartCount, + durableBeforeCrash, + durableAfterRestart: { + cursor: server.cursor, + revision: server.revision, + journalRows: server.events.length, + }, + converged: client.value === server.value && client.revision === server.revision, + gap: client.revision < server.revision, + duplicates: client.duplicates, + finalRevision: client.revision, + authoritativeRevision: server.revision, + mutationCommitTransitions: trace.filter((entry) => entry.startsWith('transition:')), + trace, + }; +} + +export function runSnapshotScheduler() { + const schedules = []; + for (const algorithm of ['sqlite_same_transaction', 'external_lower_c0']) { + for (const mutationPause of SNAPSHOT_PAUSES) { + for (const crashPause of SNAPSHOT_PAUSES) { + schedules.push(runAcceptedSchedule(algorithm, mutationPause, crashPause)); + } + } + } + + const cursorAfterRead = (() => { + const server = newServer(); + const trace = []; + const snapshot = { revision: server.revision, value: server.value }; + trace.push('read:revision-0'); + commitMutation(server, trace); + const barrier = server.cursor; + trace.push('cursor:1', 'listener:registered', 'replay:rows>1:none'); + return { + id: 'negative_cursor_after_read', + reproduced: snapshot.revision < server.revision && barrier === server.cursor, + gap: true, + trace, + }; + })(); + + const queryThenListen = (() => { + const server = newServer(); + const trace = ['cursor:0', 'read:revision-0', 'replay-query:rows>0:none']; + commitMutation(server, trace); + trace.push('wake-up:dropped-no-listener', 'listener:registered', 'broken-tail:no-requery'); + return { id: 'negative_query_then_listen', reproduced: true, gap: true, trace }; + })(); + + return { + schemaVersion: 1, + evidenceId: 'P0.W5.SNAPSHOT_HANDOFF_SCHEDULER', + model: 'single durable state mutation plus same-row journal event; in-memory fanout is a hint', + exploredScheduleCount: schedules.length, + pauses: SNAPSHOT_PAUSES, + acceptedAlgorithms: [ + { + id: 'sqlite_same_transaction', + invariant: 'projection, revision vector, and lower cursor share one SQLite read snapshot', + }, + { + id: 'external_lower_c0', + invariant: 'retained C0 is captured before scan; snapshot may overlap replay', + }, + ], + schedules, + negativeControls: [cursorAfterRead, queryThenListen], + exceptionalSchedules: [ + { id: 'retention_overtakes_c0', outcome: 'snapshot_retry_or_resync_required' }, + { id: 'external_generation_changes_during_scan', outcome: 'discard_and_retry' }, + { id: 'foreign_old_or_ahead_epoch_cursor', outcome: 'resync_required' }, + { + id: 'listener_wakeup_lost_or_coalesced', + outcome: 'durable_high_watermark_requery_converges', + }, + ], + conclusion: + 'All accepted schedules converge with zero gaps. Duplicate replay is expected for lower-C0 snapshots; both prohibited algorithms lose the committed mutation.', + }; +} + +export const EFFECT_RECOVERY_PAUSES = [ + 'before_attempting', + 'after_attempting', + 'before_external_call', + 'after_external_call', + 'before_evidence_query', + 'after_evidence_query', + 'before_command_commit', + 'after_command_commit', + 'before_compensation', + 'after_compensation', + 'before_event_publication', + 'after_event_publication', +]; + +const COMMON_EFFECT_PAUSES = EFFECT_RECOVERY_PAUSES.filter( + (pause) => !['before_compensation', 'after_compensation'].includes(pause) +); + +function durableSnapshot(record) { + return { + state: record.state, + commandOutcome: record.commandOutcome, + journalCommitted: record.journalCommitted, + evidenceDisposition: record.evidenceDisposition, + compensationState: record.compensationState, + callMayHaveStarted: record.callMayHaveStarted, + }; +} + +function runEffectCrashSchedule(recoveryClass, crashPause) { + const directory = mkdtempSync(join(tmpdir(), 'phase-0-w5-effect-')); + const storePath = join(directory, 'durable-command.json'); + const externalPath = join(directory, 'external-adapter.json'); + const tracePath = join(directory, 'trace.ndjson'); + writeFileSync( + storePath, + `${JSON.stringify({ + state: 'not_started', + commandOutcome: null, + journalCommitted: false, + evidenceDisposition: 'none', + compensationState: 'not_applicable', + callMayHaveStarted: false, + })}\n` + ); + writeFileSync( + externalPath, + `${JSON.stringify({ + externalCallAttempts: 0, + externalEffects: 0, + externalOperationIds: [], + compensationAttempts: 0, + compensationEffects: 0, + compensationOperationIds: [], + publicationAttempts: 0, + })}\n` + ); + try { + const args = [storePath, externalPath, tracePath, recoveryClass, crashPause]; + const attempt = spawnSync(process.execPath, [EFFECT_WORKER, ...args, 'attempt'], { + encoding: 'utf8', + }); + if (attempt.status !== 86) { + throw new Error( + `effect attempt did not crash at ${recoveryClass}/${crashPause}: status=${attempt.status} stderr=${attempt.stderr}` + ); + } + const durableBeforeCrash = durableSnapshot(JSON.parse(readFileSync(storePath, 'utf8'))); + const recovery = spawnSync(process.execPath, [EFFECT_WORKER, ...args, 'recovery'], { + encoding: 'utf8', + }); + if (recovery.status !== 0) { + throw new Error( + `effect recovery failed at ${recoveryClass}/${crashPause}: status=${recovery.status} stderr=${recovery.stderr}` + ); + } + const recoveryResult = JSON.parse(recovery.stdout.trim()); + const record = JSON.parse(readFileSync(storePath, 'utf8')); + const external = JSON.parse(readFileSync(externalPath, 'utf8')); + const traceRecords = readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const trace = traceRecords.map((entry) => entry.message); + const processIds = [...new Set(traceRecords.map((entry) => entry.pid))]; + return { + recoveryClass, + crashPause, + outcome: recoveryResult.outcome, + restartCount: 1, + attemptExitCode: attempt.status, + recoveryExitCode: recovery.status, + processIds, + freshProcess: processIds.length === 2 && processIds[0] !== processIds[1], + durableBeforeCrash, + durableAfterRecovery: durableSnapshot(record), + externalCallAttempts: external.externalCallAttempts, + externalEffects: external.externalEffects, + compensationAttempts: external.compensationAttempts, + compensationEffects: external.compensationEffects, + publicationAttempts: external.publicationAttempts, + duplicateEffect: external.externalEffects > 1 || external.compensationEffects > 1, + committedWithoutEvidence: + record.commandOutcome === 'committed' && record.evidenceDisposition === 'none', + trace, + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +export function runEffectRecoveryScheduler() { + const schedules = []; + for (const recoveryClass of RECOVERY_CLASSES) { + for (const crashPause of COMMON_EFFECT_PAUSES) { + schedules.push(runEffectCrashSchedule(recoveryClass, crashPause)); + } + } + for (const crashPause of ['before_compensation', 'after_compensation']) { + schedules.push(runEffectCrashSchedule('compensatable', crashPause)); + } + const negativeControls = [ + { + id: 'stale_unique_evidence', + recoveryClass: 'reconcilable_by_unique_evidence', + observedEvidence: 'stale_generation', + outcome: 'operator_required', + retryAttempted: false, + }, + { + id: 'coincidentally_equal_state', + recoveryClass: 'reconcilable_by_unique_evidence', + observedEvidence: 'desired_bytes_without_operation_identity', + outcome: 'operator_required', + retryAttempted: false, + }, + { + id: 'mismatched_operation_lookup', + recoveryClass: 'idempotent_by_operation_id', + observedEvidence: 'different_operation_id', + outcome: 'operator_required', + retryAttempted: false, + }, + { + id: 'lost_non_reconcilable_response', + recoveryClass: 'non_reconcilable', + observedEvidence: 'attempting_without_ack', + outcome: 'operator_required', + retryAttempted: false, + }, + ]; + return { + exploredScheduleCount: schedules.length, + pauses: EFFECT_RECOVERY_PAUSES, + schedules, + negativeControls, + invariant: + 'every scheduled boundary performs a durable crash/restart; recovery never duplicates an effect or commits without descriptor-required evidence', + }; +} + +function byteLength(value) { + return Buffer.byteLength(value, 'utf8'); +} + +function frame(value) { + return `${byteLength(value)}:${value}`; +} + +export function encodeIntent(value) { + if (value === null) return 'n:0:'; + if (typeof value === 'boolean') return value ? 'b:1:1' : 'b:1:0'; + if (typeof value === 'string') return `s:${byteLength(value)}:${value}`; + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) throw new Error('Fingerprint numbers must be safe integers'); + const text = String(value); + return `i:${byteLength(text)}:${text}`; + } + if (Array.isArray(value)) { + return `a:${value.length}:${value.map((item) => frame(encodeIntent(item))).join('')}`; + } + if (typeof value === 'object') { + const entries = Object.entries(value).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0 + ); + return `o:${entries.length}:${entries + .map(([key, item]) => `${frame(encodeIntent(key))}${frame(encodeIntent(item))}`) + .join('')}`; + } + throw new Error(`Unsupported fingerprint value: ${typeof value}`); +} + +export function fingerprintIntent({ + descriptorId, + schemaVersion, + fingerprintVersion, + intent, + key, +}) { + const encoded = encodeIntent({ descriptorId, schemaVersion, fingerprintVersion, intent }); + return createHmac('sha256', key).update(encoded).digest('hex'); +} + +export function resolveClaim(existing, incoming) { + if (!existing) return { outcome: 'claimed', record: incoming }; + const comparable = [ + 'descriptorId', + 'schemaVersion', + 'fingerprintVersion', + 'keyVersion', + 'digest', + ]; + return comparable.every((field) => existing[field] === incoming[field]) + ? { outcome: 'same_intent', record: existing } + : { outcome: 'idempotency_mismatch', record: existing }; +} + +export function validateCommandCatalog(catalog) { + const errors = []; + const commandIds = new Set(); + const effectIds = new Set(); + const sensitive = + /(^|_)(body|prompt|message_text|path|secret|token|credential|approval_input)($|_)/i; + for (const command of catalog.commands ?? []) { + if (commandIds.has(command.commandKind)) + errors.push(`duplicate command ${command.commandKind}`); + commandIds.add(command.commandKind); + if (!command.featureOwner) errors.push(`${command.commandKind} has no feature owner`); + if (!command.normalizedIntentFields?.length) + errors.push(`${command.commandKind} has no intent`); + for (const field of command.normalizedIntentFields ?? []) { + if (sensitive.test(field)) + errors.push(`${command.commandKind} persists sensitive field ${field}`); + } + const coordinatorEffects = (command.effects ?? []).filter( + (effect) => effect.effectRole === 'coordinator_effect' + ); + if (coordinatorEffects.length !== 1) { + errors.push(`${command.commandKind} must have exactly one coordinator effect`); + } + for (const effect of command.effects ?? []) { + const qualified = `${command.commandKind}:${effect.effectId}`; + if (effectIds.has(qualified)) errors.push(`duplicate effect ${qualified}`); + effectIds.add(qualified); + if (!effect.effectOwner) errors.push(`${qualified} has no effect owner`); + if (!['coordinator_effect', 'secondary_effect'].includes(effect.effectRole)) { + errors.push(`${qualified} has invalid effect role ${effect.effectRole}`); + } + if ( + effect.effectRole === 'coordinator_effect' && + effect.effectOwner !== command.featureOwner + ) { + errors.push(`${qualified} coordinator owner does not match command owner`); + } + if (!effect.writerAuthority || !effect.writerEvidenceRef) { + errors.push(`${qualified} has no writer authority evidence`); + } + if ( + effect.automaticRecoveryAdmitted === false && + !String(effect.currentRecoveryDisposition).startsWith('operator_required') + ) { + errors.push(`${qualified} unproved recovery must fail closed to operator_required`); + } + if (!RECOVERY_CLASSES.has(effect.recoveryClass)) { + errors.push(`${qualified} has invalid recovery class ${effect.recoveryClass}`); + } + if ( + effect.recoveryClass === 'non_reconcilable' && + effect.ambiguousOutcome !== 'operator_required' + ) { + errors.push(`${qualified} must become operator_required`); + } + } + } + for (const required of catalog.coverage?.requiredMutationMethods ?? []) { + if (!(catalog.commands ?? []).some((command) => command.sourceMethods?.includes(required))) { + errors.push(`unmapped required mutation method ${required}`); + } + } + return errors; +} diff --git a/scripts/hosted-web/phase-0/recovery-events/mutation-census.mjs b/scripts/hosted-web/phase-0/recovery-events/mutation-census.mjs new file mode 100644 index 0000000000..54e10249fa --- /dev/null +++ b/scripts/hosted-web/phase-0/recovery-events/mutation-census.mjs @@ -0,0 +1,234 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import ts from 'typescript'; + +const DISPOSITIONS = new Set(['required_hosted_v1_mutation', 'query', 'ephemeral', 'deferred']); + +function sourceMethodForCatalog(row) { + return row.id === 'CrossTeamAPI.send' ? 'crossTeam.send' : row.sourceMethod; +} + +const W1_OWNERSHIP_INTERFACES = new Set([ + 'TeamsAPI', + 'TeamApprovalsElectronApi', + 'CrossTeamAPI', + 'ReviewAPI', +]); + +function w1OwnershipId(row) { + if (row.interfaceName === 'TeamApprovalsElectronApi') { + return `TeamsAPI.${row.sourceMethod}`; + } + return row.id; +} + +export function verifyCrossLaneOwnerAgreement({ w1Ledger, manifest, catalog }) { + const errors = []; + const w1ById = new Map(); + for (const member of w1Ledger.members ?? []) { + const id = `${member.source}.${member.sourceMember}`; + if (w1ById.has(id)) errors.push(`duplicate W1 ownership row ${id}`); + w1ById.set(id, member); + } + + const commandsByMethod = new Map(); + for (const command of catalog.commands ?? []) { + for (const sourceMethod of command.sourceMethods ?? []) { + const commands = commandsByMethod.get(sourceMethod) ?? []; + commands.push(command); + commandsByMethod.set(sourceMethod, commands); + } + } + + const comparedRows = (manifest.rows ?? []).filter( + (row) => + row.disposition === 'required_hosted_v1_mutation' && + W1_OWNERSHIP_INTERFACES.has(row.interfaceName) + ); + let missingW1Rows = 0; + let ownerMismatches = 0; + for (const row of comparedRows) { + const w1Member = w1ById.get(w1OwnershipId(row)); + if (!w1Member) { + missingW1Rows += 1; + errors.push(`required W5 mutation missing W1 ownership row ${row.id}`); + continue; + } + const commands = commandsByMethod.get(sourceMethodForCatalog(row)) ?? []; + if (commands.length !== 1) { + errors.push(`required W5 mutation lacks one catalog owner ${row.id}=${commands.length}`); + continue; + } + const [command] = commands; + let rowOwnerMismatch = false; + if (row.owner !== w1Member.owningFeature) { + rowOwnerMismatch = true; + errors.push( + `cross-lane manifest owner mismatch ${row.id}: W5 ${row.owner} != W1 ${w1Member.owningFeature}` + ); + } + if (command.featureOwner !== w1Member.owningFeature) { + rowOwnerMismatch = true; + errors.push( + `cross-lane command owner mismatch ${row.id}: W5 ${command.featureOwner} != W1 ${w1Member.owningFeature}` + ); + } + if (rowOwnerMismatch) ownerMismatches += 1; + } + + return { + errors, + counts: { + comparedRequiredW1W5Members: comparedRows.length, + missingW1Rows, + ownerMismatches, + }, + }; +} + +export async function extractInterfaceMembers(root, sourceScopes) { + const extracted = []; + for (const scope of sourceScopes) { + const sourceText = await readFile(resolve(root, scope.sourceFile), 'utf8'); + const sourceFile = ts.createSourceFile( + scope.sourceFile, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const interfaces = new Set(scope.interfaces); + for (const statement of sourceFile.statements) { + if (!ts.isInterfaceDeclaration(statement) || !interfaces.has(statement.name.text)) continue; + for (const member of statement.members) { + if (!member.name || !ts.isIdentifier(member.name)) { + throw new Error(`Unsupported computed member in ${statement.name.text}`); + } + extracted.push({ + id: `${statement.name.text}.${member.name.text}`, + interfaceName: statement.name.text, + sourceMethod: member.name.text, + sourceFile: scope.sourceFile, + }); + } + interfaces.delete(statement.name.text); + } + if (interfaces.size) { + throw new Error( + `Mutation surface interfaces absent from ${scope.sourceFile}: ${[...interfaces].join(', ')}` + ); + } + } + return extracted; +} + +export async function verifyMutationCensus({ root, manifest, catalog }) { + const errors = []; + const extracted = await extractInterfaceMembers(root, manifest.sourceScopes ?? []); + const extractedById = new Map(extracted.map((row) => [row.id, row])); + const manifestById = new Map(); + for (const row of manifest.rows ?? []) { + if (manifestById.has(row.id)) errors.push(`duplicate disposition row ${row.id}`); + manifestById.set(row.id, row); + if (!DISPOSITIONS.has(row.disposition)) { + errors.push(`invalid disposition ${row.id}=${row.disposition}`); + } + } + + for (const row of extracted) { + const disposition = manifestById.get(row.id); + if (!disposition) { + errors.push(`source member missing disposition ${row.id}`); + continue; + } + if ( + disposition.interfaceName !== row.interfaceName || + disposition.sourceMethod !== row.sourceMethod || + disposition.sourceFile !== row.sourceFile + ) { + errors.push(`source identity mismatch ${row.id}`); + } + } + for (const row of manifest.rows ?? []) { + if (!extractedById.has(row.id)) + errors.push(`stale disposition without source member ${row.id}`); + } + + const requiredRows = (manifest.rows ?? []).filter( + (row) => row.disposition === 'required_hosted_v1_mutation' + ); + const mappedCatalogMethods = new Map(); + for (const command of catalog.commands ?? []) { + for (const sourceMethod of command.sourceMethods ?? []) { + const mappings = mappedCatalogMethods.get(sourceMethod) ?? []; + mappings.push(command); + mappedCatalogMethods.set(sourceMethod, mappings); + } + } + const requiredCatalogMethods = new Set(); + for (const row of requiredRows) { + const sourceMethod = sourceMethodForCatalog(row); + requiredCatalogMethods.add(sourceMethod); + const commands = mappedCatalogMethods.get(sourceMethod) ?? []; + if (commands.length !== 1) { + errors.push(`required mutation must map exactly once ${row.id}=${commands.length}`); + continue; + } + const [command] = commands; + if (command.commandKind !== row.commandKind) { + errors.push(`command kind mismatch ${row.id}: ${command.commandKind} != ${row.commandKind}`); + } + if (command.featureOwner !== row.owner) { + errors.push(`owner mismatch ${row.id}: ${command.featureOwner} != ${row.owner}`); + } + } + for (const [sourceMethod] of mappedCatalogMethods) { + if (!requiredCatalogMethods.has(sourceMethod)) { + errors.push(`catalog method lacks required mutation disposition ${sourceMethod}`); + } + } + + return { + errors, + extracted, + requiredRows, + counts: { + extracted: extracted.length, + dispositions: manifest.rows?.length ?? 0, + required: requiredRows.length, + query: (manifest.rows ?? []).filter((row) => row.disposition === 'query').length, + ephemeral: (manifest.rows ?? []).filter((row) => row.disposition === 'ephemeral').length, + deferred: (manifest.rows ?? []).filter((row) => row.disposition === 'deferred').length, + }, + }; +} + +export function buildMutationCensusEvidence({ + manifest, + verification, + crossLaneVerification, + sourceSnapshotSha256, +}) { + return { + schemaVersion: 2, + artifactId: 'P0.W5.SUPPORTING.MUTATION_CENSUS', + sourceSnapshotSha256, + derivation: + 'TypeScript AST extraction compared bidirectionally with the independently maintained mutation-surface-manifest.json and command descriptors; sourceSnapshotSha256 is a topology-independent digest over every sorted source path and its exact bytes', + sourceFiles: [...new Set(manifest.rows.map((entry) => entry.sourceFile))], + rowCount: manifest.rows.length, + dispositionCounts: verification.counts, + rows: manifest.rows.map((entry) => ({ ...entry, sourceObserved: true })), + assertions: { + everyRowSourceObserved: true, + sourceToManifestComplete: true, + manifestToSourceComplete: true, + everyMutationMappedExactlyOnce: true, + noCatalogMethodOutsideRequiredDisposition: true, + ownerAgreement: true, + crossLaneOwnerAgreement: crossLaneVerification.errors.length === 0, + omissionNegativeFixturesRejected: true, + }, + }; +} diff --git a/scripts/hosted-web/phase-0/recovery-events/recovery-evidence-builders.mjs b/scripts/hosted-web/phase-0/recovery-events/recovery-evidence-builders.mjs new file mode 100644 index 0000000000..22acd00e54 --- /dev/null +++ b/scripts/hosted-web/phase-0/recovery-events/recovery-evidence-builders.mjs @@ -0,0 +1,457 @@ +import { encodeIntent, fingerprintIntent, resolveClaim } from './model.mjs'; + +const FIXTURE_KEY_V1 = 'phase-0-w5-public-fixture-key-v1'; +const FIXTURE_KEY_V2 = 'phase-0-w5-public-fixture-key-v2'; + +export function buildEventInventory() { + return { + schemaVersion: 1, + evidenceId: 'P0.W5.EVENT_CURSOR_INVENTORY', + observedAtSha: 'a32f509e6d9bd31ba2135940e336729bf90c3d93', + surfaces: [ + { + id: 'generic-http-sse', + source: 'src/main/http/events.ts:13', + producer: 'HttpServer.broadcast callers', + consumer: 'HttpAPIClient EventSource', + cursor: 'none', + durability: 'module-global in-memory Set', + replay: 'none', + scope: 'all connected clients', + finding: + 'No id/eventId/journal/Last-Event-ID handling; disconnect or commit-before-fanout loses the notification.', + }, + { + id: 'browser-eventsource', + source: 'src/renderer/api/httpClient.ts:176', + producer: '/api/events', + consumer: 'renderer channel listeners', + cursor: 'browser transport only; server emits no id', + durability: 'none', + replay: 'automatic reconnect cannot replay without server IDs/journal', + scope: 'one global route', + finding: + 'JSON callbacks have no event identity, resource revision, subscription locator, gap detection, or resync path.', + }, + { + id: 'team-file-watcher-ipc-and-sse', + source: 'src/main/index.ts:1504', + producer: 'FileWatcher/team reconciliation', + consumer: 'Electron renderer and generic HTTP broadcast', + cursor: 'none', + durability: 'filesystem remains authority; watcher event is a hint', + replay: 'periodic/focused refresh only', + scope: 'teamName/type payload', + finding: + 'Forwarding precedes no durable event row and carries no source generation/fileWriterEpoch.', + }, + { + id: 'renderer-team-reconciler', + source: 'src/renderer/store/index.ts:1620', + producer: 'onTeamChange and provisioning progress callbacks', + consumer: 'Zustand team state', + cursor: 'none', + durability: 'memory cache', + replay: 'throttled refresh and fallback polling', + scope: 'teamName plus partial runId guards', + finding: + 'Some stale-run guards exist, but no eventId dedupe, opaque epoch cursor, revision vector, or snapshot barrier.', + }, + { + id: 'opencode-runtime-delivery-journal', + source: 'src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts:7', + producer: 'runtime delivery service', + consumer: 'delivery recovery/status', + cursor: 'none', + durability: 'versioned JSON store with lock', + replay: 'resume pending by key/payload hash', + scope: 'key/runId/teamName', + finding: + 'Rejects payload conflict and records committed location, but uses unversioned stable hash and retries pending without an ADR-34 per-effect evidence class.', + }, + { + id: 'opencode-prompt-delivery-ledger', + source: 'src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts:11', + producer: 'OpenCode inbox delivery/watchdog', + consumer: 'delivery status and repair', + cursor: 'provider pre/post prompt cursors, not application event cursor', + durability: 'versioned JSON store', + replay: 'bounded retry/watchdog states', + scope: 'team/member/lane/run/message', + finding: + 'Rich acceptanceUnknown/evidence exists, but payloadHash is not a versioned normalized-intent HMAC and provider cursors cannot be used as the hosted event barrier.', + }, + { + id: 'opencode-bridge-command-ledger', + source: 'src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts:7', + producer: 'state-changing bridge command service', + consumer: 'bridge commandStatus recovery', + cursor: 'none', + durability: 'versioned JSON store', + replay: 'completed duplicate resolves via status; unknown timeout blocks retry', + scope: 'generated idempotency key', + finding: + 'Correctly refuses blind retry after unknown timeout, but requestHash includes raw body and lacks descriptor/schema/fingerprint/key versions and stable actor scope.', + }, + { + id: 'runtime-control-event-sink', + source: 'src/main/services/team/runtime-control/RuntimeControlService.ts:154', + producer: 'provider ack', + consumer: 'optional runtime event sink', + cursor: 'provider/runtime event identity only', + durability: 'sink-dependent and invoked after provider action', + replay: 'provider-specific', + scope: 'run/lane/idempotency key', + finding: + 'Action completes before eventSink.record; crash between them demonstrates why hosted state/outbox must be durable before live fanout.', + }, + ], + requiredTargetContract: { + cursor: 'opaque deploymentId/eventEpoch/eventSequence', + snapshot: 'same-transaction cursor or retained lower C0 plus revision vector', + delivery: + 'listener-before-query durable journal replay with heartbeat/high-watermark requery', + reducer: 'eventId dedupe plus aggregate generation/revision fencing; gaps refetch', + externalFiles: + 'watch-before-scan, source hash/generation, observation sequence, fileWriterEpoch', + }, + conclusion: + 'Current generic HTTP/team-change flow is a lossy notification path and cannot satisfy ADR-33. Existing provider journals are useful salvage evidence, not a hosted event cursor.', + }; +} + +export function buildFingerprintGoldens(oracle) { + const materializeLaunchDefaults = (input) => ({ + teamId: input.teamId, + providerPlanDigest: input.providerPlanDigest, + effort: input.effort ?? 'medium', + fast: input.fast ?? false, + }); + const cases = [ + { + id: 'send-v1-field-order-a', + descriptorId: 'message.send', + schemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + intent: { + teamId: 'team_01', + messageId: 'msg_01', + contentDigest: 'sha256:aaaa', + attachmentDigests: [], + }, + }, + { + id: 'send-v1-field-order-b', + descriptorId: 'message.send', + schemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + intent: { + attachmentDigests: [], + contentDigest: 'sha256:aaaa', + messageId: 'msg_01', + teamId: 'team_01', + }, + }, + { + id: 'send-v1-changed-intent', + descriptorId: 'message.send', + schemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + intent: { + teamId: 'team_01', + messageId: 'msg_01', + contentDigest: 'sha256:bbbb', + attachmentDigests: [], + }, + }, + { + id: 'send-v1-ordered-attachment-array', + descriptorId: 'message.send', + schemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + intent: { + teamId: 'team_01', + messageId: 'msg_02', + contentDigest: 'sha256:eeee', + attachmentDigests: ['sha256:one', 'sha256:two'], + }, + }, + { + id: 'unicode-and-integer-bounds-v1', + descriptorId: 'task.create', + schemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + intent: { + teamId: 'team_雪', + taskId: 'task_é', + expectedTeamRevision: 9007199254740991, + taskIntentDigest: 'sha256:cccc', + }, + }, + { + id: 'launch-default-materialized-v1', + descriptorId: 'team.launch', + schemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + normalizationCase: 'explicit_defaults', + intent: materializeLaunchDefaults({ + teamId: 'team_01', + providerPlanDigest: 'sha256:dddd', + effort: 'medium', + fast: false, + }), + }, + { + id: 'launch-default-omitted-v1', + descriptorId: 'team.launch', + schemaVersion: 1, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + normalizationCase: 'omitted_defaults_materialized_before_fingerprint', + intent: materializeLaunchDefaults({ teamId: 'team_01', providerPlanDigest: 'sha256:dddd' }), + }, + { + id: 'launch-schema-v2-retained-key-v1', + descriptorId: 'team.launch', + schemaVersion: 2, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + intent: { + teamId: 'team_01', + providerPlanDigest: 'sha256:dddd', + effort: 'medium', + fast: false, + topologyVersion: 2, + }, + }, + { + id: 'launch-key-rotation-v2', + descriptorId: 'team.launch', + schemaVersion: 2, + fingerprintVersion: 'hmac-sha256-ld-v1', + keyVersion: 'fixture-v2', + key: FIXTURE_KEY_V2, + intent: { + teamId: 'team_01', + providerPlanDigest: 'sha256:dddd', + effort: 'medium', + fast: false, + topologyVersion: 2, + }, + }, + { + id: 'launch-fingerprint-version-v2-retained-key-v1', + descriptorId: 'team.launch', + schemaVersion: 2, + fingerprintVersion: 'hmac-sha256-ld-v2', + keyVersion: 'fixture-v1', + key: FIXTURE_KEY_V1, + intent: { + teamId: 'team_01', + providerPlanDigest: 'sha256:dddd', + effort: 'medium', + fast: false, + topologyVersion: 2, + }, + }, + ].map(({ key, ...entry }) => ({ + ...entry, + digest: fingerprintIntent({ ...entry, key }), + })); + const oracleById = new Map(oracle.vectors.map((vector) => [vector.id, vector])); + const oracleErrors = []; + for (const entry of cases) { + const vector = oracleById.get(entry.id); + if (!vector) { + oracleErrors.push(`missing immutable oracle vector ${entry.id}`); + continue; + } + const encoded = encodeIntent({ + descriptorId: entry.descriptorId, + schemaVersion: entry.schemaVersion, + fingerprintVersion: entry.fingerprintVersion, + intent: entry.intent, + }); + if (encoded !== vector.expectedEncoding) { + oracleErrors.push(`encoding mismatch against immutable oracle ${entry.id}`); + } + if (entry.digest !== vector.expectedDigest) { + oracleErrors.push(`digest mismatch against immutable oracle ${entry.id}`); + } + } + for (const vector of oracle.vectors) { + if (!cases.some((entry) => entry.id === vector.id)) { + oracleErrors.push(`stale immutable oracle vector ${vector.id}`); + } + } + if (oracleErrors.length) { + throw new Error(`Fingerprint oracle mismatch:\n${oracleErrors.join('\n')}`); + } + const byId = Object.fromEntries(cases.map((item) => [item.id, item])); + const original = byId['send-v1-field-order-a']; + return { + schemaVersion: 1, + evidenceId: 'P0.W5.FINGERPRINT_GOLDENS', + encoder: + 'recursive UTF-8 byte-length-delimited typed encoding; object keys sorted; safe integers only', + algorithm: 'HMAC-SHA-256', + fixtureKeys: + 'public test-only keys are held by the generator and never represent production secrets', + storedMaterial: 'descriptor/schema/fingerprint/key versions and digest only; no command body', + immutableOracle: + 'test/architecture/hosted-web/phase-0/recovery-events/fixtures/fingerprint-oracle-vectors.json', + immutableOracleVectorCount: oracle.vectors.length, + cases, + assertions: { + fieldOrderEqual: + byId['send-v1-field-order-a'].digest === byId['send-v1-field-order-b'].digest, + changedIntentDiffers: original.digest !== byId['send-v1-changed-intent'].digest, + omittedDefaultEqualsMaterialized: + byId['launch-default-materialized-v1'].digest === byId['launch-default-omitted-v1'].digest, + schemaVersionDiffers: + byId['launch-default-materialized-v1'].digest !== + byId['launch-schema-v2-retained-key-v1'].digest, + keyVersionDiffers: + byId['launch-schema-v2-retained-key-v1'].digest !== byId['launch-key-rotation-v2'].digest, + fingerprintVersionDiffers: + byId['launch-schema-v2-retained-key-v1'].digest !== + byId['launch-fingerprint-version-v2-retained-key-v1'].digest, + retainedFingerprintV1StillComputable: + fingerprintIntent({ ...byId['launch-schema-v2-retained-key-v1'], key: FIXTURE_KEY_V1 }) === + byId['launch-schema-v2-retained-key-v1'].digest, + retainedSameIntentOutcome: resolveClaim(original, { ...original }).outcome, + changedIntentReuseOutcome: resolveClaim(original, byId['send-v1-changed-intent']).outcome, + immutableOracleMatch: true, + }, + }; +} + +export function buildEstimate() { + return { + schemaVersion: 1, + evidenceId: 'P0.W5.ESTIMATE', + bucketId: 'EST-RECOVERY-STATE', + packages: [ + 'shared command descriptors/fingerprints', + 'internal-storage command/effect registry', + 'event journal/SSE handoff', + 'renderer reconciliation', + 'provider effect adapters', + ], + productionLines: { low: 2700, high: 4400 }, + testLines: { low: 1800, high: 3100 }, + deletedLines: { low: 200, high: 500 }, + excludedGeneratedVendorLines: true, + overlap: [ + 'W3 owns SQLite coordination, external writer classification, backup, and schema mechanics; do not sum its shared transaction/storage fixtures twice.', + ], + confidence: 'medium-low', + assumptions: [ + 'One hosted journal writer and one internal SQLite substrate are accepted.', + 'Current OpenCode delivery evidence is adapted rather than rewritten wholesale.', + 'Terminal recovery remains excluded from v1.', + 'Workspace/provider ambiguous effects remain operator_required unless later probes prove unique evidence.', + ], + evidenceRefs: [ + 'P0.W5.EVENT_CURSOR_INVENTORY', + 'P0.W5.SNAPSHOT_HANDOFF_SCHEDULER', + 'P0.W5.COMMAND_CATALOG', + 'P0.W5.EFFECT_RECOVERY_MATRIX', + 'P0.W5.FINGERPRINT_GOLDENS', + ], + totalChangedLines: { low: 4500, high: 7500 }, + reestimateTriggers: [ + 'W3 rejects a single SQLite writer/transaction seam', + 'provider launch/delivery cannot expose operation-bound evidence', + 'command catalog expands beyond hosted v1 capability matrix', + 'retention/keyring requires a separate service or migration', + ], + }; +} + +export function exactEffectScheduleMatches(schedule) { + const compensation = schedule.crashPause.includes('compensation'); + const ambiguous = + schedule.recoveryClass === 'non_reconcilable' && + ['before_external_call', 'after_external_call', 'before_evidence_query'].includes( + schedule.crashPause + ); + const outcome = compensation ? 'compensated' : ambiguous ? 'operator_required' : 'committed'; + const externalEffects = ambiguous && schedule.crashPause === 'before_external_call' ? 0 : 1; + const externalCallAttempts = + schedule.recoveryClass === 'transactional_local' || externalEffects === 0 ? 0 : 1; + const evidenceDisposition = ambiguous + ? 'unproved' + : schedule.recoveryClass === 'transactional_local' + ? 'same_transaction' + : schedule.recoveryClass === 'idempotent_by_operation_id' + ? 'durable_operation_lookup' + : schedule.recoveryClass === 'non_reconcilable' + ? 'explicit_in_call_ack' + : 'operation_bound_unique_evidence'; + return ( + schedule.outcome === outcome && + schedule.durableAfterRecovery.state === + (compensation ? 'compensated' : ambiguous ? 'ambiguous' : 'observed_succeeded') && + schedule.durableAfterRecovery.commandOutcome === outcome && + schedule.durableAfterRecovery.journalCommitted === !ambiguous && + schedule.durableAfterRecovery.evidenceDisposition === evidenceDisposition && + schedule.externalCallAttempts === externalCallAttempts && + schedule.externalEffects === externalEffects && + schedule.compensationAttempts === (compensation ? 1 : 0) && + schedule.compensationEffects === (compensation ? 1 : 0) && + schedule.publicationAttempts === + (ambiguous ? 0 : schedule.crashPause === 'after_event_publication' ? 2 : 1) + ); +} + +export function buildReport({ catalog, scheduler, effectMatrix, goldens }) { + const ambiguous = effectMatrix.effects.filter( + (effect) => effect.recoveryClass === 'non_reconcilable' + ); + return ( + `# Phase 0 W5 recovery and event evidence\n\n` + + `Pinned phase start: \`a32f509e6d9bd31ba2135940e336729bf90c3d93\`. Packet: \`phase-00-r2\`. This is Phase 0 evidence and executable modeling only; it does not implement the Phase 1 hosted journal, command registry, or renderer.\n\n` + + `## Findings\n\n` + + `- The current generic HTTP SSE route and renderer EventSource have no durable cursor, event ID, replay, scope, or gap detection. File-watcher team changes are lossy hints.\n` + + `- Existing OpenCode delivery/bridge journals provide valuable conflict and ambiguity evidence. They are JSON-store/provider-specific, hash raw or partially normalized payloads without retained ADR-34 descriptor/key versions, and cannot serve as the hosted event journal.\n` + + `- The deterministic snapshot scheduler explored ${scheduler.exploredScheduleCount} mutation schedules, including actual before/after commit transitions. All converged; lower-C0 schedules deliberately admitted duplicates. Both negative controls reproduced a lost committed event.\n` + + `- The independent pinned-source census classifies ${catalog.coverage.observedSurfaceCount} extracted interface members and maps ${catalog.coverage.observedMethodCount} required mutations exactly once to ${catalog.commands.length} normalized command kinds and ${effectMatrix.effects.length} owned effects. Bidirectional missing/extra and omitted-descriptor fixtures fail closed.\n` + + `- The external ownership gate compares ${catalog.coverage.crossLaneOwnership.comparedRequiredW1W5Members} required W1/W5 API members against the W1 API parity ledger and fails generation on a missing row or primary command-owner drift. Coordinator effects remain owned by the primary command feature; published secondary effects retain their distinct effect owner.\n` + + `- The recovery scheduler executed ${effectMatrix.faultScheduler.exploredScheduleCount} real two-process crash/restart schedules. Every attempt exited at its scheduled boundary, a different PID reloaded only durable command/provider files, and exact post-restart state/effect/compensation/publication counts passed. Stale, coincidentally equal, mismatched-operation and lost-response negative controls all fail closed.\n` + + `- Current task/inbox/provider lookup and active-writer coordination remain unproved by W3, so those external effects are \`non_reconcilable\`/\`operator_required\`; a future operation-ID class remains only a candidate until independently exercised. Same-key changed intent resolves to \`${goldens.assertions.changedIntentReuseOutcome}\`.\n\n` + + `## Accepted handoff contract\n\n` + + `SQLite-only snapshots read the projection, revision vector, and cursor from one transaction. Any external-file projection captures and pins retained C0 before its stable scan and returns C0. SSE registers its wake listener before its first durable query and repeatedly queries the high watermark; wake-ups never carry authority. Reducers deduplicate eventId and fence aggregate generation/revision.\n\n` + + `This is at-least-once convergence, not event sourcing or exactly-once delivery. The durable journal row is an after-commit projection/outbox record; feature repositories remain state authority.\n\n` + + `## Ambiguous effects\n\n` + + ambiguous + .map( + (effect) => + `- \`${effect.commandKind}/${effect.effectId}\`: ${effect.proofRequired} -> \`operator_required\`.` + ) + .join('\n') + + `\n\n## Uncertainty and cross-lane dependency\n\n` + + `W3 proves that task/config/native-inbox active writers are uncoordinated or quiescent-only today and that selected OpenCode evidence remains partial. This W5 remediation therefore admits no automatic row whose durable lookup/transaction/exclusivity proof is missing. W3 must still confirm the future single-writer SQLite transaction, retention/backup/keyring preservation, and every effect-specific external-writer seam. The 4.5k-7.5k estimate shares storage fixtures with W3 and must be deduplicated by the controller.\n\n` + + `## Evidence index\n\n` + + `- \`P0.W5.EVENT_CURSOR_INVENTORY\`: \`event-cursor-inventory.json\`\n` + + `- \`P0.W5.SNAPSHOT_HANDOFF_SCHEDULER\`: \`snapshot-handoff-scheduler.json\`\n` + + `- \`P0.W5.COMMAND_CATALOG\`: \`command-catalog.json\`\n` + + `- \`P0.W5.EFFECT_RECOVERY_MATRIX\`: \`effect-recovery-matrix.json\`\n` + + `- \`P0.W5.FINGERPRINT_GOLDENS\`: \`fingerprint-goldens.json\`\n` + + `- \`P0.W5.ESTIMATE\`: \`estimate-input.json\`\n` + ); +} diff --git a/scripts/hosted-web/phase-0/recovery-events/render-recovery-evidence.mjs b/scripts/hosted-web/phase-0/recovery-events/render-recovery-evidence.mjs new file mode 100644 index 0000000000..d5c65afc3e --- /dev/null +++ b/scripts/hosted-web/phase-0/recovery-events/render-recovery-evidence.mjs @@ -0,0 +1,236 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { format } from 'prettier'; + +import { + runEffectRecoveryScheduler, + runSnapshotScheduler, + validateCommandCatalog, +} from './model.mjs'; +import { + buildMutationCensusEvidence, + verifyCrossLaneOwnerAgreement, + verifyMutationCensus, +} from './mutation-census.mjs'; +import { + buildEstimate, + buildEventInventory, + buildFingerprintGoldens, + buildReport, + exactEffectScheduleMatches, +} from './recovery-evidence-builders.mjs'; +import { resolveMutationCensusSourceSnapshotSha256 } from './source-revision-provenance.mjs'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(SCRIPT_DIR, '../../../..'); +const OUT = resolve(ROOT, 'docs/research/hosted-web/phase-0/recovery-events'); +const MUTATION_MANIFEST_REPO_PATH = + 'docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json'; +const MUTATION_MANIFEST_PATH = resolve(ROOT, MUTATION_MANIFEST_REPO_PATH); +const W1_API_PARITY_LEDGER_REPO_PATH = + 'docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json'; +const W1_API_PARITY_LEDGER_PATH = resolve(ROOT, W1_API_PARITY_LEDGER_REPO_PATH); +const FINGERPRINT_ORACLE_PATH = resolve( + ROOT, + 'test/architecture/hosted-web/phase-0/recovery-events/fixtures/fingerprint-oracle-vectors.json' +); + +async function renderOutputs(buildCommandCatalog) { + const mutationManifest = JSON.parse(await readFile(MUTATION_MANIFEST_PATH, 'utf8')); + const mutationCensusSourceSnapshotSha256 = await resolveMutationCensusSourceSnapshotSha256({ + root: ROOT, + sourceScopes: mutationManifest.sourceScopes ?? [], + }); + const w1ApiParityLedger = JSON.parse(await readFile(W1_API_PARITY_LEDGER_PATH, 'utf8')); + const fingerprintOracle = JSON.parse(await readFile(FINGERPRINT_ORACLE_PATH, 'utf8')); + const catalog = buildCommandCatalog(mutationManifest); + const censusVerification = await verifyMutationCensus({ + root: ROOT, + manifest: mutationManifest, + catalog, + }); + if (censusVerification.errors.length) { + throw new Error(`Mutation census invalid:\n${censusVerification.errors.join('\n')}`); + } + const crossLaneOwnerVerification = verifyCrossLaneOwnerAgreement({ + w1Ledger: w1ApiParityLedger, + manifest: mutationManifest, + catalog, + }); + if (crossLaneOwnerVerification.errors.length) { + throw new Error( + `W1-to-W5 command owner drift:\n${crossLaneOwnerVerification.errors.join('\n')}` + ); + } + catalog.coverage.observedSurfaceCount = censusVerification.counts.extracted; + catalog.coverage.observedMethodCount = censusVerification.counts.required; + catalog.coverage.dispositionCounts = censusVerification.counts; + catalog.coverage.sourceFiles = [ + ...new Set(mutationManifest.rows.map((entry) => entry.sourceFile)), + ]; + catalog.coverage.sourceToManifestComplete = true; + catalog.coverage.manifestToSourceComplete = true; + catalog.coverage.exactlyOnceMapped = true; + catalog.coverage.noCatalogMethodOutsideRequiredDisposition = true; + catalog.coverage.ownerAgreement = true; + catalog.coverage.crossLaneOwnership = { + authorityArtifact: 'docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json', + authorityEvidenceId: w1ApiParityLedger.evidenceId, + ...crossLaneOwnerVerification.counts, + ownerAgreement: true, + }; + const errors = validateCommandCatalog(catalog); + if (errors.length) throw new Error(`Command catalog invalid:\n${errors.join('\n')}`); + const scheduler = runSnapshotScheduler(); + if ( + scheduler.schedules.some( + (schedule) => !schedule.converged || schedule.gap || schedule.restartCount !== 1 + ) + ) { + throw new Error('An accepted snapshot schedule did not converge'); + } + if (scheduler.schedules.some((schedule) => schedule.mutationCommitTransitions.length !== 2)) { + throw new Error('A snapshot schedule labeled commit without a real before/after transition'); + } + if (scheduler.negativeControls.some((control) => !control.reproduced || !control.gap)) { + throw new Error('A required negative schedule did not reproduce its gap'); + } + const eventInventory = buildEventInventory(); + const effectRecovery = runEffectRecoveryScheduler(); + if (effectRecovery.schedules.some((schedule) => schedule.duplicateEffect)) { + throw new Error('An effect recovery schedule repeated an external effect'); + } + if ( + effectRecovery.schedules.some( + (schedule) => + schedule.restartCount !== 1 || + schedule.attemptExitCode !== 86 || + schedule.recoveryExitCode !== 0 || + !schedule.freshProcess || + schedule.committedWithoutEvidence + ) + ) { + throw new Error( + 'An effect schedule did not perform one durable restart or committed without evidence' + ); + } + if ( + effectRecovery.negativeControls.some( + (control) => control.outcome !== 'operator_required' || control.retryAttempted + ) + ) { + throw new Error('An effect negative control did not fail closed'); + } + if (effectRecovery.schedules.some((schedule) => !exactEffectScheduleMatches(schedule))) { + throw new Error('An effect schedule did not match its exact post-restart state/effect counts'); + } + const effectRecoveryEvidence = { + ...effectRecovery, + assertions: { + realAttemptExitAtEveryBoundary: true, + freshRecoveryProcessEverySchedule: true, + exactPostRestartStateAndCounts: true, + }, + schedules: effectRecovery.schedules.map(({ processIds, ...schedule }) => ({ + ...schedule, + processCount: processIds.length, + })), + }; + const effectMatrix = { + schemaVersion: 1, + evidenceId: 'P0.W5.EFFECT_RECOVERY_MATRIX', + stateMachine: + 'not_started -> attempting -> observed_succeeded | observed_absent | ambiguous; compensating -> compensated | ambiguous', + retryRule: + 'attempting is persisted before the boundary; retry only after descriptor proof establishes deduplication or absence', + proofScope: + 'fresh Node process crash/restart fixture with durable command and independent external-adapter files; individual catalog rows admit automatic recovery only when automaticRecoveryAdmitted is true', + ownershipAssertions: { + everyEffectHasOwner: catalog.commands.every((command) => + command.effects.every((effect) => Boolean(effect.effectOwner)) + ), + everyCoordinatorOwnedByCommandFeature: catalog.commands.every((command) => + command.effects + .filter((effect) => effect.effectRole === 'coordinator_effect') + .every((effect) => effect.effectOwner === command.featureOwner) + ), + everyEffectHasWriterEvidence: catalog.commands.every((command) => + command.effects.every((effect) => effect.writerAuthority && effect.writerEvidenceRef) + ), + unprovedEffectsFailClosed: catalog.commands.every((command) => + command.effects.every( + (effect) => + effect.automaticRecoveryAdmitted || + effect.currentRecoveryDisposition.startsWith('operator_required') + ) + ), + }, + faultScheduler: effectRecoveryEvidence, + effects: catalog.commands.flatMap((command) => + command.effects.map((effect) => ({ commandKind: command.commandKind, ...effect })) + ), + }; + const goldens = buildFingerprintGoldens(fingerprintOracle); + const estimate = buildEstimate(); + const mutationCensus = buildMutationCensusEvidence({ + manifest: mutationManifest, + verification: censusVerification, + crossLaneVerification: crossLaneOwnerVerification, + sourceSnapshotSha256: mutationCensusSourceSnapshotSha256, + }); + const index = { + schemaVersion: 1, + laneId: 'w5', + packetRevision: 'phase-00-r2', + phaseStartSha: 'a32f509e6d9bd31ba2135940e336729bf90c3d93', + supportingArtifacts: [ + { id: 'P0.W5.SUPPORTING.MUTATION_CENSUS', path: 'mutation-census.json' }, + { + id: 'P0.W5.SUPPORTING.MUTATION_SURFACE_MANIFEST', + path: 'mutation-surface-manifest.json', + }, + ], + evidence: [ + ['P0.W5.EVENT_CURSOR_INVENTORY', 'event-cursor-inventory.json'], + ['P0.W5.SNAPSHOT_HANDOFF_SCHEDULER', 'snapshot-handoff-scheduler.json'], + ['P0.W5.COMMAND_CATALOG', 'command-catalog.json'], + ['P0.W5.EFFECT_RECOVERY_MATRIX', 'effect-recovery-matrix.json'], + ['P0.W5.FINGERPRINT_GOLDENS', 'fingerprint-goldens.json'], + ['P0.W5.ESTIMATE', 'estimate-input.json'], + ].map(([id, path]) => ({ id, path })), + }; + const json = (value, spacing) => `${JSON.stringify(value, null, spacing)}\n`; + const prettierJson = (value) => + format(JSON.stringify(value), { parser: 'json', printWidth: 100, trailingComma: 'none' }); + return new Map([ + ['index.json', json(index)], + ['event-cursor-inventory.json', json(eventInventory)], + ['snapshot-handoff-scheduler.json', json(scheduler)], + ['command-catalog.json', await prettierJson(catalog)], + ['effect-recovery-matrix.json', json(effectMatrix, 2)], + ['fingerprint-goldens.json', json(goldens)], + ['estimate-input.json', json(estimate)], + ['mutation-census.json', json(mutationCensus, 2)], + ['README.md', buildReport({ catalog, scheduler, effectMatrix, goldens })], + ]); +} + +export async function runRecoveryEvidenceGenerator(buildCommandCatalog, check) { + const outputs = await renderOutputs(buildCommandCatalog); + await mkdir(OUT, { recursive: true }); + const mismatches = []; + for (const [relative, content] of outputs) { + const target = resolve(OUT, relative); + if (check) { + const existing = await readFile(target, 'utf8').catch(() => null); + if (existing !== content) mismatches.push(relative); + } else { + await writeFile(target, content, 'utf8'); + } + } + if (mismatches.length) + throw new Error(`Generated W5 evidence is stale: ${mismatches.join(', ')}`); + process.stdout.write(`${check ? 'verified' : 'generated'} ${outputs.size} W5 evidence files\n`); +} diff --git a/scripts/hosted-web/phase-0/recovery-events/source-revision-provenance.mjs b/scripts/hosted-web/phase-0/recovery-events/source-revision-provenance.mjs new file mode 100644 index 0000000000..2b39790ba0 --- /dev/null +++ b/scripts/hosted-web/phase-0/recovery-events/source-revision-provenance.mjs @@ -0,0 +1,35 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +export const MUTATION_CENSUS_SOURCE_PATHS = [ + 'scripts/hosted-web/phase-0/recovery-events/generate-evidence.mjs', + 'scripts/hosted-web/phase-0/recovery-events/mutation-census.mjs', + 'scripts/hosted-web/phase-0/recovery-events/recovery-evidence-builders.mjs', + 'scripts/hosted-web/phase-0/recovery-events/render-recovery-evidence.mjs', + 'scripts/hosted-web/phase-0/recovery-events/source-revision-provenance.mjs', + 'docs/research/hosted-web/phase-0/recovery-events/mutation-surface-manifest.json', + 'docs/research/hosted-web/phase-0/parity-renderer/api-parity-ledger.json', +]; + +export async function computeSourceSnapshotSha256({ root, sourcePaths }) { + const hash = createHash('sha256'); + hash.update('mutation-census-source-snapshot-v1\0'); + for (const sourcePath of [...new Set(sourcePaths)].sort()) { + const content = await readFile(resolve(root, sourcePath)); + const pathBytes = Buffer.from(sourcePath, 'utf8'); + hash.update(`${pathBytes.length}:`); + hash.update(pathBytes); + hash.update(`${content.length}:`); + hash.update(content); + } + return hash.digest('hex'); +} + +export async function resolveMutationCensusSourceSnapshotSha256({ root, sourceScopes }) { + const sourcePaths = [ + ...MUTATION_CENSUS_SOURCE_PATHS, + ...sourceScopes.map((scope) => scope.sourceFile), + ]; + return computeSourceSnapshotSha256({ root, sourcePaths }); +} diff --git a/scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs b/scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs new file mode 100644 index 0000000000..827fc1a699 --- /dev/null +++ b/scripts/hosted-web/phase-0/state-writers/external-writer-negative-fixture.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Reproduces the TeamTaskWriter failure model: the app's promise lock protects only app callbacks. + * A separate process writes after the app read and before its atomic rename. The app then commits + * stale bytes and erases the external update. The fixture succeeds only when that loss is observed. + */ +export async function runExternalWriterNegativeFixture() { + const root = await mkdtemp(join(tmpdir(), 'agent-teams-w3-external-writer-')); + const markerPath = join(root, '.agent-teams-phase-0-w3-fixture'); + const statePath = join(root, 'task.json'); + await writeFile(markerPath, 'marker-owned\n', { mode: 0o600 }); + await writeFile( + statePath, + JSON.stringify({ revision: 1, appValue: 'before', externalValue: 'before' }) + ); + + try { + const appSnapshot = JSON.parse(await readFile(statePath, 'utf8')); + const childCode = [ + "const fs=require('node:fs');", + 'const p=process.argv[1];', + "const value=JSON.parse(fs.readFileSync(p,'utf8'));", + "value.externalValue='written-by-external-process';", + 'value.revision+=1;', + "fs.writeFileSync(p+'.external.tmp',JSON.stringify(value));", + "fs.renameSync(p+'.external.tmp',p);", + ].join(''); + const child = spawn(process.execPath, ['-e', childCode, statePath], { stdio: 'ignore' }); + const childExit = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => resolve(code)); + }); + if (childExit !== 0) throw new Error(`external fixture writer exited ${childExit}`); + + // This delay represents arbitrary work inside the in-process lock. It does not coordinate child. + await delay(5); + appSnapshot.appValue = 'written-by-app'; + appSnapshot.revision += 1; + await writeFile(`${statePath}.app.tmp`, JSON.stringify(appSnapshot)); + await import('node:fs/promises').then(({ rename }) => + rename(`${statePath}.app.tmp`, statePath) + ); + + const finalState = JSON.parse(await readFile(statePath, 'utf8')); + return { + markerOwned: true, + externalWriteCompleted: true, + finalState, + lostExternalUpdate: finalState.externalValue !== 'written-by-external-process', + conclusion: 'app-only locking does not coordinate an external process', + }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runExternalWriterNegativeFixture() + .then((result) => { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.lostExternalUpdate) process.exitCode = 1; + }) + .catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs b/scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs new file mode 100644 index 0000000000..a14a669706 --- /dev/null +++ b/scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const require = createRequire(import.meta.url); +const Database = require('better-sqlite3'); + +export class SqliteBackupFault extends Error { + constructor(code, message, cause) { + super(message, { cause }); + this.name = 'SqliteBackupFault'; + this.code = code; + } +} + +function removePartialDestination(destinationPath) { + rmSync(destinationPath, { force: true }); + rmSync(`${destinationPath}-wal`, { force: true }); + rmSync(`${destinationPath}-shm`, { force: true }); +} + +function classifyBackupError(error) { + if (error instanceof SqliteBackupFault) { + return error; + } + const code = typeof error?.code === 'string' ? error.code : ''; + if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') { + return new SqliteBackupFault( + 'backup_busy', + 'SQLite online backup was busy; no fallback copy was attempted', + error + ); + } + return new SqliteBackupFault( + 'backup_failed', + `SQLite online backup failed: ${error instanceof Error ? error.message : String(error)}`, + error + ); +} + +/** + * Minimal ADR-32 feasibility primitive. The caller supplies an already-open source so the + * production worker can retain single-request ownership. No DB/WAL/SHM copy fallback exists. + */ +export async function onlineBackup({ + source, + destinationPath, + deadlineMs = 5_000, + now = () => Date.now(), + pagesPerIteration = 64, +}) { + if (!source || typeof source.backup !== 'function' || typeof source.pragma !== 'function') { + throw new TypeError('source must expose better-sqlite3 backup() and pragma()'); + } + if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0) { + throw new TypeError('deadlineMs must be a positive safe integer'); + } + + mkdirSync(dirname(destinationPath), { recursive: true }); + removePartialDestination(destinationPath); + + let integrity; + try { + integrity = source.pragma('integrity_check', { simple: true }); + } catch (error) { + throw new SqliteBackupFault( + 'source_corrupt', + 'Source integrity check could not complete; backup was not started', + error + ); + } + if (integrity !== 'ok') { + throw new SqliteBackupFault( + 'source_corrupt', + `Source integrity check failed: ${String(integrity)}` + ); + } + + const startedAt = now(); + let progressCalls = 0; + try { + const progress = await source.backup(destinationPath, { + progress() { + progressCalls += 1; + if (now() - startedAt >= deadlineMs) { + throw new SqliteBackupFault( + 'backup_deadline', + 'SQLite online backup exceeded its bounded deadline' + ); + } + return pagesPerIteration; + }, + }); + + const reopened = new Database(destinationPath, { readonly: true, fileMustExist: true }); + try { + const copiedIntegrity = reopened.pragma('integrity_check', { simple: true }); + if (copiedIntegrity !== 'ok') { + throw new SqliteBackupFault( + 'destination_corrupt', + `Destination integrity check failed: ${String(copiedIntegrity)}` + ); + } + return { + method: 'better-sqlite3#backup', + pages: progress.totalPages, + progressCalls, + destinationBytes: statSync(destinationPath).size, + integrity: copiedIntegrity, + }; + } finally { + reopened.close(); + } + } catch (error) { + removePartialDestination(destinationPath); + throw classifyBackupError(error); + } +} + +export async function runWalDemo() { + const root = mkdtempSync(join(tmpdir(), 'agent-teams-w3-sqlite-backup-')); + const markerPath = join(root, '.agent-teams-phase-0-w3-fixture'); + writeFileSync(markerPath, 'marker-owned\n', { mode: 0o600 }); + const sourcePath = join(root, 'source.db'); + const destinationPath = join(root, 'backup.db'); + const source = new Database(sourcePath); + + try { + const journalMode = source.pragma('journal_mode = WAL', { simple: true }); + source.pragma('wal_autocheckpoint = 0'); + source.exec('CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL)'); + const insert = source.prepare('INSERT INTO items(value) VALUES (?)'); + const seed = source.transaction(() => { + for (let index = 0; index < 2_000; index += 1) + insert.run(`fixture-${index}-${'x'.repeat(256)}`); + }); + seed(); + if (!existsSync(`${sourcePath}-wal`)) + throw new Error('WAL sidecar was not active during the probe'); + + const result = await onlineBackup({ source, destinationPath }); + const independent = new Database(destinationPath, { readonly: true, fileMustExist: true }); + const rowCount = independent.prepare('SELECT count(*) AS count FROM items').get().count; + independent.close(); + return { + fixtureRoot: root, + markerOwned: existsSync(markerPath), + journalMode, + walActiveAtBackup: existsSync(`${sourcePath}-wal`), + independentRowCount: rowCount, + ...result, + }; + } finally { + source.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runWalDemo() + .then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)) + .catch((error) => { + process.stderr.write( + `${JSON.stringify({ code: error?.code ?? 'unexpected', message: error instanceof Error ? error.message : String(error) })}\n` + ); + process.exitCode = 1; + }); +} diff --git a/scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs b/scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs new file mode 100644 index 0000000000..e900ed59c8 --- /dev/null +++ b/scripts/hosted-web/phase-0/state-writers/verify-evidence.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import { access, readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '../../../..'); +const evidenceDir = join(repoRoot, 'docs/research/hosted-web/phase-0/state-writers'); +const expectedSha = 'a32f509e6d9bd31ba2135940e336729bf90c3d93'; +const evidenceFiles = [ + 'state-family-catalog.json', + 'writer-coordination.json', + 'schema-unknown-fields.json', + 'backup-behavior.json', + 'sqlite-online-backup-results.json', + 'estimate-input.json', +]; + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +async function verifySourceRefs(refs, context) { + invariant(Array.isArray(refs) && refs.length > 0, `${context}: sourceRefs must be non-empty`); + for (const ref of refs) { + invariant( + nonEmptyString(ref) && !ref.startsWith('/'), + `${context}: sourceRef must be repository-relative` + ); + const topLevel = ref.split('/')[0]; + invariant( + ['src', 'test', 'scripts', 'docs'].includes(topLevel), + `${context}: unsupported sourceRef ${ref}` + ); + await access(join(repoRoot, ref)); + } +} + +export async function validateStateFamilyCatalog(catalog) { + invariant(catalog.evidenceId === 'P0.W3.STATE_FAMILY_CATALOG', 'catalog: wrong evidenceId'); + invariant( + Array.isArray(catalog.families) && catalog.families.length > 0, + 'catalog: families missing' + ); + const ids = new Set(); + const required = [ + 'pathPattern', + 'implementationStatus', + 'authority', + 'writers', + 'unresolvedWriters', + 'schemaVersion', + 'maxBytes', + 'lockingModel', + 'atomicity', + 'unknownFieldPolicy', + 'corruptionPolicy', + 'backupRole', + 'secretClass', + ]; + for (const family of catalog.families) { + invariant( + nonEmptyString(family.id) && !ids.has(family.id), + `catalog: duplicate/empty family id ${family.id}` + ); + ids.add(family.id); + for (const key of required) { + invariant(family[key] !== undefined, `${family.id}: missing ${key}`); + } + invariant( + Array.isArray(family.writers) && family.writers.length > 0, + `${family.id}: writers missing` + ); + invariant( + Array.isArray(family.unresolvedWriters), + `${family.id}: unresolvedWriters must be an array` + ); + await verifySourceRefs(family.sourceRefs, family.id); + } + const unresolvedCount = catalog.families.filter( + (family) => family.unresolvedWriters.length > 0 + ).length; + invariant(catalog.counts.total === catalog.families.length, 'catalog: counts.total drift'); + invariant( + catalog.counts.withUnresolvedWriterIdentity === unresolvedCount, + 'catalog: unresolved count drift' + ); + return ids; +} + +async function validateCommon(record, fileName) { + invariant(record?.schemaVersion === 1, `${fileName}: schemaVersion must be 1`); + invariant( + nonEmptyString(record?.evidenceId) && record.evidenceId.startsWith('P0.W3.'), + `${fileName}: invalid evidenceId` + ); + invariant(record.phaseStartSha === expectedSha, `${fileName}: wrong phaseStartSha`); + invariant(nonEmptyString(record.proofLevel), `${fileName}: proofLevel missing`); +} + +export async function verifyEvidence({ overrideCatalog } = {}) { + const records = new Map(); + for (const fileName of evidenceFiles) { + const parsed = + fileName === 'state-family-catalog.json' && overrideCatalog + ? overrideCatalog + : JSON.parse(await readFile(join(evidenceDir, fileName), 'utf8')); + await validateCommon(parsed, fileName); + records.set(fileName, parsed); + } + + const familyIds = await validateStateFamilyCatalog(records.get('state-family-catalog.json')); + const coordination = records.get('writer-coordination.json'); + const allowedClasses = new Set(coordination.classes); + invariant( + coordination.operations.length >= 10, + 'coordination: required mutation coverage too small' + ); + for (const operation of coordination.operations) { + invariant(familyIds.has(operation.familyId), `${operation.id}: unknown familyId`); + invariant( + allowedClasses.has(operation.coordinationClass), + `${operation.id}: invalid coordinationClass` + ); + invariant( + ['app_exclusive', 'cooperative_external', 'uncoordinated_external'].includes( + operation.adr29Class + ), + `${operation.id}: invalid ADR-29 class` + ); + for (const key of ['activeWriterEvidence', 'currentProof', 'hostedDisposition']) { + invariant(nonEmptyString(operation[key]), `${operation.id}: missing ${key}`); + } + await verifySourceRefs(operation.sourceRefs, operation.id); + } + + const unknowns = records.get('schema-unknown-fields.json'); + for (const policy of unknowns.policies) { + invariant(familyIds.has(policy.familyId), `unknown fields: unknown family ${policy.familyId}`); + await verifySourceRefs(policy.sourceRefs, `unknown-fields:${policy.familyId}`); + } + + const backup = records.get('backup-behavior.json'); + invariant( + backup.currentService.classification === 'legacy_unverified safety copy', + 'backup: unsafe current-service classification' + ); + invariant( + backup.proofLevel === 'fixture_characterized', + 'backup: production service fixture proof missing' + ); + invariant( + backup.fixture?.kind === 'marker-owned production-service fault characterization', + 'backup: fixture ownership missing' + ); + invariant( + backup.fixture?.userStateAccessed === false, + 'backup: fixture must not access user state' + ); + invariant( + backup.fixture?.sourceUnderTest === 'src/main/services/team/TeamBackupService.ts', + 'backup: wrong production source under test' + ); + invariant( + backup.fixture?.result?.tests === 7 && backup.fixture?.result?.cases === 12, + 'backup: fixture result count drift' + ); + invariant( + backup.fixture?.result?.passed === 7 && backup.fixture?.result?.failed === 0, + 'backup: fixture is not fully passing' + ); + invariant( + Array.isArray(backup.faultMatrix) && backup.faultMatrix.length === 12, + 'backup: fault matrix must contain 12 cases' + ); + const backupCaseIds = backup.faultMatrix.map((entry) => entry.caseId); + invariant( + new Set(backupCaseIds).size === backupCaseIds.length, + 'backup: duplicate fault case ID' + ); + invariant( + backupCaseIds.every((caseId, index) => caseId === `TB-${String(index + 1).padStart(2, '0')}`), + 'backup: fault case IDs must be contiguous TB-01 through TB-12' + ); + invariant( + backup.faultMatrix.every((entry) => entry.asserted === true), + 'backup: unasserted fixture result' + ); + invariant( + backup.faultMatrix.every((entry) => entry.recoveryPointSafe === false), + 'backup: legacy fault incorrectly marked safe' + ); + await access(join(repoRoot, backup.fixture.path)); + await verifySourceRefs(backup.currentService.sourceRefs, 'backup current service'); + + const sqlite = records.get('sqlite-online-backup-results.json'); + invariant( + sqlite.results.some((entry) => entry.case.includes('wal-active')), + 'sqlite: WAL case missing' + ); + invariant( + sqlite.results.some((entry) => entry.case.includes('BUSY')), + 'sqlite: BUSY case missing' + ); + invariant( + sqlite.results.some((entry) => entry.case.includes('corruption')), + 'sqlite: corruption case missing' + ); + + const estimate = records.get('estimate-input.json'); + const min = estimate.buckets.reduce( + (sum, bucket) => sum + bucket.productionLines.min + bucket.testLines.min, + 0 + ); + const max = estimate.buckets.reduce( + (sum, bucket) => sum + bucket.productionLines.max + bucket.testLines.max, + 0 + ); + invariant( + estimate.totals.changedLines.min === min && estimate.totals.changedLines.max === max, + 'estimate: changed-line total drift' + ); + invariant(min >= 4_500 && max <= 7_500, 'estimate: outside parent EST-RECOVERY-STATE range'); + invariant( + estimate.buckets.every((bucket) => bucket.excludedGeneratedVendorLines === true), + 'estimate: generated/vendor exclusion missing' + ); + + const spikeSource = await readFile( + join(repoRoot, 'scripts/hosted-web/phase-0/state-writers/sqlite-online-backup-spike.mjs'), + 'utf8' + ); + invariant( + !/copyFile|wal_checkpoint|VACUUM\s+INTO/i.test(spikeSource), + 'sqlite spike: forbidden fallback/checkpoint detected' + ); + + return { + evidenceFiles: evidenceFiles.length, + stateFamilies: familyIds.size, + operations: coordination.operations.length, + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + verifyEvidence() + .then((result) => process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`)) + .catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.d.mts b/scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.d.mts new file mode 100644 index 0000000000..6241fe09e4 --- /dev/null +++ b/scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.d.mts @@ -0,0 +1,32 @@ +export const repositoryRoot: string; +export const controllerArtifactContractPath: string; + +export type ArtifactRecord = Record & { artifactId: string }; + +export interface ControllerArtifactContract { + artifactFields: string[]; + artifacts: ArtifactRecord[]; +} + +export interface ControllerArtifactProjection { + controllerContractPath?: string; + controllerContractSha256?: string; + artifacts?: Record[]; +} + +export interface ContractValidationResult { + ok: boolean; + violations: string[]; +} + +export function loadControllerArtifactContract(root?: string): ControllerArtifactContract; +export function controllerArtifactContractSha256(root?: string): string; +export function validateArtifactProjection( + controllerContract: ControllerArtifactContract, + projection: ArtifactRecord[] +): ContractValidationResult; +export function validateControllerArtifactProjection( + controllerContract: ControllerArtifactContract, + projection: ControllerArtifactProjection, + root?: string +): ContractValidationResult; diff --git a/scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs b/scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs new file mode 100644 index 0000000000..6b69f2fd14 --- /dev/null +++ b/scripts/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.mjs @@ -0,0 +1,81 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +export const repositoryRoot = resolve(scriptDirectory, '../../../..'); +export const controllerArtifactContractPath = + 'docs/research/hosted-web/phase-0/w4-w6-contract/controller-artifact-contract.json'; + +export function loadControllerArtifactContract(root = repositoryRoot) { + return JSON.parse(readFileSync(resolve(root, controllerArtifactContractPath), 'utf8')); +} + +export function controllerArtifactContractSha256(root = repositoryRoot) { + return createHash('sha256') + .update(readFileSync(resolve(root, controllerArtifactContractPath))) + .digest('hex'); +} + +export function validateArtifactProjection(controllerContract, projection) { + const violations = []; + const expectedFields = controllerContract.artifactFields; + const projectedArtifacts = projection ?? []; + const expected = new Map( + controllerContract.artifacts.map((artifact) => [artifact.artifactId, artifact]) + ); + const actual = new Map(projectedArtifacts.map((artifact) => [artifact.artifactId, artifact])); + + for (const artifactId of new Set(projectedArtifacts.map((artifact) => artifact.artifactId))) { + if (projectedArtifacts.filter((artifact) => artifact.artifactId === artifactId).length > 1) { + violations.push(`duplicate_artifact:${artifactId}`); + } + } + + for (const artifactId of expected.keys()) { + if (!actual.has(artifactId)) violations.push(`missing_artifact:${artifactId}`); + } + for (const artifactId of actual.keys()) { + if (!expected.has(artifactId)) violations.push(`extra_artifact:${artifactId}`); + } + for (const [artifactId, expectedArtifact] of expected) { + const actualArtifact = actual.get(artifactId); + if (!actualArtifact) continue; + const actualFields = Object.keys(actualArtifact); + for (const field of expectedFields) { + if (!Object.hasOwn(actualArtifact, field)) { + violations.push(`missing_field:${artifactId}:${field}`); + } + } + for (const field of actualFields) { + if (!expectedFields.includes(field)) violations.push(`extra_field:${artifactId}:${field}`); + } + for (const field of expectedFields) { + if ( + Object.hasOwn(actualArtifact, field) && + JSON.stringify(actualArtifact[field]) !== JSON.stringify(expectedArtifact[field]) + ) { + violations.push(`value_mismatch:${artifactId}:${field}`); + } + } + } + return { ok: violations.length === 0, violations }; +} + +export function validateControllerArtifactProjection( + controllerContract, + projection, + root = repositoryRoot +) { + const violations = []; + if (projection?.controllerContractPath !== controllerArtifactContractPath) { + violations.push('controller_contract_path'); + } + if (projection?.controllerContractSha256 !== controllerArtifactContractSha256(root)) { + violations.push('controller_contract_hash'); + } + const artifactResult = validateArtifactProjection(controllerContract, projection?.artifacts); + violations.push(...artifactResult.violations); + return { ok: violations.length === 0, violations }; +} diff --git a/scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.d.mts b/scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.d.mts new file mode 100644 index 0000000000..90cafc7d3d --- /dev/null +++ b/scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.d.mts @@ -0,0 +1,40 @@ +export const repositoryRoot: string; +export const drainEvidenceEnvelopeId: string; +export const drainEvidenceEnvelopeSchemaPath: string; + +export interface DrainEvidenceEnvelope { + envelopeId: string; + ready: Record; + drained: Record; +} + +export interface DrainEvidenceValidationResult { + ok: boolean; + violations: string[]; +} + +export interface DrainEvidenceEnvelopeSchema { + $id: string; + $defs: { + ready: { required: string[] }; + drained: { required: string[] }; + }; + [field: string]: unknown; +} + +export function loadDrainEvidenceEnvelopeSchema(root?: string): DrainEvidenceEnvelopeSchema; +export function drainEvidenceEnvelopeSchemaSha256(root?: string): string; +export function validateDrainEvidenceEnvelope( + envelope: DrainEvidenceEnvelope, + schema?: DrainEvidenceEnvelopeSchema +): DrainEvidenceValidationResult; +export function createDrainEvidenceEnvelope( + ready: Record, + drained: Record +): DrainEvidenceEnvelope; +export function assertDrainEvidenceEnvelope(envelope: T): T; +export function validateW4DrainEvidenceProjection( + nativeProtocolSchema: unknown, + processAnchorProtocol: unknown, + root?: string +): DrainEvidenceValidationResult; diff --git a/scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.mjs b/scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.mjs new file mode 100644 index 0000000000..95c0c9d0ff --- /dev/null +++ b/scripts/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.mjs @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +export const repositoryRoot = resolve(scriptDirectory, '../../../..'); +export const drainEvidenceEnvelopeId = 'P0.CONTROLLER.W4_W6.DRAIN_EVIDENCE_ENVELOPE.V1'; +export const drainEvidenceEnvelopeSchemaPath = + 'docs/research/hosted-web/phase-0/w4-w6-contract/drain-evidence-envelope.schema.json'; + +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const own = (value, key) => Object.hasOwn(value, key); + +export function loadDrainEvidenceEnvelopeSchema(root = repositoryRoot) { + return JSON.parse(readFileSync(resolve(root, drainEvidenceEnvelopeSchemaPath), 'utf8')); +} + +export function drainEvidenceEnvelopeSchemaSha256(root = repositoryRoot) { + return sha256(readFileSync(resolve(root, drainEvidenceEnvelopeSchemaPath))); +} + +function validateExactObject(value, rule, label, violations) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + violations.push(`type:${label}:object`); + return; + } + const required = rule.required ?? []; + for (const field of required) { + if (!own(value, field)) violations.push(`missing_field:${label}:${field}`); + } + if (rule.additionalProperties === false) { + for (const field of Object.keys(value)) { + if (!own(rule.properties ?? {}, field)) violations.push(`extra_field:${label}:${field}`); + } + } + for (const [field, fieldRule] of Object.entries(rule.properties ?? {})) { + if (!own(value, field)) continue; + const actual = value[field]; + if (own(fieldRule, 'const') && actual !== fieldRule.const) { + violations.push(`const:${label}:${field}`); + continue; + } + if (fieldRule.type === 'string' && typeof actual !== 'string') { + violations.push(`type:${label}:${field}:string`); + } else if ( + fieldRule.type === 'string' && + fieldRule.minLength && + actual.length < fieldRule.minLength + ) { + violations.push(`min_length:${label}:${field}`); + } + if (fieldRule.type === 'integer' && !Number.isInteger(actual)) { + violations.push(`type:${label}:${field}:integer`); + } else if (fieldRule.type === 'integer' && actual < (fieldRule.minimum ?? -Infinity)) { + violations.push(`minimum:${label}:${field}`); + } + if (fieldRule.type === 'array' && !Array.isArray(actual)) { + violations.push(`type:${label}:${field}:array`); + } else if (fieldRule.type === 'array' && actual.length > (fieldRule.maxItems ?? Infinity)) { + violations.push(`max_items:${label}:${field}`); + } + } +} + +export function validateDrainEvidenceEnvelope( + envelope, + schema = loadDrainEvidenceEnvelopeSchema() +) { + const violations = []; + validateExactObject(envelope, schema, 'envelope', violations); + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { + return { ok: false, violations }; + } + if (envelope.envelopeId !== schema.$id || schema.$id !== drainEvidenceEnvelopeId) { + violations.push('envelope_id'); + } + validateExactObject(envelope.ready, schema.$defs.ready, 'ready', violations); + validateExactObject(envelope.drained, schema.$defs.drained, 'drained', violations); + + const ready = envelope.ready; + const drained = envelope.drained; + if (ready && drained && typeof ready === 'object' && typeof drained === 'object') { + for (const field of [ + 'protocolVersion', + 'purpose', + 'resetGeneration', + 'deploymentGeneration', + 'processAnchorGeneration', + ]) { + if (ready[field] !== drained[field]) violations.push(`generation_binding:${field}`); + } + } + return { ok: violations.length === 0, violations }; +} + +export function createDrainEvidenceEnvelope(ready, drained) { + const envelope = { envelopeId: drainEvidenceEnvelopeId, ready, drained }; + return assertDrainEvidenceEnvelope(envelope); +} + +export function assertDrainEvidenceEnvelope(envelope) { + const result = validateDrainEvidenceEnvelope(envelope); + if (!result.ok) { + throw new Error(`invalid controller drain-evidence envelope: ${result.violations.join(',')}`); + } + return envelope; +} + +export function validateW4DrainEvidenceProjection( + nativeProtocolSchema, + processAnchorProtocol, + root = repositoryRoot +) { + const violations = []; + const controllerSchema = loadDrainEvidenceEnvelopeSchema(root); + const reference = nativeProtocolSchema?.['x-processAnchorDrainEvidence']; + const expectedReference = { + authority: 'phase-00-controller', + envelopeId: drainEvidenceEnvelopeId, + schemaPath: drainEvidenceEnvelopeSchemaPath, + schemaSha256: drainEvidenceEnvelopeSchemaSha256(root), + readyResponseType: 'ready', + drainedResponseType: 'drained', + projection: 'exact_required_fields_no_lane_owned_wrapper', + }; + for (const [field, expected] of Object.entries(expectedReference)) { + if (reference?.[field] !== expected) violations.push(`controller_reference:${field}`); + } + for (const field of Object.keys(reference ?? {})) { + if (!own(expectedReference, field)) + violations.push(`controller_reference:extra_field:${field}`); + } + if (processAnchorProtocol?.artifactId !== 'agent-teams-process-anchor') { + violations.push('w4_projection:artifact_id'); + } + if (processAnchorProtocol?.$schema !== './native-protocol.schema.json') { + violations.push('w4_projection:native_schema_reference'); + } + + const responseByType = new Map( + (processAnchorProtocol?.responses ?? []).map((response) => [response.type, response]) + ); + for (const [type, definition] of [ + ['ready', controllerSchema.$defs.ready], + ['drained', controllerSchema.$defs.drained], + ]) { + const actualFields = responseByType.get(type)?.fields; + if (JSON.stringify(actualFields) !== JSON.stringify(definition.required)) { + violations.push(`w4_projection:${type}_fields`); + } + } + const expectedUnclassifiedFields = [ + ...controllerSchema.$defs.drained.required, + 'reason', + 'containerReplacementRequired', + ]; + if ( + JSON.stringify(responseByType.get('unclassified_residual')?.fields) !== + JSON.stringify(expectedUnclassifiedFields) + ) { + violations.push('w4_projection:unclassified_fields'); + } + if ( + processAnchorProtocol?.sharedDrainDto?.kind !== + controllerSchema.$defs.drained.properties.kind.const + ) { + violations.push('w4_projection:drain_kind'); + } + if ( + own(processAnchorProtocol?.sharedDrainDto ?? {}, 'owner') || + own(processAnchorProtocol?.sharedDrainDto ?? {}, 'authority') + ) { + violations.push('w4_projection:w6_owned_authority_wrapper'); + } + return { ok: violations.length === 0, violations }; +} diff --git a/scripts/hosted-web/phase-1/check-feature-dependencies.ts b/scripts/hosted-web/phase-1/check-feature-dependencies.ts new file mode 100644 index 0000000000..f114ed658c --- /dev/null +++ b/scripts/hosted-web/phase-1/check-feature-dependencies.ts @@ -0,0 +1,69 @@ +export const FEATURE_DEPENDENCY_DIAGNOSTICS = Object.freeze({ + coreSideEffect: 'phase1-core-side-effect-forbidden', + filesystemAdapter: 'phase1-filesystem-adapter-forbidden', + forbiddenCoreImport: 'phase1-core-import-forbidden', + legacyGodDto: 'phase1-legacy-god-dto-forbidden', + productionAdapterMount: 'phase1-test-adapter-production-import', +} as const); + +export interface DependencySource { + readonly path: string; + readonly source: string; +} + +export interface DependencyDiagnostic { + readonly path: string; + readonly diagnostic: (typeof FEATURE_DEPENDENCY_DIAGNOSTICS)[keyof typeof FEATURE_DEPENDENCY_DIAGNOSTICS]; +} + +const CORE_OR_CONTRACT_PATH = /(?:^|\/)(?:core|contracts)(?:\/|$)/; +const FORBIDDEN_CORE_IMPORT = + /(?:from\s*|import\s*\(|require\s*\()\s*['"](?:node:)?(?:electron|fastify|react|zustand|fs(?:\/promises)?|path|child_process|chokidar|@main(?:\/|$)|@renderer(?:\/|$)|@preload(?:\/|$)|[^'"]*\/(?:adapters|infrastructure)(?:\/|$))/; +const CORE_SIDE_EFFECT = + /\b(?:watch(?:File)?|repair\w*|notify\w*|runtimeOverlay|transportLogger|spawn|execFile|process\.(?:env|cwd|on)|Notification)\b/; +const FILESYSTEM_SURFACE = + /(?:from\s*['"](?:node:)?(?:fs(?:\/promises)?|path|chokidar)['"]|\b(?:rootPath|projectPath|hostPath|filesystemPath|watcher|watchFile|readFile|writeFile|mkdir|readdir|realpath|cleanup|repairFiles)\b)/; +const LEGACY_GOD_DTO = + /\b(?:teamName|projectPath|hostPath|sessionId|members|tasks|providerStatus|launchDiagnostics|allParity)\??\s*:/; +const TEST_ADAPTER_PRODUCTION_IMPORT = + /(?:test\/features\/team-lifecycle\/conformance|(?:ipc|http)-shaped-list-adapter|test-composition)/; + +function pushOnce( + diagnostics: DependencyDiagnostic[], + path: string, + diagnostic: DependencyDiagnostic['diagnostic'] +): void { + if (!diagnostics.some((entry) => entry.path === path && entry.diagnostic === diagnostic)) { + diagnostics.push({ path, diagnostic }); + } +} + +/** + * Scans supplied source text only. Callers decide the exact, reviewed source set; + * this test-only scanner does not discover files or act as a production manifest. + */ +export function checkFeatureDependencies( + sources: readonly DependencySource[] +): readonly DependencyDiagnostic[] { + const diagnostics: DependencyDiagnostic[] = []; + + for (const { path, source } of sources) { + if (CORE_OR_CONTRACT_PATH.test(path) && FORBIDDEN_CORE_IMPORT.test(source)) { + pushOnce(diagnostics, path, FEATURE_DEPENDENCY_DIAGNOSTICS.forbiddenCoreImport); + } + if (CORE_OR_CONTRACT_PATH.test(path) && CORE_SIDE_EFFECT.test(source)) { + pushOnce(diagnostics, path, FEATURE_DEPENDENCY_DIAGNOSTICS.coreSideEffect); + } + if (LEGACY_GOD_DTO.test(source)) { + pushOnce(diagnostics, path, FEATURE_DEPENDENCY_DIAGNOSTICS.legacyGodDto); + } + if (FILESYSTEM_SURFACE.test(source)) { + pushOnce(diagnostics, path, FEATURE_DEPENDENCY_DIAGNOSTICS.filesystemAdapter); + } + if (path.startsWith('src/') && TEST_ADAPTER_PRODUCTION_IMPORT.test(source)) { + pushOnce(diagnostics, path, FEATURE_DEPENDENCY_DIAGNOSTICS.productionAdapterMount); + } + } + + return diagnostics; +} diff --git a/scripts/hosted-web/phase-1/check-parity-references.ts b/scripts/hosted-web/phase-1/check-parity-references.ts new file mode 100644 index 0000000000..147ac31f4b --- /dev/null +++ b/scripts/hosted-web/phase-1/check-parity-references.ts @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto'; + +export const PARITY_DIAGNOSTICS = Object.freeze({ + drift: 'phase1-parity-reference-drift', + ratchet: 'phase1-ratchet-regression', +} as const); + +export interface ParityReference { + readonly id: string; + readonly owner: string; + readonly disposition: 'quarantined' | 'decomposed' | 'desktop-only'; + readonly action: string; + readonly routeOrChannel: string; + readonly publicEntrypoint: string; + readonly semanticTest: string; + readonly sourcePath: string; + readonly exactLegacySignature: string; + readonly signatureSha256: string; +} + +export interface ContentRatchet { + readonly id: string; + readonly needle: string; + readonly maximumMatches: number; + readonly expired: boolean; +} + +export interface ParityDiagnostic { + readonly referenceId: string; + readonly diagnostic: (typeof PARITY_DIAGNOSTICS)[keyof typeof PARITY_DIAGNOSTICS]; +} + +export const PINNED_PARITY_REFERENCES: readonly ParityReference[] = Object.freeze([ + { + id: 'legacy-team-list-ipc', + owner: 'team-lifecycle', + disposition: 'quarantined', + action: 'team.lifecycle.list', + routeOrChannel: 'team:list', + publicEntrypoint: '@features/team-lifecycle', + semanticTest: + 'test/features/team-lifecycle/conformance/listTeamLifecycleSummaries.conformance.test.ts', + sourcePath: 'src/preload/constants/ipcChannels.ts', + exactLegacySignature: "export const TEAM_LIST = 'team:list';", + signatureSha256: '8e11ff3104da81f1e170edacb1997ffdfa254e98d3a139c35c5127b0efe74b1e', + }, + { + id: 'legacy-team-list-http', + owner: 'team-lifecycle', + disposition: 'quarantined', + action: 'team.lifecycle.list', + routeOrChannel: 'GET /api/teams', + publicEntrypoint: '@features/team-lifecycle', + semanticTest: + 'test/features/team-lifecycle/conformance/listTeamLifecycleSummaries.conformance.test.ts', + sourcePath: 'src/main/http/teams.ts', + exactLegacySignature: "app.get('/api/teams', async (_request, reply) => {", + signatureSha256: '99c7a37a63bcef2ab2c90fe1300cb04d88b0ff58293a9d72f3faeafa5a67d872', + }, +]); + +export const PINNED_PARITY_RATCHETS: readonly ContentRatchet[] = Object.freeze([ + { + id: 'legacy-team-list-channel-count', + needle: "'team:list'", + // Corpus-wide literal budget: the channel constant (ipcChannels.ts), the lifecycle read + // adapter label, and the outer renderer composition unwrap label. + maximumMatches: 3, + expired: false, + }, + { + id: 'legacy-team-list-http-registration-count', + needle: "app.get('/api/teams'", + maximumMatches: 1, + expired: false, + }, +]); + +export function sha256Text(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function countLiteral(haystack: string, needle: string): number { + if (!needle) return 0; + let count = 0; + let offset = 0; + while ((offset = haystack.indexOf(needle, offset)) !== -1) { + count += 1; + offset += needle.length; + } + return count; +} + +function isCompleteReference(reference: ParityReference): boolean { + return Boolean( + reference.id && + reference.owner && + reference.disposition && + reference.action && + reference.routeOrChannel && + reference.publicEntrypoint && + reference.semanticTest && + reference.sourcePath && + reference.exactLegacySignature && + /^[a-f0-9]{64}$/.test(reference.signatureSha256) + ); +} + +/** Content is provided explicitly so renamed files cannot escape corpus-wide ratchets. */ +export function checkParityReferences(input: { + readonly references: readonly ParityReference[]; + readonly ratchets: readonly ContentRatchet[]; + readonly sourceByPath: Readonly>; +}): readonly ParityDiagnostic[] { + const diagnostics: ParityDiagnostic[] = []; + const allSource = Object.values(input.sourceByPath).join('\n'); + + for (const reference of input.references) { + const source = input.sourceByPath[reference.sourcePath]; + if ( + !isCompleteReference(reference) || + typeof source !== 'string' || + !source.includes(reference.exactLegacySignature) || + sha256Text(reference.exactLegacySignature) !== reference.signatureSha256 + ) { + diagnostics.push({ + referenceId: reference.id || 'missing-reference-id', + diagnostic: PARITY_DIAGNOSTICS.drift, + }); + } + } + + for (const ratchet of input.ratchets) { + if (ratchet.expired || countLiteral(allSource, ratchet.needle) > ratchet.maximumMatches) { + diagnostics.push({ referenceId: ratchet.id, diagnostic: PARITY_DIAGNOSTICS.ratchet }); + } + } + + return diagnostics; +} diff --git a/scripts/hosted-web/phase-1/check-renderer-boundaries.ts b/scripts/hosted-web/phase-1/check-renderer-boundaries.ts new file mode 100644 index 0000000000..3e880b518e --- /dev/null +++ b/scripts/hosted-web/phase-1/check-renderer-boundaries.ts @@ -0,0 +1,27 @@ +export const RENDERER_BOUNDARY_DIAGNOSTIC = 'phase1-hosted-electron-api-forbidden' as const; + +export interface RendererBoundarySource { + readonly path: string; + readonly source: string; +} + +export interface RendererBoundaryDiagnostic { + readonly path: string; + readonly diagnostic: typeof RENDERER_BOUNDARY_DIAGNOSTIC; +} + +const BROAD_ELECTRON_FACET = + /(?:extends\s+ElectronAPI|ElectronAPI\s*\[\s*['"]teams['"]\s*\]|\bas\s+ElectronAPI\b|\bteams\s*:\s*ElectronAPI)/; +const HOSTED_RENDERER_BYPASS = + /(?:window\.electronAPI|new\s+HttpAPIClient|from\s*['"]@renderer\/api['"]|\bapi\.teams\b)/; + +/** Scans only caller-supplied hosted facet/renderer sources; no production graph is generated. */ +export function checkRendererBoundaries( + sources: readonly RendererBoundarySource[] +): readonly RendererBoundaryDiagnostic[] { + return sources.flatMap(({ path, source }) => + BROAD_ELECTRON_FACET.test(source) || HOSTED_RENDERER_BYPASS.test(source) + ? [{ path, diagnostic: RENDERER_BOUNDARY_DIAGNOSTIC }] + : [] + ); +} diff --git a/scripts/hosted-web/phase-10/state-compatibility/generate-built-manifest.mjs b/scripts/hosted-web/phase-10/state-compatibility/generate-built-manifest.mjs new file mode 100644 index 0000000000..d522291482 --- /dev/null +++ b/scripts/hosted-web/phase-10/state-compatibility/generate-built-manifest.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +const FORMAT = 'hosted-state-compatibility-manifest/v1'; +const DEFAULT_OUTPUT = resolve('dist-standalone/state-compatibility/manifest.json'); + +export function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort((left, right) => left.localeCompare(right)) + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +export function createBuiltStateManifest(artifactVersion) { + return Object.freeze({ + artifactVersion, + format: FORMAT, + hostedStateSchemaVersion: 1, + manifestId: `hosted-state-v1-artifact-${artifactVersion}`, + minimumReadableHostedStateVersion: 1, + orderedMigrations: Object.freeze([]), + schemaVersion: 1, + }); +} + +export function serializeBuiltStateManifest(manifest) { + return `${stableJson(manifest)}\n`; +} + +export async function generateBuiltStateManifest(outputPath = DEFAULT_OUTPUT) { + const packageJson = JSON.parse(await readFile(resolve('package.json'), 'utf8')); + if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { + throw new Error('built-state-manifest-artifact-version-invalid'); + } + const body = serializeBuiltStateManifest(createBuiltStateManifest(packageJson.version)); + const digest = createHash('sha256').update(body).digest('hex'); + await mkdir(dirname(outputPath), { recursive: true }); + await writeAtomic(outputPath, body); + await writeAtomic(`${outputPath}.sha256`, `${digest}\n`); + return Object.freeze({ manifest: outputPath, sha256: digest }); +} + +async function writeAtomic(path, body) { + const staging = `${path}.staging`; + await writeFile(staging, body, { encoding: 'utf8', flag: 'wx', mode: 0o644 }); + await rename(staging, path); +} + +function parseArguments(args) { + if (args.length === 0) return DEFAULT_OUTPUT; + if (args.length === 2 && args[0] === '--output' && args[1]) return resolve(args[1]); + throw new Error('usage: generate-built-manifest.mjs [--output ]'); +} + +if (import.meta.url === new URL(`file://${process.argv[1]}`).href) { + await generateBuiltStateManifest(parseArguments(process.argv.slice(2))); +} diff --git a/scripts/hosted-web/phase-10/state-compatibility/recovery-descriptor-io.mjs b/scripts/hosted-web/phase-10/state-compatibility/recovery-descriptor-io.mjs new file mode 100644 index 0000000000..bc3949b609 --- /dev/null +++ b/scripts/hosted-web/phase-10/state-compatibility/recovery-descriptor-io.mjs @@ -0,0 +1,426 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { + chmod, + constants, + lstat, + mkdir, + open, + readdir, + rename, + rmdir, + unlink, +} from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +const MAX_ENTRY_BYTES = 512 * 1024 * 1024; + +export async function assertAbsent(path) { + try { + await lstat(path); + throw new Error('stopped_stack_archive_already_exists'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} + +export async function assertDirectory(path) { + const pathStat = await lstat(path); + if (!pathStat.isDirectory() || pathStat.isSymbolicLink()) { + throw new Error('recovery_directory_invalid'); + } +} + +export function randomToken(bytes) { + return randomBytes(bytes).toString('base64url'); +} + +export function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +export function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort((left, right) => left.localeCompare(right)) + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +export async function readDescriptorBound(path, maximumBytes) { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await handle.stat(); + if (!before.isFile() || before.size > maximumBytes) { + throw new Error('recovery_file_invalid'); + } + const body = await handle.readFile(); + const after = await handle.stat(); + if (!sameDescriptorState(before, after) || body.byteLength !== before.size) { + throw new Error('recovery_file_changed_during_read'); + } + return { body, stat: before }; + } finally { + await handle.close(); + } +} + +export async function copyVerifiedDescriptor( + source, + destinationDirectory, + name, + expected, + replace +) { + const destination = descriptorChildPath(destinationDirectory, name); + const staging = descriptorChildPath(destinationDirectory, `${name}.restore-copy`); + await unlinkIfPresent(staging); + const sourceHandle = await open( + source, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + let destinationHandle; + try { + const before = await sourceHandle.stat(); + if ( + !before.isFile() || + before.size !== expected.byteLength || + (before.mode & 0o777) !== expected.mode || + before.size > MAX_ENTRY_BYTES + ) { + throw new Error('stopped_stack_archive_entry_identity_mismatch'); + } + if ((await hashDescriptor(sourceHandle, before.size)) !== expected.sha256) { + throw new Error('stopped_stack_archive_checksum_mismatch'); + } + destinationHandle = await open( + staging, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + expected.mode + ); + const copiedHash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + let position = 0; + while (position < before.size) { + const length = Math.min(buffer.byteLength, before.size - position); + const { bytesRead } = await sourceHandle.read(buffer, 0, length, position); + if (bytesRead === 0) throw new Error('stopped_stack_archive_entry_truncated'); + copiedHash.update(buffer.subarray(0, bytesRead)); + await destinationHandle.write(buffer, 0, bytesRead, position); + position += bytesRead; + } + const after = await sourceHandle.stat(); + if (!sameDescriptorState(before, after) || copiedHash.digest('hex') !== expected.sha256) { + throw new Error('stopped_stack_archive_entry_changed_during_copy'); + } + await destinationHandle.sync(); + await destinationHandle.chmod(expected.mode); + await destinationHandle.close(); + destinationHandle = undefined; + if (!replace) await assertAbsent(destination); + await rename(staging, destination); + await destinationDirectory.sync(); + } catch (error) { + await unlinkIfPresent(staging); + throw error; + } finally { + await destinationHandle?.close(); + await sourceHandle.close(); + } +} + +export async function copyVerifiedInventoryEntry(root, inventory, entry, destinationRoot, replace) { + const sourceHandles = []; + const destinationHandles = []; + try { + const rootHandle = await openDirectoryBound(root); + sourceHandles.push(rootHandle); + assertDescriptorIdentity(await rootHandle.stat(), inventory.directoryIdentities.get('')); + const destinationRootHandle = + typeof destinationRoot === 'string' + ? await openDirectoryBound(destinationRoot) + : destinationRoot; + if (typeof destinationRoot === 'string') destinationHandles.push(destinationRootHandle); + const components = entry.path.split('/'); + let relativeDirectory = ''; + for (const component of components.slice(0, -1)) { + const directoryHandle = await openChildDirectory(sourceHandles.at(-1), component); + sourceHandles.push(directoryHandle); + const destinationDirectory = await openOrCreateChildDirectory( + destinationHandles.at(-1) ?? destinationRootHandle, + component + ); + destinationHandles.push(destinationDirectory); + relativeDirectory = relativeDirectory ? `${relativeDirectory}/${component}` : component; + assertDescriptorIdentity( + await directoryHandle.stat(), + inventory.directoryIdentities.get(relativeDirectory) + ); + } + await copyVerifiedDescriptor( + descriptorChildPath(sourceHandles.at(-1), components.at(-1)), + destinationHandles.at(-1) ?? destinationRootHandle, + components.at(-1), + entry, + replace + ); + } finally { + for (const handle of destinationHandles.reverse()) await handle.close(); + for (const handle of sourceHandles.reverse()) await handle.close(); + } +} + +export async function readVerifiedInventoryEntry(root, inventory, relativePath, maximumBytes) { + const handles = []; + try { + const rootHandle = await openDirectoryBound(root); + handles.push(rootHandle); + assertDescriptorIdentity(await rootHandle.stat(), inventory.directoryIdentities.get('')); + const components = relativePath.split('/'); + let relativeDirectory = ''; + for (const component of components.slice(0, -1)) { + const directoryHandle = await openChildDirectory(handles.at(-1), component); + handles.push(directoryHandle); + relativeDirectory = relativeDirectory ? `${relativeDirectory}/${component}` : component; + assertDescriptorIdentity( + await directoryHandle.stat(), + inventory.directoryIdentities.get(relativeDirectory) + ); + } + return await readDescriptorBound( + descriptorChildPath(handles.at(-1), components.at(-1)), + maximumBytes + ); + } finally { + for (const handle of handles.reverse()) await handle.close(); + } +} + +export async function openDirectoryBound(path) { + const handle = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW + ); + const pathStat = await handle.stat(); + if (!pathStat.isDirectory()) { + await handle.close(); + throw new Error('recovery_directory_invalid'); + } + return handle; +} + +export async function tryOpenChildDirectory(parentHandle, name) { + try { + return await openChildDirectory(parentHandle, name); + } catch (error) { + if (error?.code === 'ENOTDIR') return null; + throw error; + } +} + +export async function openChildDirectory(parentHandle, name) { + validateComponentName(name); + return await openDirectoryBound(descriptorChildPath(parentHandle, name)); +} + +export async function openOrCreateChildDirectory(parentHandle, name) { + try { + return await openChildDirectory(parentHandle, name); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + await mkdir(descriptorChildPath(parentHandle, name), { mode: 0o700 }); + await parentHandle.sync(); + return await openChildDirectory(parentHandle, name); +} + +export function descriptorPath(handle) { + return `/proc/self/fd/${handle.fd}`; +} + +export function descriptorChildPath(handle, name) { + validateComponentName(name); + return `${descriptorPath(handle)}/${name}`; +} + +export function descriptorIdentity(pathStat) { + return Object.freeze({ dev: pathStat.dev, ino: pathStat.ino }); +} + +export async function writeExclusiveDurableFile(path, body, mode) { + const handle = await open( + path, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + mode + ); + try { + if (typeof body === 'string') await handle.writeFile(body, 'utf8'); + else await handle.writeFile(body); + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(dirname(path)); +} + +export async function writeExclusiveDurableFileAt(parentHandle, name, body, mode) { + const path = descriptorChildPath(parentHandle, name); + const handle = await open( + path, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + mode + ); + try { + if (typeof body === 'string') await handle.writeFile(body, 'utf8'); + else await handle.writeFile(body); + await handle.sync(); + } finally { + await handle.close(); + } + await parentHandle.sync(); +} + +export async function unlinkDescriptorEntry(parentHandle, name) { + await unlinkIfPresent(descriptorChildPath(parentHandle, name)); +} + +export async function assertDirectoryContainsOnlyOptionalEmptyChild( + rootHandle, + ignoredName, + optionalEmptyChild +) { + const entries = await readdir(descriptorPath(rootHandle)); + const unexpected = entries.filter((entry) => entry !== ignoredName); + if (unexpected.length === 0) return; + if (unexpected.length !== 1 || unexpected[0] !== optionalEmptyChild) { + throw new Error('stopped_stack_restore_target_not_empty'); + } + const childHandle = await openChildDirectory(rootHandle, optionalEmptyChild); + try { + if ((await readdir(descriptorPath(childHandle))).length > 0) { + throw new Error('stopped_stack_restore_target_not_empty'); + } + } finally { + await childHandle.close(); + } +} + +export async function removeDirectoryContainingOnly(parentHandle, name, allowedEntries) { + let directoryHandle; + try { + directoryHandle = await openChildDirectory(parentHandle, name); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } + try { + const entries = await readdir(descriptorPath(directoryHandle)); + if (entries.some((entry) => !allowedEntries.includes(entry))) { + throw new Error('stopped_stack_restore_secret_staging_invalid'); + } + for (const entry of entries) await unlinkDescriptorEntry(directoryHandle, entry); + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + await rmdir(descriptorChildPath(parentHandle, name)); + await parentHandle.sync(); +} + +export async function syncCopiedTreeDirectories(stagingRoot, entries) { + const payloadRoot = join(stagingRoot, 'payload'); + const directories = new Set([payloadRoot]); + for (const entry of entries) { + let directory = dirname(join(payloadRoot, ...entry.path.split('/'))); + while (directory.startsWith(`${payloadRoot}/`)) { + directories.add(directory); + directory = dirname(directory); + } + } + const deepestFirst = [...directories].sort( + (left, right) => right.split('/').length - left.split('/').length + ); + for (const directory of deepestFirst) await syncDirectory(directory); + await syncDirectory(stagingRoot); +} + +export async function verifySqliteSnapshots(entries, sqliteSnapshots) { + const sqliteEntries = entries.filter((entry) => entry.path.endsWith('.db')); + if (sqliteEntries.length === 0) return; + const module = await import('better-sqlite3'); + const Constructor = module.default; + for (const entry of sqliteEntries) { + const snapshot = sqliteSnapshots.get(entry.path); + if (!snapshot) throw new Error('stopped_stack_archive_sqlite_snapshot_missing'); + await chmod(snapshot, 0o400); + const database = new Constructor(snapshot, { fileMustExist: true, readonly: true }); + try { + if (database.pragma('integrity_check', { simple: true }) !== 'ok') { + throw new Error('stopped_stack_archive_sqlite_integrity_failed'); + } + } finally { + database.close(); + } + } +} + +export async function syncDirectory(path) { + const handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function hashDescriptor(handle, byteLength) { + const digest = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + let position = 0; + while (position < byteLength) { + const length = Math.min(buffer.byteLength, byteLength - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead === 0) throw new Error('stopped_stack_archive_entry_truncated'); + digest.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + return digest.digest('hex'); +} + +function sameDescriptorState(left, right) { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); +} + +function assertDescriptorIdentity(pathStat, expected) { + if (!expected || pathStat.dev !== expected.dev || pathStat.ino !== expected.ino) { + throw new Error('stopped_stack_archive_directory_identity_mismatch'); + } +} + +function validateComponentName(name) { + if ( + typeof name !== 'string' || + name.length === 0 || + name === '.' || + name === '..' || + name.includes('/') + ) { + throw new Error('recovery_relative_path_invalid'); + } +} + +async function unlinkIfPresent(path) { + try { + await unlink(path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} diff --git a/scripts/hosted-web/phase-10/state-compatibility/recovery-drill.mjs b/scripts/hosted-web/phase-10/state-compatibility/recovery-drill.mjs new file mode 100644 index 0000000000..cd14fbce0c --- /dev/null +++ b/scripts/hosted-web/phase-10/state-compatibility/recovery-drill.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import Database from 'better-sqlite3'; + +import { + createStoppedStackArchive, + restoreStoppedStackArchive, + verifyStoppedStackArchive, +} from './stopped-stack-recovery.mjs'; + +export async function runProductionShapeRecoveryDrill() { + const root = await mkdtemp(join(tmpdir(), 'hosted-recovery-drill-')); + const sourceRoot = join(root, 'source', '.agent-teams'); + const archiveRoot = join(root, 'recovery', 'app-volume-archive'); + const targetRoot = join(root, 'target', '.agent-teams'); + const fixtureLockRoots = [join(sourceRoot, 'instance-lock'), join(targetRoot, 'instance-lock')]; + try { + await mkdir(join(sourceRoot, 'data', 'storage'), { recursive: true, mode: 0o700 }); + await mkdir(fixtureLockRoots[0], { mode: 0o755 }); + await writeFile(join(fixtureLockRoots[0], 'instance.lock'), '', { mode: 0o644 }); + await chmod(join(fixtureLockRoots[0], 'instance.lock'), 0o444); + await chmod(fixtureLockRoots[0], 0o555); + await writeFile( + join(sourceRoot, 'data', 'hosted-state-header.v1.json'), + `${JSON.stringify({ + format: 'hosted-state-header/v1', + schemaVersion: 1, + deploymentId: 'deployment_drill', + hostedStateSchemaVersion: 1, + })}\n`, + { mode: 0o600 } + ); + createDrillDatabase(join(sourceRoot, 'data', 'storage', 'app.db')); + await mkdir(fixtureLockRoots[1], { recursive: true, mode: 0o755 }); + await writeFile(join(fixtureLockRoots[1], 'instance.lock'), '', { mode: 0o644 }); + await chmod(join(fixtureLockRoots[1], 'instance.lock'), 0o444); + await chmod(fixtureLockRoots[1], 0o555); + + const backup = await createStoppedStackArchive({ sourceRoot, archiveRoot }); + const verification = await verifyStoppedStackArchive({ archiveRoot }); + const restored = await restoreStoppedStackArchive({ + archiveRoot, + targetRoot, + restoreGeneration: 1, + random: (bytes) => Buffer.alloc(bytes, 7).toString('base64url'), + }); + return Object.freeze({ + format: 'hosted-production-shape-recovery-drill/v1', + status: 'passed', + backup, + verification, + restored: { + status: restored.status, + manifestHash: restored.manifestHash, + browserAuthorityRotated: restored.rotation.browserAuthorityRotated, + runtimeAuthorityRotationRequired: restored.rotation.runtimeAuthorityRotationRequired, + freshMountBindingsRequired: restored.rotation.freshMountBindingsRequired, + }, + }); + } finally { + try { + await restoreFixtureOwnedPermissions(fixtureLockRoots); + } finally { + await rm(root, { recursive: true, force: true }); + } + } +} + +async function restoreFixtureOwnedPermissions(lockRoots) { + let firstError; + for (const lockRoot of lockRoots) { + for (const [path, mode] of [ + [lockRoot, 0o755], + [join(lockRoot, 'instance.lock'), 0o644], + ]) { + try { + await chmod(path, mode); + } catch (error) { + if (error?.code !== 'ENOENT' && firstError === undefined) { + firstError = error; + } + } + } + } + if (firstError !== undefined) { + throw firstError; + } +} + +function createDrillDatabase(path) { + const database = new Database(path); + try { + database.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE hosted_access_authority ( + singleton INTEGER PRIMARY KEY, + state_json TEXT NOT NULL, + revision INTEGER NOT NULL, + rollback_fence_revision INTEGER NOT NULL + ); + CREATE TABLE operator_sessions ( + session_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + revoked_at INTEGER, + revocation_reason TEXT + ); + CREATE TABLE oidc_login_attempts (attempt_id TEXT PRIMARY KEY); + CREATE TABLE oidc_logout_replay (jti TEXT PRIMARY KEY); + CREATE TABLE coordination_event_journal_metadata ( + deployment_id TEXT PRIMARY KEY, + event_epoch TEXT NOT NULL, + UNIQUE (deployment_id, event_epoch) + ); + CREATE TABLE coordination_event_journal ( + deployment_id TEXT NOT NULL, + event_epoch TEXT NOT NULL, + event_sequence INTEGER NOT NULL, + PRIMARY KEY (deployment_id, event_epoch, event_sequence), + FOREIGN KEY (deployment_id, event_epoch) + REFERENCES coordination_event_journal_metadata(deployment_id, event_epoch) + ON UPDATE RESTRICT + ); + `); + const state = { + schemaVersion: 1, + revision: 2, + binding: { deploymentId: 'deployment_drill', restoreGeneration: 0 }, + expectedKeyringId: 'akr_xoriginal00', + consumedResetGeneration: 0, + operatorId: 'opr_xoperator00', + pairingChallenges: [], + deviceFamilies: [{ retained: false }], + deviceGrants: [{ retained: false }], + sessions: [{ retained: false }], + resetIntent: null, + }; + database + .prepare('INSERT INTO hosted_access_authority VALUES (1, ?, 2, 2)') + .run(JSON.stringify(state)); + database + .prepare("INSERT INTO operator_sessions VALUES ('session', 'active', NULL, NULL)") + .run(); + database + .prepare( + "INSERT INTO coordination_event_journal_metadata VALUES ('deployment_drill', 'epoch_old')" + ) + .run(); + database + .prepare("INSERT INTO coordination_event_journal VALUES ('deployment_drill', 'epoch_old', 1)") + .run(); + } finally { + database.close(); + } +} + +if (import.meta.url === new URL(`file://${process.argv[1]}`).href) { + process.stdout.write(`${JSON.stringify(await runProductionShapeRecoveryDrill())}\n`); +} diff --git a/scripts/hosted-web/phase-10/state-compatibility/stopped-stack-recovery.d.mts b/scripts/hosted-web/phase-10/state-compatibility/stopped-stack-recovery.d.mts new file mode 100644 index 0000000000..e96d14b66c --- /dev/null +++ b/scripts/hosted-web/phase-10/state-compatibility/stopped-stack-recovery.d.mts @@ -0,0 +1,60 @@ +export type StoppedStackRecoveryStage = + | 'archive_verified' + | 'journal_published' + | 'rotation_marker_published' + | 'payload_copy_completed' + | 'payload_restored' + | 'database_transaction_committed' + | 'database_rotated' + | 'secret_generation_published' + | 'secrets_published' + | 'restore_completed' + | 'authority_rotated'; + +export type StoppedStackArchiveCommitStage = + | 'payload_directories_synced' + | 'manifest_durable' + | 'ready_durable' + | 'staging_directory_synced' + | 'archive_published' + | 'archive_parent_synced'; + +export interface StoppedStackRecoveryOptions { + readonly sourceRoot?: string; + readonly archiveRoot: string; + readonly targetRoot?: string; + readonly restoreGeneration?: number; + readonly random?: (bytes: number) => string; + readonly openDatabase?: (path: string) => unknown; + readonly onRestoreStage?: (stage: StoppedStackRecoveryStage) => void | Promise; + readonly onSqliteSourceDescriptorVerified?: (path: string) => void | Promise; + readonly onDirectoryDescriptorVerified?: (path: string) => void | Promise; + readonly onTargetRootDescriptorVerified?: () => void | Promise; + readonly onArchiveCommitStage?: (stage: StoppedStackArchiveCommitStage) => void | Promise; +} + +export function createStoppedStackArchive( + options: StoppedStackRecoveryOptions +): Promise>; + +export function verifyStoppedStackArchive( + options: StoppedStackRecoveryOptions +): Promise>; + +export function restoreStoppedStackArchive(options: StoppedStackRecoveryOptions): Promise< + Readonly<{ + status: 'restored'; + manifestHash: string; + rotation: Readonly<{ + format: 'hosted-restored-authority-rotation/v1'; + schemaVersion: 1; + deploymentId: string; + restoreGeneration: number; + bootId: string; + eventEpoch: string; + browserAuthorityRotated: true; + runtimeAuthorityRotationRequired: true; + freshMountBindingsRequired: true; + }>; + }> +>; diff --git a/scripts/hosted-web/phase-10/state-compatibility/stopped-stack-recovery.mjs b/scripts/hosted-web/phase-10/state-compatibility/stopped-stack-recovery.mjs new file mode 100644 index 0000000000..01af0cc801 --- /dev/null +++ b/scripts/hosted-web/phase-10/state-compatibility/stopped-stack-recovery.mjs @@ -0,0 +1,795 @@ +#!/usr/bin/env node + +import { lstat, mkdir, mkdtemp, readdir, rename, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + assertAbsent, + assertDirectory, + assertDirectoryContainsOnlyOptionalEmptyChild, + copyVerifiedInventoryEntry, + descriptorChildPath, + descriptorIdentity, + descriptorPath, + openChildDirectory, + openDirectoryBound, + openOrCreateChildDirectory, + readDescriptorBound, + readVerifiedInventoryEntry, + randomToken, + removeDirectoryContainingOnly, + syncCopiedTreeDirectories, + syncDirectory, + sha256, + stableJson, + tryOpenChildDirectory, + unlinkDescriptorEntry, + writeExclusiveDurableFile, + writeExclusiveDurableFileAt, + verifySqliteSnapshots, +} from './recovery-descriptor-io.mjs'; + +const ARCHIVE_FORMAT = 'hosted-stopped-stack-archive/v1'; +const READY_FORMAT = 'hosted-stopped-stack-ready/v1'; +const ROTATION_FORMAT = 'hosted-restored-authority-rotation/v1'; +const SOURCE_ROOT = '/data/.agent-teams'; +const READY_FILE = 'READY.json'; +const MANIFEST_FILE = 'manifest.json'; +const ROTATION_FILE = 'hosted-restore-rotation.v1.json'; +const RESTORE_JOURNAL_FILE = 'hosted-restore-journal.v1.json'; +const COMPLETED_ROTATION_FILE = 'hosted-restore-rotation.completed.v1.json'; +const RESTORE_JOURNAL_FORMAT = 'hosted-stopped-stack-restore-journal/v1'; +const EXCLUDED_SOURCE_PREFIX = 'instance-lock'; +const INSTANCE_LEASE_ANCHOR = `${SOURCE_ROOT}/${EXCLUDED_SOURCE_PREFIX}/instance.lock`; +const INSTANCE_LEASE_FD_PATH = '/proc/self/fd/3'; +const MAX_ENTRIES = 20_000; +const MAX_METADATA_BYTES = 256 * 1024; +const MAX_ENTRY_BYTES = 512 * 1024 * 1024; +const DEPLOYMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/; +const RECOVERY_CONTROL_PATHS = new Set([ + `data/${ROTATION_FILE}`, + `data/${ROTATION_FILE}.staging`, + `data/${RESTORE_JOURNAL_FILE}`, + `data/${RESTORE_JOURNAL_FILE}.staging`, + `data/${COMPLETED_ROTATION_FILE}`, + `data/${COMPLETED_ROTATION_FILE}.staging`, +]); + +export async function createStoppedStackArchive(options = {}) { + const sourceRoot = resolve(options.sourceRoot ?? SOURCE_ROOT); + const archiveRoot = requireArchiveRoot(options.archiveRoot); + const stagingRoot = `${archiveRoot}.partial`; + await assertDirectory(sourceRoot); + await assertAbsent(archiveRoot); + await rm(stagingRoot, { recursive: true, force: true }); + await mkdir(join(stagingRoot, 'payload'), { recursive: true, mode: 0o700 }); + try { + const inventory = await inventoryVerifiedTree(sourceRoot, true, options); + for (const entry of inventory.entries) { + await copyVerifiedInventoryEntry( + sourceRoot, + inventory, + entry, + join(stagingRoot, 'payload'), + false + ); + } + await syncCopiedTreeDirectories(stagingRoot, inventory.entries); + await options.onArchiveCommitStage?.('payload_directories_synced'); + const header = await readHostedStateHeader(sourceRoot, inventory); + const body = Object.freeze({ + format: ARCHIVE_FORMAT, + schemaVersion: 1, + deploymentId: header.deploymentId, + hostedStateSchemaVersion: header.hostedStateSchemaVersion, + entries: inventory.entries, + sqliteIntegrity: 'ok', + }); + const serialized = `${stableJson(body)}\n`; + const manifestHash = sha256(serialized); + await writeExclusiveDurableFile(join(stagingRoot, MANIFEST_FILE), serialized, 0o400); + await options.onArchiveCommitStage?.('manifest_durable'); + await verifyArchiveTree(stagingRoot, false, options); + await writeExclusiveDurableFile( + join(stagingRoot, READY_FILE), + `${stableJson({ format: READY_FORMAT, manifestHash, schemaVersion: 1 })}\n`, + 0o400 + ); + await options.onArchiveCommitStage?.('ready_durable'); + await syncDirectory(stagingRoot); + await options.onArchiveCommitStage?.('staging_directory_synced'); + await rename(stagingRoot, archiveRoot); + await options.onArchiveCommitStage?.('archive_published'); + await syncDirectory(dirname(archiveRoot)); + await options.onArchiveCommitStage?.('archive_parent_synced'); + return Object.freeze({ + status: 'committed', + manifestHash, + entries: inventory.entries.length, + }); + } catch (error) { + await rm(stagingRoot, { recursive: true, force: true }); + throw error; + } +} + +export async function verifyStoppedStackArchive(options = {}) { + const archiveRoot = requireArchiveRoot(options.archiveRoot); + const verified = await verifyArchiveTree(archiveRoot, true, options); + return Object.freeze({ + status: 'verified', + manifestHash: verified.manifestHash, + entries: verified.manifest.entries.length, + }); +} + +export async function restoreStoppedStackArchive(options = {}) { + const archiveRoot = requireArchiveRoot(options.archiveRoot); + const targetRoot = resolve(options.targetRoot ?? SOURCE_ROOT); + const restoreGeneration = parseGeneration( + options.restoreGeneration ?? process.env.AUTH_RESTORE_GENERATION + ); + const targetRootHandle = await openDirectoryBound(targetRoot); + try { + await options.onTargetRootDescriptorVerified?.(); + const verified = await verifyArchiveTree(archiveRoot, true, options); + await options.onRestoreStage?.('archive_verified'); + const journal = await initializeOrResumeRestore({ + targetRootHandle, + verified, + restoreGeneration, + random: options.random ?? randomToken, + }); + await options.onRestoreStage?.('journal_published'); + await ensureRotationMarker(targetRootHandle, journal.rotation); + await options.onRestoreStage?.('rotation_marker_published'); + + if (phaseBefore(journal.phase, 'payload_restored')) { + for (const entry of verified.manifest.entries) { + if (RECOVERY_CONTROL_PATHS.has(entry.path)) continue; + if (entry.path.startsWith('data/hosted-auth-secrets/')) continue; + await copyVerifiedInventoryEntry( + join(archiveRoot, 'payload'), + verified.inventory, + entry, + targetRootHandle, + true + ); + } + await options.onRestoreStage?.('payload_copy_completed'); + journal.phase = 'payload_restored'; + await writeRestoreJournal(targetRootHandle, journal); + } + await options.onRestoreStage?.('payload_restored'); + + if (phaseBefore(journal.phase, 'database_rotated')) { + await rotateRestoredDatabase({ + targetRootHandle, + rotation: journal.rotation, + keyringId: journal.secretPlan.keyring.keyringId, + openDatabase: options.openDatabase, + }); + await options.onRestoreStage?.('database_transaction_committed'); + journal.phase = 'database_rotated'; + await writeRestoreJournal(targetRootHandle, journal); + } + await options.onRestoreStage?.('database_rotated'); + + if (phaseBefore(journal.phase, 'secrets_published')) { + await publishSecretGeneration(targetRootHandle, journal.secretPlan); + await options.onRestoreStage?.('secret_generation_published'); + journal.phase = 'secrets_published'; + await writeRestoreJournal(targetRootHandle, journal); + } + await options.onRestoreStage?.('secrets_published'); + if (journal.phase !== 'completed') { + const completed = { + format: RESTORE_JOURNAL_FORMAT, + schemaVersion: 1, + manifestHash: journal.manifestHash, + phase: 'completed', + rotation: journal.rotation, + keyringId: journal.secretPlan.keyring.keyringId, + }; + await writeRestoreJournal(targetRootHandle, completed); + } + await options.onRestoreStage?.('restore_completed'); + await options.onRestoreStage?.('authority_rotated'); + return Object.freeze({ + status: 'restored', + manifestHash: verified.manifestHash, + rotation: journal.rotation, + }); + } finally { + await targetRootHandle.close(); + } +} + +async function verifyArchiveTree(archiveRoot, requireReady = false, options = {}) { + const archiveHandle = await openDirectoryBound(archiveRoot); + try { + const manifestBody = ( + await readDescriptorBound( + descriptorChildPath(archiveHandle, MANIFEST_FILE), + MAX_METADATA_BYTES + ) + ).body.toString('utf8'); + const manifest = JSON.parse(manifestBody); + validateManifest(manifest); + const inventory = await inventoryVerifiedTree( + descriptorChildPath(archiveHandle, 'payload'), + false, + options + ); + if (stableJson(inventory.entries) !== stableJson(manifest.entries)) { + throw new Error('stopped_stack_archive_checksum_mismatch'); + } + const stateHeader = await readHostedStateHeader( + descriptorChildPath(archiveHandle, 'payload'), + inventory + ); + if ( + stateHeader.deploymentId !== manifest.deploymentId || + stateHeader.hostedStateSchemaVersion !== manifest.hostedStateSchemaVersion + ) { + throw new Error('stopped_stack_archive_state_identity_mismatch'); + } + const manifestHash = sha256(manifestBody); + if (requireReady) { + const ready = JSON.parse( + ( + await readDescriptorBound( + descriptorChildPath(archiveHandle, READY_FILE), + MAX_METADATA_BYTES + ) + ).body.toString('utf8') + ); + if ( + ready?.format !== READY_FORMAT || + ready.schemaVersion !== 1 || + ready.manifestHash !== manifestHash + ) { + throw new Error('stopped_stack_archive_ready_marker_invalid'); + } + } + return { manifest, manifestHash, inventory }; + } finally { + await archiveHandle.close(); + } +} + +async function inventoryVerifiedTree(root, excludeLock, options) { + const verificationRoot = await mkdtemp(join(tmpdir(), 'hosted-sqlite-verify-')); + const sqliteSnapshots = new Map(); + try { + const inventory = await inventoryTree( + root, + excludeLock, + verificationRoot, + sqliteSnapshots, + options + ); + await verifySqliteSnapshots(inventory.entries, sqliteSnapshots); + return inventory; + } finally { + await rm(verificationRoot, { recursive: true, force: true }); + } +} + +async function inventoryTree(root, excludeLock, verificationRoot, sqliteSnapshots, options) { + const entries = []; + const directoryIdentities = new Map(); + const rootHandle = await openDirectoryBound(root); + const rootStat = await rootHandle.stat(); + directoryIdentities.set('', descriptorIdentity(rootStat)); + async function visit(directoryHandle, relativeDirectory) { + await options.onDirectoryDescriptorVerified?.(relativeDirectory || '.'); + const children = await readdir(descriptorPath(directoryHandle), { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name)); + for (const child of children) { + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + if (excludeLock && relativePath.split('/')[0] === EXCLUDED_SOURCE_PREFIX) continue; + if (excludeLock && RECOVERY_CONTROL_PATHS.has(relativePath)) continue; + const childDirectory = await tryOpenChildDirectory(directoryHandle, child.name); + if (childDirectory) { + try { + directoryIdentities.set(relativePath, descriptorIdentity(await childDirectory.stat())); + await visit(childDirectory, relativePath); + } finally { + await childDirectory.close(); + } + continue; + } + const descriptor = await readDescriptorBound( + descriptorChildPath(directoryHandle, child.name), + MAX_ENTRY_BYTES + ); + if (relativePath.endsWith('.db')) { + const snapshot = join(verificationRoot, `${sqliteSnapshots.size}.db`); + await writeExclusiveDurableFile(snapshot, descriptor.body, 0o400); + sqliteSnapshots.set(relativePath, snapshot); + await options.onSqliteSourceDescriptorVerified?.(relativePath); + } + entries.push( + Object.freeze({ + path: relativePath, + byteLength: descriptor.stat.size, + mode: descriptor.stat.mode & 0o777, + sha256: sha256(descriptor.body), + }) + ); + if (entries.length > MAX_ENTRIES) throw new Error('stopped_stack_archive_entry_limit'); + } + } + try { + await visit(rootHandle, ''); + } finally { + await rootHandle.close(); + } + return Object.freeze({ + entries: Object.freeze(entries), + directoryIdentities, + }); +} + +async function rotateRestoredDatabase(input) { + const { rotation, keyringId } = input; + const dataHandle = await openChildDirectory(input.targetRootHandle, 'data'); + let storageHandle; + let database; + try { + storageHandle = await openOrCreateChildDirectory(dataHandle, 'storage'); + const databasePath = descriptorChildPath(storageHandle, 'app.db'); + const openDatabase = input.openDatabase ?? (await loadDatabaseConstructor()); + database = openDatabase(databasePath); + const integrity = database.pragma('integrity_check', { simple: true }); + if (integrity !== 'ok') throw new Error('stopped_stack_restore_sqlite_integrity_failed'); + database.pragma('foreign_keys = OFF'); + database.transaction(() => { + if (tableExists(database, 'operator_sessions')) { + database + .prepare( + `UPDATE operator_sessions SET status = 'revoked', revoked_at = 0, + revocation_reason = 'offline_restore' WHERE status = 'active'` + ) + .run(); + } + for (const table of ['oidc_login_attempts', 'oidc_logout_replay']) { + if (tableExists(database, table)) database.prepare(`DELETE FROM ${table}`).run(); + } + if (tableExists(database, 'hosted_access_authority')) { + const row = database + .prepare( + 'SELECT state_json AS stateJson, revision FROM hosted_access_authority WHERE singleton = 1' + ) + .get(); + if (row) { + const state = JSON.parse(row.stateJson); + if (!Number.isSafeInteger(state?.binding?.restoreGeneration)) { + throw new Error('stopped_stack_restore_generation_not_rotated'); + } + if (state.binding.restoreGeneration > rotation.restoreGeneration) { + throw new Error('stopped_stack_restore_generation_not_rotated'); + } + if (state.binding.restoreGeneration === rotation.restoreGeneration) { + if ( + state.binding.deploymentId !== rotation.deploymentId || + state.expectedKeyringId !== keyringId + ) { + throw new Error('stopped_stack_restore_rotation_resume_mismatch'); + } + } else { + const nextRevision = Number(row.revision) + 1; + const next = { + ...state, + binding: { + deploymentId: rotation.deploymentId, + restoreGeneration: rotation.restoreGeneration, + }, + deviceFamilies: [], + deviceGrants: [], + expectedKeyringId: keyringId, + pairingChallenges: [], + resetIntent: null, + revision: nextRevision, + sessions: [], + }; + database + .prepare( + `UPDATE hosted_access_authority SET state_json = ?, revision = ?, + rollback_fence_revision = ? WHERE singleton = 1` + ) + .run(JSON.stringify(next), nextRevision, nextRevision); + } + } + } + if (tableExists(database, 'coordination_event_journal_metadata')) { + database + .prepare('UPDATE coordination_event_journal SET event_epoch = ?') + .run(rotation.eventEpoch); + database + .prepare('UPDATE coordination_event_journal_metadata SET event_epoch = ?') + .run(rotation.eventEpoch); + } + const foreignKeyFailures = database.pragma('foreign_key_check'); + if (Array.isArray(foreignKeyFailures) && foreignKeyFailures.length > 0) { + throw new Error('stopped_stack_restore_foreign_key_failed'); + } + })(); + database.pragma('foreign_keys = ON'); + } finally { + database?.close(); + await storageHandle?.close(); + await dataHandle.close(); + } +} + +function createRotationRequest(input) { + return Object.freeze({ + format: ROTATION_FORMAT, + schemaVersion: 1, + deploymentId: input.deploymentId, + restoreGeneration: input.restoreGeneration, + bootId: `boot_x${input.random(18)}`, + eventEpoch: `epoch_x${input.random(18)}`, + browserAuthorityRotated: true, + runtimeAuthorityRotationRequired: true, + freshMountBindingsRequired: true, + }); +} + +async function initializeOrResumeRestore(input) { + const existing = await readOptionalRestoreJournal(input.targetRootHandle); + if (existing) { + validateRestoreJournal(existing); + if ( + existing.manifestHash !== input.verified.manifestHash || + existing.rotation.deploymentId !== input.verified.manifest.deploymentId || + existing.rotation.restoreGeneration !== input.restoreGeneration + ) { + throw new Error('stopped_stack_restore_journal_mismatch'); + } + return existing; + } + await recoverUnpublishedJournalStaging(input.targetRootHandle); + await assertEmptyRestoreTarget(input.targetRootHandle); + const rotation = createRotationRequest({ + deploymentId: input.verified.manifest.deploymentId, + restoreGeneration: input.restoreGeneration, + random: input.random, + }); + const journal = { + format: RESTORE_JOURNAL_FORMAT, + schemaVersion: 1, + manifestHash: input.verified.manifestHash, + phase: 'initialized', + rotation, + secretPlan: { + identityKey: input.random(32), + keyring: { + binding: { + deploymentId: rotation.deploymentId, + restoreGeneration: rotation.restoreGeneration, + }, + createdAt: 0, + csrfKey: input.random(32), + format: 'hosted-access-keyring/v1', + hashKey: input.random(32), + keyringId: `akr_x${input.random(18)}`, + }, + }, + }; + const dataHandle = await openOrCreateChildDirectory(input.targetRootHandle, 'data'); + await dataHandle.close(); + await writeRestoreJournal(input.targetRootHandle, journal); + return journal; +} + +async function recoverUnpublishedJournalStaging(targetRootHandle) { + const rootEntries = await readdir(descriptorPath(targetRootHandle)); + const unexpectedRoot = rootEntries.filter( + (entry) => entry !== EXCLUDED_SOURCE_PREFIX && entry !== 'data' + ); + if (unexpectedRoot.length > 0 || !rootEntries.includes('data')) return; + const dataHandle = await openChildDirectory(targetRootHandle, 'data'); + try { + const dataEntries = await readdir(descriptorPath(dataHandle)); + if (dataEntries.length === 1 && dataEntries[0] === `${RESTORE_JOURNAL_FILE}.staging`) { + await unlinkDescriptorEntry(dataHandle, `${RESTORE_JOURNAL_FILE}.staging`); + await dataHandle.sync(); + } + } finally { + await dataHandle.close(); + } +} + +async function ensureRotationMarker(targetRootHandle, rotation) { + const dataHandle = await openChildDirectory(targetRootHandle, 'data'); + try { + const existing = JSON.parse( + ( + await readDescriptorBound( + descriptorChildPath(dataHandle, ROTATION_FILE), + MAX_METADATA_BYTES + ) + ).body.toString('utf8') + ); + if (stableJson(existing) !== stableJson(rotation)) { + throw new Error('stopped_stack_restore_rotation_marker_mismatch'); + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + await writeExclusiveDurableFileAt( + dataHandle, + ROTATION_FILE, + `${stableJson(rotation)}\n`, + 0o600 + ); + } finally { + await dataHandle.close(); + } +} + +async function readOptionalRestoreJournal(targetRootHandle) { + let dataHandle; + try { + dataHandle = await openChildDirectory(targetRootHandle, 'data'); + return JSON.parse( + ( + await readDescriptorBound( + descriptorChildPath(dataHandle, RESTORE_JOURNAL_FILE), + MAX_METADATA_BYTES + ) + ).body.toString('utf8') + ); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } finally { + await dataHandle?.close(); + } +} + +async function writeRestoreJournal(targetRootHandle, journal) { + const dataHandle = await openChildDirectory(targetRootHandle, 'data'); + try { + const staging = `${RESTORE_JOURNAL_FILE}.staging`; + await unlinkDescriptorEntry(dataHandle, staging); + await writeExclusiveDurableFileAt(dataHandle, staging, `${stableJson(journal)}\n`, 0o600); + await rename( + descriptorChildPath(dataHandle, staging), + descriptorChildPath(dataHandle, RESTORE_JOURNAL_FILE) + ); + await dataHandle.sync(); + } finally { + await dataHandle.close(); + } +} + +function validateRestoreJournal(journal) { + const commonInvalid = + journal?.format !== RESTORE_JOURNAL_FORMAT || + journal.schemaVersion !== 1 || + typeof journal.manifestHash !== 'string' || + !journal.rotation || + journal.rotation.format !== ROTATION_FORMAT; + const completedInvalid = journal?.phase === 'completed' && typeof journal.keyringId !== 'string'; + const activeInvalid = + journal?.phase !== 'completed' && + (!['initialized', 'payload_restored', 'database_rotated', 'secrets_published'].includes( + journal?.phase + ) || + typeof journal.secretPlan?.identityKey !== 'string' || + journal.secretPlan.identityKey.length < 32 || + journal.secretPlan?.keyring?.format !== 'hosted-access-keyring/v1' || + journal.secretPlan.keyring.binding?.deploymentId !== journal.rotation.deploymentId || + journal.secretPlan.keyring.binding?.restoreGeneration !== + journal.rotation.restoreGeneration || + typeof journal.secretPlan.keyring.keyringId !== 'string'); + if (commonInvalid || completedInvalid || activeInvalid) { + throw new Error('stopped_stack_restore_journal_invalid'); + } +} + +function phaseBefore(observed, expected) { + const phases = [ + 'initialized', + 'payload_restored', + 'database_rotated', + 'secrets_published', + 'completed', + ]; + return phases.indexOf(observed) < phases.indexOf(expected); +} + +async function publishSecretGeneration(targetRootHandle, secretPlan) { + const dataHandle = await openChildDirectory(targetRootHandle, 'data'); + try { + let secretsHandle; + try { + secretsHandle = await openChildDirectory(dataHandle, 'hosted-auth-secrets'); + const [keyring, identity] = await Promise.all([ + readDescriptorBound( + descriptorChildPath(secretsHandle, 'personal-keyring.json'), + MAX_METADATA_BYTES + ), + readDescriptorBound(descriptorChildPath(secretsHandle, 'identity.key'), MAX_METADATA_BYTES), + ]); + if ( + keyring.body.toString('utf8') !== `${stableJson(secretPlan.keyring)}\n` || + identity.body.toString('utf8') !== `${secretPlan.identityKey}\n` + ) { + throw new Error('stopped_stack_restore_published_secrets_mismatch'); + } + return; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } finally { + await secretsHandle?.close(); + } + const stagingName = '.hosted-auth-secrets.restore-staging'; + await removeDirectoryContainingOnly(dataHandle, stagingName, [ + 'identity.key', + 'personal-keyring.json', + ]); + const stagingHandle = await openOrCreateChildDirectory(dataHandle, stagingName); + try { + await writeExclusiveDurableFileAt( + stagingHandle, + 'personal-keyring.json', + `${stableJson(secretPlan.keyring)}\n`, + 0o600 + ); + await writeExclusiveDurableFileAt( + stagingHandle, + 'identity.key', + `${secretPlan.identityKey}\n`, + 0o600 + ); + await stagingHandle.sync(); + } finally { + await stagingHandle.close(); + } + await rename( + descriptorChildPath(dataHandle, stagingName), + descriptorChildPath(dataHandle, 'hosted-auth-secrets') + ); + await dataHandle.sync(); + } finally { + await dataHandle.close(); + } +} + +async function loadDatabaseConstructor() { + const module = await import('better-sqlite3'); + const Constructor = module.default; + return (path, options) => new Constructor(path, options); +} + +function tableExists(database, table) { + return Boolean( + database.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table) + ); +} + +async function readHostedStateHeader(payloadRoot, inventory) { + const path = 'data/hosted-state-header.v1.json'; + const expected = inventory.entries.find((entry) => entry.path === path); + const descriptor = await readVerifiedInventoryEntry( + payloadRoot, + inventory, + path, + MAX_METADATA_BYTES + ); + if ( + !expected || + descriptor.stat.size !== expected.byteLength || + (descriptor.stat.mode & 0o777) !== expected.mode || + sha256(descriptor.body) !== expected.sha256 + ) { + throw new Error('stopped_stack_archive_checksum_mismatch'); + } + const header = JSON.parse(descriptor.body.toString('utf8')); + if ( + header?.format !== 'hosted-state-header/v1' || + header.schemaVersion !== 1 || + typeof header.deploymentId !== 'string' || + !DEPLOYMENT_ID_PATTERN.test(header.deploymentId) || + !Number.isSafeInteger(header.hostedStateSchemaVersion) + ) { + throw new Error('stopped_stack_archive_state_header_invalid'); + } + return header; +} + +function validateManifest(manifest) { + if ( + manifest?.format !== ARCHIVE_FORMAT || + manifest.schemaVersion !== 1 || + manifest.sqliteIntegrity !== 'ok' || + typeof manifest.deploymentId !== 'string' || + !DEPLOYMENT_ID_PATTERN.test(manifest.deploymentId) || + !Number.isSafeInteger(manifest.hostedStateSchemaVersion) || + manifest.hostedStateSchemaVersion <= 0 || + !Array.isArray(manifest.entries) || + manifest.entries.some((entry) => RECOVERY_CONTROL_PATHS.has(entry?.path)) + ) { + throw new Error('stopped_stack_archive_manifest_invalid'); + } +} + +async function assertEmptyRestoreTarget(rootHandle) { + await assertDirectoryContainsOnlyOptionalEmptyChild(rootHandle, EXCLUDED_SOURCE_PREFIX, 'data'); +} + +function requireArchiveRoot(value) { + if (typeof value !== 'string' || value.length === 0 || !isAbsolute(value)) { + throw new Error('stopped_stack_archive_root_required'); + } + const archiveRoot = resolve(value); + if ( + archiveRoot === '/' || + archiveRoot === SOURCE_ROOT || + archiveRoot.startsWith(`${SOURCE_ROOT}/`) + ) { + throw new Error('stopped_stack_archive_root_unsafe'); + } + return archiveRoot; +} + +function parseGeneration(value) { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error('stopped_stack_restore_generation_invalid'); + } + return parsed; +} + +async function main() { + const [command, ...extra] = process.argv.slice(2); + if (extra.length > 0 || !['backup', 'verify', 'restore'].includes(command)) { + throw new Error('usage: stopped-stack-recovery.mjs '); + } + await assertStoppedStackLeaseHeld(); + const archiveRoot = process.env.HOSTED_RECOVERY_ARCHIVE_ROOT; + const result = + command === 'backup' + ? await createStoppedStackArchive({ archiveRoot }) + : command === 'verify' + ? await verifyStoppedStackArchive({ archiveRoot }) + : await restoreStoppedStackArchive({ archiveRoot }); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +async function assertStoppedStackLeaseHeld() { + try { + const [lease, anchor] = await Promise.all([ + stat(INSTANCE_LEASE_FD_PATH), + lstat(INSTANCE_LEASE_ANCHOR), + ]); + if ( + !lease.isFile() || + !anchor.isFile() || + anchor.isSymbolicLink() || + anchor.uid !== 0 || + anchor.nlink !== 1 || + lease.dev !== anchor.dev || + lease.ino !== anchor.ino + ) { + throw new Error('lease_identity_invalid'); + } + } catch { + throw new Error('stopped_stack_instance_lease_required'); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + await main().catch((error) => { + process.stderr.write( + `hosted_recovery_refused:${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + }); +} diff --git a/scripts/hosted-web/phase-10/state-compatibility/verify-state-compatibility.mjs b/scripts/hosted-web/phase-10/state-compatibility/verify-state-compatibility.mjs new file mode 100755 index 0000000000..e6a46cf56c --- /dev/null +++ b/scripts/hosted-web/phase-10/state-compatibility/verify-state-compatibility.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node + +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { tsImport } from 'tsx/esm/api'; + +const RESULT_FORMAT = 'hosted-state-compatibility-verifier-result/v1'; +const FIXTURE_FORMAT = 'hosted-state-compatibility-fixture/v1'; +const REQUIRED_CASE_IDS = Object.freeze([ + 'corrupted-archive-refusal', + 'cross-snapshot-mismatch-refusal', + 'future-version-refusal', + 'interrupted-migration-recovery', + 'n-read-write', + 'n-to-n-plus-one', +]); + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDirectory, '../../../..'); +const defaultFixtureDirectory = join(repositoryRoot, 'test/fixtures/hosted-state-compatibility'); +const publicFeatureEntrypoint = join( + repositoryRoot, + 'src/features/hosted-state-compatibility/index.ts' +); + +export async function runStateCompatibilityVerifier(options = {}) { + const fixtureDirectory = resolve(options.fixtureDirectory ?? defaultFixtureDirectory); + const fixtureDirectoryLabel = labelPath(fixtureDirectory); + let feature; + let fixtureFiles; + try { + [feature, fixtureFiles] = await Promise.all([ + tsImport(publicFeatureEntrypoint, { parentURL: import.meta.url }), + listFixtureFiles(fixtureDirectory), + ]); + } catch (error) { + return fatalResult('fixture_load_failed', safeErrorMessage(error), fixtureDirectoryLabel); + } + + const checks = []; + const caseIds = []; + for (const fileName of fixtureFiles) { + try { + const fixture = JSON.parse(await readFile(join(fixtureDirectory, fileName), 'utf8')); + validateFixtureEnvelope(fixture, fileName); + caseIds.push(fixture.caseId); + const actual = evaluateFixture(feature, fixture); + const passed = stableJson(actual) === stableJson(fixture.expected); + checks.push({ + caseId: fixture.caseId, + file: fileName, + status: passed ? 'passed' : 'failed', + expected: fixture.expected, + actual, + ...(passed ? {} : { error: 'unexpected_policy_result' }), + }); + } catch (error) { + checks.push({ + caseId: `invalid:${fileName}`, + file: fileName, + status: 'failed', + error: safeErrorMessage(error), + }); + } + } + + const fixtureSetError = inspectFixtureSet(caseIds); + if (fixtureSetError) { + checks.push({ + caseId: 'fixture-set', + file: null, + status: 'failed', + error: fixtureSetError, + }); + } + const failed = checks.filter((check) => check.status === 'failed').length; + const result = { + format: RESULT_FORMAT, + status: failed === 0 ? 'passed' : 'failed', + fixtureDirectory: fixtureDirectoryLabel, + summary: { + total: checks.length, + passed: checks.length - failed, + failed, + }, + checks, + }; + return { exitCode: failed === 0 ? 0 : 1, result }; +} + +function evaluateFixture(feature, fixture) { + if (fixture.kind === 'state_admission') { + return summarizeStateAdmission( + feature.evaluateHostedStateAdmission({ + artifactManifest: fixture.artifactManifest, + artifactIntegrity: 'verified', + stateHeader: fixture.stateHeader, + migrationJournal: fixture.migrationJournal, + }) + ); + } + if (fixture.kind === 'restore_archive') { + const inspection = feature.inspectRestoreArchive(fixture.evidence); + return inspection.status === 'verified' + ? { status: inspection.status } + : { status: inspection.status, reasons: inspection.reasons }; + } + throw new Error('fixture_kind_unsupported'); +} + +function summarizeStateAdmission(admission) { + if (admission.status === 'read_write') { + return { + status: admission.status, + hostedStateSchemaVersion: admission.hostedStateSchemaVersion, + }; + } + if (admission.status === 'migration_required') { + return { + status: admission.status, + fromVersion: admission.fromVersion, + toVersion: admission.toVersion, + migrationIds: admission.orderedMigrations.map((migration) => migration.migrationId), + backupRequired: admission.backupRequired, + }; + } + if (admission.status === 'migration_recovery_required') { + return { + status: admission.status, + recovery: admission.recovery, + migrationId: admission.migration.migrationId, + journalPhase: admission.journalPhase, + }; + } + return { status: admission.status, reason: admission.reason }; +} + +function validateFixtureEnvelope(fixture, fileName) { + if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) { + throw new Error('fixture_not_an_object'); + } + if (fixture.fixtureFormat !== FIXTURE_FORMAT || fixture.schemaVersion !== 1) { + throw new Error('fixture_format_unsupported'); + } + if (typeof fixture.caseId !== 'string' || fixture.caseId.length === 0) { + throw new Error('fixture_case_id_invalid'); + } + if (!fileName.endsWith('.json') || fixture.expected === undefined) { + throw new Error('fixture_shape_invalid'); + } +} + +function inspectFixtureSet(caseIds) { + if (new Set(caseIds).size !== caseIds.length) return 'fixture_case_id_duplicate'; + const observed = [...caseIds].sort((left, right) => left.localeCompare(right)); + return stableJson(observed) === stableJson(REQUIRED_CASE_IDS) ? null : 'fixture_set_incomplete'; +} + +async function listFixtureFiles(fixtureDirectory) { + const entries = await readdir(fixtureDirectory, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); + if (files.length === 0) throw new Error('fixture_directory_empty'); + return files; +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort((left, right) => left.localeCompare(right)) + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function labelPath(path) { + const repositoryRelative = relative(repositoryRoot, path); + return repositoryRelative && + !repositoryRelative.startsWith('..') && + !isAbsolute(repositoryRelative) + ? repositoryRelative + : path; +} + +function fatalResult(code, message, fixtureDirectory = null) { + return { + exitCode: 2, + result: { + format: RESULT_FORMAT, + status: 'error', + fixtureDirectory, + error: { code, message }, + summary: { total: 0, passed: 0, failed: 0 }, + checks: [], + }, + }; +} + +function safeErrorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function parseCommandLine(arguments_) { + if (arguments_.length === 0) return {}; + if (arguments_.length === 2 && arguments_[0] === '--fixture-dir') { + if (!arguments_[1]) throw new Error('fixture_directory_missing'); + return { fixtureDirectory: arguments_[1] }; + } + throw new Error('usage: verify-state-compatibility.mjs [--fixture-dir ]'); +} + +async function main() { + let outcome; + try { + outcome = await runStateCompatibilityVerifier(parseCommandLine(process.argv.slice(2))); + } catch (error) { + outcome = fatalResult('invalid_arguments', safeErrorMessage(error)); + } + process.stdout.write(`${JSON.stringify(outcome.result)}\n`); + process.exitCode = outcome.exitCode; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/smoke/codex-runtime-install.ts b/scripts/smoke/codex-runtime-install.ts index 24a636ff87..994e5405d8 100644 --- a/scripts/smoke/codex-runtime-install.ts +++ b/scripts/smoke/codex-runtime-install.ts @@ -37,6 +37,7 @@ interface CodexRuntimeSmokeReport { rootVersion: string | null; platformVersion: string | null; platformTarget: string | null; + installDurationMs: number; } function assertCondition(condition: unknown, message: string): asserts condition { @@ -77,7 +78,9 @@ async function runSmoke(): Promise { try { const service = new CodexRuntimeInstallerService(); + const installStartedAt = Date.now(); const status = await service.install(); + const installDurationMs = Date.now() - installStartedAt; assertCondition(status.installed, `Codex runtime install failed: ${JSON.stringify(status)}`); assertCondition(status.binaryPath, 'Codex runtime install did not return a binary path'); assertCondition( @@ -148,6 +151,7 @@ async function runSmoke(): Promise { rootVersion: manifest.rootVersion, platformVersion: manifest.platformVersion, platformTarget: manifest.platformTarget, + installDurationMs, }; } finally { CodexBinaryResolver.clearCache(); diff --git a/scripts/verify-hosted-opencode-runtime-materialization.mjs b/scripts/verify-hosted-opencode-runtime-materialization.mjs new file mode 100644 index 0000000000..3197f70f66 --- /dev/null +++ b/scripts/verify-hosted-opencode-runtime-materialization.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { basename, dirname, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const run = promisify(execFile); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const [manifestInput, platform] = process.argv.slice(2); +if (!manifestInput || !/^(?:darwin|linux|win32)-(?:arm64|x64)$/u.test(platform ?? '')) { + throw new Error('usage: verify-hosted-opencode-runtime-materialization '); +} + +const manifestPath = resolve(manifestInput); +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +const lock = JSON.parse(await readFile(resolve(root, 'opencode-hosted-runtime.lock.json'), 'utf8')); +const candidatePlatform = platform.replace(/^win32-/u, 'windows-'); +const asset = manifest.assets?.find((value) => `${value.os}-${value.arch}` === candidatePlatform); +const locked = lock.platforms?.[platform]; +if ( + !asset || + locked?.status !== 'available' || + lock.releaseRepository !== manifest.workflow?.repository || + lock.source?.commit !== manifest.release?.sourceCommit || + locked.file !== asset.archive || + locked.binaryName !== basename(asset.binaryPath) || + locked.archiveSha256 !== asset.archiveSha256 || + locked.binarySha256 !== asset.binarySha256 +) { + throw new Error('hosted-opencode-materialization-authority-mismatch'); +} + +const archivePath = resolve(dirname(manifestPath), asset.archive); +const archiveHash = createHash('sha256'); +for await (const chunk of createReadStream(archivePath)) archiveHash.update(chunk); +if (archiveHash.digest('hex') !== asset.archiveSha256) { + throw new Error('hosted-opencode-materialization-archive-mismatch'); +} +const archiveKind = asset.archive.endsWith('.tar.gz') ? 'tar.gz' : 'zip'; +const { stdout } = await run( + archiveKind === 'tar.gz' ? '/usr/bin/tar' : '/usr/bin/unzip', + archiveKind === 'tar.gz' + ? ['-xOzf', archivePath, asset.binaryPath] + : ['-p', archivePath, asset.binaryPath], + { encoding: 'buffer', maxBuffer: Math.max(asset.binarySize + 1024, 256 * 1024 * 1024) } +); +const binarySha256 = createHash('sha256').update(stdout).digest('hex'); +if (binarySha256 !== asset.binarySha256) { + throw new Error('hosted-opencode-materialization-binary-mismatch'); +} +process.stdout.write(`${JSON.stringify({ + verified: true, + authorityManifestSha256: createHash('sha256') + .update(await readFile(manifestPath)) + .digest('hex'), + repository: manifest.workflow.repository, + sourceCommit: manifest.release.sourceCommit, + platform, + archiveSha256: asset.archiveSha256, + binarySha256, +})}\n`); diff --git a/scripts/verify-hosted-opencode-runtime-provenance.mjs b/scripts/verify-hosted-opencode-runtime-provenance.mjs new file mode 100644 index 0000000000..5359aa1d98 --- /dev/null +++ b/scripts/verify-hosted-opencode-runtime-provenance.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { access, readFile } from 'node:fs/promises'; +import { basename, dirname, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const run = promisify(execFile); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const lock = JSON.parse(await readFile(resolve(root, 'opencode-hosted-runtime.lock.json'), 'utf8')); +const candidateManifestPath = process.argv[2] ? resolve(process.argv[2]) : null; +const failures = []; + +function check(value, message) { + if (!value) failures.push(message); +} + +async function exists(path) { + return access(path).then(() => true, () => false); +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +async function sha256ArchiveBinary(path, asset) { + const archiveKind = path.endsWith('.tar.gz') ? 'tar.gz' : path.endsWith('.zip') ? 'zip' : null; + if (archiveKind === null) throw new Error('archive-kind'); + const executable = archiveKind === 'tar.gz' ? '/usr/bin/tar' : '/usr/bin/unzip'; + const args = + archiveKind === 'tar.gz' + ? ['-xOzf', path, asset.binaryPath] + : ['-p', path, asset.binaryPath]; + const child = await run(executable, args, { + encoding: 'buffer', + maxBuffer: Math.max(asset.binarySize + 1024, 256 * 1024 * 1024), + }); + return createHash('sha256').update(child.stdout).digest('hex'); +} + +async function verifyAttestation(subjectPath, repository, platform) { + const attestationPath = `${subjectPath}.intoto.jsonl`; + if (!(await exists(attestationPath))) { + failures.push(`materialized-attestation-missing:${platform}`); + return; + } + try { + await run('/usr/bin/gh', [ + 'attestation', + 'verify', + subjectPath, + '--repo', + repository, + '--bundle', + attestationPath, + ]); + } catch { + failures.push(`materialized-attestation-invalid:${platform}`); + } +} + +check(candidateManifestPath !== null, 'candidate-manifest-required'); +if (candidateManifestPath === null) { + throw new Error(`hosted-opencode-provenance-invalid:${failures.join(',')}`); +} + +const candidateBytes = await readFile(candidateManifestPath); +const candidate = JSON.parse(candidateBytes.toString('utf8')); +const candidateDirectory = dirname(candidateManifestPath); +const manifestDigest = createHash('sha256').update(candidateBytes).digest('hex'); + +check(candidate.schemaVersion === 1, 'candidate-schema'); +check(candidate.release?.productionEligible === false, 'candidate-eligibility'); +check(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate.workflow?.repository ?? ''), 'candidate-repository'); +check(typeof candidate.workflow?.workflow === 'string' && candidate.workflow.workflow.length > 0, 'candidate-workflow'); +check(/^[1-9][0-9]*$/.test(candidate.workflow?.runId ?? ''), 'candidate-workflow-run'); +check(/^[1-9][0-9]*$/.test(candidate.workflow?.runAttempt ?? ''), 'candidate-workflow-attempt'); +check(typeof candidate.workflow?.actor === 'string' && candidate.workflow.actor.length > 0, 'candidate-workflow-actor'); +check(/^refs\//.test(candidate.workflow?.ref ?? ''), 'candidate-workflow-ref'); +check(/^[0-9a-f]{40}$/.test(candidate.workflow?.sha ?? ''), 'candidate-workflow-sha'); +check(candidate.release?.tag === `v${candidate.release?.version}`, 'candidate-tag'); +check(/^[0-9a-f]{40}$/.test(candidate.release?.sourceCommit ?? ''), 'candidate-source-commit'); +check(/^[0-9a-f]{40}$/.test(candidate.release?.sourceTree ?? ''), 'candidate-source-tree'); +check(/^[0-9a-f]{40}$/.test(candidate.release?.baseCommit ?? ''), 'candidate-base-commit'); +check(/^[0-9a-f]{64}$/.test(candidate.release?.patchSha256 ?? ''), 'candidate-patch'); +check(Array.isArray(candidate.assets) && candidate.assets.length === 5, 'candidate-assets'); +await verifyAttestation(candidateManifestPath, candidate.workflow?.repository, 'release-manifest'); +const patchPath = resolve(candidateDirectory, 'reviewed.patch'); +if (!(await exists(patchPath))) { + failures.push('materialized-reviewed-patch-missing'); +} else { + check((await sha256File(patchPath)) === candidate.release?.patchSha256, 'materialized-patch-hash'); +} + +check(lock.productionEligible === false, 'lock-eligibility'); +check(lock.releaseRepository === candidate.workflow?.repository, 'lock-repository'); +check(lock.version === candidate.release?.version, 'lock-version'); +check(lock.tag === candidate.release?.tag, 'lock-tag'); +check(lock.source?.commit === candidate.release?.sourceCommit, 'lock-source-commit'); +check(lock.source?.baseCommit === candidate.release?.baseCommit, 'lock-base-commit'); +check(lock.source?.reviewedPatchSha256 === candidate.release?.patchSha256, 'lock-patch'); + +const platforms = new Set(); + +for (const asset of candidate.assets ?? []) { + const platform = `${asset.os === 'windows' ? 'win32' : asset.os}-${asset.arch}`; + check(!platforms.has(platform), `candidate-platform-duplicate:${platform}`); + platforms.add(platform); + const locked = lock.platforms?.[platform]; + check(locked?.status === 'available', `lock-platform:${platform}`); + check(locked?.file === asset.archive, `lock-archive:${platform}`); + check(locked?.archiveSha256 === asset.archiveSha256, `lock-archive-hash:${platform}`); + check(locked?.binaryName === basename(asset.binaryPath), `lock-binary:${platform}`); + check(locked?.binarySha256 === asset.binarySha256, `lock-binary-hash:${platform}`); + check( + locked?.assetUrl === + `https://github.com/${candidate.workflow.repository}/releases/download/${candidate.release.tag}/${asset.archive}`, + `lock-tag-url:${platform}` + ); + const archivePath = resolve(candidateDirectory, asset.archive); + if (!(await exists(archivePath))) { + failures.push(`materialized-archive-missing:${platform}`); + continue; + } + check((await sha256File(archivePath)) === asset.archiveSha256, `materialized-archive-hash:${platform}`); + try { + check( + (await sha256ArchiveBinary(archivePath, asset)) === asset.binarySha256, + `materialized-binary-hash:${platform}` + ); + } catch { + failures.push(`materialized-binary-unverifiable:${platform}`); + } + await verifyAttestation(archivePath, candidate.workflow.repository, platform); +} + +if (failures.length > 0) { + throw new Error(`hosted-opencode-provenance-invalid:${failures.join(',')}`); +} +process.stdout.write( + `${JSON.stringify({ + verified: true, + manifestSha256: manifestDigest, + repository: candidate.workflow.repository, + sourceCommit: candidate.release.sourceCommit, + sourceTree: candidate.release.sourceTree, + workflowRunId: candidate.workflow.runId, + tag: candidate.release.tag, + assets: candidate.assets.length, + })}\n` +); diff --git a/src/features/CLAUDE.md b/src/features/CLAUDE.md index 4e8525245c..cfcf4b65f8 100644 --- a/src/features/CLAUDE.md +++ b/src/features/CLAUDE.md @@ -42,6 +42,8 @@ When modifying an existing feature: - preserve the feature's current shape unless the change introduces a real new boundary - route app shell and cross-feature imports through public entrypoints +- run `pnpm guard:feature-architecture`; do not add or broaden legacy baseline + entries - move duplicated rules toward `core/domain` before adding another adapter copy - keep transport validation and normalization close to the boundary that receives the data diff --git a/src/features/agent-attachments/core/domain/validation.ts b/src/features/agent-attachments/core/domain/validation.ts index 490e1801b4..316917086a 100644 --- a/src/features/agent-attachments/core/domain/validation.ts +++ b/src/features/agent-attachments/core/domain/validation.ts @@ -1,4 +1,11 @@ -import { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from './budgets'; +import { + DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET, + estimateAgentAttachmentSerializedPayloadBytes, + MAX_AGENT_ATTACHMENT_DELIVERY_BYTES_TOTAL, + MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES, + MAX_AGENT_VIDEO_ATTACHMENT_BYTES, +} from './budgets'; +import { resolveAgentAttachmentCapability } from './capabilities'; import { isAgentImageMimeType, isAgentVideoMimeType } from './mimeTypes'; import type { @@ -17,6 +24,160 @@ const OPTIMIZABLE_AGENT_IMAGE_MIME_TYPES = new Set MAX_AGENT_IPC_ATTACHMENTS) { + return { valid: false, error: `Maximum ${MAX_AGENT_IPC_ATTACHMENTS} attachments allowed` }; + } + + let totalSize = 0; + let videoCount = 0; + const result: AgentAttachmentIpcPayload[] = []; + for (const attachment of attachments) { + if (!attachment || typeof attachment !== 'object') { + return { valid: false, error: 'Invalid attachment entry' }; + } + const candidate = attachment as Partial; + if (typeof candidate.id !== 'string' || typeof candidate.filename !== 'string') { + return { valid: false, error: 'Attachment must have id and filename' }; + } + if (typeof candidate.data !== 'string' || typeof candidate.mimeType !== 'string') { + return { valid: false, error: 'Attachment must have data and mimeType' }; + } + if ( + typeof candidate.size !== 'number' || + !Number.isSafeInteger(candidate.size) || + candidate.size <= 0 + ) { + return { valid: false, error: 'Attachment must have a positive size' }; + } + if (!ALLOWED_AGENT_ATTACHMENT_IPC_TYPES.has(candidate.mimeType)) { + return { valid: false, error: `Unsupported attachment type: ${candidate.mimeType}` }; + } + + const isVideo = isAgentVideoMimeType(candidate.mimeType); + const perAttachmentLimit = isVideo + ? MAX_AGENT_VIDEO_ATTACHMENT_BYTES + : MAX_AGENT_ATTACHMENT_IPC_BYTES; + if (candidate.size > perAttachmentLimit) { + return { + valid: false, + error: `Attachment "${candidate.filename}" exceeds ${isVideo ? '8MB' : '10MB'} limit`, + }; + } + + const paddingBytes = candidate.data.endsWith('==') ? 2 : candidate.data.endsWith('=') ? 1 : 0; + const estimatedBinarySize = Math.max(0, Math.ceil(candidate.data.length * 0.75) - paddingBytes); + if (estimatedBinarySize > perAttachmentLimit * 1.1) { + return { valid: false, error: `Attachment "${candidate.filename}" data exceeds size limit` }; + } + if (isVideo && ++videoCount > MAX_AGENT_VIDEO_ATTACHMENTS) { + return { + valid: false, + error: `Maximum ${MAX_AGENT_VIDEO_ATTACHMENTS} video attachment allowed`, + }; + } + + totalSize += Math.max(candidate.size, estimatedBinarySize); + result.push({ + id: candidate.id, + filename: candidate.filename, + data: candidate.data, + mimeType: candidate.mimeType, + size: candidate.size, + }); + } + + if (videoCount > 0 && totalSize > MAX_AGENT_ATTACHMENT_DELIVERY_BYTES_TOTAL) { + return { valid: false, error: 'Video and other attachments exceed the 8MB total size limit' }; + } + if (totalSize > MAX_AGENT_ATTACHMENT_IPC_BYTES_TOTAL) { + return { valid: false, error: 'Total attachment size exceeds 20MB limit' }; + } + return { valid: true, value: result }; +} + +export function validateAgentAttachmentSerializedIpcPayload(input: { + text: string; + attachments: AgentAttachmentIpcPayload[]; +}): { valid: true } | { valid: false; error: string } { + const estimatedBytes = estimateAgentAttachmentSerializedPayloadBytes(input); + const includesVideo = input.attachments.some((attachment) => + isAgentVideoMimeType(attachment.mimeType) + ); + const serializedLimit = includesVideo + ? MAX_AGENT_VIDEO_ATTACHMENT_BASE64_PAYLOAD_BYTES + + utf8Encoder.encode(JSON.stringify(input.text)).byteLength + + 4096 + : MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES; + if (estimatedBytes <= serializedLimit) return { valid: true }; + + return { + valid: false, + error: `Attachment payload is too large after optimization: ${formatBytes( + estimatedBytes + )} serialized. Limit is ${formatBytes( + serializedLimit + )}. Remove an attachment or use a smaller file.`, + }; +} + +export function getAgentVideoAttachmentRecipientRestriction(input: { + attachments: AgentAttachmentIpcPayload[]; + model?: string; + providerId: string; +}): string | null { + const videoAttachments = input.attachments.filter((attachment) => + isAgentVideoMimeType(attachment.mimeType) + ); + if (videoAttachments.length === 0) return null; + + const capability = resolveAgentAttachmentCapability(input); + if (!capability.supportsVideo) return capability.videoDisplayText; + return videoAttachments.every((attachment) => + capability.supportedVideoMimeTypes.some((mimeType) => mimeType === attachment.mimeType) + ) + ? null + : 'This video type is not supported by the selected model.'; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} export function isProviderImageMimeType(mimeType: string): mimeType is AgentImageMimeType { return isAgentImageMimeType(mimeType); diff --git a/src/features/agent-attachments/main/adapters/input/attachmentIpcPayloadValidation.ts b/src/features/agent-attachments/main/adapters/input/attachmentIpcPayloadValidation.ts index 3b99d11d98..df7ae72368 100644 --- a/src/features/agent-attachments/main/adapters/input/attachmentIpcPayloadValidation.ts +++ b/src/features/agent-attachments/main/adapters/input/attachmentIpcPayloadValidation.ts @@ -1,132 +1,6 @@ -import { - estimateAgentAttachmentSerializedPayloadBytes, - isAgentVideoMimeType, - MAX_AGENT_ATTACHMENT_DELIVERY_BYTES_TOTAL, - MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES, - MAX_AGENT_VIDEO_ATTACHMENT_BYTES, +export { + getAgentVideoAttachmentRecipientRestriction, + MAX_AGENT_IPC_ATTACHMENTS, + validateAgentAttachmentIpcPayload, + validateAgentAttachmentSerializedIpcPayload, } from '../../../core/domain'; - -import type { AttachmentPayload } from '@shared/types'; - -const ALLOWED_ATTACHMENT_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', - 'video/mp4', - 'video/webm', - 'video/quicktime', - 'application/pdf', - 'text/plain', -]); -const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; -const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; -const MAX_VIDEO_ATTACHMENTS = 1; -const MAX_VIDEO_ATTACHMENT_BASE64_PAYLOAD_BYTES = - Math.ceil(MAX_AGENT_ATTACHMENT_DELIVERY_BYTES_TOTAL / 3) * 4; -const utf8Encoder = new TextEncoder(); - -export const MAX_AGENT_IPC_ATTACHMENTS = 5; - -export function validateAgentAttachmentIpcPayload( - attachments: unknown -): { valid: true; value: AttachmentPayload[] } | { valid: false; error: string } { - if (!Array.isArray(attachments)) { - return { valid: false, error: 'attachments must be an array' }; - } - if (attachments.length > MAX_AGENT_IPC_ATTACHMENTS) { - return { valid: false, error: `Maximum ${MAX_AGENT_IPC_ATTACHMENTS} attachments allowed` }; - } - - let totalSize = 0; - let videoCount = 0; - const result: AttachmentPayload[] = []; - for (const attachment of attachments) { - if (!attachment || typeof attachment !== 'object') { - return { valid: false, error: 'Invalid attachment entry' }; - } - const candidate = attachment as Partial; - if (typeof candidate.id !== 'string' || typeof candidate.filename !== 'string') { - return { valid: false, error: 'Attachment must have id and filename' }; - } - if (typeof candidate.data !== 'string' || typeof candidate.mimeType !== 'string') { - return { valid: false, error: 'Attachment must have data and mimeType' }; - } - if ( - typeof candidate.size !== 'number' || - !Number.isSafeInteger(candidate.size) || - candidate.size <= 0 - ) { - return { valid: false, error: 'Attachment must have a positive size' }; - } - if (!ALLOWED_ATTACHMENT_TYPES.has(candidate.mimeType)) { - return { valid: false, error: `Unsupported attachment type: ${candidate.mimeType}` }; - } - - const isVideo = isAgentVideoMimeType(candidate.mimeType); - const perAttachmentLimit = isVideo ? MAX_AGENT_VIDEO_ATTACHMENT_BYTES : MAX_ATTACHMENT_SIZE; - if (candidate.size > perAttachmentLimit) { - return { - valid: false, - error: `Attachment "${candidate.filename}" exceeds ${isVideo ? '8MB' : '10MB'} limit`, - }; - } - - const paddingBytes = candidate.data.endsWith('==') ? 2 : candidate.data.endsWith('=') ? 1 : 0; - const estimatedBinarySize = Math.max(0, Math.ceil(candidate.data.length * 0.75) - paddingBytes); - if (estimatedBinarySize > perAttachmentLimit * 1.1) { - return { valid: false, error: `Attachment "${candidate.filename}" data exceeds size limit` }; - } - if (isVideo && ++videoCount > MAX_VIDEO_ATTACHMENTS) { - return { valid: false, error: `Maximum ${MAX_VIDEO_ATTACHMENTS} video attachment allowed` }; - } - - totalSize += Math.max(candidate.size, estimatedBinarySize); - result.push({ - id: candidate.id, - filename: candidate.filename, - data: candidate.data, - mimeType: candidate.mimeType, - size: candidate.size, - }); - } - - if (videoCount > 0 && totalSize > MAX_AGENT_ATTACHMENT_DELIVERY_BYTES_TOTAL) { - return { valid: false, error: 'Video and other attachments exceed the 8MB total size limit' }; - } - if (totalSize > MAX_TOTAL_ATTACHMENT_SIZE) { - return { valid: false, error: 'Total attachment size exceeds 20MB limit' }; - } - return { valid: true, value: result }; -} - -export function validateAgentAttachmentSerializedIpcPayload(input: { - text: string; - attachments: AttachmentPayload[]; -}): { valid: true } | { valid: false; error: string } { - const estimatedBytes = estimateAgentAttachmentSerializedPayloadBytes(input); - const includesVideo = input.attachments.some((attachment) => - isAgentVideoMimeType(attachment.mimeType) - ); - const serializedLimit = includesVideo - ? MAX_VIDEO_ATTACHMENT_BASE64_PAYLOAD_BYTES + - utf8Encoder.encode(JSON.stringify(input.text)).byteLength + - 4096 - : MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES; - if (estimatedBytes <= serializedLimit) return { valid: true }; - - return { - valid: false, - error: `Attachment payload is too large after optimization: ${formatBytes( - estimatedBytes - )} serialized. Limit is ${formatBytes( - serializedLimit - )}. Remove an attachment or use a smaller file.`, - }; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} diff --git a/src/features/agent-attachments/main/index.ts b/src/features/agent-attachments/main/index.ts index f694e6c658..3a03c0e521 100644 --- a/src/features/agent-attachments/main/index.ts +++ b/src/features/agent-attachments/main/index.ts @@ -1,5 +1,10 @@ -export { AgentAttachmentError } from '../core/domain'; -export * from './adapters/input/attachmentIpcPayloadValidation'; +export { + AgentAttachmentError, + getAgentVideoAttachmentRecipientRestriction, + MAX_AGENT_IPC_ATTACHMENTS, + validateAgentAttachmentIpcPayload, + validateAgentAttachmentSerializedIpcPayload, +} from '../core/domain'; export * from './infrastructure/attachmentArtifactStore'; export * from './providers/claudeAttachmentAdapter'; export * from './providers/codexNativeAttachmentAdapter'; diff --git a/src/features/agent-graph/README.md b/src/features/agent-graph/README.md index 0c3a4729d9..c938daf36f 100644 --- a/src/features/agent-graph/README.md +++ b/src/features/agent-graph/README.md @@ -3,19 +3,25 @@ This feature is a thin renderer slice over the reusable graph engine in `packages/agent-graph`. Read first: -- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) + +- [Feature Architecture Standard](../../../docs/FEATURE_ARCHITECTURE_STANDARD.md) - [Feature root guidance](../CLAUDE.md) - [Stable Slot Layout Plan](./STABLE_SLOT_LAYOUT_PLAN.md) -Public entrypoint: -- `@features/agent-graph/renderer` +Public entrypoints: + +- `@features/agent-graph` - layout policies plus the port-driven store action factory +- `@features/agent-graph/renderer` - renderer adapters, hooks, and UI Responsibilities: + - `packages/agent-graph` owns reusable graph rendering and low-level graph mechanics -- `src/features/agent-graph/core/domain` owns project-specific graph semantics and pure projection helpers +- `src/features/agent-graph/core/domain` owns project-specific graph semantics, layout state transitions, and pure projection helpers +- `src/features/agent-graph/core/application` owns store action orchestration through narrow state, seed-selection, and diagnostic ports - `src/features/agent-graph/renderer` owns the renderer integration layer, hooks, adapters, and UI Use this feature as the thin-slice example when a feature: + - has no dedicated `main` or `preload` transport boundary - integrates an existing reusable package into the app shell - still needs its own feature boundary and public entrypoint diff --git a/src/features/agent-graph/core/application/createTeamGraphLayoutActions.ts b/src/features/agent-graph/core/application/createTeamGraphLayoutActions.ts new file mode 100644 index 0000000000..4769ad9d54 --- /dev/null +++ b/src/features/agent-graph/core/application/createTeamGraphLayoutActions.ts @@ -0,0 +1,166 @@ +import { + type TeamGraphLayoutActions, + type TeamGraphLayoutDiagnostic, + type TeamGraphLayoutState, + type TeamGraphLayoutStatePatch, + type TeamGraphLayoutTransition, +} from '../domain/teamGraphLayoutState'; +import { + assignTeamGraphOwnerSlot, + changeTeamGraphLayoutMode, + clearTeamGraphLayout, + commitTeamGraphOwnerSlotDrop, + ensureTeamGraphLayoutState, + resetTeamGraphLayoutToDefaults, + swapTeamGraphGridOwners, + swapTeamGraphOwnerSlots, +} from '../domain/teamGraphLayoutTransitions'; + +import type { TeamGraphDefaultLayoutSeed } from '../domain/teamGraphDefaultLayout'; + +interface TeamGraphLayoutActionPorts { + setState: (updater: (state: TState) => TeamGraphLayoutStatePatch | null) => void; + selectDefaultLayoutSeed: (state: TState, teamName: string) => TeamGraphDefaultLayoutSeed | null; + warn: (message: string) => void; +} + +function formatDiagnostic(diagnostic: TeamGraphLayoutDiagnostic): string { + switch (diagnostic.code) { + case 'duplicate-stable-owner-id': + return ( + `[graph-layout] refusing duplicate owner identities team=${diagnostic.teamName} ` + + `owners=${diagnostic.duplicateStableOwnerIds.join(',')}` + ); + case 'invalid-team-name': + return '[graph-layout] refusing blank team name'; + case 'invalid-slot-assignment': + return ( + `[graph-layout] refusing invalid ${diagnostic.assignmentRole} slot assignment ` + + `team=${diagnostic.teamName} owner=${diagnostic.stableOwnerId} ` + + `target=${diagnostic.assignment.ringIndex}:${diagnostic.assignment.sectorIndex}` + ); + case 'incomplete-slot-drop-displacement': + return ( + `[graph-layout] refusing incomplete slot drop team=${diagnostic.teamName} ` + + `owner=${diagnostic.stableOwnerId} ` + + `target=${diagnostic.assignment.ringIndex}:${diagnostic.assignment.sectorIndex}` + ); + case 'inconsistent-slot-drop-displacement': + return ( + `[graph-layout] refusing inconsistent slot drop team=${diagnostic.teamName} ` + + `owner=${diagnostic.stableOwnerId} displaced=${diagnostic.displacedStableOwnerId} ` + + `target=${diagnostic.assignment.ringIndex}:${diagnostic.assignment.sectorIndex} ` + + `reason=${diagnostic.reason}` + ); + case 'occupied-slot-assignment': + return ( + `[graph-layout] refusing occupied slot assignment team=${diagnostic.teamName} ` + + `owner=${diagnostic.stableOwnerId} ` + + `target=${diagnostic.assignment.ringIndex}:${diagnostic.assignment.sectorIndex} ` + + `occupiedBy=${diagnostic.conflictingStableOwnerId}` + ); + case 'slot-drop-conflict': + return ( + `[graph-layout] refusing slot drop team=${diagnostic.teamName} ` + + `owner=${diagnostic.stableOwnerId} ` + + `target=${diagnostic.assignment.ringIndex}:${diagnostic.assignment.sectorIndex} ` + + `conflict=${diagnostic.conflictingStableOwnerId}` + ); + } +} + +function resolveTransition( + transition: TeamGraphLayoutTransition, + warn: (message: string) => void +): TeamGraphLayoutStatePatch | null { + if (transition.kind === 'updated') { + return transition.patch; + } + if (transition.kind === 'refused') { + warn(formatDiagnostic(transition.diagnostic)); + } + return null; +} + +export function createTeamGraphLayoutActions( + ports: TeamGraphLayoutActionPorts +): TeamGraphLayoutActions { + const apply = (transition: (state: TState) => TeamGraphLayoutTransition): void => { + ports.setState((state) => resolveTransition(transition(state), ports.warn)); + }; + + return { + ensureTeamGraphSlotAssignments: (teamName, members, configMembers = []) => { + apply((state) => ensureTeamGraphLayoutState(state, teamName, members, configMembers)); + }, + setTeamGraphOwnerSlotAssignment: (teamName, stableOwnerId, assignment) => { + apply((state) => assignTeamGraphOwnerSlot(state, teamName, stableOwnerId, assignment)); + }, + commitTeamGraphOwnerSlotDrop: ( + teamName, + stableOwnerId, + assignment, + displacedStableOwnerId, + displacedAssignment + ) => { + apply((state) => { + const visibleOwnerIds = + ports.selectDefaultLayoutSeed(state, teamName)?.orderedVisibleOwnerIds ?? []; + return commitTeamGraphOwnerSlotDrop( + state, + teamName, + stableOwnerId, + assignment, + displacedStableOwnerId, + displacedAssignment, + visibleOwnerIds + ); + }); + }, + setTeamGraphLayoutMode: (teamName, mode) => { + apply((state) => changeTeamGraphLayoutMode(state, teamName, mode)); + }, + swapTeamGraphGridOwners: (teamName, stableOwnerId, targetStableOwnerId) => { + if (stableOwnerId === targetStableOwnerId) { + return; + } + apply((state) => { + const defaultSeed = ports.selectDefaultLayoutSeed(state, teamName); + const fallbackVisibleOwnerIds = [...(state.gridOwnerOrderByTeam[teamName] ?? [])]; + for (const ownerId of [stableOwnerId, targetStableOwnerId]) { + if (!fallbackVisibleOwnerIds.includes(ownerId)) { + fallbackVisibleOwnerIds.push(ownerId); + } + } + const visibleOwnerIds = defaultSeed?.orderedVisibleOwnerIds ?? fallbackVisibleOwnerIds; + return swapTeamGraphGridOwners( + state, + teamName, + stableOwnerId, + targetStableOwnerId, + visibleOwnerIds + ); + }); + }, + swapTeamGraphOwnerSlots: (teamName, stableOwnerId, otherStableOwnerId) => { + if (stableOwnerId === otherStableOwnerId) { + return; + } + apply((state) => swapTeamGraphOwnerSlots(state, teamName, stableOwnerId, otherStableOwnerId)); + }, + clearTeamGraphSlotAssignments: (teamName) => { + apply((state) => clearTeamGraphLayout(state, teamName)); + }, + resetTeamGraphSlotAssignmentsToDefaults: (teamName) => { + apply((state) => { + const defaultSeed = ports.selectDefaultLayoutSeed(state, teamName) ?? { + orderedVisibleOwnerIds: [], + signature: null, + assignments: {}, + duplicateStableOwnerIds: [], + }; + return resetTeamGraphLayoutToDefaults(state, teamName, defaultSeed); + }); + }, + }; +} diff --git a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts index 02a02aa884..114b523e80 100644 --- a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts +++ b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts @@ -1,7 +1,5 @@ import { getStableTeamOwnerId, type StableTeamOwnerLike } from '@shared/utils/teamStableOwnerId'; -export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; - export function getGraphStableOwnerId(member: StableTeamOwnerLike): string { return getStableTeamOwnerId(member); } diff --git a/src/shared/utils/teamGraphDefaultLayout.ts b/src/features/agent-graph/core/domain/teamGraphDefaultLayout.ts similarity index 80% rename from src/shared/utils/teamGraphDefaultLayout.ts rename to src/features/agent-graph/core/domain/teamGraphDefaultLayout.ts index 8c631dc1fa..e256cf36c2 100644 --- a/src/shared/utils/teamGraphDefaultLayout.ts +++ b/src/features/agent-graph/core/domain/teamGraphDefaultLayout.ts @@ -13,6 +13,7 @@ export interface TeamGraphDefaultLayoutSeed { orderedVisibleOwnerIds: string[]; signature: string | null; assignments: Record; + duplicateStableOwnerIds: string[]; } const DEFAULT_OWNER_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[])[] = [ @@ -136,18 +137,34 @@ const DEFAULT_OWNER_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[]) ], ]; -export function buildOrderedVisibleTeamGraphOwnerIds( +function resolveOrderedVisibleTeamGraphOwnerIds( members: readonly TeamGraphDefaultLayoutMemberInput[], configMembers: readonly TeamGraphDefaultLayoutMemberInput[] = [] -): string[] { +): { orderedVisibleOwnerIds: string[]; duplicateStableOwnerIds: string[] } { const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member)); if (visibleMembers.length === 0) { - return []; + return { orderedVisibleOwnerIds: [], duplicateStableOwnerIds: [] }; + } + + const visibleMemberByStableOwnerId = new Map(); + const duplicateStableOwnerIds = new Set(); + for (const member of visibleMembers) { + const stableOwnerId = getStableTeamOwnerId(member); + if (visibleMemberByStableOwnerId.has(stableOwnerId)) { + duplicateStableOwnerIds.add(stableOwnerId); + continue; + } + visibleMemberByStableOwnerId.set(stableOwnerId, member); + } + if (duplicateStableOwnerIds.size > 0) { + return { + orderedVisibleOwnerIds: [], + duplicateStableOwnerIds: [...duplicateStableOwnerIds].toSorted((left, right) => + left.localeCompare(right) + ), + }; } - const visibleMemberByStableOwnerId = new Map( - visibleMembers.map((member) => [getStableTeamOwnerId(member), member] as const) - ); const orderedVisibleOwnerIds: string[] = []; const seenVisibleOwnerIds = new Set(); @@ -166,20 +183,27 @@ export function buildOrderedVisibleTeamGraphOwnerIds( seenVisibleOwnerIds.add(stableOwnerId); } - const remainingVisibleOwnerIds = visibleMembers - .map((member) => getStableTeamOwnerId(member)) + const remainingVisibleOwnerIds = [...visibleMemberByStableOwnerId.keys()] .filter((stableOwnerId) => !seenVisibleOwnerIds.has(stableOwnerId)) .toSorted((left, right) => left.localeCompare(right)); orderedVisibleOwnerIds.push(...remainingVisibleOwnerIds); - return orderedVisibleOwnerIds; + return { orderedVisibleOwnerIds, duplicateStableOwnerIds: [] }; +} + +export function buildOrderedVisibleTeamGraphOwnerIds( + members: readonly TeamGraphDefaultLayoutMemberInput[], + configMembers: readonly TeamGraphDefaultLayoutMemberInput[] = [] +): string[] { + return resolveOrderedVisibleTeamGraphOwnerIds(members, configMembers).orderedVisibleOwnerIds; } export function buildTeamGraphDefaultLayoutSeed( members: readonly TeamGraphDefaultLayoutMemberInput[], configMembers: readonly TeamGraphDefaultLayoutMemberInput[] = [] ): TeamGraphDefaultLayoutSeed { - const orderedVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds(members, configMembers); + const { orderedVisibleOwnerIds, duplicateStableOwnerIds } = + resolveOrderedVisibleTeamGraphOwnerIds(members, configMembers); const signature = orderedVisibleOwnerIds.length > 0 ? orderedVisibleOwnerIds.join('|') : null; const preset = DEFAULT_OWNER_SLOT_PRESETS[orderedVisibleOwnerIds.length]; const assignments: Record = {}; @@ -194,5 +218,6 @@ export function buildTeamGraphDefaultLayoutSeed( orderedVisibleOwnerIds, signature, assignments, + duplicateStableOwnerIds, }; } diff --git a/src/renderer/store/team/teamGraphLayout.ts b/src/features/agent-graph/core/domain/teamGraphLayoutAssignments.ts similarity index 88% rename from src/renderer/store/team/teamGraphLayout.ts rename to src/features/agent-graph/core/domain/teamGraphLayoutAssignments.ts index 6f7329e8d6..e4cb75a443 100644 --- a/src/renderer/store/team/teamGraphLayout.ts +++ b/src/features/agent-graph/core/domain/teamGraphLayoutAssignments.ts @@ -1,23 +1,12 @@ -import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; -import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; -import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types'; - -export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; -export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; - -export type TeamGraphSlotAssignments = Record; -export type TeamGraphMemberSeedInput = Pick; -export type TeamGraphConfigMemberSeedInput = Pick< - NonNullable[number], - 'name' | 'agentId' | 'removedAt' ->; - -export interface TeamGraphLayoutSessionState { - mode: 'default' | 'manual'; - signature: string | null; -} +import { buildTeamGraphDefaultLayoutSeed } from './teamGraphDefaultLayout'; +import { + DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS, + type TeamGraphConfigMemberSeedInput, + type TeamGraphMemberSeedInput, + type TeamGraphSlotAssignments, +} from './teamGraphLayoutState'; export function migrateStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments | undefined, diff --git a/src/features/agent-graph/core/domain/teamGraphLayoutState.ts b/src/features/agent-graph/core/domain/teamGraphLayoutState.ts new file mode 100644 index 0000000000..9ad3c549c0 --- /dev/null +++ b/src/features/agent-graph/core/domain/teamGraphLayoutState.ts @@ -0,0 +1,128 @@ +import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; +import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types'; + +export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; +export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; + +export type TeamGraphSlotAssignments = Record; +export type TeamGraphMemberSeedInput = Pick; +export type TeamGraphConfigMemberSeedInput = Pick< + NonNullable[number], + 'name' | 'agentId' | 'removedAt' +>; + +export interface TeamGraphLayoutSessionState { + mode: 'default' | 'manual'; + signature: string | null; +} + +export interface TeamGraphLayoutState { + slotLayoutVersion: string; + graphLayoutModeByTeam: Record; + gridOwnerOrderByTeam: Record; + slotAssignmentsByTeam: Record; + graphLayoutSessionByTeam: Record; +} + +export interface TeamGraphLayoutActions { + ensureTeamGraphSlotAssignments: ( + teamName: string, + members: readonly TeamGraphMemberSeedInput[], + configMembers?: readonly TeamGraphConfigMemberSeedInput[] + ) => void; + setTeamGraphOwnerSlotAssignment: ( + teamName: string, + stableOwnerId: string, + assignment: GraphOwnerSlotAssignment + ) => void; + commitTeamGraphOwnerSlotDrop: ( + teamName: string, + stableOwnerId: string, + assignment: GraphOwnerSlotAssignment, + displacedStableOwnerId?: string, + displacedAssignment?: GraphOwnerSlotAssignment + ) => void; + setTeamGraphLayoutMode: (teamName: string, mode: GraphLayoutMode) => void; + swapTeamGraphGridOwners: ( + teamName: string, + stableOwnerId: string, + targetStableOwnerId: string + ) => void; + swapTeamGraphOwnerSlots: ( + teamName: string, + stableOwnerId: string, + otherStableOwnerId: string + ) => void; + clearTeamGraphSlotAssignments: (teamName?: string) => void; + resetTeamGraphSlotAssignmentsToDefaults: (teamName: string) => void; +} + +export type TeamGraphLayoutSlice = TeamGraphLayoutState & TeamGraphLayoutActions; +export type TeamGraphLayoutStatePatch = Partial; + +export type TeamGraphLayoutDiagnostic = + | { + code: 'duplicate-stable-owner-id'; + teamName: string; + duplicateStableOwnerIds: string[]; + } + | { + code: 'invalid-slot-assignment'; + teamName: string; + stableOwnerId: string; + assignment: GraphOwnerSlotAssignment; + assignmentRole: 'target' | 'displaced'; + } + | { + code: 'invalid-team-name'; + teamName: string; + } + | { + code: 'occupied-slot-assignment'; + teamName: string; + stableOwnerId: string; + assignment: GraphOwnerSlotAssignment; + conflictingStableOwnerId: string; + } + | { + code: 'incomplete-slot-drop-displacement'; + teamName: string; + stableOwnerId: string; + assignment: GraphOwnerSlotAssignment; + } + | { + code: 'inconsistent-slot-drop-displacement'; + teamName: string; + stableOwnerId: string; + assignment: GraphOwnerSlotAssignment; + displacedStableOwnerId: string; + reason: + | 'same-owner' + | 'same-slot' + | 'missing-source-owner' + | 'missing-displaced-owner' + | 'stale-source-assignment' + | 'stale-displaced-assignment'; + } + | { + code: 'slot-drop-conflict'; + teamName: string; + stableOwnerId: string; + assignment: GraphOwnerSlotAssignment; + conflictingStableOwnerId: string; + }; + +export type TeamGraphLayoutTransition = + | { kind: 'updated'; patch: TeamGraphLayoutStatePatch } + | { kind: 'unchanged' } + | { kind: 'refused'; diagnostic: TeamGraphLayoutDiagnostic }; + +export function createInitialTeamGraphLayoutState(): TeamGraphLayoutState { + return { + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + graphLayoutModeByTeam: {}, + gridOwnerOrderByTeam: {}, + slotAssignmentsByTeam: {}, + graphLayoutSessionByTeam: {}, + }; +} diff --git a/src/features/agent-graph/core/domain/teamGraphLayoutTransitions.ts b/src/features/agent-graph/core/domain/teamGraphLayoutTransitions.ts new file mode 100644 index 0000000000..c94b8017c2 --- /dev/null +++ b/src/features/agent-graph/core/domain/teamGraphLayoutTransitions.ts @@ -0,0 +1,615 @@ +import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode'; + +import { + buildTeamGraphDefaultLayoutSeed, + type TeamGraphDefaultLayoutSeed, +} from './teamGraphDefaultLayout'; +import { + areTeamGraphSlotAssignmentsEqual, + migrateStableSlotAssignmentsForMembers, + normalizeTeamGraphGridOwnerOrder, + pruneTeamGraphSlotAssignmentsForVisibleOwners, + seedStableSlotAssignmentsForMembers, +} from './teamGraphLayoutAssignments'; +import { + DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS, + GRAPH_STABLE_SLOT_LAYOUT_VERSION, + type TeamGraphConfigMemberSeedInput, + type TeamGraphLayoutState, + type TeamGraphLayoutStatePatch, + type TeamGraphLayoutTransition, + type TeamGraphMemberSeedInput, + type TeamGraphSlotAssignments, +} from './teamGraphLayoutState'; + +import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; + +const UNCHANGED: TeamGraphLayoutTransition = { kind: 'unchanged' }; + +function updated(patch: TeamGraphLayoutStatePatch): TeamGraphLayoutTransition { + return { kind: 'updated', patch }; +} + +function areSlotAssignmentsEqual( + left: GraphOwnerSlotAssignment | undefined, + right: GraphOwnerSlotAssignment +): boolean { + return left?.ringIndex === right.ringIndex && left.sectorIndex === right.sectorIndex; +} + +function isValidSlotAssignment(assignment: GraphOwnerSlotAssignment): boolean { + return ( + Number.isSafeInteger(assignment.ringIndex) && + assignment.ringIndex >= 0 && + Number.isSafeInteger(assignment.sectorIndex) && + assignment.sectorIndex >= 0 + ); +} + +function getWritableLayoutContainers( + state: TeamGraphLayoutState +): Pick { + if (state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION) { + return { + slotAssignmentsByTeam: state.slotAssignmentsByTeam, + graphLayoutSessionByTeam: state.graphLayoutSessionByTeam, + }; + } + return { + slotAssignmentsByTeam: {}, + graphLayoutSessionByTeam: {}, + }; +} + +export function ensureTeamGraphLayoutState( + state: TeamGraphLayoutState, + teamName: string, + members: readonly TeamGraphMemberSeedInput[], + configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] +): TeamGraphLayoutTransition { + const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers); + if (defaultSeed.duplicateStableOwnerIds.length > 0) { + return { + kind: 'refused', + diagnostic: { + code: 'duplicate-stable-owner-id', + teamName, + duplicateStableOwnerIds: defaultSeed.duplicateStableOwnerIds, + }, + }; + } + + const nextState: TeamGraphLayoutStatePatch = {}; + let changed = false; + + let nextSlotAssignmentsByTeam = state.slotAssignmentsByTeam; + let nextGraphLayoutSessionByTeam = state.graphLayoutSessionByTeam; + if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) { + nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION; + nextSlotAssignmentsByTeam = {}; + nextGraphLayoutSessionByTeam = {}; + changed = true; + } + + const visibleAssignments = pruneTeamGraphSlotAssignmentsForVisibleOwners( + nextSlotAssignmentsByTeam[teamName], + defaultSeed.orderedVisibleOwnerIds + ); + const currentSession = nextGraphLayoutSessionByTeam[teamName]; + + if (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + if (currentSession?.mode === 'manual') { + if ( + !areTeamGraphSlotAssignmentsEqual(nextSlotAssignmentsByTeam[teamName], visibleAssignments) + ) { + nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam }; + if (visibleAssignments) { + nextSlotAssignmentsByTeam[teamName] = visibleAssignments; + } else { + delete nextSlotAssignmentsByTeam[teamName]; + } + changed = true; + } + } else { + if ( + !areTeamGraphSlotAssignmentsEqual( + nextSlotAssignmentsByTeam[teamName], + visibleAssignments + ) || + !areTeamGraphSlotAssignmentsEqual(visibleAssignments, defaultSeed.assignments) + ) { + nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam }; + if (Object.keys(defaultSeed.assignments).length === 0) { + delete nextSlotAssignmentsByTeam[teamName]; + } else { + nextSlotAssignmentsByTeam[teamName] = defaultSeed.assignments; + } + changed = true; + } + if ( + currentSession?.mode !== 'default' || + currentSession?.signature !== defaultSeed.signature + ) { + nextGraphLayoutSessionByTeam = { + ...nextGraphLayoutSessionByTeam, + [teamName]: { + mode: 'default', + signature: defaultSeed.signature, + }, + }; + changed = true; + } + } + + if (!changed) { + return UNCHANGED; + } + + nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam; + nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam; + return updated(nextState); + } + + const currentAssignments = nextSlotAssignmentsByTeam[teamName]; + const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members); + const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members, configMembers); + if (migrated.changed || seeded.changed) { + nextSlotAssignmentsByTeam = { + ...nextSlotAssignmentsByTeam, + [teamName]: seeded.assignments, + }; + changed = true; + } + + if (!changed) { + return UNCHANGED; + } + + nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam; + if (nextGraphLayoutSessionByTeam !== state.graphLayoutSessionByTeam) { + nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam; + } + return updated(nextState); +} + +export function assignTeamGraphOwnerSlot( + state: TeamGraphLayoutState, + teamName: string, + stableOwnerId: string, + assignment: GraphOwnerSlotAssignment +): TeamGraphLayoutTransition { + if (!isValidSlotAssignment(assignment)) { + return { + kind: 'refused', + diagnostic: { + code: 'invalid-slot-assignment', + teamName, + stableOwnerId, + assignment, + assignmentRole: 'target', + }, + }; + } + + const writable = getWritableLayoutContainers(state); + const currentAssignments = writable.slotAssignmentsByTeam[teamName] ?? {}; + const existing = currentAssignments[stableOwnerId]; + const occupiedByOther = Object.entries(currentAssignments).find( + ([otherStableOwnerId, otherAssignment]) => + otherStableOwnerId !== stableOwnerId && + otherAssignment.ringIndex === assignment.ringIndex && + otherAssignment.sectorIndex === assignment.sectorIndex + ); + + if ( + areSlotAssignmentsEqual(existing, assignment) && + state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION + ) { + return UNCHANGED; + } + + if (occupiedByOther) { + return { + kind: 'refused', + diagnostic: { + code: 'occupied-slot-assignment', + teamName, + stableOwnerId, + assignment, + conflictingStableOwnerId: occupiedByOther[0], + }, + }; + } + + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: { + ...writable.slotAssignmentsByTeam, + [teamName]: { + ...currentAssignments, + [stableOwnerId]: assignment, + }, + }, + graphLayoutSessionByTeam: { + ...writable.graphLayoutSessionByTeam, + [teamName]: { + mode: 'manual', + signature: writable.graphLayoutSessionByTeam[teamName]?.signature ?? null, + }, + }, + }); +} + +export function commitTeamGraphOwnerSlotDrop( + state: TeamGraphLayoutState, + teamName: string, + stableOwnerId: string, + assignment: GraphOwnerSlotAssignment, + displacedStableOwnerId?: string, + displacedAssignment?: GraphOwnerSlotAssignment, + visibleOwnerIds: readonly string[] = [] +): TeamGraphLayoutTransition { + if (!isValidSlotAssignment(assignment)) { + return { + kind: 'refused', + diagnostic: { + code: 'invalid-slot-assignment', + teamName, + stableOwnerId, + assignment, + assignmentRole: 'target', + }, + }; + } + + const hasDisplacedOwner = displacedStableOwnerId !== undefined; + const hasDisplacedAssignment = displacedAssignment !== undefined; + if (hasDisplacedOwner !== hasDisplacedAssignment) { + return { + kind: 'refused', + diagnostic: { + code: 'incomplete-slot-drop-displacement', + teamName, + stableOwnerId, + assignment, + }, + }; + } + + if (displacedAssignment && !isValidSlotAssignment(displacedAssignment)) { + return { + kind: 'refused', + diagnostic: { + code: 'invalid-slot-assignment', + teamName, + stableOwnerId, + assignment: displacedAssignment, + assignmentRole: 'displaced', + }, + }; + } + + const writable = getWritableLayoutContainers(state); + const currentAssignments = writable.slotAssignmentsByTeam[teamName] ?? {}; + const existing = currentAssignments[stableOwnerId]; + + if ( + areSlotAssignmentsEqual(existing, assignment) && + !displacedStableOwnerId && + state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION + ) { + return UNCHANGED; + } + + if (displacedStableOwnerId && displacedAssignment) { + const refuseInconsistentDisplacement = ( + reason: + | 'same-owner' + | 'same-slot' + | 'missing-source-owner' + | 'missing-displaced-owner' + | 'stale-source-assignment' + | 'stale-displaced-assignment' + ): TeamGraphLayoutTransition => ({ + kind: 'refused', + diagnostic: { + code: 'inconsistent-slot-drop-displacement', + teamName, + stableOwnerId, + assignment, + displacedStableOwnerId, + reason, + }, + }); + + if (displacedStableOwnerId === stableOwnerId) { + return refuseInconsistentDisplacement('same-owner'); + } + if (areSlotAssignmentsEqual(displacedAssignment, assignment)) { + return refuseInconsistentDisplacement('same-slot'); + } + const existingDisplacedAssignment = currentAssignments[displacedStableOwnerId]; + if (!existing && !existingDisplacedAssignment) { + if (!visibleOwnerIds.includes(stableOwnerId)) { + return refuseInconsistentDisplacement('missing-source-owner'); + } + if (!visibleOwnerIds.includes(displacedStableOwnerId)) { + return refuseInconsistentDisplacement('missing-displaced-owner'); + } + } else { + if (!existing) { + return refuseInconsistentDisplacement('missing-source-owner'); + } + if (!existingDisplacedAssignment) { + return refuseInconsistentDisplacement('missing-displaced-owner'); + } + if (!areSlotAssignmentsEqual(existing, displacedAssignment)) { + return refuseInconsistentDisplacement('stale-source-assignment'); + } + if (!areSlotAssignmentsEqual(existingDisplacedAssignment, assignment)) { + return refuseInconsistentDisplacement('stale-displaced-assignment'); + } + } + } + + const occupiedByConflict = Object.entries(currentAssignments).find( + ([ownerId, nextAssignment]) => { + if (ownerId === stableOwnerId || ownerId === displacedStableOwnerId) { + return false; + } + return ( + areSlotAssignmentsEqual(nextAssignment, assignment) || + (displacedAssignment != null && + areSlotAssignmentsEqual(nextAssignment, displacedAssignment)) + ); + } + ); + + if (occupiedByConflict) { + return { + kind: 'refused', + diagnostic: { + code: 'slot-drop-conflict', + teamName, + stableOwnerId, + assignment, + conflictingStableOwnerId: occupiedByConflict[0], + }, + }; + } + + const nextAssignments: TeamGraphSlotAssignments = { + ...currentAssignments, + [stableOwnerId]: assignment, + }; + if (displacedStableOwnerId && displacedAssignment) { + nextAssignments[displacedStableOwnerId] = displacedAssignment; + } + + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: { + ...writable.slotAssignmentsByTeam, + [teamName]: nextAssignments, + }, + graphLayoutSessionByTeam: { + ...writable.graphLayoutSessionByTeam, + [teamName]: { + mode: 'manual', + signature: writable.graphLayoutSessionByTeam[teamName]?.signature ?? null, + }, + }, + }); +} + +export function changeTeamGraphLayoutMode( + state: TeamGraphLayoutState, + teamName: string, + mode: GraphLayoutMode +): TeamGraphLayoutTransition { + if ((state.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) === mode) { + return UNCHANGED; + } + + return updated({ + graphLayoutModeByTeam: { + ...state.graphLayoutModeByTeam, + [teamName]: mode, + }, + }); +} + +export function swapTeamGraphGridOwners( + state: TeamGraphLayoutState, + teamName: string, + stableOwnerId: string, + targetStableOwnerId: string, + visibleOwnerIds: readonly string[] +): TeamGraphLayoutTransition { + if (stableOwnerId === targetStableOwnerId) { + return UNCHANGED; + } + + const normalizedOrder = normalizeTeamGraphGridOwnerOrder( + state.gridOwnerOrderByTeam[teamName], + visibleOwnerIds + ); + const stableOwnerIndex = normalizedOrder.indexOf(stableOwnerId); + const targetOwnerIndex = normalizedOrder.indexOf(targetStableOwnerId); + + if (stableOwnerIndex < 0 || targetOwnerIndex < 0) { + return UNCHANGED; + } + + const nextOrder = [...normalizedOrder]; + nextOrder[stableOwnerIndex] = targetStableOwnerId; + nextOrder[targetOwnerIndex] = stableOwnerId; + + return updated({ + gridOwnerOrderByTeam: { + ...state.gridOwnerOrderByTeam, + [teamName]: nextOrder, + }, + }); +} + +export function swapTeamGraphOwnerSlots( + state: TeamGraphLayoutState, + teamName: string, + stableOwnerId: string, + otherStableOwnerId: string +): TeamGraphLayoutTransition { + if (stableOwnerId === otherStableOwnerId) { + return UNCHANGED; + } + + const writable = getWritableLayoutContainers(state); + const currentAssignments = writable.slotAssignmentsByTeam[teamName] ?? {}; + const left = currentAssignments[stableOwnerId]; + const right = currentAssignments[otherStableOwnerId]; + if (!left || !right) { + return UNCHANGED; + } + + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: { + ...writable.slotAssignmentsByTeam, + [teamName]: { + ...currentAssignments, + [stableOwnerId]: right, + [otherStableOwnerId]: left, + }, + }, + graphLayoutSessionByTeam: { + ...writable.graphLayoutSessionByTeam, + [teamName]: { + mode: 'manual', + signature: writable.graphLayoutSessionByTeam[teamName]?.signature ?? null, + }, + }, + }); +} + +export function clearTeamGraphLayout( + state: TeamGraphLayoutState, + teamName?: string +): TeamGraphLayoutTransition { + if (teamName === undefined) { + if ( + Object.keys(state.slotAssignmentsByTeam).length === 0 && + state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION && + Object.keys(state.graphLayoutSessionByTeam).length === 0 + ) { + return UNCHANGED; + } + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: {}, + graphLayoutSessionByTeam: {}, + }); + } + + if (teamName.trim().length === 0) { + return { + kind: 'refused', + diagnostic: { + code: 'invalid-team-name', + teamName, + }, + }; + } + + if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) { + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: {}, + graphLayoutSessionByTeam: {}, + }); + } + + if (!(teamName in state.slotAssignmentsByTeam) && !(teamName in state.graphLayoutSessionByTeam)) { + return UNCHANGED; + } + + const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; + const nextGraphLayoutSessionByTeam = { ...state.graphLayoutSessionByTeam }; + delete nextAssignmentsByTeam[teamName]; + delete nextGraphLayoutSessionByTeam[teamName]; + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: nextAssignmentsByTeam, + graphLayoutSessionByTeam: nextGraphLayoutSessionByTeam, + }); +} + +export function resetTeamGraphLayoutToDefaults( + state: TeamGraphLayoutState, + teamName: string, + defaultSeed: TeamGraphDefaultLayoutSeed +): TeamGraphLayoutTransition { + if (defaultSeed.duplicateStableOwnerIds.length > 0) { + return { + kind: 'refused', + diagnostic: { + code: 'duplicate-stable-owner-id', + teamName, + duplicateStableOwnerIds: defaultSeed.duplicateStableOwnerIds, + }, + }; + } + + const writable = getWritableLayoutContainers(state); + const versionChanged = state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION; + + if (!DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + const currentAssignments = writable.slotAssignmentsByTeam[teamName]; + if (!currentAssignments || Object.keys(currentAssignments).length === 0) { + return versionChanged + ? updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: {}, + graphLayoutSessionByTeam: {}, + }) + : UNCHANGED; + } + + const nextAssignmentsByTeam = { ...writable.slotAssignmentsByTeam }; + delete nextAssignmentsByTeam[teamName]; + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: nextAssignmentsByTeam, + }); + } + + const currentAssignments = writable.slotAssignmentsByTeam[teamName]; + const currentSession = writable.graphLayoutSessionByTeam[teamName]; + + if ( + !versionChanged && + areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultSeed.assignments) && + currentSession?.mode === 'default' && + currentSession.signature === defaultSeed.signature + ) { + return UNCHANGED; + } + + const nextAssignmentsByTeam = { ...writable.slotAssignmentsByTeam }; + if (Object.keys(defaultSeed.assignments).length === 0) { + delete nextAssignmentsByTeam[teamName]; + } else { + nextAssignmentsByTeam[teamName] = defaultSeed.assignments; + } + + return updated({ + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: nextAssignmentsByTeam, + graphLayoutSessionByTeam: { + ...writable.graphLayoutSessionByTeam, + [teamName]: { + mode: 'default', + signature: defaultSeed.signature, + }, + }, + }); +} diff --git a/src/features/agent-graph/index.ts b/src/features/agent-graph/index.ts new file mode 100644 index 0000000000..c90060ceae --- /dev/null +++ b/src/features/agent-graph/index.ts @@ -0,0 +1,54 @@ +/** + * Agent graph feature - browser-safe public API. + * + * Renderer UI remains available from `@features/agent-graph/renderer`. + * This root entrypoint exposes layout contracts, pure policies, and a port-driven action factory. + */ + +export { createTeamGraphLayoutActions } from './core/application/createTeamGraphLayoutActions'; +export type { + TeamGraphDefaultLayoutMemberInput, + TeamGraphDefaultLayoutSeed, +} from './core/domain/teamGraphDefaultLayout'; +export { + buildOrderedVisibleTeamGraphOwnerIds, + buildTeamGraphDefaultLayoutSeed, +} from './core/domain/teamGraphDefaultLayout'; +export { + areTeamGraphSlotAssignmentsEqual, + getDefaultTeamGraphSlotAssignmentsForMembers, + isTeamGraphSlotPersistenceDisabled, + migrateStableSlotAssignmentsForMembers, + normalizeLegacySixRowOrbitAssignments, + normalizeTeamGraphGridOwnerOrder, + normalizeTeamGraphSlotAssignmentsForVisibleOwners, + pruneTeamGraphSlotAssignmentsForVisibleOwners, + seedStableSlotAssignmentsForMembers, +} from './core/domain/teamGraphLayoutAssignments'; +export type { + TeamGraphConfigMemberSeedInput, + TeamGraphLayoutActions, + TeamGraphLayoutDiagnostic, + TeamGraphLayoutSessionState, + TeamGraphLayoutSlice, + TeamGraphLayoutState, + TeamGraphLayoutStatePatch, + TeamGraphLayoutTransition, + TeamGraphMemberSeedInput, + TeamGraphSlotAssignments, +} from './core/domain/teamGraphLayoutState'; +export { + createInitialTeamGraphLayoutState, + DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS, + GRAPH_STABLE_SLOT_LAYOUT_VERSION, +} from './core/domain/teamGraphLayoutState'; +export { + assignTeamGraphOwnerSlot, + changeTeamGraphLayoutMode, + clearTeamGraphLayout, + commitTeamGraphOwnerSlotDrop, + ensureTeamGraphLayoutState, + resetTeamGraphLayoutToDefaults, + swapTeamGraphGridOwners, + swapTeamGraphOwnerSlots, +} from './core/domain/teamGraphLayoutTransitions'; diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 60d160b0f7..78c093ce1f 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -37,7 +37,6 @@ import { } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout'; import { hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext, isBootstrapConfirmedProvisionedButNotAliveFailure, @@ -56,7 +55,6 @@ import { buildGraphMemberNodeIdAliasMap, buildGraphMemberNodeIdForMember, getGraphStableOwnerId, - GRAPH_STABLE_SLOT_LAYOUT_VERSION, } from '../../core/domain/graphOwnerIdentity'; import { isTaskBlocked, @@ -64,6 +62,8 @@ import { resolveTaskGraphColumn, resolveTaskReviewer, } from '../../core/domain/taskGraphSemantics'; +import { buildOrderedVisibleTeamGraphOwnerIds } from '../../core/domain/teamGraphDefaultLayout'; +import { GRAPH_STABLE_SLOT_LAYOUT_VERSION } from '../../core/domain/teamGraphLayoutState'; import type { ActiveToolCall, diff --git a/src/features/agent-graph/renderer/agentGraphRendererArchitecture.test.ts b/src/features/agent-graph/renderer/agentGraphRendererArchitecture.test.ts new file mode 100644 index 0000000000..4256c79493 --- /dev/null +++ b/src/features/agent-graph/renderer/agentGraphRendererArchitecture.test.ts @@ -0,0 +1,49 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, extname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const rendererRoot = dirname(fileURLToPath(import.meta.url)); +const sourceExtensions = new Set(['.ts', '.tsx']); + +async function collectProductionSourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry): Promise => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return collectProductionSourceFiles(path); + } + if (!sourceExtensions.has(extname(entry.name)) || entry.name.includes('.test.')) { + return []; + } + return [path]; + }) + ); + return files.flat(); +} + +describe('agent-graph renderer architecture', () => { + it('keeps renderer production code behind feature-owned ports', async () => { + const rendererApiModule = ['@renderer', 'api'].join('/'); + const teamsApiAccess = ['api', 'teams'].join('.'); + const memberLogStreamApiAccess = ['api', 'memberLogStream'].join('.'); + const processSendAccess = [teamsApiAccess, 'processSend'].join('.'); + const sourceFiles = await collectProductionSourceFiles(rendererRoot); + + for (const sourceFile of sourceFiles) { + const source = await readFile(sourceFile, 'utf8'); + const sourceLabel = relative(rendererRoot, sourceFile); + expect(source, `${sourceLabel} sends directly to a team process`).not.toContain( + processSendAccess + ); + + expect(source, `${sourceLabel} imports the renderer API`).not.toContain(rendererApiModule); + expect(source, `${sourceLabel} calls the teams API`).not.toContain(teamsApiAccess); + expect(source, `${sourceLabel} calls the member log stream API`).not.toContain( + memberLogStreamApiAccess + ); + } + }); +}); diff --git a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.test.tsx b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.test.tsx new file mode 100644 index 0000000000..0f43b8d075 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.test.tsx @@ -0,0 +1,201 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog'; + +import type { TeamGraphTaskNotificationPort } from '../ports/TeamGraphTaskNotificationPort'; +import type { CreateTaskRequest } from '@shared/types'; +import type { ReactElement, ReactNode } from 'react'; +import type { Root } from 'react-dom/client'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const mocks = vi.hoisted(() => ({ + createTeamTask: vi.fn(), + storeState: { + createTeamTask: vi.fn(), + isTeamProvisioning: false, + members: [], + teamData: { + isAlive: true, + tasks: [], + }, + }, +})); + +vi.mock('@renderer/components/team/dialogs/CreateTaskDialog', () => ({ + CreateTaskDialog: () => null, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), +})); + +vi.mock('@renderer/store/slices/teamSlice', () => ({ + isTeamProvisioningActive: (state: typeof mocks.storeState) => state.isTeamProvisioning, + selectResolvedMembersForTeamName: (state: typeof mocks.storeState) => state.members, + selectTeamDataForName: (state: typeof mocks.storeState) => state.teamData, +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +interface CreateTaskDialogProps { + open: boolean; + onSubmit: (request: CreateTaskRequest) => Promise; + submitting: boolean; +} + +const request: CreateTaskRequest = { + subject: 'Fix the graph', + description: 'Make it reliable', + owner: 'alice', + prompt: 'Preserve every ordering guarantee.', + startImmediately: true, +}; + +function getDialogProps(dialog: ReactNode): CreateTaskDialogProps { + return (dialog as ReactElement).props; +} + +const mountedRoots: Root[] = []; + +function renderTestHook(useHook: () => Result): { readonly current: Result } { + const container = document.createElement('div'); + const root = createRoot(container); + let current: Result | undefined; + + const HookHost = (): null => { + current = useHook(); + return null; + }; + + act(() => root.render()); + mountedRoots.push(root); + + return { + get current(): Result { + if (current === undefined) throw new Error('Hook did not render'); + return current; + }, + }; +} + +function renderCreateTaskHook(notificationPort: TeamGraphTaskNotificationPort) { + return renderTestHook(function useSubject() { + return useGraphCreateTaskDialog('alpha', notificationPort); + }); +} + +describe('useGraphCreateTaskDialog', () => { + let notifyTeam: ReturnType; + let notificationPort: TeamGraphTaskNotificationPort; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.storeState.createTeamTask = mocks.createTeamTask; + mocks.storeState.isTeamProvisioning = false; + mocks.storeState.teamData = { isAlive: true, tasks: [] }; + mocks.createTeamTask.mockResolvedValue(undefined); + notifyTeam = vi.fn().mockResolvedValue(undefined); + notificationPort = { notifyTeam }; + }); + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()); + } + }); + + it('creates before notifying, preserves the exact message, and closes after success', async () => { + const result = renderCreateTaskHook(notificationPort); + + act(() => result.current.openCreateTaskDialog('alice')); + expect(getDialogProps(result.current.dialog).open).toBe(true); + + await act(async () => getDialogProps(result.current.dialog).onSubmit(request)); + + expect(mocks.createTeamTask).toHaveBeenCalledWith('alpha', request); + expect(notifyTeam).toHaveBeenCalledWith( + 'alpha', + 'New task assigned to alice: "Fix the graph". Instructions:\nPreserve every ordering guarantee.' + ); + expect(mocks.createTeamTask.mock.invocationCallOrder[0]).toBeLessThan( + notifyTeam.mock.invocationCallOrder[0] + ); + expect(getDialogProps(result.current.dialog)).toMatchObject({ open: false, submitting: false }); + }); + + it('stays open and submitting until the ordered notification attempt settles', async () => { + let resolveNotification: (() => void) | undefined; + notifyTeam.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveNotification = resolve; + }) + ); + const result = renderCreateTaskHook(notificationPort); + + act(() => result.current.openCreateTaskDialog('alice')); + let submission: Promise | undefined; + act(() => { + submission = getDialogProps(result.current.dialog).onSubmit(request); + }); + await act(async () => Promise.resolve()); + + expect(notifyTeam).toHaveBeenCalled(); + expect(getDialogProps(result.current.dialog)).toMatchObject({ open: true, submitting: true }); + + await act(async () => { + resolveNotification?.(); + await submission; + }); + expect(getDialogProps(result.current.dialog)).toMatchObject({ open: false, submitting: false }); + }); + + it('swallows notification failures and still closes the successful create dialog', async () => { + notifyTeam.mockRejectedValueOnce(new Error('offline')); + const result = renderCreateTaskHook(notificationPort); + + act(() => result.current.openCreateTaskDialog('alice')); + await act(async () => getDialogProps(result.current.dialog).onSubmit(request)); + + expect(getDialogProps(result.current.dialog)).toMatchObject({ open: false, submitting: false }); + }); + + it('leaves the dialog open when task creation fails and clears submitting', async () => { + mocks.createTeamTask.mockRejectedValueOnce(new Error('create failed')); + const result = renderCreateTaskHook(notificationPort); + + act(() => result.current.openCreateTaskDialog('alice')); + await act(async () => getDialogProps(result.current.dialog).onSubmit(request)); + + expect(notifyTeam).not.toHaveBeenCalled(); + expect(getDialogProps(result.current.dialog)).toMatchObject({ open: true, submitting: false }); + }); + + it.each([ + ['missing prompt', { ...request, prompt: '' }, true, false], + ['missing owner', { ...request, owner: '' }, true, false], + ['team not alive', request, false, false], + ['provisioning active', request, true, true], + ['startImmediately false', { ...request, startImmediately: false }, true, false], + ])( + 'does not notify when the %s guard blocks it', + async (_label, guardedRequest, isAlive, provisioning) => { + mocks.storeState.teamData = { isAlive, tasks: [] }; + mocks.storeState.isTeamProvisioning = provisioning; + const result = renderCreateTaskHook(notificationPort); + + await act(async () => getDialogProps(result.current.dialog).onSubmit(guardedRequest)); + + expect(mocks.createTeamTask).toHaveBeenCalledWith('alpha', guardedRequest); + expect(notifyTeam).not.toHaveBeenCalled(); + expect(getDialogProps(result.current.dialog).submitting).toBe(false); + } + ); +}); diff --git a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx index 435cf07cdc..281d128762 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx +++ b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx @@ -1,6 +1,5 @@ import { useCallback, useState } from 'react'; -import { api } from '@renderer/api'; import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog'; import { useStore } from '@renderer/store'; import { @@ -10,6 +9,7 @@ import { } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; +import type { TeamGraphTaskNotificationPort } from '../ports/TeamGraphTaskNotificationPort'; import type { CreateTaskRequest } from '@shared/types'; interface CreateTaskDialogState { @@ -22,7 +22,10 @@ interface UseGraphCreateTaskDialogResult { openCreateTaskDialog: (owner?: string) => void; } -export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDialogResult { +export function useGraphCreateTaskDialog( + teamName: string, + taskNotificationPort: TeamGraphTaskNotificationPort +): UseGraphCreateTaskDialogResult { const [dialogState, setDialogState] = useState({ open: false, defaultOwner: '', @@ -70,7 +73,7 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi ) { const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; try { - await api.teams.processSend(teamName, msg); + await taskNotificationPort.notifyTeam(teamName, msg); } catch { // best-effort only } @@ -83,7 +86,14 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi setSubmitting(false); } }, - [closeCreateTaskDialog, createTeamTask, isTeamProvisioning, teamData?.isAlive, teamName] + [ + closeCreateTaskDialog, + createTeamTask, + isTeamProvisioning, + taskNotificationPort, + teamData?.isAlive, + teamName, + ] ); return { diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts index b30cd1a381..0fea5600b1 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -5,9 +5,9 @@ import { type MemberLogPreviewRequestOptions, normalizeMemberLogPreviewResponse, } from '@features/member-log-stream/contracts'; -import { api } from '@renderer/api'; +import { memberLogObservationPorts } from '@features/member-log-stream/renderer'; -import type { ResolvedTeamMember, TeamChangeEvent } from '@shared/types/team'; +import type { ResolvedTeamMember } from '@shared/types/team'; const LIVE_RELOAD_DEBOUNCE_MS = 650; const PREVIEW_CACHE_TTL_MS = 3_500; @@ -416,8 +416,8 @@ export function useGraphMemberLogPreviews(input: { : {}), ...(options?.forceRefresh ? { forceRefresh: true } : {}), }; - request = api.memberLogStream - .getMemberLogPreviews(input.teamName, membersToRequest, requestOptions) + request = memberLogObservationPorts + .readMemberLogPreviews(input.teamName, membersToRequest, requestOptions) .then((response) => { const normalized = normalizeMemberLogPreviewResponse(response); const members = memberMapFromResponse(normalized.members); @@ -569,7 +569,7 @@ export function useGraphMemberLogPreviews(input: { useEffect(() => { if (!enabled) return; - const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => { + const unsubscribe = memberLogObservationPorts.subscribeToChanges((event) => { if (event.teamName !== input.teamName) return; if (event.type === 'log-source-change') { scheduleReload({ background: true, forceRefresh: true }); diff --git a/src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx b/src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx index ab2ee7f32d..71612c9397 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx +++ b/src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx @@ -7,6 +7,7 @@ import { useGraphSendMessageDialog } from './useGraphSendMessageDialog'; import { useGraphTaskActions } from './useGraphTaskActions'; import { useGraphTaskDetailDialog } from './useGraphTaskDetailDialog'; +import type { TeamGraphTaskNotificationPort } from '../ports/TeamGraphTaskNotificationPort'; import type { MemberActivityFilter, MemberDetailTab, @@ -17,7 +18,10 @@ interface OpenProfileOptions { initialTab?: MemberDetailTab; } -export function useGraphSurfaceInteractions(teamName: string): { +export function useGraphSurfaceInteractions( + teamName: string, + taskNotificationPort: TeamGraphTaskNotificationPort +): { dialogs: React.ReactNode; onApproveTask: (taskId: string) => void; onCancelTask: (taskId: string) => void; @@ -34,9 +38,9 @@ export function useGraphSurfaceInteractions(teamName: string): { openTaskDetail: (taskId: string) => void; } { const changeReview = useGraphChangeReviewDialog(teamName); - const createTask = useGraphCreateTaskDialog(teamName); + const createTask = useGraphCreateTaskDialog(teamName, taskNotificationPort); const sendMessage = useGraphSendMessageDialog(teamName); - const taskActions = useGraphTaskActions(teamName); + const taskActions = useGraphTaskActions(teamName, taskNotificationPort); const taskDetail = useGraphTaskDetailDialog(teamName, { onDeleteTask: taskActions.onDeleteTask, onViewChanges: changeReview.openTaskChanges, diff --git a/src/features/agent-graph/renderer/hooks/useGraphTaskActions.test.tsx b/src/features/agent-graph/renderer/hooks/useGraphTaskActions.test.tsx new file mode 100644 index 0000000000..0e02f974ab --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphTaskActions.test.tsx @@ -0,0 +1,256 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphTaskActions } from './useGraphTaskActions'; + +import type { TeamGraphTaskNotificationPort } from '../ports/TeamGraphTaskNotificationPort'; +import type { Root } from 'react-dom/client'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const mocks = vi.hoisted(() => ({ + requestReview: vi.fn(), + sendTeamMessage: vi.fn(), + softDeleteTask: vi.fn(), + startTaskByUser: vi.fn(), + updateKanban: vi.fn(), + updateTaskStatus: vi.fn(), + storeState: { + members: [], + requestReview: vi.fn(), + sendTeamMessage: vi.fn(), + softDeleteTask: vi.fn(), + startTaskByUser: vi.fn(), + teamData: { + isAlive: true, + tasks: [ + { + id: 'task-7', + subject: 'Fix the graph', + description: ' Preserve ordering. ', + owner: 'alice', + }, + ], + }, + updateKanban: vi.fn(), + updateTaskStatus: vi.fn(), + }, +})); + +vi.mock('@renderer/components/common/ConfirmDialog', () => ({ + confirm: vi.fn(), +})); + +vi.mock('@renderer/components/team/dialogs/ReviewDialog', () => ({ + ReviewDialog: () => null, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), +})); + +vi.mock('@renderer/store/slices/teamSlice', () => ({ + selectResolvedMembersForTeamName: (state: typeof mocks.storeState) => state.members, + selectTeamDataForName: (state: typeof mocks.storeState) => state.teamData, +})); + +vi.mock('@shared/utils/taskIdentity', () => ({ + deriveTaskDisplayId: () => '7', + formatTaskDisplayLabel: () => '#7', +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +const mountedRoots: Root[] = []; + +function renderTestHook(useHook: () => Result): { readonly current: Result } { + const container = document.createElement('div'); + const root = createRoot(container); + let current: Result | undefined; + + const HookHost = (): null => { + current = useHook(); + return null; + }; + + act(() => root.render()); + mountedRoots.push(root); + + return { + get current(): Result { + if (current === undefined) throw new Error('Hook did not render'); + return current; + }, + }; +} + +function renderTaskActionsHook(notificationPort: TeamGraphTaskNotificationPort) { + return renderTestHook(function useSubject() { + return useGraphTaskActions('alpha', notificationPort); + }); +} + +async function runAction(action: () => void): Promise { + await act(async () => { + action(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe('useGraphTaskActions', () => { + let notifyTeam: ReturnType; + let notificationPort: TeamGraphTaskNotificationPort; + + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(mocks.storeState, { + requestReview: mocks.requestReview, + sendTeamMessage: mocks.sendTeamMessage, + softDeleteTask: mocks.softDeleteTask, + startTaskByUser: mocks.startTaskByUser, + updateKanban: mocks.updateKanban, + updateTaskStatus: mocks.updateTaskStatus, + }); + mocks.storeState.teamData = { + isAlive: true, + tasks: [ + { + id: 'task-7', + subject: 'Fix the graph', + description: ' Preserve ordering. ', + owner: 'alice', + }, + ], + }; + mocks.startTaskByUser.mockResolvedValue({ notifiedOwner: true }); + mocks.sendTeamMessage.mockResolvedValue(undefined); + mocks.updateTaskStatus.mockResolvedValue(undefined); + notifyTeam = vi.fn().mockResolvedValue(undefined); + notificationPort = { notifyTeam }; + }); + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()); + } + }); + + it('starts before sending the exact assigned-owner notification', async () => { + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onStartTask('task-7')); + + expect(notifyTeam).toHaveBeenCalledWith( + 'alpha', + 'Task #7 "Fix the graph" has started. Please begin working on it.' + ); + expect(mocks.startTaskByUser).toHaveBeenCalledWith('alpha', 'task-7'); + expect(mocks.startTaskByUser.mock.invocationCallOrder[0]).toBeLessThan( + notifyTeam.mock.invocationCallOrder[0] + ); + }); + + it('sends the exact unassigned start notification with the trimmed description', async () => { + mocks.startTaskByUser.mockResolvedValueOnce({ notifiedOwner: false }); + mocks.storeState.teamData.tasks[0].owner = ''; + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onStartTask('task-7')); + + expect(notifyTeam).toHaveBeenCalledWith( + 'alpha', + 'Task #7 "Fix the graph" has been moved to IN PROGRESS but has no assignee.\nDescription: Preserve ordering.\nPlease assign it to an available team member, or take it yourself if everyone is busy.' + ); + }); + + it('does not send a start notification when the team is not alive', async () => { + mocks.storeState.teamData.isAlive = false; + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onStartTask('task-7')); + + expect(mocks.startTaskByUser).toHaveBeenCalled(); + expect(notifyTeam).not.toHaveBeenCalled(); + }); + + it('swallows start notification failures', async () => { + notifyTeam.mockRejectedValueOnce(new Error('offline')); + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onStartTask('task-7')); + + expect(notifyTeam).toHaveBeenCalled(); + }); + + it('cancels, direct-messages the owner, then sends the exact team notification', async () => { + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onCancelTask('task-7')); + + expect(notifyTeam).toHaveBeenCalledWith( + 'alpha', + 'Task #7 "Fix the graph" has been cancelled and moved back to TODO. alice has been notified to stop.' + ); + expect(mocks.updateTaskStatus).toHaveBeenCalledWith('alpha', 'task-7', 'pending'); + expect(mocks.sendTeamMessage).toHaveBeenCalledWith('alpha', { + member: 'alice', + text: 'Task #7 "Fix the graph" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.', + summary: 'Task #7 cancelled', + }); + expect(mocks.updateTaskStatus.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendTeamMessage.mock.invocationCallOrder[0] + ); + expect(mocks.sendTeamMessage.mock.invocationCallOrder[0]).toBeLessThan( + notifyTeam.mock.invocationCallOrder[0] + ); + }); + + it('still notifies the alive team when the best-effort owner message fails', async () => { + mocks.sendTeamMessage.mockRejectedValueOnce(new Error('owner offline')); + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onCancelTask('task-7')); + + expect(notifyTeam).toHaveBeenCalled(); + expect(mocks.sendTeamMessage.mock.invocationCallOrder[0]).toBeLessThan( + notifyTeam.mock.invocationCallOrder[0] + ); + }); + + it('swallows cancellation team-notification failures', async () => { + notifyTeam.mockRejectedValueOnce(new Error('team offline')); + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onCancelTask('task-7')); + + expect(mocks.updateTaskStatus).toHaveBeenCalled(); + expect(mocks.sendTeamMessage).toHaveBeenCalled(); + expect(notifyTeam).toHaveBeenCalled(); + }); + + it('direct-messages the owner but does not notify a team that is not alive', async () => { + mocks.storeState.teamData.isAlive = false; + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onCancelTask('task-7')); + + expect(mocks.sendTeamMessage).toHaveBeenCalled(); + expect(notifyTeam).not.toHaveBeenCalled(); + }); + + it('stops cancellation notifications when the status update fails', async () => { + mocks.updateTaskStatus.mockRejectedValueOnce(new Error('update failed')); + const result = renderTaskActionsHook(notificationPort); + + await runAction(() => result.current.onCancelTask('task-7')); + + expect(mocks.updateTaskStatus).toHaveBeenCalled(); + expect(mocks.sendTeamMessage).not.toHaveBeenCalled(); + expect(notifyTeam).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx b/src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx index 32939b40f2..bb88bd83b3 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx +++ b/src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx @@ -1,6 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import { api } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ReviewDialog } from '@renderer/components/team/dialogs/ReviewDialog'; import { useStore } from '@renderer/store'; @@ -11,6 +10,7 @@ import { import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { useShallow } from 'zustand/react/shallow'; +import type { TeamGraphTaskNotificationPort } from '../ports/TeamGraphTaskNotificationPort'; import type { TaskRef } from '@shared/types'; interface GraphTaskActionHandlers { @@ -29,7 +29,10 @@ interface UseGraphTaskActionsResult extends GraphTaskActionHandlers { taskActionHandlers: GraphTaskActionHandlers; } -export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult { +export function useGraphTaskActions( + teamName: string, + taskNotificationPort: TeamGraphTaskNotificationPort +): UseGraphTaskActionsResult { const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const { teamData, @@ -63,7 +66,7 @@ export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult const task = teamData.tasks.find((candidate) => candidate.id === taskId); try { if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( + await taskNotificationPort.notifyTeam( teamName, `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` ); @@ -74,7 +77,7 @@ export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult const desc = task?.description?.trim() ? `\nDescription: ${task.description.trim()}` : ''; - await api.teams.processSend( + await taskNotificationPort.notifyTeam( teamName, `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` ); @@ -87,7 +90,7 @@ export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult } })(); }, - [startTaskByUser, teamData, teamName] + [startTaskByUser, taskNotificationPort, teamData, teamName] ); const onCompleteTask = useCallback( @@ -139,7 +142,7 @@ export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult if (teamData?.isAlive) { try { const ownerSuffix = task?.owner ? ` ${task.owner} has been notified to stop.` : ''; - await api.teams.processSend( + await taskNotificationPort.notifyTeam( teamName, `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); @@ -152,7 +155,7 @@ export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult } })(); }, - [sendTeamMessage, teamData, teamName, updateTaskStatus] + [sendTeamMessage, taskNotificationPort, teamData, teamName, updateTaskStatus] ); const onMoveBackToDone = useCallback( diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index e0ccc9497c..9abcc6338f 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -5,22 +5,24 @@ import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'; +import { + buildTeamGraphDefaultLayoutSeed, + isTeamGraphSlotPersistenceDisabled, +} from '@features/agent-graph'; import { useAppTranslation } from '@features/localization/renderer'; import { useTeamAgentRuntimeWatcher } from '@renderer/components/team/useTeamAgentRuntimeWatcher'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, - isTeamGraphSlotPersistenceDisabled, selectResolvedMembersForTeamName, selectTeamDataForName, selectTeamMessages, } from '@renderer/store/slices/teamSlice'; import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode'; -import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { useShallow } from 'zustand/react/shallow'; -import { GRAPH_STABLE_SLOT_LAYOUT_VERSION } from '../../core/domain/graphOwnerIdentity'; +import { GRAPH_STABLE_SLOT_LAYOUT_VERSION } from '../../core/domain/teamGraphLayoutState'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts index 19cb0856b1..535e4c07b7 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; +import { isTeamGraphSlotPersistenceDisabled } from '@features/agent-graph'; import { useStore } from '@renderer/store'; -import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode'; import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity'; @@ -45,16 +45,19 @@ export function useTeamGraphSurfaceActions(teamName: string): { const displacedStableOwnerId = payload.displacedNodeId ? parseGraphMemberNodeId(payload.displacedNodeId, teamName) : null; + if (payload.displacedNodeId !== undefined && !displacedStableOwnerId) { + return; + } const store = useStore.getState(); if ((store.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) !== 'radial') { return; } - if (displacedStableOwnerId && payload.displacedAssignment) { + if (payload.displacedNodeId !== undefined || payload.displacedAssignment !== undefined) { store.commitTeamGraphOwnerSlotDrop( teamName, stableOwnerId, payload.assignment, - displacedStableOwnerId, + displacedStableOwnerId ?? undefined, payload.displacedAssignment ); return; diff --git a/src/features/agent-graph/renderer/index.ts b/src/features/agent-graph/renderer/index.ts index ba6320ac06..73ef9a9c72 100644 --- a/src/features/agent-graph/renderer/index.ts +++ b/src/features/agent-graph/renderer/index.ts @@ -9,6 +9,7 @@ export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntr export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries'; export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity'; export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; +export type { TeamGraphTaskNotificationPort } from './ports/TeamGraphTaskNotificationPort'; export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay'; export { TeamGraphOverlay } from './ui/TeamGraphOverlay'; export { TeamGraphTab } from './ui/TeamGraphTab'; diff --git a/src/features/agent-graph/renderer/ports/TeamGraphTaskNotificationPort.ts b/src/features/agent-graph/renderer/ports/TeamGraphTaskNotificationPort.ts new file mode 100644 index 0000000000..685e745c9e --- /dev/null +++ b/src/features/agent-graph/renderer/ports/TeamGraphTaskNotificationPort.ts @@ -0,0 +1,3 @@ +export interface TeamGraphTaskNotificationPort { + notifyTeam(teamName: string, message: string): Promise; +} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 537d3a14ca..18f5cb36cf 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -23,6 +23,7 @@ import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; +import type { TeamGraphTaskNotificationPort } from '../ports/TeamGraphTaskNotificationPort'; import type { GraphDomainRef, GraphEventPort, @@ -31,6 +32,7 @@ import type { export interface TeamGraphOverlayProps { teamName: string; + taskNotificationPort: TeamGraphTaskNotificationPort; onClose: () => void; onPinAsTab?: () => void; sidebarVisible?: boolean; @@ -40,6 +42,7 @@ export interface TeamGraphOverlayProps { export const TeamGraphOverlay = ({ teamName, + taskNotificationPort, onClose, onPinAsTab, sidebarVisible, @@ -55,7 +58,7 @@ export const TeamGraphOverlay = ({ } = useTeamGraphSurfaceActions(teamName); const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); - const interactions = useGraphSurfaceInteractions(teamName); + const interactions = useGraphSurfaceInteractions(teamName, taskNotificationPort); const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( null ); diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index a841443038..d4f25c84be 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -23,6 +23,7 @@ import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; +import type { TeamGraphTaskNotificationPort } from '../ports/TeamGraphTaskNotificationPort'; import type { GraphDomainRef, GraphEventPort, @@ -37,12 +38,14 @@ export interface TeamGraphTabProps { teamName: string; isActive?: boolean; isPaneFocused?: boolean; + taskNotificationPort: TeamGraphTaskNotificationPort; } export const TeamGraphTab = ({ teamName, isActive = true, isPaneFocused = false, + taskNotificationPort, }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName, { active: isActive }); const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } = @@ -55,7 +58,7 @@ export const TeamGraphTab = ({ const sidebarSnapshot = useTeamSidebarPortalSnapshot(); const hasSidebarSource = Boolean(sidebarSnapshot.activeSourceIdByTeam[teamName]); const effectiveSidebarVisible = sidebarVisible && hasSidebarSource; - const interactions = useGraphSurfaceInteractions(teamName); + const interactions = useGraphSurfaceInteractions(teamName, taskNotificationPort); const openCreateTask = useCallback(() => { interactions.openCreateTask(''); }, [interactions]); @@ -237,6 +240,7 @@ export const TeamGraphTab = ({ setFullscreen(false)} sidebarVisible={effectiveSidebarVisible} onToggleSidebar={hasSidebarSource ? toggleSidebarVisible : undefined} diff --git a/src/features/application-command-ledger/contracts/durableCommandProtocol.ts b/src/features/application-command-ledger/contracts/durableCommandProtocol.ts new file mode 100644 index 0000000000..fe9dc0df57 --- /dev/null +++ b/src/features/application-command-ledger/contracts/durableCommandProtocol.ts @@ -0,0 +1,164 @@ +export const HMAC_SHA256_LD_V1 = 'hmac-sha256-ld-v1' as const; + +export const COMMAND_IDEMPOTENCY_SCOPE = + 'deploymentId+stableActorId+commandKind+idempotencyKey' as const; + +export const EFFECT_RECOVERY_CLASSES = Object.freeze([ + 'transactional_local', + 'idempotent_by_operation_id', + 'reconcilable_by_unique_evidence', + 'compensatable', + 'non_reconcilable', +] as const); + +export type EffectRecoveryClass = (typeof EFFECT_RECOVERY_CLASSES)[number]; + +export const DURABLE_COMMAND_STATES = Object.freeze([ + 'prepared', + 'running', + 'committed', + 'recovering', + 'failed', + 'operator_required', +] as const); + +export type DurableCommandState = (typeof DURABLE_COMMAND_STATES)[number]; + +export const DURABLE_EFFECT_STATES = Object.freeze([ + 'not_started', + 'attempting', + 'observed_succeeded', + 'observed_absent', + 'ambiguous', + 'compensating', + 'compensated', +] as const); + +export type DurableEffectState = (typeof DURABLE_EFFECT_STATES)[number]; + +export type NormalizedIntentValue = + | null + | boolean + | number + | string + | readonly NormalizedIntentValue[] + | { readonly [key: string]: NormalizedIntentValue }; + +export type NormalizedCommandIntent = Readonly>; + +export interface EffectDescriptor { + readonly effectId: string; + readonly effectVersion: number; + readonly recoveryClass: EffectRecoveryClass; + readonly evidenceSchemaVersion: number; +} + +export interface CommandDescriptor { + readonly descriptorId: string; + /** Versions the complete immutable command contract, including its ordered effect plan. */ + readonly descriptorVersion: number; + readonly commandKind: TCommandKind; + readonly inputSchemaVersion: number; + readonly fingerprintVersion: typeof HMAC_SHA256_LD_V1; + /** Bumps whenever effect order, identity, version, recovery class, or evidence schema changes. */ + readonly effectPlanVersion: number; + readonly idempotencyScope: typeof COMMAND_IDEMPOTENCY_SCOPE; + readonly retentionClass: string; + readonly normalizedIntentProjection: (input: TInput) => NormalizedCommandIntent; + readonly effects: readonly [EffectDescriptor, ...EffectDescriptor[]]; +} + +export interface DurableCommandDescriptorIdentity { + readonly descriptorId: string; + readonly descriptorVersion: number; + readonly commandKind: TCommandKind; + readonly inputSchemaVersion: number; + readonly fingerprintVersion: typeof HMAC_SHA256_LD_V1; + readonly effectPlanVersion: number; +} + +export interface CommandFingerprintPreimage { + readonly descriptorId: string; + readonly descriptorVersion: number; + readonly schemaVersion: number; + readonly fingerprintVersion: typeof HMAC_SHA256_LD_V1; + readonly effectPlanVersion: number; + /** Binds order and every recovery-relevant effect field, even if a version was not bumped. */ + readonly effectPlan: readonly EffectDescriptor[]; + readonly intent: NormalizedCommandIntent; +} + +/** The persisted fingerprint contains no normalized intent or command body. */ +export interface CommandFingerprintRecord { + readonly descriptorId: string; + readonly descriptorVersion: number; + readonly schemaVersion: number; + readonly fingerprintVersion: typeof HMAC_SHA256_LD_V1; + readonly effectPlanVersion: number; + readonly keyVersion: string; + readonly digest: string; +} + +export interface CommandClaimScope { + readonly deploymentId: string; + readonly stableActorId: string; + readonly commandKind: TCommandKind; + readonly idempotencyKey: string; +} + +export interface CommandClaimRecord { + readonly scope: CommandClaimScope; + readonly fingerprint: CommandFingerprintRecord; +} + +export interface IdempotencyMismatch { + readonly code: 'idempotency_mismatch'; + readonly existingFingerprint: CommandFingerprintRecord; + readonly requestedFingerprint: CommandFingerprintRecord; +} + +export type CommandClaimResolution = + | { + readonly outcome: 'claimed'; + readonly claimAction: 'create'; + readonly effectAction: 'none'; + readonly record: CommandClaimRecord; + } + | { + readonly outcome: 'same_intent'; + readonly claimAction: 'reuse'; + readonly effectAction: 'none'; + readonly record: CommandClaimRecord; + } + | { + readonly outcome: 'idempotency_mismatch'; + readonly claimAction: 'reject'; + readonly effectAction: 'none'; + readonly record: CommandClaimRecord; + readonly mismatch: IdempotencyMismatch; + }; + +export interface DurableEffectPlanItem extends EffectDescriptor { + readonly ordinal: number; + readonly state: DurableEffectState; +} + +export type DurableEffectEvidenceOutcome = 'observed_succeeded' | 'observed_absent'; + +/** + * An owning adapter constructs this only after validating evidence under the descriptor's schema. + * The pure state machine verifies that the evidence is bound to the exact effect contract. + */ +export interface ValidatedDurableEffectEvidence extends EffectDescriptor { + readonly outcome: DurableEffectEvidenceOutcome; +} + +export type AmbiguousEffectDisposition = + | { + readonly commandState: 'recovering'; + readonly automaticAction: 'require_declared_evidence'; + } + | { + readonly commandState: 'operator_required'; + readonly automaticAction: 'none'; + }; diff --git a/src/features/application-command-ledger/contracts/index.ts b/src/features/application-command-ledger/contracts/index.ts index 1eb448e222..78bf5d6112 100644 --- a/src/features/application-command-ledger/contracts/index.ts +++ b/src/features/application-command-ledger/contracts/index.ts @@ -27,6 +27,11 @@ export enum ApplicationCommandConflictReason { IdempotencyKeyReused = 'idempotency_key_reused', OperationMismatch = 'operation_mismatch', PayloadHashMismatch = 'payload_hash_mismatch', + MutationFenceExpired = 'mutation_fence_expired', + MutationFenceStale = 'mutation_fence_stale', + MutationOperationRebound = 'mutation_operation_rebound', + MutationTokenRebound = 'mutation_token_rebound', + MutationSuccessorBlocked = 'mutation_successor_blocked', } export enum ApplicationCommandRunOutcome { @@ -81,6 +86,23 @@ export interface ApplicationCommandLedgerBeginRequest< nowIso: string; /** A matching started attempt older than this is fenced as unknown before reconciliation. */ startedStaleAfterMs: number; + /** + * Optional durable authority for an external mutation. The storage adapter + * claims this fence in the same transaction that starts the command. + */ + mutationFence?: ApplicationCommandMutationFenceClaim; +} + +export interface ApplicationCommandMutationFenceClaim { + laneId: string; + backend: string; + effectKind: string; + operationId: string; + leaseToken: string; + leaseOwnerId: string; + leaseFence: number; + claimedAtIso: string; + expiresAtIso: string; } export type ApplicationCommandLedgerBeginResult = @@ -151,3 +173,5 @@ export interface ApplicationCommandErrorClassification { failureKind: ApplicationCommandFailureKind; message?: string; } + +export * from './durableCommandProtocol'; diff --git a/src/features/application-command-ledger/core/application/ApplicationCommandRunner.ts b/src/features/application-command-ledger/core/application/ApplicationCommandRunner.ts index 2872ce807b..8a7c18f4ec 100644 --- a/src/features/application-command-ledger/core/application/ApplicationCommandRunner.ts +++ b/src/features/application-command-ledger/core/application/ApplicationCommandRunner.ts @@ -6,6 +6,7 @@ import { type ApplicationCommandLedgerBeginResult, ApplicationCommandLedgerErrorCode, type ApplicationCommandLedgerRecord, + type ApplicationCommandMutationFenceClaim, ApplicationCommandRunOutcome, } from '../../contracts'; import { type ApplicationCommandJsonValue, stableJsonStringify } from '../domain/stableJson'; @@ -31,6 +32,10 @@ export type ApplicationCommandReconciliation ): Promise>; @@ -126,6 +133,7 @@ export class ApplicationCommandRunner { metadataJson, nowIso, startedStaleAfterMs, + mutationFence: input.mutationFence, }); } catch (error) { throw this.storeError('begin', 'Application command could not be started', input, error); @@ -165,6 +173,48 @@ export class ApplicationCommandRunner { throw error; } + if (input.classifyResult) { + let classification: ApplicationCommandResultClassification; + try { + classification = input.classifyResult(result); + if (classification.outcome !== 'completed' && classification.outcome !== 'unknown') { + throw new TypeError('Application command result classifier returned an invalid outcome'); + } + } catch (error) { + await this.persistFailure( + activeIdentity, + { + failureKind: ApplicationCommandFailureKind.UnknownAfterTimeout, + message: `Application command result classification failed: ${this.describeError(error)}`, + }, + error, + 'classify_result' + ); + throw error; + } + if (classification.outcome === 'unknown') { + const message = + classification.message ?? 'Application command result has an ambiguous provider outcome'; + await this.persistFailure( + activeIdentity, + { + failureKind: ApplicationCommandFailureKind.UnknownAfterTimeout, + message, + }, + new Error(message), + 'classify_result' + ); + return { + outcome: + begin.outcome === ApplicationCommandBeginOutcome.RetryStarted + ? ApplicationCommandRunOutcome.Retried + : ApplicationCommandRunOutcome.Executed, + result, + record: await this.readCommittedRecord(activeIdentity), + }; + } + } + let resultJson: string; try { resultJson = serializeResult(result); @@ -317,7 +367,7 @@ export class ApplicationCommandRunner { identity: { namespace: string; scopeKey: string; commandId: string; attemptCount: number }, classification: ApplicationCommandErrorClassification, originalError: unknown, - stage: 'execute' | 'serialize_result' | 'reconcile_not_applied' + stage: 'execute' | 'classify_result' | 'serialize_result' | 'reconcile_not_applied' ): Promise { try { await this.ledger.markFailed({ diff --git a/src/features/application-command-ledger/core/application/ports.ts b/src/features/application-command-ledger/core/application/ports.ts index b6698a6a2e..b2c3d6eb8d 100644 --- a/src/features/application-command-ledger/core/application/ports.ts +++ b/src/features/application-command-ledger/core/application/ports.ts @@ -7,8 +7,318 @@ import type { ApplicationCommandLedgerReadByCommandIdRequest, ApplicationCommandLedgerReadByIdempotencyKeyRequest, ApplicationCommandLedgerRecord, + CommandClaimRecord, + CommandClaimResolution, + CommandClaimScope, + CommandFingerprintRecord, + DurableCommandDescriptorIdentity, + DurableCommandState, + DurableEffectPlanItem, + DurableEffectState, + ValidatedDurableEffectEvidence, } from '../../contracts'; +export interface DurableApplicationCommandAttemptReference { + readonly generation: number; + readonly attemptId: string; + readonly ownerId: string; + readonly leaseToken: string; +} + +export interface DurableApplicationCommandAttemptClaim { + readonly attemptId: string; + readonly ownerId: string; + readonly leaseToken: string; + readonly claimedAtIso: string; + readonly leaseExpiresAtIso: string; +} + +export interface DurableApplicationCommandAttemptRecord extends DurableApplicationCommandAttemptReference { + readonly claimedAt: string; + readonly leaseExpiresAt: string; +} + +export interface DurableApplicationCommandEffectEvidenceRecord extends ValidatedDurableEffectEvidence { + readonly sequence: number; + readonly evidenceJson: string; + readonly recordedAt: string; +} + +export interface DurableApplicationCommandEffectRecord extends DurableEffectPlanItem { + readonly updatedAt: string; + readonly evidence: readonly DurableApplicationCommandEffectEvidenceRecord[]; +} + +export interface DurableApplicationCommandRecord { + readonly commandId: string; + readonly claim: CommandClaimRecord; + readonly descriptor: DurableCommandDescriptorIdentity; + readonly attempt: DurableApplicationCommandAttemptRecord; + readonly state: DurableCommandState; + readonly retentionClass: string; + readonly auditSessionId: string | null; + readonly outcomeJson: string | null; + readonly errorCode: string | null; + readonly errorJson: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly committedAt: string | null; + readonly effects: readonly DurableApplicationCommandEffectRecord[]; +} + +export interface DurableApplicationCommandClaimRequest { + readonly commandId: string; + readonly scope: CommandClaimScope; + readonly fingerprint: CommandFingerprintRecord; + readonly attempt: DurableApplicationCommandAttemptClaim; + readonly auditSessionId: string | null; + readonly createdAtIso: string; +} + +export interface DurableApplicationCommandPersistClaimRequest< + TCommandKind extends string = string, +> extends DurableApplicationCommandClaimRequest { + readonly descriptor: DurableCommandDescriptorIdentity; + readonly retentionClass: string; + readonly effectPlan: readonly DurableEffectPlanItem[]; +} + +export interface DurableApplicationCommandClaimResult { + readonly resolution: CommandClaimResolution; + readonly attemptAcquired: boolean; + readonly command: DurableApplicationCommandRecord; +} + +export interface DurableApplicationCommandStatusRequest { + readonly deploymentId: string; + readonly commandId: string; +} + +export interface DurableApplicationCommandClaimStatusRequest { + readonly scope: CommandClaimScope; +} + +export interface DurableApplicationCommandAttemptLeaseRequest extends DurableApplicationCommandStatusRequest { + readonly attempt: DurableApplicationCommandAttemptReference; + readonly renewedAtIso: string; + readonly leaseExpiresAtIso: string; +} + +export interface DurableApplicationCommandTransitionRequest extends DurableApplicationCommandStatusRequest { + readonly attempt: DurableApplicationCommandAttemptReference; + readonly expectedState: DurableCommandState; + readonly nextState: Exclude; + readonly errorCode: string | null; + readonly errorJson: string | null; + readonly transitionedAtIso: string; +} + +export interface DurableApplicationCommandEffectTransitionRequest extends DurableApplicationCommandStatusRequest { + readonly attempt: DurableApplicationCommandAttemptReference; + readonly ordinal: number; + readonly expectedState: DurableEffectState; + readonly nextState: DurableEffectState; + readonly evidence: ValidatedDurableEffectEvidence | null; + readonly evidenceJson: string | null; + readonly transitionedAtIso: string; +} + +export interface DurableApplicationCommandOutboxInput { + readonly eventId: string; + readonly eventType: string; + readonly scopeKind: string; + readonly scopeId: string; + readonly schemaVersion: number; + /** + * Monotonic domain revision of the projection changed by this event. This + * is envelope metadata, not an optional convention inside payloadJson. + * Positive safe integer validated at every storage boundary. + */ + readonly semanticRevision: number; + readonly payloadJson: string; + readonly createdAtIso: string; +} + +export type HostedAuthorityProjectionEventActor = + | { readonly kind: 'operator'; readonly actorRef: string } + | { + readonly kind: 'verified_runtime'; + readonly actorRef: string; + readonly runId: string; + readonly memberId?: string; + } + | { readonly kind: 'recovery'; readonly actorRef: string }; + +export interface HostedAuthorityProjectionIdentity { + readonly deploymentId: string; + readonly projectionKind: string; + readonly projectionKey: string; +} + +export interface HostedAuthorityProjectionRecord extends HostedAuthorityProjectionIdentity { + readonly generation: number; + readonly revision: number; + readonly stateJson: string; + readonly lastCommandId: string; + readonly updatedAt: string; +} + +export interface HostedAuthorityProjectionReadRequest extends HostedAuthorityProjectionIdentity { + /** Absolute epoch deadline checked again inside the SQLite worker. */ + readonly deadlineAtMs: number; +} + +export interface HostedAuthorityProjectionMutation { + readonly projectionKind: string; + readonly projectionKey: string; + readonly expectedGeneration: number; + readonly expectedRevision: number; + readonly nextGeneration: number; + readonly nextRevision: number; + /** Canonical JSON projection bytes committed under the generation/revision CAS. */ + readonly stateJson: string; +} + +export interface HostedAuthorityProjectionCommitRequest { + readonly commandId: string; + readonly scope: CommandClaimScope; + readonly fingerprint: CommandFingerprintRecord; + readonly auditSessionId: string | null; + readonly projection: HostedAuthorityProjectionMutation; + /** Canonical feature receipt. Command bodies and secrets are never stored here. */ + readonly receiptJson: string; + readonly outbox: DurableApplicationCommandOutboxInput; + readonly attribution: { readonly actor: HostedAuthorityProjectionEventActor }; + readonly committedAtIso: string; + /** Absolute epoch deadline checked before the first write and immediately before commit. */ + readonly deadlineAtMs: number; +} + +export interface HostedAuthorityProjectionPersistRequest< + TCommandKind extends string = string, +> extends HostedAuthorityProjectionCommitRequest { + readonly descriptor: DurableCommandDescriptorIdentity; + readonly retentionClass: string; + readonly effectPlan: readonly DurableEffectPlanItem[]; +} + +export interface HostedAuthorityProjectionReceiptRecord extends HostedAuthorityProjectionIdentity { + readonly commandId: string; + readonly generation: number; + readonly revision: number; + readonly eventId: string; + readonly receiptJson: string; + readonly committedAt: string; +} + +export type HostedAuthorityProjectionCommitResult = + | { + readonly outcome: 'committed' | 'idempotent_replay'; + readonly projection: HostedAuthorityProjectionRecord; + readonly receipt: HostedAuthorityProjectionReceiptRecord; + } + | { readonly outcome: 'fingerprint_conflict' } + | { + readonly outcome: 'stale_generation'; + readonly currentGeneration: number; + readonly currentRevision: number; + } + | { + readonly outcome: 'stale_revision'; + readonly currentGeneration: number; + readonly currentRevision: number; + }; + +export interface DurableApplicationCommandCommitRequest extends DurableApplicationCommandStatusRequest { + readonly attempt: DurableApplicationCommandAttemptReference; + readonly expectedState: 'running' | 'recovering'; + readonly outcomeJson: string; + readonly committedAtIso: string; + readonly outbox: DurableApplicationCommandOutboxInput; +} + +export interface DurableApplicationCommandOutboxDeliveryLeaseRecord { + readonly generation: number; + readonly ownerId: string; + readonly leaseToken: string; + readonly claimedAt: string; + readonly leaseExpiresAt: string; +} + +export interface DurableApplicationCommandOutboxRecord extends Omit< + DurableApplicationCommandOutboxInput, + 'createdAtIso' +> { + readonly sequence: number; + readonly commandId: string; + readonly deploymentId: string; + readonly createdAt: string; + readonly deliveryLease: DurableApplicationCommandOutboxDeliveryLeaseRecord | null; + readonly deliveryAcknowledgedAt: string | null; +} + +export interface DurableApplicationCommandOutboxListRequest { + readonly afterSequence: number; + readonly limit: number; +} + +export interface DurableApplicationCommandOutboxClaimRequest { + readonly ownerId: string; + readonly leaseToken: string; + readonly claimedAtIso: string; + readonly leaseExpiresAtIso: string; + readonly limit: number; +} + +export interface DurableApplicationCommandConsumerProjectionRequest { + readonly consumerId: string; + readonly projectionKey: string; +} + +export interface DurableApplicationCommandConsumerApplyRequest extends DurableApplicationCommandConsumerProjectionRequest { + readonly eventId: string; + readonly semanticRevision: number; + /** Durable projection state written in the same transaction as the event fence. */ + readonly stateJson: string; + readonly appliedAtIso: string; +} + +export interface DurableApplicationCommandConsumerApplicationRecord extends DurableApplicationCommandConsumerProjectionRequest { + readonly eventId: string; + readonly semanticRevision: number; + readonly stateJson: string; + readonly appliedAt: string; +} + +export interface DurableApplicationCommandConsumerProjectionRecord extends DurableApplicationCommandConsumerProjectionRequest { + readonly semanticRevision: number; + readonly lastEventId: string; + readonly stateJson: string; + /** Number of distinct semantic events atomically applied to this projection. */ + readonly applicationCount: number; + readonly updatedAt: string; +} + +export interface DurableApplicationCommandConsumerApplyResult { + readonly outcome: 'applied' | 'duplicate'; + readonly application: DurableApplicationCommandConsumerApplicationRecord; + readonly projection: DurableApplicationCommandConsumerProjectionRecord; +} + +/** + * Records only that the current fenced lease completed delivery. A crash after + * external delivery but before this acknowledgement intentionally replays the + * same eventId/sequence/semanticRevision/payload. This is delivery bookkeeping, + * not publication proof or proof that a consumer applied the event. + */ +export interface DurableApplicationCommandOutboxDeliveryAcknowledgementRequest { + readonly eventId: string; + readonly deliveryGeneration: number; + readonly ownerId: string; + readonly leaseToken: string; + readonly acknowledgedAtIso: string; +} + export interface ApplicationCommandLedgerStore { begin( request: ApplicationCommandLedgerBeginRequest @@ -49,6 +359,100 @@ export interface ApplicationCommandLedgerStorageGateway { ): Promise[]>; } +export interface DurableApplicationCommandLedgerStore { + claimDurable( + request: DurableApplicationCommandClaimRequest + ): Promise>; + getDurableStatus( + request: DurableApplicationCommandStatusRequest + ): Promise | null>; + getDurableByClaim( + request: DurableApplicationCommandClaimStatusRequest + ): Promise | null>; + renewDurableAttemptLease( + request: DurableApplicationCommandAttemptLeaseRequest + ): Promise; + transitionDurableCommand( + request: DurableApplicationCommandTransitionRequest + ): Promise; + transitionDurableEffect( + request: DurableApplicationCommandEffectTransitionRequest + ): Promise; + commitDurable( + request: DurableApplicationCommandCommitRequest + ): Promise; + listDurableOutbox( + request: DurableApplicationCommandOutboxListRequest + ): Promise; + claimDurableOutbox( + request: DurableApplicationCommandOutboxClaimRequest + ): Promise; + acknowledgeDurableOutboxDelivery( + request: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest + ): Promise; + applyDurableConsumerEvent( + request: DurableApplicationCommandConsumerApplyRequest + ): Promise; + getDurableConsumerProjection( + request: DurableApplicationCommandConsumerProjectionRequest + ): Promise; + commitHostedAuthorityProjection( + request: HostedAuthorityProjectionCommitRequest + ): Promise; + getHostedAuthorityProjection( + request: HostedAuthorityProjectionReadRequest + ): Promise; +} + +/** + * Additive durable-command persistence port. The application core owns the + * protocol; internal-storage supplies the concrete SQLite implementation. + */ +export interface DurableApplicationCommandLedgerStorageGateway { + applicationCommandLedgerDurableClaim( + request: DurableApplicationCommandPersistClaimRequest + ): Promise>; + applicationCommandLedgerDurableGetStatus( + request: DurableApplicationCommandStatusRequest + ): Promise | null>; + applicationCommandLedgerDurableGetByClaim( + request: DurableApplicationCommandClaimStatusRequest + ): Promise | null>; + applicationCommandLedgerDurableRenewAttemptLease( + request: DurableApplicationCommandAttemptLeaseRequest + ): Promise; + applicationCommandLedgerDurableTransitionCommand( + request: DurableApplicationCommandTransitionRequest + ): Promise; + applicationCommandLedgerDurableTransitionEffect( + request: DurableApplicationCommandEffectTransitionRequest + ): Promise; + applicationCommandLedgerDurableCommit( + request: DurableApplicationCommandCommitRequest + ): Promise; + applicationCommandLedgerDurableListOutbox( + request: DurableApplicationCommandOutboxListRequest + ): Promise; + applicationCommandLedgerDurableClaimOutbox( + request: DurableApplicationCommandOutboxClaimRequest + ): Promise; + applicationCommandLedgerDurableAcknowledgeOutboxDelivery( + request: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest + ): Promise; + applicationCommandLedgerDurableApplyConsumerEvent( + request: DurableApplicationCommandConsumerApplyRequest + ): Promise; + applicationCommandLedgerDurableGetConsumerProjection( + request: DurableApplicationCommandConsumerProjectionRequest + ): Promise; + applicationCommandLedgerHostedAuthorityProjectionCommit( + request: HostedAuthorityProjectionPersistRequest + ): Promise; + applicationCommandLedgerHostedAuthorityProjectionGet( + request: HostedAuthorityProjectionReadRequest + ): Promise; +} + export interface ApplicationCommandHasher { hashJson(value: unknown): string; hashString(value: string): string; diff --git a/src/features/application-command-ledger/core/domain/commandDescriptorRegistry.ts b/src/features/application-command-ledger/core/domain/commandDescriptorRegistry.ts new file mode 100644 index 0000000000..613e81159b --- /dev/null +++ b/src/features/application-command-ledger/core/domain/commandDescriptorRegistry.ts @@ -0,0 +1,370 @@ +import { + COMMAND_IDEMPOTENCY_SCOPE, + type CommandDescriptor, + type CommandFingerprintRecord, + EFFECT_RECOVERY_CLASSES, + type EffectDescriptor, + HMAC_SHA256_LD_V1, +} from '../../contracts'; + +export type CommandDescriptorRegistryErrorCode = + | 'invalid_descriptor_registry' + | 'unknown_command_descriptor'; + +export class CommandDescriptorRegistryError extends Error { + constructor( + readonly code: CommandDescriptorRegistryErrorCode, + message: string, + readonly details: Readonly> = {} + ) { + super(message); + this.name = 'CommandDescriptorRegistryError'; + } +} + +export interface CommandDescriptorLookup { + readonly commandKind: TCommandKind; + readonly descriptorId: string; + readonly descriptorVersion: number; + readonly inputSchemaVersion: number; + readonly fingerprintVersion: typeof HMAC_SHA256_LD_V1; + readonly effectPlanVersion: number; +} + +const DESCRIPTOR_KEYS = [ + 'descriptorId', + 'descriptorVersion', + 'commandKind', + 'inputSchemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + 'idempotencyScope', + 'retentionClass', + 'normalizedIntentProjection', + 'effects', +] as const; + +const EFFECT_KEYS = [ + 'effectId', + 'effectVersion', + 'recoveryClass', + 'evidenceSchemaVersion', +] as const; + +export class CommandDescriptorRegistry { + readonly #descriptors: readonly CommandDescriptor[]; + readonly #byVersion: ReadonlyMap; + + constructor(descriptors: readonly CommandDescriptor[]) { + assertNonEmptyDescriptorArray(descriptors); + assertDenseArray(descriptors, 'descriptor registry'); + + const frozenDescriptors: CommandDescriptor[] = []; + const byVersion = new Map(); + for (const candidate of descriptors) { + const descriptor = validateAndFreezeDescriptor(candidate); + const key = descriptorKey(descriptor); + if (byVersion.has(key)) { + throw invalidRegistry('Command descriptor versions must be unique', { + descriptorId: descriptor.descriptorId, + descriptorVersion: descriptor.descriptorVersion, + commandKind: descriptor.commandKind, + inputSchemaVersion: descriptor.inputSchemaVersion, + fingerprintVersion: descriptor.fingerprintVersion, + effectPlanVersion: descriptor.effectPlanVersion, + }); + } + byVersion.set(key, descriptor); + frozenDescriptors.push(descriptor); + } + + this.#descriptors = Object.freeze(frozenDescriptors); + this.#byVersion = byVersion; + Object.freeze(this); + } + + list(): readonly CommandDescriptor[] { + return this.#descriptors; + } + + resolve( + lookup: CommandDescriptorLookup + ): CommandDescriptor { + validateLookup(lookup); + const descriptor = this.#byVersion.get(descriptorKey(lookup)); + if (!descriptor) { + throw new CommandDescriptorRegistryError( + 'unknown_command_descriptor', + 'No exact command descriptor version is registered', + { + commandKind: lookup.commandKind, + descriptorId: lookup.descriptorId, + descriptorVersion: lookup.descriptorVersion, + inputSchemaVersion: lookup.inputSchemaVersion, + fingerprintVersion: lookup.fingerprintVersion, + effectPlanVersion: lookup.effectPlanVersion, + } + ); + } + return descriptor as CommandDescriptor; + } + + resolveFingerprintRecord( + commandKind: TCommandKind, + record: CommandFingerprintRecord + ): CommandDescriptor { + validateFingerprintRecordLookup(record); + return this.resolve({ + commandKind, + descriptorId: record.descriptorId, + descriptorVersion: record.descriptorVersion, + inputSchemaVersion: record.schemaVersion, + fingerprintVersion: record.fingerprintVersion, + effectPlanVersion: record.effectPlanVersion, + }); + } +} + +function assertNonEmptyDescriptorArray(descriptors: readonly CommandDescriptor[]): void { + if (!Array.isArray(descriptors) || descriptors.length === 0) { + throw invalidRegistry('Command descriptor registry must be a non-empty array'); + } +} + +function validateFingerprintRecordLookup(record: CommandFingerprintRecord): void { + assertExactDataObject( + record, + [ + 'descriptorId', + 'descriptorVersion', + 'schemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + 'keyVersion', + 'digest', + ], + 'command fingerprint record' + ); + assertIdentifier('descriptorId', record.descriptorId); + assertPositiveVersion('descriptorVersion', record.descriptorVersion); + assertPositiveVersion('schemaVersion', record.schemaVersion); + if (record.fingerprintVersion !== HMAC_SHA256_LD_V1) { + throw invalidRegistry('Command fingerprint record has an unsupported fingerprint version', { + fingerprintVersion: record.fingerprintVersion, + }); + } + assertPositiveVersion('effectPlanVersion', record.effectPlanVersion); + assertIdentifier('keyVersion', record.keyVersion); + if (!/^[a-f0-9]{64}$/.test(record.digest)) { + throw invalidRegistry('Command fingerprint record has an invalid digest'); + } +} + +export function createCommandDescriptorRegistry( + descriptors: readonly CommandDescriptor[] +): CommandDescriptorRegistry { + return new CommandDescriptorRegistry(descriptors); +} + +function validateAndFreezeDescriptor(candidate: CommandDescriptor): CommandDescriptor { + assertExactDataObject(candidate, DESCRIPTOR_KEYS, 'command descriptor'); + assertIdentifier('descriptorId', candidate.descriptorId); + assertPositiveVersion('descriptorVersion', candidate.descriptorVersion); + assertIdentifier('commandKind', candidate.commandKind); + assertPositiveVersion('inputSchemaVersion', candidate.inputSchemaVersion); + if (candidate.fingerprintVersion !== HMAC_SHA256_LD_V1) { + throw invalidRegistry('Command descriptor has an unsupported fingerprint version', { + descriptorId: candidate.descriptorId, + fingerprintVersion: candidate.fingerprintVersion, + }); + } + if (candidate.idempotencyScope !== COMMAND_IDEMPOTENCY_SCOPE) { + throw invalidRegistry('Command descriptor has an unsupported idempotency scope', { + descriptorId: candidate.descriptorId, + idempotencyScope: candidate.idempotencyScope, + }); + } + assertPositiveVersion('effectPlanVersion', candidate.effectPlanVersion); + assertIdentifier('retentionClass', candidate.retentionClass); + if (typeof candidate.normalizedIntentProjection !== 'function') { + throw invalidRegistry('Command descriptor must define a normalized intent projection', { + descriptorId: candidate.descriptorId, + }); + } + if (!Array.isArray(candidate.effects) || candidate.effects.length === 0) { + throw invalidRegistry('Command descriptor must define an ordered non-empty effect plan', { + descriptorId: candidate.descriptorId, + }); + } + assertDenseArray(candidate.effects, `effects for ${candidate.descriptorId}`); + + const effectIds = new Set(); + const effects = candidate.effects.map((effect) => { + const frozen = validateAndFreezeEffect(effect, candidate.descriptorId); + if (effectIds.has(frozen.effectId)) { + throw invalidRegistry('Effect identifiers must be unique within a command descriptor', { + descriptorId: candidate.descriptorId, + effectId: frozen.effectId, + }); + } + effectIds.add(frozen.effectId); + return frozen; + }) as [EffectDescriptor, ...EffectDescriptor[]]; + + return Object.freeze({ + descriptorId: candidate.descriptorId, + descriptorVersion: candidate.descriptorVersion, + commandKind: candidate.commandKind, + inputSchemaVersion: candidate.inputSchemaVersion, + fingerprintVersion: candidate.fingerprintVersion, + effectPlanVersion: candidate.effectPlanVersion, + idempotencyScope: candidate.idempotencyScope, + retentionClass: candidate.retentionClass, + normalizedIntentProjection: candidate.normalizedIntentProjection, + effects: Object.freeze(effects), + }); +} + +function validateAndFreezeEffect( + candidate: EffectDescriptor, + descriptorId: string +): EffectDescriptor { + assertExactDataObject(candidate, EFFECT_KEYS, `effect descriptor for ${descriptorId}`); + assertIdentifier('effectId', candidate.effectId); + assertPositiveVersion('effectVersion', candidate.effectVersion); + assertPositiveVersion('evidenceSchemaVersion', candidate.evidenceSchemaVersion); + if (!EFFECT_RECOVERY_CLASSES.includes(candidate.recoveryClass)) { + throw invalidRegistry('Effect descriptor has an unsupported recovery class', { + descriptorId, + effectId: candidate.effectId, + recoveryClass: candidate.recoveryClass, + }); + } + return Object.freeze({ ...candidate }); +} + +function validateLookup(lookup: CommandDescriptorLookup): void { + assertExactDataObject( + lookup, + [ + 'commandKind', + 'descriptorId', + 'descriptorVersion', + 'inputSchemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + ], + 'command descriptor lookup' + ); + assertIdentifier('commandKind', lookup.commandKind); + assertIdentifier('descriptorId', lookup.descriptorId); + assertPositiveVersion('descriptorVersion', lookup.descriptorVersion); + assertPositiveVersion('inputSchemaVersion', lookup.inputSchemaVersion); + if (lookup.fingerprintVersion !== HMAC_SHA256_LD_V1) { + throw invalidRegistry('Command descriptor lookup has an unsupported fingerprint version', { + fingerprintVersion: lookup.fingerprintVersion, + }); + } + assertPositiveVersion('effectPlanVersion', lookup.effectPlanVersion); +} + +function descriptorKey(input: { + commandKind: string; + descriptorId: string; + descriptorVersion: number; + inputSchemaVersion: number; + fingerprintVersion: string; + effectPlanVersion: number; +}): string { + return [ + input.commandKind, + input.descriptorId, + input.descriptorVersion, + input.inputSchemaVersion, + input.fingerprintVersion, + input.effectPlanVersion, + ].join('\0'); +} + +function assertIdentifier(field: string, value: unknown): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0 || value.includes('\0')) { + throw invalidRegistry(`Command descriptor ${field} must be a non-empty string`, { field }); + } +} + +function assertPositiveVersion(field: string, value: unknown): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw invalidRegistry(`Command descriptor ${field} must be a positive safe integer`, { field }); + } +} + +function assertDenseArray(value: readonly unknown[], label: string): void { + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw invalidRegistry(`${label} must use the standard array prototype`); + } + const ownNames = Object.getOwnPropertyNames(value); + const expected = new Set([ + 'length', + ...Array.from({ length: value.length }, (_, index) => String(index)), + ]); + for (let index = 0; index < value.length; index += 1) { + if (!(index in value)) { + throw invalidRegistry(`${label} must be dense and contain no extra properties`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw invalidRegistry(`${label} must contain only enumerable data items`); + } + } + if ( + ownNames.some((name) => !expected.has(name)) || + Object.getOwnPropertySymbols(value).length > 0 + ) { + throw invalidRegistry(`${label} must be dense and contain no extra properties`); + } +} + +function assertExactDataObject( + value: unknown, + expectedKeys: readonly string[], + label: string +): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidRegistry(`${label} must be a plain data object`); + } + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw invalidRegistry(`${label} must be a plain data object`); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw invalidRegistry(`${label} must not contain symbol keys`); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const actualKeys = Object.keys(descriptors).sort(compareCodeUnit); + const sortedExpected = [...expectedKeys].sort(compareCodeUnit); + if ( + actualKeys.length !== sortedExpected.length || + actualKeys.some((key, index) => key !== sortedExpected[index]) + ) { + throw invalidRegistry(`${label} has missing or unknown fields`, { + expectedKeys: sortedExpected, + actualKeys, + }); + } + for (const descriptor of Object.values(descriptors)) { + if (!descriptor.enumerable || !('value' in descriptor)) { + throw invalidRegistry(`${label} must contain only enumerable data properties`); + } + } +} + +function compareCodeUnit(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function invalidRegistry( + message: string, + details: Readonly> = {} +): CommandDescriptorRegistryError { + return new CommandDescriptorRegistryError('invalid_descriptor_registry', message, details); +} diff --git a/src/features/application-command-ledger/core/domain/commandFingerprint.ts b/src/features/application-command-ledger/core/domain/commandFingerprint.ts new file mode 100644 index 0000000000..44d4ef97cc --- /dev/null +++ b/src/features/application-command-ledger/core/domain/commandFingerprint.ts @@ -0,0 +1,580 @@ +import { + type CommandClaimRecord, + type CommandClaimResolution, + type CommandClaimScope, + type CommandDescriptor, + type CommandFingerprintPreimage, + type CommandFingerprintRecord, + EFFECT_RECOVERY_CLASSES, + type EffectDescriptor, + HMAC_SHA256_LD_V1, + type NormalizedCommandIntent, + type NormalizedIntentValue, +} from '../../contracts'; + +export type CommandFingerprintContractErrorCode = + | 'invalid_claim_scope' + | 'invalid_fingerprint_input' + | 'invalid_fingerprint_record' + | 'intent_projection_failed' + | 'unsupported_fingerprint_version'; + +export class CommandFingerprintContractError extends Error { + constructor( + readonly code: CommandFingerprintContractErrorCode, + message: string, + readonly details: Readonly> = {}, + readonly cause?: unknown + ) { + super(message); + this.name = 'CommandFingerprintContractError'; + } +} + +export interface PreparedCommandFingerprint { + readonly preimage: CommandFingerprintPreimage; + readonly encodedPreimage: string; +} + +export function prepareCommandFingerprint( + descriptor: CommandDescriptor, + input: TInput +): PreparedCommandFingerprint { + assertFingerprintVersion(descriptor.fingerprintVersion); + + let intent: NormalizedCommandIntent; + try { + intent = descriptor.normalizedIntentProjection(input); + } catch (error) { + throw new CommandFingerprintContractError( + 'intent_projection_failed', + 'Command normalized intent projection failed', + { descriptorId: descriptor.descriptorId }, + error + ); + } + + const preimage = buildCommandFingerprintPreimage(descriptor, intent); + return Object.freeze({ + preimage, + encodedPreimage: encodeCommandFingerprintPreimage(preimage), + }); +} + +export function buildCommandFingerprintPreimage( + descriptor: Pick< + CommandDescriptor, + | 'descriptorId' + | 'descriptorVersion' + | 'inputSchemaVersion' + | 'fingerprintVersion' + | 'effectPlanVersion' + | 'effects' + >, + intent: NormalizedCommandIntent +): CommandFingerprintPreimage { + assertNonEmptyString('descriptorId', descriptor.descriptorId, 'invalid_fingerprint_input'); + assertPositiveVersion('descriptorVersion', descriptor.descriptorVersion); + assertPositiveVersion('inputSchemaVersion', descriptor.inputSchemaVersion); + assertFingerprintVersion(descriptor.fingerprintVersion); + assertPositiveVersion('effectPlanVersion', descriptor.effectPlanVersion); + const effectPlan = freezeEffectPlan(descriptor.effects); + assertPlainIntentObject(intent); + const frozenIntent = freezeNormalizedValue( + intent, + new WeakSet() + ) as NormalizedCommandIntent; + encodeLengthDelimitedValue(frozenIntent); + + return Object.freeze({ + descriptorId: descriptor.descriptorId, + descriptorVersion: descriptor.descriptorVersion, + schemaVersion: descriptor.inputSchemaVersion, + fingerprintVersion: descriptor.fingerprintVersion, + effectPlanVersion: descriptor.effectPlanVersion, + effectPlan, + intent: frozenIntent, + }); +} + +export function encodeCommandFingerprintPreimage(preimage: CommandFingerprintPreimage): string { + assertExactDataObject( + preimage, + [ + 'descriptorId', + 'descriptorVersion', + 'schemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + 'effectPlan', + 'intent', + ], + 'fingerprint preimage', + 'invalid_fingerprint_input' + ); + assertNonEmptyString('descriptorId', preimage.descriptorId, 'invalid_fingerprint_input'); + assertPositiveVersion('descriptorVersion', preimage.descriptorVersion); + assertPositiveVersion('schemaVersion', preimage.schemaVersion); + assertFingerprintVersion(preimage.fingerprintVersion); + assertPositiveVersion('effectPlanVersion', preimage.effectPlanVersion); + freezeEffectPlan(preimage.effectPlan); + assertPlainIntentObject(preimage.intent); + return encodeLengthDelimitedValue(preimage); +} + +/** + * Pure UTF-8 length-delimited encoding used by hmac-sha256-ld-v1. + * Object keys are sorted by code unit; array order and string code points are retained. + */ +export function encodeLengthDelimitedValue(value: unknown): string { + return encodeValue(value, new WeakSet()); +} + +export function buildCommandFingerprintRecord( + preimage: CommandFingerprintPreimage, + keyVersion: string, + digest: string +): CommandFingerprintRecord { + encodeCommandFingerprintPreimage(preimage); + assertNonEmptyString('keyVersion', keyVersion, 'invalid_fingerprint_record'); + if (!/^[a-f0-9]{64}$/.test(digest)) { + throw new CommandFingerprintContractError( + 'invalid_fingerprint_record', + 'Command fingerprint digest must be 64 lowercase hexadecimal characters', + { field: 'digest' } + ); + } + return Object.freeze({ + descriptorId: preimage.descriptorId, + descriptorVersion: preimage.descriptorVersion, + schemaVersion: preimage.schemaVersion, + fingerprintVersion: preimage.fingerprintVersion, + effectPlanVersion: preimage.effectPlanVersion, + keyVersion, + digest, + }); +} + +export function createCommandClaimScope( + input: CommandClaimScope +): CommandClaimScope { + assertExactDataObject( + input, + ['deploymentId', 'stableActorId', 'commandKind', 'idempotencyKey'], + 'command claim scope', + 'invalid_claim_scope' + ); + for (const [field, value] of Object.entries(input)) { + assertNonEmptyString(field, value, 'invalid_claim_scope'); + } + return Object.freeze({ ...input }); +} + +/** + * A new claim uses the active key. A retry uses the key version retained by its existing claim so + * key rotation alone cannot manufacture an idempotency mismatch. Key lookup and HMAC stay outside + * this pure contract. + */ +export function selectCommandFingerprintKeyVersion( + existing: CommandClaimRecord | null, + activeKeyVersion: string +): string { + assertNonEmptyString('activeKeyVersion', activeKeyVersion, 'invalid_fingerprint_record'); + return existing ? validateClaimRecord(existing).fingerprint.keyVersion : activeKeyVersion; +} + +export function resolveCommandClaim( + existing: CommandClaimRecord | null, + incoming: CommandClaimRecord +): CommandClaimResolution { + const incomingRecord = validateClaimRecord(incoming); + if (!existing) { + return Object.freeze({ + outcome: 'claimed', + claimAction: 'create', + effectAction: 'none', + record: incomingRecord, + }); + } + + const existingRecord = validateClaimRecord(existing); + if (!sameClaimScope(existingRecord.scope, incomingRecord.scope)) { + throw new CommandFingerprintContractError( + 'invalid_claim_scope', + 'Claim resolution requires the exact deployment, actor, command kind, and key scope' + ); + } + + if (sameVersionedDigest(existingRecord.fingerprint, incomingRecord.fingerprint)) { + return Object.freeze({ + outcome: 'same_intent', + claimAction: 'reuse', + effectAction: 'none', + record: existingRecord, + }); + } + + return Object.freeze({ + outcome: 'idempotency_mismatch', + claimAction: 'reject', + effectAction: 'none', + record: existingRecord, + mismatch: Object.freeze({ + code: 'idempotency_mismatch', + existingFingerprint: existingRecord.fingerprint, + requestedFingerprint: incomingRecord.fingerprint, + }), + }); +} + +function encodeValue(value: unknown, ancestors: WeakSet): string { + if (value === null) return 'n:0:'; + if (typeof value === 'boolean') return value ? 'b:1:1' : 'b:1:0'; + if (typeof value === 'string') { + assertWellFormedUnicode(value); + return `s:${utf8ByteLength(value)}:${value}`; + } + if (typeof value === 'number') { + const encoded = encodeCanonicalNumber(value); + const tag = Number.isInteger(value) && !Object.is(value, -0) ? 'i' : 'd'; + return `${tag}:${utf8ByteLength(encoded)}:${encoded}`; + } + if (Array.isArray(value)) { + assertStrictArray(value); + return withAncestor(value, ancestors, () => { + const items = value.map((item) => frame(encodeValue(item, ancestors))).join(''); + return `a:${value.length}:${items}`; + }); + } + if (typeof value === 'object') { + assertPlainDataObject(value); + return withAncestor(value, ancestors, () => { + const entries = Object.entries(value).sort(([left], [right]) => compareCodeUnit(left, right)); + const items = entries + .map( + ([key, item]) => + `${frame(encodeValue(key, ancestors))}${frame(encodeValue(item, ancestors))}` + ) + .join(''); + return `o:${entries.length}:${items}`; + }); + } + throw invalidFingerprintInput(`Unsupported fingerprint value: ${typeof value}`); +} + +function encodeCanonicalNumber(value: number): string { + assertSupportedNumber(value); + // Number#toString is the locale-independent ECMAScript shortest round-trip representation. + return Object.is(value, -0) ? '-0' : String(value); +} + +function assertWellFormedUnicode(value: string): void { + let index = 0; + while (index < value.length) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw invalidFingerprintInput('Fingerprint strings must contain well-formed Unicode'); + } + index += 2; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw invalidFingerprintInput('Fingerprint strings must contain well-formed Unicode'); + } else { + index += 1; + } + } +} + +function assertSupportedNumber(value: number): void { + if (!Number.isFinite(value)) { + throw invalidFingerprintInput('Fingerprint numbers must be finite'); + } + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw invalidFingerprintInput('Fingerprint integers must be safe integers'); + } +} + +function freezeEffectPlan(effects: readonly EffectDescriptor[]): readonly EffectDescriptor[] { + if (!Array.isArray(effects) || effects.length === 0) { + throw invalidFingerprintInput('Fingerprint effect plan must be an ordered non-empty array'); + } + assertStrictArray(effects); + const seen = new Set(); + const frozen = effects.map((effect) => { + assertExactDataObject( + effect, + ['effectId', 'effectVersion', 'recoveryClass', 'evidenceSchemaVersion'], + 'fingerprint effect descriptor', + 'invalid_fingerprint_input' + ); + assertNonEmptyString('effectId', effect.effectId, 'invalid_fingerprint_input'); + assertPositiveVersion('effectVersion', effect.effectVersion); + assertPositiveVersion('evidenceSchemaVersion', effect.evidenceSchemaVersion); + const recoveryClass = effect.recoveryClass as EffectDescriptor['recoveryClass']; + if (!EFFECT_RECOVERY_CLASSES.includes(recoveryClass)) { + throw invalidFingerprintInput('Fingerprint effect descriptor has an invalid recovery class'); + } + if (seen.has(effect.effectId)) { + throw invalidFingerprintInput('Fingerprint effect identifiers must be unique'); + } + seen.add(effect.effectId); + return Object.freeze({ + effectId: effect.effectId, + effectVersion: effect.effectVersion, + recoveryClass, + evidenceSchemaVersion: effect.evidenceSchemaVersion, + }); + }); + return Object.freeze(frozen); +} + +// eslint-disable-next-line sonarjs/function-return-type -- Recursive values intentionally return different union members. +function freezeNormalizedValue(value: unknown, ancestors: WeakSet): NormalizedIntentValue { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return value; + } + if (typeof value === 'number') { + assertSupportedNumber(value); + return value; + } + if (Array.isArray(value)) { + assertStrictArray(value); + return withAncestor(value, ancestors, () => + Object.freeze(value.map((item) => freezeNormalizedValue(item, ancestors))) + ); + } + if (typeof value !== 'object') { + throw invalidFingerprintInput(`Unsupported fingerprint value: ${typeof value}`); + } + + assertPlainDataObject(value); + return withAncestor(value, ancestors, () => { + const copy = Object.create(null) as Record; + for (const key of Object.keys(value).sort(compareCodeUnit)) { + copy[key] = freezeNormalizedValue(value[key], ancestors); + } + return Object.freeze(copy); + }); +} + +function withAncestor(value: object, ancestors: WeakSet, encode: () => T): T { + if (ancestors.has(value)) { + throw invalidFingerprintInput('Fingerprint values must not be cyclic'); + } + ancestors.add(value); + try { + return encode(); + } finally { + ancestors.delete(value); + } +} + +function assertPlainIntentObject(value: unknown): asserts value is NormalizedCommandIntent { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidFingerprintInput('Normalized command intent must be a plain data object'); + } + assertPlainDataObject(value); +} + +function assertPlainDataObject(value: object): asserts value is Record { + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw invalidFingerprintInput('Fingerprint values must contain only plain data objects'); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw invalidFingerprintInput('Fingerprint values must not contain symbol keys'); + } + for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) { + if (!descriptor.enumerable || !('value' in descriptor)) { + throw invalidFingerprintInput( + 'Fingerprint values must contain only enumerable data properties' + ); + } + } +} + +function assertStrictArray(value: readonly unknown[]): void { + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw invalidFingerprintInput('Fingerprint arrays must use the standard array prototype'); + } + const expectedNames = new Set([ + 'length', + ...Array.from({ length: value.length }, (_, index) => String(index)), + ]); + if ( + Object.getOwnPropertyNames(value).some((name) => !expectedNames.has(name)) || + Object.getOwnPropertySymbols(value).length > 0 + ) { + throw invalidFingerprintInput('Fingerprint arrays must not contain holes or extra properties'); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor) { + throw invalidFingerprintInput( + 'Fingerprint arrays must not contain holes or extra properties' + ); + } + if (!descriptor.enumerable || !('value' in descriptor)) { + throw invalidFingerprintInput('Fingerprint arrays must contain only enumerable data items'); + } + } +} + +function validateClaimRecord( + value: CommandClaimRecord +): CommandClaimRecord { + assertExactDataObject( + value, + ['scope', 'fingerprint'], + 'command claim record', + 'invalid_fingerprint_record' + ); + const scope = createCommandClaimScope(value.scope); + validateFingerprintRecord(value.fingerprint); + return Object.freeze({ scope, fingerprint: Object.freeze({ ...value.fingerprint }) }); +} + +function validateFingerprintRecord(value: CommandFingerprintRecord): void { + assertExactDataObject( + value, + [ + 'descriptorId', + 'descriptorVersion', + 'schemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + 'keyVersion', + 'digest', + ], + 'fingerprint record', + 'invalid_fingerprint_record' + ); + assertNonEmptyString('descriptorId', value.descriptorId, 'invalid_fingerprint_record'); + assertPositiveVersion('descriptorVersion', value.descriptorVersion, 'invalid_fingerprint_record'); + assertPositiveVersion('schemaVersion', value.schemaVersion, 'invalid_fingerprint_record'); + assertFingerprintVersion(value.fingerprintVersion); + assertPositiveVersion('effectPlanVersion', value.effectPlanVersion, 'invalid_fingerprint_record'); + assertNonEmptyString('keyVersion', value.keyVersion, 'invalid_fingerprint_record'); + if (!/^[a-f0-9]{64}$/.test(value.digest)) { + throw new CommandFingerprintContractError( + 'invalid_fingerprint_record', + 'Command fingerprint digest must be 64 lowercase hexadecimal characters', + { field: 'digest' } + ); + } +} + +function assertExactDataObject( + value: unknown, + expectedKeys: readonly string[], + label: string, + code: CommandFingerprintContractErrorCode +): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new CommandFingerprintContractError(code, `${label} must be a plain data object`); + } + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw new CommandFingerprintContractError(code, `${label} must be a plain data object`); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new CommandFingerprintContractError(code, `${label} must not contain symbol keys`); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const actualKeys = Object.keys(descriptors).sort(compareCodeUnit); + const sortedExpected = [...expectedKeys].sort(compareCodeUnit); + if ( + actualKeys.length !== sortedExpected.length || + actualKeys.some((key, index) => key !== sortedExpected[index]) + ) { + throw new CommandFingerprintContractError(code, `${label} has missing or unknown fields`); + } + for (const descriptor of Object.values(descriptors)) { + if (!descriptor.enumerable || !('value' in descriptor)) { + throw new CommandFingerprintContractError( + code, + `${label} must contain only enumerable data properties` + ); + } + } +} + +function assertFingerprintVersion(value: unknown): asserts value is typeof HMAC_SHA256_LD_V1 { + if (value !== HMAC_SHA256_LD_V1) { + throw new CommandFingerprintContractError( + 'unsupported_fingerprint_version', + 'Unsupported command fingerprint version', + { fingerprintVersion: value } + ); + } +} + +function assertPositiveVersion( + field: string, + value: unknown, + code: CommandFingerprintContractErrorCode = 'invalid_fingerprint_input' +): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new CommandFingerprintContractError(code, `${field} must be a positive safe integer`, { + field, + }); + } +} + +function assertNonEmptyString( + field: string, + value: unknown, + code: CommandFingerprintContractErrorCode +): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0 || value.includes('\0')) { + throw new CommandFingerprintContractError(code, `${field} must be a non-empty string`, { + field, + }); + } +} + +function sameClaimScope(left: CommandClaimScope, right: CommandClaimScope): boolean { + return ( + left.deploymentId === right.deploymentId && + left.stableActorId === right.stableActorId && + left.commandKind === right.commandKind && + left.idempotencyKey === right.idempotencyKey + ); +} + +function sameVersionedDigest( + left: CommandFingerprintRecord, + right: CommandFingerprintRecord +): boolean { + return ( + left.descriptorId === right.descriptorId && + left.descriptorVersion === right.descriptorVersion && + left.schemaVersion === right.schemaVersion && + left.fingerprintVersion === right.fingerprintVersion && + left.effectPlanVersion === right.effectPlanVersion && + left.keyVersion === right.keyVersion && + left.digest === right.digest + ); +} + +function frame(value: string): string { + return `${utf8ByteLength(value)}:${value}`; +} + +function utf8ByteLength(value: string): number { + let bytes = 0; + for (const character of value) { + const codePoint = character.codePointAt(0)!; + bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + } + return bytes; +} + +function compareCodeUnit(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function invalidFingerprintInput(message: string): CommandFingerprintContractError { + return new CommandFingerprintContractError('invalid_fingerprint_input', message); +} diff --git a/src/features/application-command-ledger/core/domain/durableCommandState.ts b/src/features/application-command-ledger/core/domain/durableCommandState.ts new file mode 100644 index 0000000000..1eed6de0f6 --- /dev/null +++ b/src/features/application-command-ledger/core/domain/durableCommandState.ts @@ -0,0 +1,581 @@ +import { + type AmbiguousEffectDisposition, + type CommandDescriptor, + DURABLE_COMMAND_STATES, + DURABLE_EFFECT_STATES, + type DurableCommandDescriptorIdentity, + type DurableCommandState, + type DurableEffectPlanItem, + type DurableEffectState, + EFFECT_RECOVERY_CLASSES, + type EffectDescriptor, + type EffectRecoveryClass, + HMAC_SHA256_LD_V1, + type ValidatedDurableEffectEvidence, +} from '../../contracts'; + +export type DurableCommandStateTransitionErrorCode = + | 'invalid_command_transition' + | 'invalid_effect_plan' + | 'invalid_effect_transition' + | 'invalid_recovery_class'; + +export class DurableCommandStateTransitionError extends Error { + constructor( + readonly code: DurableCommandStateTransitionErrorCode, + message: string, + readonly details: Readonly> = {} + ) { + super(message); + this.name = 'DurableCommandStateTransitionError'; + } +} + +const COMMAND_TRANSITIONS: Readonly> = { + prepared: ['running', 'failed'], + running: ['recovering'], + committed: [], + recovering: ['failed', 'operator_required'], + failed: [], + operator_required: [], +}; + +const EFFECT_TRANSITIONS: Readonly> = { + not_started: ['attempting'], + attempting: ['observed_succeeded', 'observed_absent', 'ambiguous'], + observed_succeeded: ['compensating'], + observed_absent: [], + ambiguous: [], + compensating: ['compensated', 'ambiguous'], + compensated: [], +}; + +const EFFECT_DESCRIPTOR_KEYS = [ + 'effectId', + 'effectVersion', + 'recoveryClass', + 'evidenceSchemaVersion', +] as const; + +const COMMAND_DESCRIPTOR_IDENTITY_KEYS = [ + 'descriptorId', + 'descriptorVersion', + 'commandKind', + 'inputSchemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', +] as const; + +const DURABLE_EFFECT_PLAN_ITEM_KEYS = [...EFFECT_DESCRIPTOR_KEYS, 'ordinal', 'state'] as const; + +const VALIDATED_EFFECT_EVIDENCE_KEYS = [...EFFECT_DESCRIPTOR_KEYS, 'outcome'] as const; + +const EVIDENCE_RESOLVABLE_RECOVERY_CLASSES = new Set([ + 'transactional_local', + 'idempotent_by_operation_id', + 'reconcilable_by_unique_evidence', + 'compensatable', +]); + +const ABSENT_RETRY_RECOVERY_CLASSES = new Set([ + 'transactional_local', + 'idempotent_by_operation_id', + 'reconcilable_by_unique_evidence', + 'compensatable', +]); + +export function transitionDurableCommandState( + current: DurableCommandState, + next: DurableCommandState +): DurableCommandState { + assertCommandState(current, 'current'); + assertCommandState(next, 'next'); + if (!COMMAND_TRANSITIONS[current].includes(next)) { + throw new DurableCommandStateTransitionError( + 'invalid_command_transition', + 'Durable command state transition is not allowed', + { current, next } + ); + } + return next; +} + +export function createDurableCommandDescriptorIdentity( + descriptor: Pick< + CommandDescriptor, + | 'descriptorId' + | 'descriptorVersion' + | 'commandKind' + | 'inputSchemaVersion' + | 'fingerprintVersion' + | 'effectPlanVersion' + > +): DurableCommandDescriptorIdentity { + assertIdentifier(descriptor.descriptorId, 'descriptorId'); + assertPositiveVersion(descriptor.descriptorVersion, 'descriptorVersion'); + assertIdentifier(descriptor.commandKind, 'commandKind'); + assertPositiveVersion(descriptor.inputSchemaVersion, 'inputSchemaVersion'); + if (descriptor.fingerprintVersion !== HMAC_SHA256_LD_V1) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Durable command descriptor has an unsupported fingerprint version', + { fingerprintVersion: descriptor.fingerprintVersion } + ); + } + assertPositiveVersion(descriptor.effectPlanVersion, 'effectPlanVersion'); + + return Object.freeze({ + descriptorId: descriptor.descriptorId, + descriptorVersion: descriptor.descriptorVersion, + commandKind: descriptor.commandKind, + inputSchemaVersion: descriptor.inputSchemaVersion, + fingerprintVersion: descriptor.fingerprintVersion, + effectPlanVersion: descriptor.effectPlanVersion, + }); +} + +/** + * The only immediate-command path to committed. It binds the persisted descriptor identity and + * ordered effect plan to the exact descriptor and admits commit only after every effect succeeded. + */ +export function commitDurableCommand( + current: DurableCommandState, + descriptor: Pick< + CommandDescriptor, + | 'descriptorId' + | 'descriptorVersion' + | 'commandKind' + | 'inputSchemaVersion' + | 'fingerprintVersion' + | 'effectPlanVersion' + | 'effects' + >, + persistedIdentity: DurableCommandDescriptorIdentity, + persistedEffectPlan: readonly DurableEffectPlanItem[] +): 'committed' { + assertCommandState(current, 'current'); + if (current !== 'running' && current !== 'recovering') { + throw new DurableCommandStateTransitionError( + 'invalid_command_transition', + 'Only a running or recovering durable command may be committed', + { current, next: 'committed' } + ); + } + + const expectedIdentity = createDurableCommandDescriptorIdentity(descriptor); + assertDurableCommandDescriptorIdentity(persistedIdentity); + if (!sameCommandDescriptorIdentity(expectedIdentity, persistedIdentity)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Persisted durable command descriptor identity does not match the exact descriptor', + { expectedIdentity, persistedIdentity } + ); + } + + const expectedPlan = createInitialEffectPlan(descriptor); + assertEffectPlanArray(persistedEffectPlan); + if (persistedEffectPlan.length !== expectedPlan.length) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Persisted durable command effect plan length does not match the exact descriptor', + { + descriptorId: descriptor.descriptorId, + expectedLength: expectedPlan.length, + actualLength: persistedEffectPlan.length, + } + ); + } + + for (let ordinal = 0; ordinal < expectedPlan.length; ordinal += 1) { + const expected = expectedPlan[ordinal]; + const actual = persistedEffectPlan[ordinal]; + assertDurableEffectPlanItem(actual); + if (!sameEffectPlanIdentity(expected, actual)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Persisted durable command effect does not match the exact ordered descriptor plan', + { + descriptorId: descriptor.descriptorId, + ordinal, + expectedEffectId: expected.effectId, + actualEffectId: actual.effectId, + } + ); + } + if (actual.state !== 'observed_succeeded') { + throw new DurableCommandStateTransitionError( + 'invalid_command_transition', + 'Every declared durable effect must be observed_succeeded before command commit', + { + current, + next: 'committed', + effectId: actual.effectId, + ordinal, + effectState: actual.state, + } + ); + } + } + + return 'committed'; +} + +export function transitionDurableEffectState( + descriptor: EffectDescriptor, + current: DurableEffectState, + next: DurableEffectState +): DurableEffectState { + assertEffectDescriptor(descriptor); + assertEffectState(current, 'current'); + assertEffectState(next, 'next'); + if (!EFFECT_TRANSITIONS[current].includes(next)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'Durable effect state transition is not allowed', + { effectId: descriptor.effectId, current, next } + ); + } + if ( + (current === 'observed_succeeded' || current === 'compensating') && + descriptor.recoveryClass !== 'compensatable' + ) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'Only compensatable effects may enter or complete compensation', + { effectId: descriptor.effectId, recoveryClass: descriptor.recoveryClass, current, next } + ); + } + return next; +} + +export function resolveAmbiguousDurableEffect( + descriptor: EffectDescriptor, + current: DurableEffectState, + evidence: ValidatedDurableEffectEvidence +): 'observed_succeeded' | 'observed_absent' { + assertEffectDescriptor(descriptor); + assertEffectState(current, 'current'); + assertValidatedEffectEvidence(evidence); + if (current !== 'ambiguous') { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'Evidence resolution requires an ambiguous durable effect', + { effectId: descriptor.effectId, current, next: evidence.outcome } + ); + } + if (!sameEffectDescriptorIdentity(descriptor, evidence)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'Validated evidence does not match the exact durable effect descriptor', + { + effectId: descriptor.effectId, + evidenceEffectId: evidence.effectId, + current, + next: evidence.outcome, + } + ); + } + if (!EVIDENCE_RESOLVABLE_RECOVERY_CLASSES.has(descriptor.recoveryClass)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'This recovery class cannot resolve ambiguity automatically', + { + effectId: descriptor.effectId, + recoveryClass: descriptor.recoveryClass, + current, + next: evidence.outcome, + } + ); + } + return evidence.outcome; +} + +export function retryDurableEffectAfterObservedAbsent( + descriptor: EffectDescriptor, + current: DurableEffectState +): 'attempting' { + assertEffectDescriptor(descriptor); + assertEffectState(current, 'current'); + if (current !== 'observed_absent') { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'A durable effect retry requires proven observed_absent evidence', + { effectId: descriptor.effectId, current, next: 'attempting' } + ); + } + if (!ABSENT_RETRY_RECOVERY_CLASSES.has(descriptor.recoveryClass)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'This recovery class cannot retry automatically after an attempted boundary crossing', + { + effectId: descriptor.effectId, + recoveryClass: descriptor.recoveryClass, + current, + next: 'attempting', + } + ); + } + return 'attempting'; +} + +export function createInitialEffectPlan( + descriptor: Pick +): readonly DurableEffectPlanItem[] { + if (!Array.isArray(descriptor.effects) || descriptor.effects.length === 0) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Durable command effect plan must be ordered and non-empty', + { descriptorId: descriptor.descriptorId } + ); + } + assertEffectArray(descriptor.effects); + const seen = new Set(); + const plan = descriptor.effects.map((effect, ordinal) => { + assertEffectDescriptor(effect); + if (seen.has(effect.effectId)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Durable command effect identifiers must be unique', + { descriptorId: descriptor.descriptorId, effectId: effect.effectId } + ); + } + seen.add(effect.effectId); + return Object.freeze({ ...effect, ordinal, state: 'not_started' as const }); + }); + return Object.freeze(plan); +} + +export function classifyAmbiguousEffect( + recoveryClass: EffectRecoveryClass +): AmbiguousEffectDisposition { + assertRecoveryClass(recoveryClass); + return recoveryClass === 'non_reconcilable' + ? Object.freeze({ commandState: 'operator_required', automaticAction: 'none' }) + : Object.freeze({ + commandState: 'recovering', + automaticAction: 'require_declared_evidence', + }); +} + +function assertCommandState(value: string, field: string): asserts value is DurableCommandState { + if (!DURABLE_COMMAND_STATES.includes(value as DurableCommandState)) { + throw new DurableCommandStateTransitionError( + 'invalid_command_transition', + `Unknown durable command ${field} state`, + { field, value } + ); + } +} + +function assertEffectState(value: string, field: string): asserts value is DurableEffectState { + if (!DURABLE_EFFECT_STATES.includes(value as DurableEffectState)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + `Unknown durable effect ${field} state`, + { field, value } + ); + } +} + +function assertRecoveryClass(value: string): asserts value is EffectRecoveryClass { + if (!EFFECT_RECOVERY_CLASSES.includes(value as EffectRecoveryClass)) { + throw new DurableCommandStateTransitionError( + 'invalid_recovery_class', + 'Unknown durable effect recovery class', + { recoveryClass: value } + ); + } +} + +function assertEffectDescriptor(effect: EffectDescriptor): void { + assertExactEffectDataObject(effect); + assertEffectDescriptorFields(effect); +} + +function assertEffectDescriptorFields(effect: EffectDescriptor): void { + if (typeof effect.effectId !== 'string' || effect.effectId.trim().length === 0) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Durable effect identifier must be a non-empty string' + ); + } + if (!Number.isSafeInteger(effect.effectVersion) || effect.effectVersion <= 0) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Durable effect version must be a positive safe integer', + { effectId: effect.effectId } + ); + } + if (!Number.isSafeInteger(effect.evidenceSchemaVersion) || effect.evidenceSchemaVersion <= 0) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Durable effect evidence schema version must be a positive safe integer', + { effectId: effect.effectId } + ); + } + assertRecoveryClass(effect.recoveryClass); +} + +function assertDurableCommandDescriptorIdentity(identity: DurableCommandDescriptorIdentity): void { + assertExactDataObject( + identity, + COMMAND_DESCRIPTOR_IDENTITY_KEYS, + 'Durable command descriptor identity' + ); + createDurableCommandDescriptorIdentity(identity); +} + +function assertDurableEffectPlanItem(item: DurableEffectPlanItem): void { + assertExactDataObject(item, DURABLE_EFFECT_PLAN_ITEM_KEYS, 'Durable effect plan item'); + assertEffectDescriptorFields(item); + if (!Number.isSafeInteger(item.ordinal) || item.ordinal < 0) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + 'Durable effect plan ordinal must be a non-negative safe integer', + { effectId: item.effectId, ordinal: item.ordinal } + ); + } + assertEffectState(item.state, 'persisted'); +} + +function assertValidatedEffectEvidence(evidence: ValidatedDurableEffectEvidence): void { + assertExactDataObject( + evidence, + VALIDATED_EFFECT_EVIDENCE_KEYS, + 'Validated durable effect evidence' + ); + assertEffectDescriptorFields(evidence); + if (evidence.outcome !== 'observed_succeeded' && evidence.outcome !== 'observed_absent') { + throw new DurableCommandStateTransitionError( + 'invalid_effect_transition', + 'Validated durable effect evidence has an unsupported outcome', + { effectId: evidence.effectId, outcome: evidence.outcome } + ); + } +} + +function assertEffectArray(effects: readonly EffectDescriptor[]): void { + assertDenseDataArray(effects, 'Durable command effects'); +} + +function assertEffectPlanArray(effects: readonly DurableEffectPlanItem[]): void { + assertDenseDataArray(effects, 'Persisted durable command effect plan'); +} + +function assertDenseDataArray(value: readonly unknown[], label: string): void { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `${label} must use the standard array prototype` + ); + } + const expectedNames = new Set([ + 'length', + ...Array.from({ length: value.length }, (_, index) => String(index)), + ]); + if ( + Object.getOwnPropertyNames(value).some((name) => !expectedNames.has(name)) || + Object.getOwnPropertySymbols(value).length > 0 + ) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `${label} must be dense and contain no extra properties` + ); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `${label} must contain only enumerable data items` + ); + } + } +} + +function assertExactEffectDataObject(value: unknown): asserts value is EffectDescriptor { + assertExactDataObject(value, EFFECT_DESCRIPTOR_KEYS, 'Durable effect descriptor'); +} + +function assertExactDataObject( + value: unknown, + expectedKeys: readonly string[], + label: string +): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `${label} must be a plain data object` + ); + } + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `${label} must be a plain data object` + ); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `${label} must not contain symbol keys` + ); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Object.keys(descriptors).sort(compareCodeUnit); + const sortedExpectedKeys = [...expectedKeys].sort(compareCodeUnit); + if ( + keys.length !== sortedExpectedKeys.length || + keys.some((key, index) => key !== sortedExpectedKeys[index]) || + Object.values(descriptors).some( + (descriptor) => !descriptor.enumerable || !('value' in descriptor) + ) + ) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `${label} has missing, unknown, or accessor fields` + ); + } +} + +function sameCommandDescriptorIdentity( + left: DurableCommandDescriptorIdentity, + right: DurableCommandDescriptorIdentity +): boolean { + return COMMAND_DESCRIPTOR_IDENTITY_KEYS.every((key) => left[key] === right[key]); +} + +function sameEffectPlanIdentity( + expected: DurableEffectPlanItem, + actual: DurableEffectPlanItem +): boolean { + return expected.ordinal === actual.ordinal && sameEffectDescriptorIdentity(expected, actual); +} + +function sameEffectDescriptorIdentity(left: EffectDescriptor, right: EffectDescriptor): boolean { + return EFFECT_DESCRIPTOR_KEYS.every((key) => left[key] === right[key]); +} + +function assertIdentifier(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0 || value.includes('\0')) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `Durable command ${field} must be a non-empty string`, + { field } + ); + } +} + +function assertPositiveVersion(value: unknown, field: string): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new DurableCommandStateTransitionError( + 'invalid_effect_plan', + `Durable command ${field} must be a positive safe integer`, + { field } + ); + } +} + +function compareCodeUnit(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/features/application-command-ledger/core/domain/index.ts b/src/features/application-command-ledger/core/domain/index.ts index a5d80be90d..2f0624d5a5 100644 --- a/src/features/application-command-ledger/core/domain/index.ts +++ b/src/features/application-command-ledger/core/domain/index.ts @@ -1 +1,4 @@ +export * from './commandDescriptorRegistry'; +export * from './commandFingerprint'; +export * from './durableCommandState'; export * from './stableJson'; diff --git a/src/features/application-command-ledger/index.ts b/src/features/application-command-ledger/index.ts index 60f21cad8c..c88ab589f9 100644 --- a/src/features/application-command-ledger/index.ts +++ b/src/features/application-command-ledger/index.ts @@ -1,3 +1,34 @@ export * from './contracts'; export * from './core/application'; -export type { ApplicationCommandJsonValue } from './core/domain'; +export type { + ApplicationCommandJsonValue, + CommandDescriptorLookup, + CommandDescriptorRegistryErrorCode, + CommandFingerprintContractErrorCode, + DurableCommandStateTransitionErrorCode, + PreparedCommandFingerprint, +} from './core/domain'; +export { + buildCommandFingerprintPreimage, + buildCommandFingerprintRecord, + classifyAmbiguousEffect, + CommandDescriptorRegistry, + CommandDescriptorRegistryError, + CommandFingerprintContractError, + commitDurableCommand, + createCommandClaimScope, + createCommandDescriptorRegistry, + createDurableCommandDescriptorIdentity, + createInitialEffectPlan, + DurableCommandStateTransitionError, + encodeCommandFingerprintPreimage, + encodeLengthDelimitedValue, + prepareCommandFingerprint, + resolveAmbiguousDurableEffect, + resolveCommandClaim, + retryDurableEffectAfterObservedAbsent, + selectCommandFingerprintKeyVersion, + stableJsonStringify, + transitionDurableCommandState, + transitionDurableEffectState, +} from './core/domain'; diff --git a/src/features/application-command-ledger/main/adapters/output/InternalStorageApplicationCommandLedgerStore.ts b/src/features/application-command-ledger/main/adapters/output/InternalStorageApplicationCommandLedgerStore.ts index cce155fb9a..a1f931e997 100644 --- a/src/features/application-command-ledger/main/adapters/output/InternalStorageApplicationCommandLedgerStore.ts +++ b/src/features/application-command-ledger/main/adapters/output/InternalStorageApplicationCommandLedgerStore.ts @@ -1,3 +1,9 @@ +import { + type CommandDescriptorRegistry, + createDurableCommandDescriptorIdentity, + createInitialEffectPlan, +} from '../../../core/domain'; + import type { ApplicationCommandLedgerBeginRequest, ApplicationCommandLedgerBeginResult, @@ -11,10 +17,41 @@ import type { import type { ApplicationCommandLedgerStorageGateway, ApplicationCommandLedgerStore, + DurableApplicationCommandAttemptLeaseRequest, + DurableApplicationCommandClaimRequest, + DurableApplicationCommandClaimResult, + DurableApplicationCommandClaimStatusRequest, + DurableApplicationCommandCommitRequest, + DurableApplicationCommandConsumerApplyRequest, + DurableApplicationCommandConsumerApplyResult, + DurableApplicationCommandConsumerProjectionRecord, + DurableApplicationCommandConsumerProjectionRequest, + DurableApplicationCommandEffectTransitionRequest, + DurableApplicationCommandLedgerStorageGateway, + DurableApplicationCommandLedgerStore, + DurableApplicationCommandOutboxClaimRequest, + DurableApplicationCommandOutboxDeliveryAcknowledgementRequest, + DurableApplicationCommandOutboxListRequest, + DurableApplicationCommandOutboxRecord, + DurableApplicationCommandRecord, + DurableApplicationCommandStatusRequest, + DurableApplicationCommandTransitionRequest, + HostedAuthorityProjectionCommitRequest, + HostedAuthorityProjectionCommitResult, + HostedAuthorityProjectionReadRequest, + HostedAuthorityProjectionRecord, } from '../../../core/application'; -export class InternalStorageApplicationCommandLedgerStore implements ApplicationCommandLedgerStore { - constructor(private readonly gateway: ApplicationCommandLedgerStorageGateway) {} +type CompatibleApplicationCommandLedgerGateway = ApplicationCommandLedgerStorageGateway & + Partial; + +export class InternalStorageApplicationCommandLedgerStore + implements ApplicationCommandLedgerStore, DurableApplicationCommandLedgerStore +{ + constructor( + private readonly gateway: CompatibleApplicationCommandLedgerGateway, + private readonly descriptorRegistry: CommandDescriptorRegistry | null = null + ) {} begin( request: ApplicationCommandLedgerBeginRequest @@ -47,4 +84,228 @@ export class InternalStorageApplicationCommandLedgerStore implements Application ): Promise[]> { return this.gateway.applicationCommandLedgerListByScope(request); } + + async claimDurable( + request: DurableApplicationCommandClaimRequest + ): Promise> { + const registry = this.requireDescriptorRegistry(); + const descriptor = registry.resolveFingerprintRecord( + request.scope.commandKind, + request.fingerprint + ); + const existing = await this.requireDurableMethod( + 'applicationCommandLedgerDurableGetByClaim' + )({ scope: request.scope }); + if (existing) this.assertRegisteredRecord(existing); + const result = await this.requireDurableMethod('applicationCommandLedgerDurableClaim')({ + ...request, + descriptor: createDurableCommandDescriptorIdentity(descriptor), + retentionClass: descriptor.retentionClass, + effectPlan: createInitialEffectPlan(descriptor), + }); + this.assertRegisteredRecord(result.command); + return result; + } + + async getDurableStatus( + request: DurableApplicationCommandStatusRequest + ): Promise | null> { + const record = await this.requireDurableMethod( + 'applicationCommandLedgerDurableGetStatus' + )(request); + if (record) this.assertRegisteredRecord(record); + return record; + } + + async getDurableByClaim( + request: DurableApplicationCommandClaimStatusRequest + ): Promise | null> { + const record = await this.requireDurableMethod( + 'applicationCommandLedgerDurableGetByClaim' + )(request); + if (record) this.assertRegisteredRecord(record); + return record; + } + + async renewDurableAttemptLease( + request: DurableApplicationCommandAttemptLeaseRequest + ): Promise { + await this.assertRegisteredMutationTarget(request); + const record = await this.requireDurableMethod( + 'applicationCommandLedgerDurableRenewAttemptLease' + )(request); + this.assertRegisteredRecord(record); + return record; + } + + async transitionDurableCommand( + request: DurableApplicationCommandTransitionRequest + ): Promise { + await this.assertRegisteredMutationTarget(request); + const record = await this.requireDurableMethod( + 'applicationCommandLedgerDurableTransitionCommand' + )(request); + this.assertRegisteredRecord(record); + return record; + } + + async transitionDurableEffect( + request: DurableApplicationCommandEffectTransitionRequest + ): Promise { + await this.assertRegisteredMutationTarget(request); + const record = await this.requireDurableMethod( + 'applicationCommandLedgerDurableTransitionEffect' + )(request); + this.assertRegisteredRecord(record); + return record; + } + + async commitDurable( + request: DurableApplicationCommandCommitRequest + ): Promise { + await this.assertRegisteredMutationTarget(request); + const record = await this.requireDurableMethod('applicationCommandLedgerDurableCommit')( + request + ); + this.assertRegisteredRecord(record); + return record; + } + + listDurableOutbox( + request: DurableApplicationCommandOutboxListRequest + ): Promise { + return this.requireDurableMethod('applicationCommandLedgerDurableListOutbox')(request); + } + + claimDurableOutbox( + request: DurableApplicationCommandOutboxClaimRequest + ): Promise { + return this.requireDurableMethod('applicationCommandLedgerDurableClaimOutbox')(request); + } + + acknowledgeDurableOutboxDelivery( + request: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest + ): Promise { + return this.requireDurableMethod('applicationCommandLedgerDurableAcknowledgeOutboxDelivery')( + request + ); + } + + applyDurableConsumerEvent( + request: DurableApplicationCommandConsumerApplyRequest + ): Promise { + return this.requireDurableMethod('applicationCommandLedgerDurableApplyConsumerEvent')(request); + } + + getDurableConsumerProjection( + request: DurableApplicationCommandConsumerProjectionRequest + ): Promise { + return this.requireDurableMethod('applicationCommandLedgerDurableGetConsumerProjection')( + request + ); + } + + commitHostedAuthorityProjection( + request: HostedAuthorityProjectionCommitRequest + ): Promise { + const descriptor = this.requireDescriptorRegistry().resolveFingerprintRecord< + unknown, + TCommandKind + >(request.scope.commandKind, request.fingerprint); + const effectPlan = createInitialEffectPlan(descriptor); + if (effectPlan.some((effect) => effect.recoveryClass !== 'transactional_local')) { + throw new Error( + 'Atomic hosted authority projections require a transactional-local command descriptor' + ); + } + return this.requireDurableMethod('applicationCommandLedgerHostedAuthorityProjectionCommit')({ + ...request, + descriptor: createDurableCommandDescriptorIdentity(descriptor), + retentionClass: descriptor.retentionClass, + effectPlan, + }); + } + + getHostedAuthorityProjection( + request: HostedAuthorityProjectionReadRequest + ): Promise { + return this.requireDurableMethod('applicationCommandLedgerHostedAuthorityProjectionGet')( + request + ); + } + + private requireDescriptorRegistry(): CommandDescriptorRegistry { + if (!this.descriptorRegistry) { + throw new Error( + 'Durable application command persistence requires a checked-in command descriptor registry' + ); + } + return this.descriptorRegistry; + } + + private async assertRegisteredMutationTarget(request: { + deploymentId: string; + commandId: string; + }): Promise { + const record = await this.requireDurableMethod('applicationCommandLedgerDurableGetStatus')( + request + ); + if (!record) { + throw new Error(`Durable application command not found: ${request.commandId}`); + } + this.assertRegisteredRecord(record); + } + + private requireDurableMethod( + key: TKey + ): DurableApplicationCommandLedgerStorageGateway[TKey] { + const method = this.gateway[key]; + if (typeof method !== 'function') { + throw new Error(`Internal storage does not support durable application commands: ${key}`); + } + return method.bind(this.gateway) as DurableApplicationCommandLedgerStorageGateway[TKey]; + } + + private assertRegisteredRecord(record: DurableApplicationCommandRecord): void { + const registry = this.requireDescriptorRegistry(); + const descriptor = registry.resolveFingerprintRecord( + record.claim.scope.commandKind, + record.claim.fingerprint + ); + const expectedIdentity = createDurableCommandDescriptorIdentity(descriptor); + if (record.retentionClass !== descriptor.retentionClass) { + throw new Error('Persisted durable command retention class mismatch'); + } + for (const key of [ + 'descriptorId', + 'descriptorVersion', + 'commandKind', + 'inputSchemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + ] as const) { + if (expectedIdentity[key] !== record.descriptor[key]) { + throw new Error(`Persisted durable command descriptor mismatch: ${key}`); + } + } + const expectedPlan = createInitialEffectPlan(descriptor); + if (expectedPlan.length !== record.effects.length) { + throw new Error('Persisted durable command effect plan length mismatch'); + } + for (let ordinal = 0; ordinal < expectedPlan.length; ordinal += 1) { + const expected = expectedPlan[ordinal]; + const actual = record.effects[ordinal]; + for (const key of [ + 'effectId', + 'effectVersion', + 'recoveryClass', + 'evidenceSchemaVersion', + 'ordinal', + ] as const) { + if (expected[key] !== actual[key]) { + throw new Error(`Persisted durable command effect plan mismatch: ${ordinal}.${key}`); + } + } + } + } } diff --git a/src/features/application-command-ledger/main/composition/createApplicationCommandLedgerFeature.ts b/src/features/application-command-ledger/main/composition/createApplicationCommandLedgerFeature.ts index de50ce1706..2330acca97 100644 --- a/src/features/application-command-ledger/main/composition/createApplicationCommandLedgerFeature.ts +++ b/src/features/application-command-ledger/main/composition/createApplicationCommandLedgerFeature.ts @@ -1,23 +1,48 @@ import { ApplicationCommandRunner } from '../../core/application'; +import { createCommandDescriptorRegistry } from '../../core/domain'; import { InternalStorageApplicationCommandLedgerStore } from '../adapters/output/InternalStorageApplicationCommandLedgerStore'; -import { NodeApplicationCommandHasher } from '../adapters/output/NodeApplicationCommandHasher'; -import type { ApplicationCommandLedgerStorageGateway } from '../../core/application'; +import type { CommandDescriptor } from '../../contracts'; +import type { + ApplicationCommandHasher, + ApplicationCommandLedgerStorageGateway, + ApplicationCommandLedgerStore, + DurableApplicationCommandLedgerStorageGateway, + DurableApplicationCommandLedgerStore, +} from '../../core/application'; +import type { CommandDescriptorRegistry } from '../../core/domain'; export interface ApplicationCommandLedgerFeature { - ledgerStore: InternalStorageApplicationCommandLedgerStore; + descriptorRegistry?: CommandDescriptorRegistry | null; + hasher: ApplicationCommandHasher; + ledgerStore: ApplicationCommandLedgerStore & DurableApplicationCommandLedgerStore; runner: ApplicationCommandRunner; } export function createApplicationCommandLedgerFeature(input: { - storageGateway: ApplicationCommandLedgerStorageGateway; + storageGateway: ApplicationCommandLedgerStorageGateway & + Partial; + hasher: ApplicationCommandHasher; + /** + * Additive durable-command registry. Existing desktop composition may omit + * it and keeps the legacy ledger surface; durable methods then fail closed. + */ + commandDescriptors?: readonly CommandDescriptor[]; }): ApplicationCommandLedgerFeature { - const ledgerStore = new InternalStorageApplicationCommandLedgerStore(input.storageGateway); + const descriptorRegistry = input.commandDescriptors + ? createCommandDescriptorRegistry(input.commandDescriptors) + : null; + const ledgerStore = new InternalStorageApplicationCommandLedgerStore( + input.storageGateway, + descriptorRegistry + ); return { + descriptorRegistry, + hasher: input.hasher, ledgerStore, runner: new ApplicationCommandRunner({ ledger: ledgerStore, - hasher: new NodeApplicationCommandHasher(), + hasher: input.hasher, }), }; } diff --git a/src/features/application-command-ledger/main/index.ts b/src/features/application-command-ledger/main/index.ts index b4da6dcfc9..b29d90d3dc 100644 --- a/src/features/application-command-ledger/main/index.ts +++ b/src/features/application-command-ledger/main/index.ts @@ -1,3 +1 @@ -export * from './adapters/output/InternalStorageApplicationCommandLedgerStore'; -export * from './adapters/output/NodeApplicationCommandHasher'; export * from './composition/createApplicationCommandLedgerFeature'; diff --git a/src/features/change-review-history/README.md b/src/features/change-review-history/README.md new file mode 100644 index 0000000000..b4e7cec60b --- /dev/null +++ b/src/features/change-review-history/README.md @@ -0,0 +1,19 @@ +# Change Review History + +This feature owns durable review decisions, per-file manual editor history, CAS +recovery branches, and their IPC channels. + +- `contracts/` contains browser-safe history DTOs and stable channel names. +- `core/application/` coordinates the shared persistence lock, mutation recovery, + authoritative reviewed-file authorization, and narrow history repository + ports. +- `core/domain/` validates generic decision history, recovery candidates, and + response-loss reconciliation without Electron or filesystem dependencies. +- `main/adapters/input/ipc/` owns Electron registration and receives the legacy + `IpcResult` error boundary from the composition root. +- `main/infrastructure/` owns the constrained filesystem store. +- `renderer/` owns CodeMirror serialization and write buffering. + +The legacy review composition root supplies the same persistence lock used by +decision mutations and resolves reviewed-file authorization on every call. The +feature never imports the legacy review IPC module. diff --git a/src/features/change-review-history/contracts/index.ts b/src/features/change-review-history/contracts/index.ts index f2e6a70d0a..4bbee8d928 100644 --- a/src/features/change-review-history/contracts/index.ts +++ b/src/features/change-review-history/contracts/index.ts @@ -1,3 +1,18 @@ +export { + REVIEW_CLEAR_DECISIONS, + REVIEW_CLEAR_DRAFT_HISTORY, + REVIEW_DECISION_HISTORY_IPC_CHANNELS, + REVIEW_DRAFT_HISTORY_IPC_CHANNELS, + REVIEW_LOAD_DECISION_CONFLICT_CANDIDATES, + REVIEW_LOAD_DECISIONS, + REVIEW_LOAD_DRAFT_HISTORY, + REVIEW_LOAD_DRAFT_HISTORY_CONFLICT_CANDIDATES, + REVIEW_REPLACE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + REVIEW_RESOLVE_DECISION_CONFLICT_CANDIDATE, + REVIEW_RESOLVE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + REVIEW_SAVE_DECISIONS, + REVIEW_SAVE_DRAFT_HISTORY_ENTRY, +} from './ipcChannels'; export type { ReviewDraftHistoryConflictCandidate, ReviewDraftHistoryConflictCandidateSummary, diff --git a/src/features/change-review-history/contracts/ipcChannels.ts b/src/features/change-review-history/contracts/ipcChannels.ts new file mode 100644 index 0000000000..aa2462fd92 --- /dev/null +++ b/src/features/change-review-history/contracts/ipcChannels.ts @@ -0,0 +1,46 @@ +/** Load persisted review decisions from disk. */ +export const REVIEW_LOAD_DECISIONS = 'review:loadDecisions'; + +/** Save review decisions to disk. */ +export const REVIEW_SAVE_DECISIONS = 'review:saveDecisions'; + +/** Clear review decisions from disk. */ +export const REVIEW_CLEAR_DECISIONS = 'review:clearDecisions'; + +/** Load/resolve durable renderer branches preserved after decision CAS conflicts. */ +export const REVIEW_LOAD_DECISION_CONFLICT_CANDIDATES = 'review:loadDecisionConflictCandidates'; +export const REVIEW_RESOLVE_DECISION_CONFLICT_CANDIDATE = 'review:resolveDecisionConflictCandidate'; + +export const REVIEW_DECISION_HISTORY_IPC_CHANNELS = [ + REVIEW_LOAD_DECISIONS, + REVIEW_LOAD_DECISION_CONFLICT_CANDIDATES, + REVIEW_RESOLVE_DECISION_CONFLICT_CANDIDATE, + REVIEW_SAVE_DECISIONS, + REVIEW_CLEAR_DECISIONS, +] as const; + +/** Load the exact-scope durable CodeMirror manual-edit history. */ +export const REVIEW_LOAD_DRAFT_HISTORY = 'review:loadDraftHistory'; + +/** Atomically publish one file's latest durable editor-history checkpoint. */ +export const REVIEW_SAVE_DRAFT_HISTORY_ENTRY = 'review:saveDraftHistoryEntry'; + +/** Remove one file (or the whole exact scope) from durable editor history. */ +export const REVIEW_CLEAR_DRAFT_HISTORY = 'review:clearDraftHistory'; + +/** Load/resolve durable manual-editor branches preserved after draft CAS conflicts. */ +export const REVIEW_LOAD_DRAFT_HISTORY_CONFLICT_CANDIDATES = + 'review:loadDraftHistoryConflictCandidates'; +export const REVIEW_RESOLVE_DRAFT_HISTORY_CONFLICT_CANDIDATE = + 'review:resolveDraftHistoryConflictCandidate'; +export const REVIEW_REPLACE_DRAFT_HISTORY_CONFLICT_CANDIDATE = + 'review:replaceDraftHistoryConflictCandidate'; + +export const REVIEW_DRAFT_HISTORY_IPC_CHANNELS = [ + REVIEW_LOAD_DRAFT_HISTORY, + REVIEW_LOAD_DRAFT_HISTORY_CONFLICT_CANDIDATES, + REVIEW_RESOLVE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + REVIEW_REPLACE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + REVIEW_SAVE_DRAFT_HISTORY_ENTRY, + REVIEW_CLEAR_DRAFT_HISTORY, +] as const; diff --git a/src/features/change-review-history/core/application/ReviewDecisionHistoryApplication.ts b/src/features/change-review-history/core/application/ReviewDecisionHistoryApplication.ts new file mode 100644 index 0000000000..6d0d18159b --- /dev/null +++ b/src/features/change-review-history/core/application/ReviewDecisionHistoryApplication.ts @@ -0,0 +1,241 @@ +import { + assertExactGenericReviewHistoryTransition, + assertReviewCandidateWithinAuthorization, + bindTrustedReviewHistory, + getNewReviewHistoryActions, + hasNewReviewDiskHistory, + isDurableReviewEqual, + isGenericReviewSnapshotContainedByCurrent, +} from '../domain/reviewDecisionHistoryPolicy'; + +import type { + HunkDecision, + LoadedReviewDecisionState, + ReviewConflictResolution, + ReviewDecisionConflictCandidateSummary, + ReviewDecisionHistoryDependencies, + ReviewPersistedStateSnapshot, + ReviewRedoAction, + ReviewUndoAction, + SaveReviewDecisionsResult, +} from './ReviewDecisionHistoryPorts'; + +export class ReviewDecisionHistoryApplication { + constructor(private readonly dependencies: ReviewDecisionHistoryDependencies) {} + + load( + teamName: string, + scopeKey: string, + scopeToken: string | null = null + ): Promise { + if (!scopeToken) { + return this.dependencies.queries.load(teamName, scopeKey); + } + const persistenceScope = { scopeKey, scopeToken }; + return this.dependencies.lock.run(teamName, persistenceScope, async () => { + await this.dependencies.recovery.recover(teamName, persistenceScope); + return this.dependencies.queries.load(teamName, scopeKey, scopeToken); + }); + } + + loadConflictCandidates( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise { + const persistenceScope = { scopeKey, scopeToken }; + return this.dependencies.lock.run(teamName, persistenceScope, async () => { + await this.dependencies.authorization.authorize(teamName, scopeKey); + await this.dependencies.recovery.recover(teamName, persistenceScope); + return this.dependencies.queries.loadConflictCandidateSummaries( + teamName, + scopeKey, + scopeToken + ); + }); + } + + resolveConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string, + resolution: ReviewConflictResolution, + expectedCurrentRevision: number + ): Promise<{ revision: number }> { + const persistenceScope = { scopeKey, scopeToken }; + return this.dependencies.lock.run(teamName, persistenceScope, async () => { + const authorization = await this.dependencies.authorization.authorize(teamName, scopeKey); + await this.dependencies.recovery.recover(teamName, persistenceScope); + if (resolution === 'recover-candidate') { + const candidate = await this.dependencies.queries.loadConflictCandidate( + teamName, + scopeKey, + scopeToken, + candidateId + ); + if (candidate.origin !== 'current-snapshot') { + throw new Error( + 'Recovery copy belongs to a different review snapshot; only discard is safe' + ); + } + assertReviewCandidateWithinAuthorization(candidate.state, authorization); + } + const revision = await this.dependencies.mutations.resolveConflictCandidate( + teamName, + scopeKey, + scopeToken, + candidateId, + resolution, + expectedCurrentRevision + ); + return { revision }; + }); + } + + save( + teamName: string, + scopeKey: string, + scopeToken: string, + hunkDecisions: Record, + fileDecisions: Record, + hunkContextHashesByFile: Record> | null = null, + reviewActionHistory: ReviewUndoAction[] = [], + expectedRevision: number | undefined = undefined, + reviewRedoHistory: ReviewRedoAction[] = [] + ): Promise { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision! < 0) { + throw new Error('Saving review decisions requires an exact decision revision'); + } + const persistenceScope = { scopeKey, scopeToken }; + return this.dependencies.lock.run(teamName, persistenceScope, async () => { + await this.dependencies.recovery.recover(teamName, persistenceScope); + const incomingState: ReviewPersistedStateSnapshot = { + hunkDecisions, + fileDecisions, + hunkContextHashesByFile: hunkContextHashesByFile ?? undefined, + reviewActionHistory, + reviewRedoHistory, + }; + this.dependencies.validation.assertValidSnapshot(incomingState); + const current = await this.dependencies.queries.load(teamName, scopeKey, scopeToken); + if (hasNewReviewDiskHistory(incomingState, current)) { + throw new Error('Disk review history must be committed atomically with its mutation'); + } + const currentRevision = current?.revision ?? 0; + if (expectedRevision !== currentRevision) { + const currentSnapshot = current && { + hunkDecisions: current.hunkDecisions, + fileDecisions: current.fileDecisions, + hunkContextHashesByFile: current.hunkContextHashesByFile, + reviewActionHistory: current.reviewActionHistory, + reviewRedoHistory: current.reviewRedoHistory, + }; + if (currentSnapshot && isDurableReviewEqual(incomingState, currentSnapshot)) { + return { revision: currentRevision }; + } + const authorization = await this.dependencies.authorization.authorize(teamName, scopeKey); + if (isGenericReviewSnapshotContainedByCurrent(incomingState, current, authorization)) { + if (!current) { + throw new Error('Canonical review state disappeared during retry reconciliation'); + } + return { + revision: currentRevision, + reconciledState: { + hunkDecisions: current.hunkDecisions, + fileDecisions: current.fileDecisions, + hunkContextHashesByFile: current.hunkContextHashesByFile, + reviewActionHistory: current.reviewActionHistory, + reviewRedoHistory: current.reviewRedoHistory, + }, + }; + } + assertReviewCandidateWithinAuthorization(incomingState, authorization); + const boundCandidate = bindTrustedReviewHistory(incomingState, current); + const revision = await this.dependencies.mutations.save(teamName, scopeKey, { + scopeToken, + ...boundCandidate, + expectedRevision: expectedRevision!, + }); + return { revision }; + } + const newActions = getNewReviewHistoryActions(incomingState, current); + if (newActions.length > 0) { + const authorization = await this.dependencies.authorization.authorize(teamName, scopeKey); + assertExactGenericReviewHistoryTransition( + incomingState, + current, + authorization, + newActions + ); + } + if ( + newActions.length === 0 && + (!isDurableReviewEqual( + incomingState.reviewActionHistory ?? [], + current?.reviewActionHistory ?? [] + ) || + !isDurableReviewEqual( + incomingState.reviewRedoHistory ?? [], + current?.reviewRedoHistory ?? [] + )) + ) { + throw new Error('Generic saves cannot remove, reorder, or move durable review history'); + } + const boundState = bindTrustedReviewHistory(incomingState, current); + const revision = await this.dependencies.mutations.save(teamName, scopeKey, { + scopeToken, + ...boundState, + expectedRevision, + }); + return { revision }; + }); + } + + async clear( + teamName: string, + scopeKey: string, + scopeToken: string | null = null, + expectedRevision: number | undefined = undefined + ): Promise<{ revision: number }> { + if (!scopeToken) { + await this.dependencies.mutations.clear(teamName, scopeKey); + return { revision: 0 }; + } + const persistenceScope = { scopeKey, scopeToken }; + return this.dependencies.lock.run(teamName, persistenceScope, async () => { + if (expectedRevision === undefined) { + const inspection = await this.dependencies.recovery.inspectForDiscard( + teamName, + persistenceScope + ); + if (inspection.containsPotentialDiskMutation) { + throw new Error( + 'Cannot discard a disk mutation that may be partially applied. Retry recovery instead.' + ); + } + await this.dependencies.mutations.clearUnreadableExactScope(teamName, scopeKey, scopeToken); + if (inspection.corruptRecordCount > 0) { + await this.dependencies.recovery.quarantineCorruptScope(teamName, persistenceScope); + } else { + await this.dependencies.recovery.clearScope(teamName, persistenceScope); + } + return { revision: 0 }; + } + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new Error('Clearing review decisions requires an exact decision revision'); + } + await this.dependencies.recovery.recover(teamName, persistenceScope); + const revision = await this.dependencies.mutations.save(teamName, scopeKey, { + scopeToken, + hunkDecisions: {}, + fileDecisions: {}, + hunkContextHashesByFile: {}, + reviewActionHistory: [], + reviewRedoHistory: [], + expectedRevision, + }); + return { revision }; + }); + } +} diff --git a/src/features/change-review-history/core/application/ReviewDecisionHistoryPorts.ts b/src/features/change-review-history/core/application/ReviewDecisionHistoryPorts.ts new file mode 100644 index 0000000000..91d6947347 --- /dev/null +++ b/src/features/change-review-history/core/application/ReviewDecisionHistoryPorts.ts @@ -0,0 +1,129 @@ +import type { + HunkDecision, + ReviewDecisionAuthorization as DomainReviewDecisionAuthorization, + ReviewPersistedStateSnapshot, + ReviewRedoAction, + ReviewUndoAction, +} from '../domain/reviewDecisionHistoryPolicy'; +import type { + ReviewConflictResolution, + ReviewHistoryPersistenceLockPort, + ReviewHistoryPersistenceScope, +} from './ports'; + +export type { + HunkDecision, + ReviewDecisionFile, + ReviewPersistedStateSnapshot, + ReviewRedoAction, + ReviewUndoAction, +} from '../domain/reviewDecisionHistoryPolicy'; +export type { ReviewConflictResolution } from './ports'; + +export interface LoadedReviewDecisionState extends ReviewPersistedStateSnapshot { + revision: number; +} + +export type ReviewDecisionAuthorization = DomainReviewDecisionAuthorization; + +export interface ReviewDecisionConflictCandidate { + id: string; + capturedAt: string; + origin: 'current-snapshot' | 'prior-snapshot'; + expectedRevision: number; + observedCurrentRevision: number; + state: ReviewPersistedStateSnapshot; +} + +export interface ReviewDecisionConflictCandidateSummary { + id: string; + capturedAt: string; + origin: 'current-snapshot' | 'prior-snapshot'; + recoverability: 'recoverable' | 'different-review-snapshot'; + expectedRevision: number; + observedCurrentRevision: number; + hunkDecisionCount: number; + fileDecisionCount: number; + undoDepth: number; + redoDepth: number; +} + +export interface SaveReviewDecisionsResult { + revision: number; + reconciledState?: ReviewPersistedStateSnapshot; +} + +export interface ReviewDecisionAuthorizationPort { + authorize(teamName: string, scopeKey: string): Promise; +} + +export interface ReviewDecisionQueryPort { + load( + teamName: string, + scopeKey: string, + scopeToken?: string + ): Promise; + loadConflictCandidateSummaries( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise; + loadConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string + ): Promise; +} + +export interface SaveReviewDecisionStateInput { + scopeToken: string; + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + reviewActionHistory?: ReviewUndoAction[]; + reviewRedoHistory?: ReviewRedoAction[]; + expectedRevision?: number; +} + +export interface ReviewDecisionMutationPort { + resolveConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string, + resolution: ReviewConflictResolution, + expectedCurrentRevision: number + ): Promise; + save(teamName: string, scopeKey: string, input: SaveReviewDecisionStateInput): Promise; + clear(teamName: string, scopeKey: string): Promise; + clearUnreadableExactScope(teamName: string, scopeKey: string, scopeToken: string): Promise; +} + +export interface ReviewDecisionValidationPort { + assertValidSnapshot(state: ReviewPersistedStateSnapshot): void; +} + +export interface ReviewDecisionRecoveryInspection { + containsPotentialDiskMutation: boolean; + corruptRecordCount: number; +} + +export interface ReviewDecisionRecoveryPort { + recover(teamName: string, scope: ReviewHistoryPersistenceScope): Promise; + inspectForDiscard( + teamName: string, + scope: ReviewHistoryPersistenceScope + ): Promise; + quarantineCorruptScope(teamName: string, scope: ReviewHistoryPersistenceScope): Promise; + clearScope(teamName: string, scope: ReviewHistoryPersistenceScope): Promise; +} + +export interface ReviewDecisionHistoryDependencies { + lock: ReviewHistoryPersistenceLockPort; + authorization: ReviewDecisionAuthorizationPort; + queries: ReviewDecisionQueryPort; + mutations: ReviewDecisionMutationPort; + validation: ReviewDecisionValidationPort; + recovery: ReviewDecisionRecoveryPort; +} diff --git a/src/features/change-review-history/core/application/ReviewDraftHistoryApplication.ts b/src/features/change-review-history/core/application/ReviewDraftHistoryApplication.ts new file mode 100644 index 0000000000..f7323b19d1 --- /dev/null +++ b/src/features/change-review-history/core/application/ReviewDraftHistoryApplication.ts @@ -0,0 +1,195 @@ +import type { + ReviewDraftHistoryConflictCandidateSummary, + ReviewDraftHistoryEntry, + ReviewDraftHistorySnapshot, +} from '../../contracts'; +import type { + ReviewConflictResolution, + ReviewDraftHistoryAuthorization, + ReviewDraftHistoryAuthorizationPort, + ReviewDraftHistoryConflictMutationPort, + ReviewDraftHistoryEntryMutationPort, + ReviewDraftHistoryPersistenceLockPort, + ReviewDraftHistoryPersistenceScope, + ReviewDraftHistoryQueryPort, +} from './ports'; + +export interface ReviewDraftHistoryApplicationDependencies { + lock: ReviewDraftHistoryPersistenceLockPort; + authorization: ReviewDraftHistoryAuthorizationPort; + queries: ReviewDraftHistoryQueryPort; + conflictMutations: ReviewDraftHistoryConflictMutationPort; + entryMutations: ReviewDraftHistoryEntryMutationPort; +} + +export class ReviewDraftHistoryApplication { + constructor(private readonly dependencies: ReviewDraftHistoryApplicationDependencies) {} + + private runAuthorized( + teamName: string, + scopeKey: string, + scopeToken: string, + operation: (authorization: ReviewDraftHistoryAuthorization) => Promise + ): Promise { + const persistenceScope: ReviewDraftHistoryPersistenceScope = { scopeKey, scopeToken }; + return this.dependencies.lock.run(teamName, persistenceScope, async () => { + const authorization = await this.dependencies.authorization.authorize(teamName, scopeKey); + return operation(authorization); + }); + } + + load( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise { + return this.runAuthorized(teamName, scopeKey, scopeToken, async (authorization) => { + const snapshot = await this.dependencies.queries.load(teamName, scopeKey, scopeToken); + for (const filePath of Object.keys(snapshot?.entries ?? {})) { + await authorization.assertCurrentReviewedFile(filePath); + } + return snapshot; + }); + } + + loadConflictCandidates( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise { + return this.runAuthorized(teamName, scopeKey, scopeToken, async (authorization) => { + const candidates = await this.dependencies.queries.loadConflictCandidateSummaries( + teamName, + scopeKey, + scopeToken + ); + return Promise.all( + candidates.map(async (candidate) => { + if ( + candidate.origin === 'prior-snapshot' && + !authorization.isCurrentReviewedFile(candidate.filePath) + ) { + return { ...candidate, recoverability: 'file-not-in-current-review' as const }; + } + await authorization.assertCurrentReviewedFile(candidate.filePath); + return candidate; + }) + ); + }); + } + + resolveConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string, + resolution: ReviewConflictResolution, + expectedCurrentRevision: number, + expectedCurrentGeneration: string | null + ): Promise { + return this.runAuthorized(teamName, scopeKey, scopeToken, async (authorization) => { + const candidate = await this.dependencies.queries.loadConflictCandidate( + teamName, + scopeKey, + scopeToken, + candidateId + ); + if (resolution === 'recover-candidate') { + await authorization.assertCurrentReviewedFile(candidate.filePath); + } + return this.dependencies.conflictMutations.resolveConflictCandidate( + teamName, + scopeKey, + scopeToken, + candidateId, + resolution, + expectedCurrentRevision, + expectedCurrentGeneration + ); + }); + } + + replaceConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + expectedEntry: Omit, + replacementEntry: Omit, + expectedCurrentRevision: number, + expectedCurrentGeneration: string | null + ): Promise { + return this.runAuthorized(teamName, scopeKey, scopeToken, async (authorization) => { + await authorization.assertCurrentReviewedFile(expectedEntry.filePath); + if (replacementEntry.filePath !== expectedEntry.filePath) { + throw new Error('Manual-edit recovery update changed file identity'); + } + const replacement = await this.dependencies.conflictMutations.replaceConflictCandidate( + teamName, + scopeKey, + scopeToken, + expectedEntry, + replacementEntry, + expectedCurrentRevision, + expectedCurrentGeneration + ); + return { + id: replacement.id, + capturedAt: replacement.capturedAt, + origin: replacement.origin, + recoverability: 'recoverable', + filePath: replacement.filePath, + expectedRevision: replacement.expectedRevision, + expectedGeneration: replacement.expectedGeneration, + observedCurrentRevision: replacement.observedCurrentRevision, + observedCurrentGeneration: replacement.observedCurrentGeneration, + entryRevision: replacement.entry?.revision ?? null, + }; + }); + } + + saveEntry( + teamName: string, + scopeKey: string, + scopeToken: string, + entry: Omit, + expectedRevision: number, + expectedGeneration: string | null + ): Promise { + return this.runAuthorized(teamName, scopeKey, scopeToken, async (authorization) => { + await authorization.assertCurrentReviewedFile(entry.filePath); + return this.dependencies.entryMutations.saveEntry(teamName, scopeKey, scopeToken, { + ...entry, + expectedRevision, + expectedGeneration, + }); + }); + } + + clear( + teamName: string, + scopeKey: string, + scopeToken: string, + filePath: string | null = null, + expectedRevision: number | null = null, + expectedGeneration: string | null = null + ): Promise { + return this.runAuthorized(teamName, scopeKey, scopeToken, async (authorization) => { + if (filePath === null) { + await this.dependencies.entryMutations.clearUnreadableScope(teamName, scopeKey, scopeToken); + return; + } + if (expectedRevision === null) { + throw new Error('Clearing review draft history requires an exact revision'); + } + await authorization.assertCurrentReviewedFile(filePath); + await this.dependencies.entryMutations.clearEntry( + teamName, + scopeKey, + scopeToken, + filePath, + expectedRevision, + expectedGeneration + ); + }); + } +} diff --git a/src/features/change-review-history/core/application/ports.ts b/src/features/change-review-history/core/application/ports.ts new file mode 100644 index 0000000000..ec3908b227 --- /dev/null +++ b/src/features/change-review-history/core/application/ports.ts @@ -0,0 +1,94 @@ +import type { + ReviewDraftHistoryConflictCandidate, + ReviewDraftHistoryConflictCandidateSummary, + ReviewDraftHistoryEntry, + ReviewDraftHistorySnapshot, +} from '../../contracts'; + +export type ReviewConflictResolution = 'recover-candidate' | 'keep-current'; + +export interface ReviewHistoryPersistenceScope { + scopeKey: string; + scopeToken: string; +} + +export interface ReviewHistoryPersistenceLockPort { + run( + teamName: string, + scope: ReviewHistoryPersistenceScope, + operation: () => Promise + ): Promise; +} + +export type ReviewDraftHistoryPersistenceScope = ReviewHistoryPersistenceScope; +export type ReviewDraftHistoryPersistenceLockPort = ReviewHistoryPersistenceLockPort; + +export interface ReviewDraftHistoryAuthorization { + isCurrentReviewedFile(filePath: string): boolean; + assertCurrentReviewedFile(filePath: string): Promise; +} + +export interface ReviewDraftHistoryAuthorizationPort { + authorize(teamName: string, scopeKey: string): Promise; +} + +export interface ReviewDraftHistoryQueryPort { + load( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise; + loadConflictCandidateSummaries( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise; + loadConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string + ): Promise; +} + +export interface ReviewDraftHistoryConflictMutationPort { + resolveConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string, + resolution: ReviewConflictResolution, + expectedCurrentRevision: number, + expectedCurrentGeneration: string | null + ): Promise; + replaceConflictCandidate( + teamName: string, + scopeKey: string, + scopeToken: string, + expectedEntry: Omit, + replacementEntry: Omit, + expectedCurrentRevision: number, + expectedCurrentGeneration: string | null + ): Promise; +} + +export interface ReviewDraftHistoryEntryMutationPort { + saveEntry( + teamName: string, + scopeKey: string, + scopeToken: string, + input: Omit & { + expectedRevision: number; + expectedGeneration: string | null; + } + ): Promise; + clearEntry( + teamName: string, + scopeKey: string, + scopeToken: string, + filePath: string, + expectedRevision: number, + expectedGeneration: string | null + ): Promise; + clearUnreadableScope(teamName: string, scopeKey: string, scopeToken: string): Promise; +} diff --git a/src/features/change-review-history/core/domain/reviewDecisionHistoryPolicy.ts b/src/features/change-review-history/core/domain/reviewDecisionHistoryPolicy.ts new file mode 100644 index 0000000000..d034edcd5e --- /dev/null +++ b/src/features/change-review-history/core/domain/reviewDecisionHistoryPolicy.ts @@ -0,0 +1,534 @@ +export type HunkDecision = 'accepted' | 'rejected' | 'pending'; + +export interface ReviewDecisionFile { + filePath: string; + changeKey?: string; +} + +export interface ReviewDecisionSnapshot { + hunkDecisions: Record; + fileDecisions: Record; +} + +export type ReviewActionDescriptor = + | { + intent: 'accept-hunk' | 'reject-hunk'; + filePath: string; + hunkIndex: number; + } + | { + intent: 'accept-file' | 'reject-file' | 'restore-file' | 'restore-rename'; + filePath: string; + } + | { intent: 'accept-all' | 'reject-all'; fileCount: number }; + +interface ReviewUndoActionBase { + id: string; + createdAt: string; + descriptor?: ReviewActionDescriptor; +} + +export type ReviewUndoAction = + | (ReviewUndoActionBase & { + kind: 'bulk'; + decisionSnapshot: ReviewDecisionSnapshot; + diskSnapshots: { + filePath: string; + beforeContent: string; + afterContent: string | null; + }[]; + }) + | (ReviewUndoActionBase & { + kind: 'disk'; + action: { + snapshot: { + filePath: string; + beforeContent: string; + afterContent: string | null; + }; + originalIndex?: number; + decisionSnapshot?: ReviewDecisionSnapshot; + }; + }) + | (ReviewUndoActionBase & { + kind: 'hunk'; + action: { filePath: string; originalIndex: number }; + }); + +export interface ReviewRedoAction { + action: ReviewUndoAction; + decisionSnapshot: ReviewDecisionSnapshot; + hunkContextHashesByFile?: Record>; +} + +export interface ReviewPersistedStateSnapshot extends ReviewDecisionSnapshot { + hunkContextHashesByFile?: Record>; + reviewActionHistory: ReviewUndoAction[]; + reviewRedoHistory: ReviewRedoAction[]; +} + +export interface ReviewDecisionAuthorization { + files: readonly ReviewDecisionFile[] | null; + normalizePath(filePath: string): string; + resolveFile(filePath: string): ReviewDecisionFile; +} + +function normalizeDurableReviewValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => normalizeDurableReviewValue(entry)); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .map(([key, entry]) => [key, normalizeDurableReviewValue(entry)]) + ); +} + +function areNormalizedReviewValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index++) { + const leftHasEntry = Object.prototype.hasOwnProperty.call(left, index); + const rightHasEntry = Object.prototype.hasOwnProperty.call(right, index); + if ( + leftHasEntry !== rightHasEntry || + (leftHasEntry && !areNormalizedReviewValuesEqual(left[index], right[index])) + ) { + return false; + } + } + return true; + } + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') { + return false; + } + const leftEntries = Object.entries(left); + const rightRecord = right as Record; + if (leftEntries.length !== Object.keys(rightRecord).length) return false; + return leftEntries.every( + ([key, value]) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + areNormalizedReviewValuesEqual(value, rightRecord[key]) + ); +} + +/** + * Compares JSON-durable review values while treating omitted and undefined + * object properties identically. Array order remains significant. + */ +export function isDurableReviewEqual(left: unknown, right: unknown): boolean { + return areNormalizedReviewValuesEqual( + normalizeDurableReviewValue(left), + normalizeDurableReviewValue(right) + ); +} + +function restoreReviewDecisionRecordsForFile( + file: ReviewDecisionFile, + current: ReviewDecisionSnapshot, + snapshot: ReviewDecisionSnapshot +): ReviewDecisionSnapshot { + const aliases = [file.changeKey ?? file.filePath, file.filePath]; + const matchesHunkAlias = (key: string): boolean => + aliases.some((alias) => { + const prefix = `${alias}:`; + return key.startsWith(prefix) && /^\d+$/.test(key.slice(prefix.length)); + }); + const hunkDecisions = { ...current.hunkDecisions }; + for (const key of Object.keys(hunkDecisions)) { + if (matchesHunkAlias(key)) delete hunkDecisions[key]; + } + for (const [key, decision] of Object.entries(snapshot.hunkDecisions)) { + if (matchesHunkAlias(key)) hunkDecisions[key] = decision; + } + + const fileDecisions = { ...current.fileDecisions }; + for (const alias of aliases) delete fileDecisions[alias]; + for (const alias of aliases) { + const decision = snapshot.fileDecisions[alias]; + if (decision) fileDecisions[alias] = decision; + } + return { hunkDecisions, fileDecisions }; +} + +function buildReviewUndoDecisionState( + action: ReviewUndoAction, + current: ReviewDecisionSnapshot, + resolveFile: (filePath: string) => ReviewDecisionFile | null +): ReviewDecisionSnapshot | null { + if (action.kind === 'bulk') { + return { + hunkDecisions: { ...action.decisionSnapshot.hunkDecisions }, + fileDecisions: { ...action.decisionSnapshot.fileDecisions }, + }; + } + + const filePath = + action.kind === 'disk' ? action.action.snapshot.filePath : action.action.filePath; + const file = resolveFile(filePath); + if (!file) return null; + + const originalIndex = action.action.originalIndex; + if (action.kind === 'hunk' || originalIndex !== undefined) { + if (originalIndex === undefined) return null; + const hunkDecisions = { ...current.hunkDecisions }; + delete hunkDecisions[`${file.changeKey ?? file.filePath}:${originalIndex}`]; + return { hunkDecisions, fileDecisions: { ...current.fileDecisions } }; + } + + const decisionSnapshot = action.action.decisionSnapshot; + if (!decisionSnapshot) return null; + return restoreReviewDecisionRecordsForFile(file, current, decisionSnapshot); +} + +function getCanonicalFiles( + authorization: ReviewDecisionAuthorization +): Map { + if (!authorization.files) { + throw new Error('Authoritative review file set is unavailable'); + } + const canonicalFiles = new Map(); + for (const file of authorization.files) { + canonicalFiles.set(file.changeKey ?? file.filePath, file); + } + return canonicalFiles; +} + +function isAuthorizedReviewDecisionKey( + canonicalFiles: ReadonlyMap, + key: string, + hunk: boolean +): boolean { + if (!hunk) return canonicalFiles.has(key); + for (const reviewKey of canonicalFiles.keys()) { + const prefix = `${reviewKey}:`; + if (key.startsWith(prefix) && /^\d+$/.test(key.slice(prefix.length))) return true; + } + return false; +} + +export function hasNewReviewDiskHistory( + state: ReviewPersistedStateSnapshot, + current: ReviewPersistedStateSnapshot | null +): boolean { + const trustedIds = new Set(); + for (const action of current?.reviewActionHistory ?? []) trustedIds.add(action.id); + for (const entry of current?.reviewRedoHistory ?? []) trustedIds.add(entry.action.id); + const hasDisk = (action: ReviewUndoAction): boolean => + action.kind === 'disk' || (action.kind === 'bulk' && action.diskSnapshots.length > 0); + return [ + ...(state.reviewActionHistory ?? []), + ...(state.reviewRedoHistory ?? []).map((entry) => entry.action), + ].some((action) => !trustedIds.has(action.id) && hasDisk(action)); +} + +export function getNewReviewHistoryActions( + state: ReviewPersistedStateSnapshot, + current: ReviewPersistedStateSnapshot | null +): ReviewUndoAction[] { + const trustedIds = new Set(); + for (const action of current?.reviewActionHistory ?? []) trustedIds.add(action.id); + for (const entry of current?.reviewRedoHistory ?? []) trustedIds.add(entry.action.id); + return [ + ...(state.reviewActionHistory ?? []), + ...(state.reviewRedoHistory ?? []).map((entry) => entry.action), + ].filter((action) => !trustedIds.has(action.id)); +} + +export function bindTrustedReviewHistory( + state: ReviewPersistedStateSnapshot, + current: ReviewPersistedStateSnapshot | null +): ReviewPersistedStateSnapshot { + const trustedActions = new Map(); + const trustedRedo = new Map(); + for (const action of current?.reviewActionHistory ?? []) trustedActions.set(action.id, action); + for (const entry of current?.reviewRedoHistory ?? []) { + trustedActions.set(entry.action.id, entry.action); + trustedRedo.set(entry.action.id, entry); + } + const bindAction = (action: ReviewUndoAction): ReviewUndoAction => + trustedActions.get(action.id) ?? action; + return { + ...state, + reviewActionHistory: (state.reviewActionHistory ?? []).map(bindAction), + reviewRedoHistory: (state.reviewRedoHistory ?? []).map( + (entry) => trustedRedo.get(entry.action.id) ?? { ...entry, action: bindAction(entry.action) } + ), + }; +} + +export function isGenericReviewSnapshotContainedByCurrent( + incoming: ReviewPersistedStateSnapshot, + current: ReviewPersistedStateSnapshot | null, + authorization: ReviewDecisionAuthorization +): boolean { + if (!current || incoming.reviewActionHistory.length === 0) return false; + if (incoming.reviewRedoHistory.length > 0 || current.reviewRedoHistory.length > 0) return false; + if ( + current.reviewActionHistory.length < incoming.reviewActionHistory.length || + !isDurableReviewEqual( + current.reviewActionHistory.slice(0, incoming.reviewActionHistory.length), + incoming.reviewActionHistory + ) + ) { + return false; + } + let expectedDecisions: ReviewDecisionSnapshot = { + hunkDecisions: current.hunkDecisions, + fileDecisions: current.fileDecisions, + }; + for ( + let index = current.reviewActionHistory.length - 1; + index >= incoming.reviewActionHistory.length; + index-- + ) { + const action = current.reviewActionHistory[index]; + if (!action) return false; + const previous = buildReviewUndoDecisionState(action, expectedDecisions, (filePath) => + authorization.resolveFile(filePath) + ); + if (!previous) return false; + expectedDecisions = previous; + } + const recordIsContained = ( + expected: Readonly>, + observed: Readonly> + ): boolean => + Object.entries(expected).every(([key, value]) => isDurableReviewEqual(value, observed[key])); + return ( + isDurableReviewEqual(incoming.hunkDecisions, expectedDecisions.hunkDecisions) && + isDurableReviewEqual(incoming.fileDecisions, expectedDecisions.fileDecisions) && + recordIsContained(incoming.hunkContextHashesByFile ?? {}, current.hunkContextHashesByFile ?? {}) + ); +} + +export function assertReviewCandidateWithinAuthorization( + state: ReviewPersistedStateSnapshot, + authorization: ReviewDecisionAuthorization +): void { + const canonicalFiles = getCanonicalFiles(authorization); + if ( + Object.keys(state.hunkDecisions).some( + (key) => !isAuthorizedReviewDecisionKey(canonicalFiles, key, true) + ) || + Object.keys(state.fileDecisions).some( + (key) => !isAuthorizedReviewDecisionKey(canonicalFiles, key, false) + ) || + Object.keys(state.hunkContextHashesByFile ?? {}).some((key) => !canonicalFiles.has(key)) + ) { + throw new Error('Review recovery branch contains decisions outside the active review'); + } + + const actions = [ + ...(state.reviewActionHistory ?? []), + ...(state.reviewRedoHistory ?? []).map((entry) => entry.action), + ]; + for (const action of actions) { + if (action.kind === 'hunk') { + const file = authorization.resolveFile(action.action.filePath); + const key = `${file.changeKey ?? file.filePath}:${action.action.originalIndex}`; + if (!isAuthorizedReviewDecisionKey(canonicalFiles, key, true)) { + throw new Error('Review recovery branch contains an unauthorized hunk action'); + } + continue; + } + if (action.kind === 'bulk') { + if ( + Object.keys(action.decisionSnapshot.hunkDecisions).some( + (key) => !isAuthorizedReviewDecisionKey(canonicalFiles, key, true) + ) || + Object.keys(action.decisionSnapshot.fileDecisions).some( + (key) => !isAuthorizedReviewDecisionKey(canonicalFiles, key, false) + ) + ) { + throw new Error('Review recovery branch contains an unauthorized bulk snapshot'); + } + } + } + + const isGenericAction = (action: ReviewUndoAction): boolean => + action.kind === 'hunk' || (action.kind === 'bulk' && action.diskSnapshots.length === 0); + if (actions.every(isGenericAction)) { + const undoHistory = state.reviewActionHistory ?? []; + if (undoHistory.length > 0) { + assertExactGenericReviewHistoryTransition( + { ...state, reviewRedoHistory: [] }, + null, + authorization, + undoHistory + ); + } else if ( + Object.keys(state.hunkDecisions).length > 0 || + Object.keys(state.fileDecisions).length > 0 + ) { + throw new Error('Review recovery branch decisions have no matching Undo history'); + } + let workingState: ReviewDecisionSnapshot = { + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + }; + let workingHistory = [...undoHistory]; + const redoHistory = state.reviewRedoHistory ?? []; + for (let index = redoHistory.length - 1; index >= 0; index--) { + const redo = redoHistory[index]; + const nextHistory = [...workingHistory, redo.action]; + assertExactGenericReviewHistoryTransition( + { + hunkDecisions: redo.decisionSnapshot.hunkDecisions, + fileDecisions: redo.decisionSnapshot.fileDecisions, + reviewActionHistory: nextHistory, + reviewRedoHistory: [], + }, + { + ...workingState, + hunkContextHashesByFile: {}, + reviewActionHistory: workingHistory, + reviewRedoHistory: [], + }, + authorization, + [redo.action] + ); + workingState = redo.decisionSnapshot; + workingHistory = nextHistory; + } + } +} + +export function assertExactGenericReviewHistoryTransition( + state: ReviewPersistedStateSnapshot, + current: ReviewPersistedStateSnapshot | null, + authorization: ReviewDecisionAuthorization, + newActions: readonly ReviewUndoAction[] +): void { + const previousHistory = current?.reviewActionHistory ?? []; + const nextHistory = state.reviewActionHistory ?? []; + if ( + newActions.length === 0 || + newActions.some((action) => + (state.reviewRedoHistory ?? []).some((entry) => entry.action.id === action.id) + ) || + nextHistory.length !== previousHistory.length + newActions.length || + !isDurableReviewEqual(nextHistory.slice(0, previousHistory.length), previousHistory) || + !isDurableReviewEqual(nextHistory.slice(previousHistory.length), newActions) || + (state.reviewRedoHistory?.length ?? 0) !== 0 + ) { + throw new Error('Generic review history transition is not an exact append'); + } + + const canonicalFiles = getCanonicalFiles(authorization); + const resolveHunkKey = (filePath: string, originalIndex: number): string => { + const file = authorization.resolveFile(filePath); + return `${file.changeKey ?? file.filePath}:${originalIndex}`; + }; + const resolveHunkReviewKey = (key: string): string | null => { + for (const reviewKey of canonicalFiles.keys()) { + const prefix = `${reviewKey}:`; + if (key.startsWith(prefix) && /^\d+$/.test(key.slice(prefix.length))) return reviewKey; + } + return null; + }; + let working = { + hunkDecisions: { ...state.hunkDecisions }, + fileDecisions: { ...state.fileDecisions }, + }; + const touchedHunkKeys = new Set(); + for (let index = newActions.length - 1; index >= 0; index--) { + const action = newActions[index]; + if (!action) continue; + if (action.kind === 'disk') { + throw new Error('Disk review history must be committed atomically with its mutation'); + } + if (action.kind === 'hunk') { + const key = resolveHunkKey(action.action.filePath, action.action.originalIndex); + const value = working.hunkDecisions[key]; + if (touchedHunkKeys.has(key) || (value !== 'accepted' && value !== 'rejected')) { + throw new Error('Generic hunk history does not match its decision transition'); + } + if (action.descriptor) { + const descriptor = action.descriptor; + if ( + !('hunkIndex' in descriptor) || + descriptor.intent !== (value === 'accepted' ? 'accept-hunk' : 'reject-hunk') || + authorization.normalizePath(descriptor.filePath) !== + authorization.normalizePath(action.action.filePath) || + descriptor.hunkIndex !== action.action.originalIndex + ) { + throw new Error('Generic hunk history descriptor does not match its decision transition'); + } + } + touchedHunkKeys.add(key); + delete working.hunkDecisions[key]; + continue; + } + if (action.diskSnapshots.length > 0) { + throw new Error('Disk review history must be committed atomically with its mutation'); + } + + const snapshot = action.decisionSnapshot; + const hunkKeys = new Set([ + ...Object.keys(snapshot.hunkDecisions), + ...Object.keys(working.hunkDecisions), + ]); + const fileKeys = new Set([ + ...Object.keys(snapshot.fileDecisions), + ...Object.keys(working.fileDecisions), + ]); + const changedHunks = [...hunkKeys].filter( + (key) => snapshot.hunkDecisions[key] !== working.hunkDecisions[key] + ); + const changedFiles = [...fileKeys].filter( + (key) => snapshot.fileDecisions[key] !== working.fileDecisions[key] + ); + if ( + changedHunks.length + changedFiles.length === 0 || + changedHunks.some( + (key) => + !isAuthorizedReviewDecisionKey(canonicalFiles, key, true) || + working.hunkDecisions[key] !== 'accepted' + ) || + changedFiles.some( + (key) => + !isAuthorizedReviewDecisionKey(canonicalFiles, key, false) || + working.fileDecisions[key] !== 'accepted' + ) + ) { + throw new Error('Generic bulk history does not match an authoritative Accept transition'); + } + if (action.descriptor) { + const affectedReviewKeys = new Set(changedFiles); + for (const key of changedHunks) { + const reviewKey = resolveHunkReviewKey(key); + if (reviewKey) affectedReviewKeys.add(reviewKey); + } + const descriptorMatches = + action.descriptor.intent === 'accept-all' + ? action.descriptor.fileCount === affectedReviewKeys.size + : action.descriptor.intent === 'accept-file' && + affectedReviewKeys.size === 1 && + authorization.normalizePath(action.descriptor.filePath) === + authorization.normalizePath( + canonicalFiles.get([...affectedReviewKeys][0])?.filePath ?? '' + ); + if (!descriptorMatches) { + throw new Error('Generic bulk history descriptor does not match its Accept transition'); + } + } + working = { + hunkDecisions: { ...snapshot.hunkDecisions }, + fileDecisions: { ...snapshot.fileDecisions }, + }; + } + + if ( + !isDurableReviewEqual(working.hunkDecisions, current?.hunkDecisions ?? {}) || + !isDurableReviewEqual(working.fileDecisions, current?.fileDecisions ?? {}) + ) { + throw new Error('Generic review history does not invert to the persisted decision state'); + } +} diff --git a/src/features/change-review-history/main/adapters/input/ipc/registerReviewDecisionHistoryIpc.ts b/src/features/change-review-history/main/adapters/input/ipc/registerReviewDecisionHistoryIpc.ts new file mode 100644 index 0000000000..57e97963e4 --- /dev/null +++ b/src/features/change-review-history/main/adapters/input/ipc/registerReviewDecisionHistoryIpc.ts @@ -0,0 +1,110 @@ +import { + REVIEW_CLEAR_DECISIONS, + REVIEW_DECISION_HISTORY_IPC_CHANNELS, + REVIEW_LOAD_DECISION_CONFLICT_CANDIDATES, + REVIEW_LOAD_DECISIONS, + REVIEW_RESOLVE_DECISION_CONFLICT_CANDIDATE, + REVIEW_SAVE_DECISIONS, +} from '@features/change-review-history/contracts'; + +import type { ReviewDecisionHistoryApplication } from '../../../../core/application/ReviewDecisionHistoryApplication'; +import type { ReviewHistoryIpcHandlerWrapper } from './types'; +import type { + HunkDecision, + ReviewConflictResolution, + ReviewRedoAction, + ReviewUndoAction, +} from '@shared/types/review'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +export function registerReviewDecisionHistoryIpc( + ipcMain: IpcMain, + application: ReviewDecisionHistoryApplication, + wrapHandler: ReviewHistoryIpcHandlerWrapper +): void { + ipcMain.handle( + REVIEW_LOAD_DECISIONS, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string | null = null + ) => wrapHandler('loadDecisions', () => application.load(teamName, scopeKey, scopeToken)) + ); + ipcMain.handle( + REVIEW_LOAD_DECISION_CONFLICT_CANDIDATES, + (_event: IpcMainInvokeEvent, teamName: string, scopeKey: string, scopeToken: string) => + wrapHandler('loadDecisionConflictCandidates', () => + application.loadConflictCandidates(teamName, scopeKey, scopeToken) + ) + ); + ipcMain.handle( + REVIEW_RESOLVE_DECISION_CONFLICT_CANDIDATE, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string, + resolution: ReviewConflictResolution, + expectedCurrentRevision: number + ) => + wrapHandler('resolveDecisionConflictCandidate', () => + application.resolveConflictCandidate( + teamName, + scopeKey, + scopeToken, + candidateId, + resolution, + expectedCurrentRevision + ) + ) + ); + ipcMain.handle( + REVIEW_SAVE_DECISIONS, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string, + hunkDecisions: Record, + fileDecisions: Record, + hunkContextHashesByFile: Record> | null = null, + reviewActionHistory: ReviewUndoAction[] = [], + expectedRevision: number | undefined = undefined, + reviewRedoHistory: ReviewRedoAction[] = [] + ) => + wrapHandler('saveDecisions', () => + application.save( + teamName, + scopeKey, + scopeToken, + hunkDecisions, + fileDecisions, + hunkContextHashesByFile, + reviewActionHistory, + expectedRevision, + reviewRedoHistory + ) + ) + ); + ipcMain.handle( + REVIEW_CLEAR_DECISIONS, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string | null = null, + expectedRevision: number | undefined = undefined + ) => + wrapHandler('clearDecisions', () => + application.clear(teamName, scopeKey, scopeToken, expectedRevision) + ) + ); +} + +export function removeReviewDecisionHistoryIpc(ipcMain: IpcMain): void { + for (const channel of REVIEW_DECISION_HISTORY_IPC_CHANNELS) { + ipcMain.removeHandler(channel); + } +} diff --git a/src/features/change-review-history/main/adapters/input/ipc/registerReviewDraftHistoryIpc.ts b/src/features/change-review-history/main/adapters/input/ipc/registerReviewDraftHistoryIpc.ts new file mode 100644 index 0000000000..6feec000fc --- /dev/null +++ b/src/features/change-review-history/main/adapters/input/ipc/registerReviewDraftHistoryIpc.ts @@ -0,0 +1,134 @@ +import { + REVIEW_CLEAR_DRAFT_HISTORY, + REVIEW_DRAFT_HISTORY_IPC_CHANNELS, + REVIEW_LOAD_DRAFT_HISTORY, + REVIEW_LOAD_DRAFT_HISTORY_CONFLICT_CANDIDATES, + REVIEW_REPLACE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + REVIEW_RESOLVE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + REVIEW_SAVE_DRAFT_HISTORY_ENTRY, +} from '@features/change-review-history/contracts'; + +import type { ReviewDraftHistoryApplication } from '../../../../core/application/ReviewDraftHistoryApplication'; +import type { ReviewHistoryIpcHandlerWrapper } from './types'; +import type { ReviewDraftHistoryEntry } from '@features/change-review-history/contracts'; +import type { ReviewConflictResolution } from '@shared/types/review'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +export type ReviewDraftHistoryIpcHandlerWrapper = ReviewHistoryIpcHandlerWrapper; + +export function registerReviewDraftHistoryIpc( + ipcMain: IpcMain, + application: ReviewDraftHistoryApplication, + wrapHandler: ReviewDraftHistoryIpcHandlerWrapper +): void { + ipcMain.handle( + REVIEW_LOAD_DRAFT_HISTORY, + (_event: IpcMainInvokeEvent, teamName: string, scopeKey: string, scopeToken: string) => + wrapHandler('loadDraftHistory', () => application.load(teamName, scopeKey, scopeToken)) + ); + ipcMain.handle( + REVIEW_LOAD_DRAFT_HISTORY_CONFLICT_CANDIDATES, + (_event: IpcMainInvokeEvent, teamName: string, scopeKey: string, scopeToken: string) => + wrapHandler('loadDraftHistoryConflictCandidates', () => + application.loadConflictCandidates(teamName, scopeKey, scopeToken) + ) + ); + ipcMain.handle( + REVIEW_RESOLVE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string, + candidateId: string, + resolution: ReviewConflictResolution, + expectedCurrentRevision: number, + expectedCurrentGeneration: string | null + ) => + wrapHandler('resolveDraftHistoryConflictCandidate', () => + application.resolveConflictCandidate( + teamName, + scopeKey, + scopeToken, + candidateId, + resolution, + expectedCurrentRevision, + expectedCurrentGeneration + ) + ) + ); + ipcMain.handle( + REVIEW_REPLACE_DRAFT_HISTORY_CONFLICT_CANDIDATE, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string, + expectedEntry: Omit, + replacementEntry: Omit, + expectedCurrentRevision: number, + expectedCurrentGeneration: string | null + ) => + wrapHandler('replaceDraftHistoryConflictCandidate', () => + application.replaceConflictCandidate( + teamName, + scopeKey, + scopeToken, + expectedEntry, + replacementEntry, + expectedCurrentRevision, + expectedCurrentGeneration + ) + ) + ); + ipcMain.handle( + REVIEW_SAVE_DRAFT_HISTORY_ENTRY, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string, + entry: Omit, + expectedRevision: number, + expectedGeneration: string | null + ) => + wrapHandler('saveDraftHistoryEntry', () => + application.saveEntry( + teamName, + scopeKey, + scopeToken, + entry, + expectedRevision, + expectedGeneration + ) + ) + ); + ipcMain.handle( + REVIEW_CLEAR_DRAFT_HISTORY, + ( + _event: IpcMainInvokeEvent, + teamName: string, + scopeKey: string, + scopeToken: string, + filePath: string | null = null, + expectedRevision: number | null = null, + expectedGeneration: string | null = null + ) => + wrapHandler('clearDraftHistory', () => + application.clear( + teamName, + scopeKey, + scopeToken, + filePath, + expectedRevision, + expectedGeneration + ) + ) + ); +} + +export function removeReviewDraftHistoryIpc(ipcMain: IpcMain): void { + for (const channel of REVIEW_DRAFT_HISTORY_IPC_CHANNELS) { + ipcMain.removeHandler(channel); + } +} diff --git a/src/features/change-review-history/main/adapters/input/ipc/types.ts b/src/features/change-review-history/main/adapters/input/ipc/types.ts new file mode 100644 index 0000000000..7a912ac976 --- /dev/null +++ b/src/features/change-review-history/main/adapters/input/ipc/types.ts @@ -0,0 +1,6 @@ +import type { IpcResult } from '@shared/types/ipc'; + +export type ReviewHistoryIpcHandlerWrapper = ( + operationName: string, + operation: () => Promise +) => Promise>; diff --git a/src/features/change-review-history/main/composition/createReviewDecisionHistoryFeature.ts b/src/features/change-review-history/main/composition/createReviewDecisionHistoryFeature.ts new file mode 100644 index 0000000000..16338d2c8d --- /dev/null +++ b/src/features/change-review-history/main/composition/createReviewDecisionHistoryFeature.ts @@ -0,0 +1,9 @@ +import { ReviewDecisionHistoryApplication } from '../../core/application/ReviewDecisionHistoryApplication'; + +import type { ReviewDecisionHistoryDependencies } from '../../core/application/ReviewDecisionHistoryPorts'; + +export function createReviewDecisionHistoryFeature( + dependencies: ReviewDecisionHistoryDependencies +): ReviewDecisionHistoryApplication { + return new ReviewDecisionHistoryApplication(dependencies); +} diff --git a/src/features/change-review-history/main/composition/createReviewDraftHistoryFeature.ts b/src/features/change-review-history/main/composition/createReviewDraftHistoryFeature.ts new file mode 100644 index 0000000000..b45ba2ae65 --- /dev/null +++ b/src/features/change-review-history/main/composition/createReviewDraftHistoryFeature.ts @@ -0,0 +1,24 @@ +import { ReviewDraftHistoryApplication } from '../../core/application/ReviewDraftHistoryApplication'; +import { ReviewDraftHistoryStore } from '../infrastructure/ReviewDraftHistoryStore'; + +import type { + ReviewDraftHistoryAuthorizationPort, + ReviewDraftHistoryPersistenceLockPort, +} from '../../core/application/ports'; + +export interface ReviewDraftHistoryFeatureDependencies { + lock: ReviewDraftHistoryPersistenceLockPort; + authorization: ReviewDraftHistoryAuthorizationPort; +} + +export function createReviewDraftHistoryFeature( + dependencies: ReviewDraftHistoryFeatureDependencies +): ReviewDraftHistoryApplication { + const store = new ReviewDraftHistoryStore(); + return new ReviewDraftHistoryApplication({ + ...dependencies, + queries: store, + conflictMutations: store, + entryMutations: store, + }); +} diff --git a/src/features/change-review-history/main/index.ts b/src/features/change-review-history/main/index.ts index 83cf008fd0..2e029e8f5c 100644 --- a/src/features/change-review-history/main/index.ts +++ b/src/features/change-review-history/main/index.ts @@ -1,3 +1,28 @@ +export type { + ReviewDraftHistoryAuthorization, + ReviewDraftHistoryAuthorizationPort, + ReviewDraftHistoryPersistenceLockPort, + ReviewDraftHistoryPersistenceScope, + ReviewHistoryPersistenceLockPort, + ReviewHistoryPersistenceScope, +} from '../core/application/ports'; +export type { + LoadedReviewDecisionState, + ReviewDecisionAuthorization, + ReviewDecisionAuthorizationPort, + ReviewDecisionHistoryDependencies, + ReviewDecisionMutationPort, + ReviewDecisionQueryPort, + ReviewDecisionRecoveryInspection, + ReviewDecisionRecoveryPort, + ReviewDecisionValidationPort, + SaveReviewDecisionStateInput, +} from '../core/application/ReviewDecisionHistoryPorts'; +export { createReviewDecisionHistoryFeature } from './composition/createReviewDecisionHistoryFeature'; +export { + createReviewDraftHistoryFeature, + type ReviewDraftHistoryFeatureDependencies, +} from './composition/createReviewDraftHistoryFeature'; export { ReviewDraftHistoryStore, type SaveReviewDraftHistoryEntryInput, diff --git a/src/features/change-review/README.md b/src/features/change-review/README.md new file mode 100644 index 0000000000..1019dcad82 --- /dev/null +++ b/src/features/change-review/README.md @@ -0,0 +1,34 @@ +# Change Review + +This feature is extracted incrementally from the legacy renderer dialog and +main-process review IPC shell. + +- `renderer/view-models` owns pure presentation projections. +- `renderer/utils` owns pure scope and operation-generation policies. +- `renderer/adapters` owns narrow browser/API/Zustand bridges and the dialog's + shared CodeMirror, keyboard, mutation-status, session, and write-evidence view bridges. +- `renderer/hooks` owns scope/lifecycle, draft history, conflict recovery, action-history, + decision-persistence, keyboard orchestration, bulk Accept/Reject, manual file draft + save/reload/discard flows, file-level and hunk-level Accept/Reject/Restore, and durable + Undo/Redo/checkpoint Restore. It also owns dialog view state, selection/collapse/viewed + presentation interactions, diff/history keyboard navigation, operation-state latches, + mutation guards, external-change processing and watcher suppression, dialog + open/fetch/hydration, close/app-close flushing, saved-state recovery/discard, Apply cleanup, + and Escape orchestration through narrow ports. +- `renderer/ui` owns store-free presentation components, including the review navigation + sidebar, file tree, and active-file edit timeline. Decision state reaches that subtree + through explicit props rather than direct Zustand access. +- `core/domain` owns pure review scope, rename expectation, snippet-shape, watcher-input, and + decision-persistence policy. +- `main/application` owns authoritative scope/path authorization, review watcher lifecycle, and + decision-persistence coordination behind narrow ports. +- `main/infrastructure` owns Node path, filesystem, sensitive-path, hardlink, and watcher-root + validation details. +- The legacy app-shell composition modules bind feature ports and policies to Zustand, the + renderer API, and remaining review utilities. A focused decision-action hook owns + per-instance bulk/file/hunk Accept/Reject wiring. A store-free dialog view owns presentation + projections and rendering, while the app-shell dialog keeps draft/history/lifecycle + composition and CodeMirror action injection. + +Production callers import through `@features/change-review/renderer` or +`@features/change-review/main`. diff --git a/src/features/change-review/core/domain/reviewDecisionPersistencePolicy.ts b/src/features/change-review/core/domain/reviewDecisionPersistencePolicy.ts new file mode 100644 index 0000000000..5a2c0d8bc5 --- /dev/null +++ b/src/features/change-review/core/domain/reviewDecisionPersistencePolicy.ts @@ -0,0 +1,125 @@ +import { + assertNonEmptyString, + assertSnippetShapes, + MAX_REVIEW_HUNK_DECISIONS_PER_FILE, +} from './reviewScopePolicy'; + +import type { + FileReviewDecision, + ReviewDecisionPersistenceScope, + ReviewFileScope, +} from '@shared/types/review'; + +export type ReviewHistoryScopeIdentity = { taskId: string } | { memberName: string }; + +export function assertReviewDecisionShape(value: unknown): asserts value is FileReviewDecision { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid review decision'); + } + const raw = value as Record; + assertNonEmptyString(raw.filePath, 'decision.filePath'); + if ( + raw.reviewKey !== undefined && + (typeof raw.reviewKey !== 'string' || + raw.reviewKey.length === 0 || + raw.reviewKey.length > 32_768 || + raw.reviewKey.includes('\0')) + ) { + throw new Error('Invalid decision.reviewKey'); + } + if (!['accepted', 'rejected', 'pending'].includes(String(raw.fileDecision))) { + throw new Error('Invalid fileDecision'); + } + if ( + !raw.hunkDecisions || + typeof raw.hunkDecisions !== 'object' || + Array.isArray(raw.hunkDecisions) || + Object.keys(raw.hunkDecisions).length > MAX_REVIEW_HUNK_DECISIONS_PER_FILE + ) { + throw new Error('Invalid hunkDecisions'); + } + for (const [index, decision] of Object.entries(raw.hunkDecisions)) { + const numericIndex = Number(index); + if ( + !/^\d+$/.test(index) || + !Number.isSafeInteger(numericIndex) || + numericIndex >= MAX_REVIEW_HUNK_DECISIONS_PER_FILE || + !['accepted', 'rejected', 'pending'].includes(String(decision)) + ) { + throw new Error('Invalid hunk decision'); + } + } + if (raw.hunkContextHashes !== undefined) { + if ( + !raw.hunkContextHashes || + typeof raw.hunkContextHashes !== 'object' || + Array.isArray(raw.hunkContextHashes) || + Object.keys(raw.hunkContextHashes).length > MAX_REVIEW_HUNK_DECISIONS_PER_FILE + ) { + throw new Error('Invalid hunkContextHashes'); + } + for (const [index, hash] of Object.entries(raw.hunkContextHashes)) { + const numericIndex = Number(index); + if ( + !/^\d+$/.test(index) || + !Number.isSafeInteger(numericIndex) || + numericIndex >= MAX_REVIEW_HUNK_DECISIONS_PER_FILE || + typeof hash !== 'string' || + hash.length === 0 || + hash.length > 256 + ) { + throw new Error('Invalid hunk context hash'); + } + } + } + if ( + raw.contentSnapshotToken !== undefined && + (typeof raw.contentSnapshotToken !== 'string' || raw.contentSnapshotToken.length > 200) + ) { + throw new Error('Invalid contentSnapshotToken'); + } + if (raw.snippets !== undefined) assertSnippetShapes(raw.snippets); + for (const field of ['originalFullContent', 'modifiedFullContent']) { + if (raw[field] !== undefined && raw[field] !== null && typeof raw[field] !== 'string') { + throw new Error(`Invalid ${field}`); + } + } + if (raw.isNewFile !== undefined && typeof raw.isNewFile !== 'boolean') { + throw new Error('Invalid isNewFile'); + } +} + +export function parseReviewDecisionPersistenceScope( + value: unknown, + scope: ReviewFileScope +): ReviewDecisionPersistenceScope | null { + if (value === undefined) return null; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid decision persistence scope'); + } + const raw = value as Record; + assertNonEmptyString(raw.scopeKey, 'decisionPersistenceScope.scopeKey'); + assertNonEmptyString(raw.scopeToken, 'decisionPersistenceScope.scopeToken'); + if (raw.scopeToken.length > 32 * 1024 * 1024 || raw.scopeToken.includes('\0')) { + throw new Error('Invalid decision persistence scope token'); + } + const expectedScopeKey = scope.taskId + ? `task-${scope.taskId}` + : scope.memberName + ? `agent-${scope.memberName}` + : null; + if (!expectedScopeKey || raw.scopeKey !== expectedScopeKey) { + throw new Error('Decision persistence scope does not match the authoritative review'); + } + return { scopeKey: raw.scopeKey, scopeToken: raw.scopeToken }; +} + +export function parseReviewHistoryScopeIdentity(scopeKey: string): ReviewHistoryScopeIdentity { + if (scopeKey.startsWith('task-')) { + return { taskId: scopeKey.slice('task-'.length) }; + } + if (scopeKey.startsWith('agent-')) { + return { memberName: scopeKey.slice('agent-'.length) }; + } + throw new Error('Review decision scope cannot authorize history'); +} diff --git a/src/features/change-review/core/domain/reviewFileWatchPolicy.ts b/src/features/change-review/core/domain/reviewFileWatchPolicy.ts new file mode 100644 index 0000000000..7061044b9e --- /dev/null +++ b/src/features/change-review/core/domain/reviewFileWatchPolicy.ts @@ -0,0 +1,5 @@ +export function normalizeReviewWatchedFiles(filePaths: unknown): string[] { + return Array.isArray(filePaths) + ? filePaths.filter((filePath): filePath is string => typeof filePath === 'string') + : []; +} diff --git a/src/features/change-review/core/domain/reviewHunkCountPolicy.ts b/src/features/change-review/core/domain/reviewHunkCountPolicy.ts new file mode 100644 index 0000000000..291dade4d4 --- /dev/null +++ b/src/features/change-review/core/domain/reviewHunkCountPolicy.ts @@ -0,0 +1,7 @@ +export function resolveChangeReviewFileHunkCount( + filePath: string, + snippetsLength: number, + fileChunkCounts: Readonly> +): number { + return fileChunkCounts[filePath] ?? snippetsLength; +} diff --git a/src/features/change-review/core/domain/reviewQueryPolicy.ts b/src/features/change-review/core/domain/reviewQueryPolicy.ts new file mode 100644 index 0000000000..567e94a139 --- /dev/null +++ b/src/features/change-review/core/domain/reviewQueryPolicy.ts @@ -0,0 +1,69 @@ +import type { TaskChangeRequestOptions, TeamTaskChangeSummaryRequest } from '@shared/types/review'; + +const TEAM_TASK_CHANGE_SUMMARY_RAW_REQUEST_LIMIT = 1_000; +const TEAM_TASK_CHANGE_SUMMARY_UNIQUE_REQUEST_LIMIT = 201; + +export function sanitizeTaskChangeOptions(options?: unknown): TaskChangeRequestOptions | undefined { + if (!options || typeof options !== 'object') { + return undefined; + } + + const raw = options as Record; + return { + owner: typeof raw.owner === 'string' ? raw.owner : undefined, + status: typeof raw.status === 'string' ? raw.status : undefined, + since: typeof raw.since === 'string' ? raw.since : undefined, + intervals: Array.isArray(raw.intervals) + ? (raw.intervals.filter( + (interval): interval is { startedAt: string; completedAt?: string } => + Boolean(interval) && + typeof interval === 'object' && + typeof (interval as Record).startedAt === 'string' && + ((interval as Record).completedAt === undefined || + typeof (interval as Record).completedAt === 'string') + ) as { startedAt: string; completedAt?: string }[]) + : undefined, + stateBucket: + raw.stateBucket === 'approved' || + raw.stateBucket === 'review' || + raw.stateBucket === 'completed' || + raw.stateBucket === 'active' + ? raw.stateBucket + : undefined, + summaryOnly: raw.summaryOnly === true, + forceFresh: raw.forceFresh === true, + }; +} + +export function sanitizeTeamTaskChangeSummaryRequests( + requests: unknown +): TeamTaskChangeSummaryRequest[] { + if (!Array.isArray(requests)) { + return []; + } + + const sanitizedRequests: TeamTaskChangeSummaryRequest[] = []; + const seenTaskIds = new Set(); + for (const request of requests.slice(0, TEAM_TASK_CHANGE_SUMMARY_RAW_REQUEST_LIMIT)) { + if (sanitizedRequests.length >= TEAM_TASK_CHANGE_SUMMARY_UNIQUE_REQUEST_LIMIT) { + break; + } + if (!request || typeof request !== 'object') { + continue; + } + const raw = request as Record; + if (typeof raw.taskId !== 'string') { + continue; + } + const taskId = raw.taskId.trim(); + if (!taskId || seenTaskIds.has(taskId)) { + continue; + } + seenTaskIds.add(taskId); + sanitizedRequests.push({ + taskId, + options: sanitizeTaskChangeOptions(raw.options), + }); + } + return sanitizedRequests; +} diff --git a/src/features/change-review/core/domain/reviewScopePolicy.ts b/src/features/change-review/core/domain/reviewScopePolicy.ts new file mode 100644 index 0000000000..3d8ebf3b1a --- /dev/null +++ b/src/features/change-review/core/domain/reviewScopePolicy.ts @@ -0,0 +1,223 @@ +import type { + FileChangeWithContent, + ReviewFileScope, + ReviewRenameRecoveryExpectation, + SnippetDiff, +} from '@shared/types/review'; + +export const MAX_REVIEW_SNIPPETS_PER_FILE = 10_000; +export const MAX_REVIEW_HUNK_DECISIONS_PER_FILE = 100_000; + +export interface ReviewIdentityValidationResult { + valid: boolean; + value?: string; + error?: string; +} + +export interface ReviewIdentityValidators { + validateTeamName(value: unknown): ReviewIdentityValidationResult; + validateTaskId(value: unknown): ReviewIdentityValidationResult; +} + +export interface ReviewRootConfig { + projectPath?: string; + members?: readonly { cwd?: string }[]; +} + +export function assertNonEmptyString(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Invalid ${field}: non-empty string required`); + } +} + +export function assertOptionalString( + value: unknown, + field: string +): asserts value is string | undefined { + if (value !== undefined && typeof value !== 'string') { + throw new Error(`Invalid ${field}: string required`); + } +} + +export function normalizeReviewIdentity(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +export function parseReviewFileScope( + value: unknown, + validators: ReviewIdentityValidators +): ReviewFileScope { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid review scope'); + } + const raw = value as Record; + const team = validators.validateTeamName(raw.teamName); + if (!team.valid || !team.value) { + throw new Error(team.error ?? 'Invalid teamName'); + } + assertOptionalString(raw.memberName, 'memberName'); + assertOptionalString(raw.taskId, 'taskId'); + const memberName = normalizeReviewIdentity(raw.memberName); + const taskId = normalizeReviewIdentity(raw.taskId); + if (taskId) { + const task = validators.validateTaskId(taskId); + if (!task.valid || !task.value) { + throw new Error(task.error ?? 'Invalid taskId'); + } + } + if (memberName && (memberName.length > 256 || memberName.includes('\0'))) { + throw new Error('Invalid memberName'); + } + return { + teamName: team.value, + ...(memberName ? { memberName } : {}), + ...(taskId ? { taskId } : {}), + }; +} + +export function parseReviewRenameRecoveryExpectation( + value: unknown +): ReviewRenameRecoveryExpectation { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid rename recovery expectation'); + } + const raw = value as Record; + const relation = raw.relation; + if (!relation || typeof relation !== 'object' || Array.isArray(relation)) { + throw new Error('Invalid rename recovery relation'); + } + const relationRaw = relation as Record; + if ( + typeof raw.eventId !== 'string' || + !raw.eventId || + raw.eventId.length > 512 || + (raw.beforeHash !== null && typeof raw.beforeHash !== 'string') || + (raw.afterHash !== null && typeof raw.afterHash !== 'string') || + relationRaw.kind !== 'rename' || + typeof relationRaw.oldPath !== 'string' || + !relationRaw.oldPath || + relationRaw.oldPath.length > 4096 || + relationRaw.oldPath.includes('\0') || + typeof relationRaw.newPath !== 'string' || + !relationRaw.newPath || + relationRaw.newPath.length > 4096 || + relationRaw.newPath.includes('\0') + ) { + throw new Error('Invalid rename recovery expectation'); + } + if ( + (typeof raw.beforeHash === 'string' && raw.beforeHash.length > 512) || + (typeof raw.afterHash === 'string' && raw.afterHash.length > 512) + ) { + throw new Error('Invalid rename recovery expectation'); + } + return { + eventId: raw.eventId, + beforeHash: raw.beforeHash, + afterHash: raw.afterHash, + relation: { + kind: 'rename', + oldPath: relationRaw.oldPath, + newPath: relationRaw.newPath, + }, + }; +} + +export function collectReviewRootCandidates(config: ReviewRootConfig): string[] { + const roots: string[] = []; + const add = (value: unknown): void => { + if (typeof value === 'string' && value.trim()) { + roots.push(value.trim()); + } + }; + add(config.projectPath); + const members = Array.isArray(config.members) + ? (config.members as readonly { cwd?: string }[]) + : []; + for (const member of members) { + add(member.cwd); + } + return roots; +} + +export function assertExpectedAuthoritativeRename( + content: FileChangeWithContent, + expectation: ReviewRenameRecoveryExpectation +): void { + const renameLedger = content.snippets.find( + (snippet) => snippet.ledger?.relation?.kind === 'rename' + )?.ledger; + const relation = renameLedger?.relation; + if (!renameLedger || relation?.kind !== 'rename') { + throw new Error('Review file is not an authoritative ledger rename'); + } + if ( + renameLedger.eventId !== expectation.eventId || + (renameLedger.beforeHash ?? null) !== expectation.beforeHash || + (renameLedger.afterHash ?? null) !== expectation.afterHash || + relation.oldPath !== expectation.relation.oldPath || + relation.newPath !== expectation.relation.newPath + ) { + throw new Error('Review changes were updated; refusing stale rename recovery'); + } +} + +export function assertHunkIndices(value: unknown): asserts value is number[] { + if ( + !Array.isArray(value) || + value.length > MAX_REVIEW_HUNK_DECISIONS_PER_FILE || + value.some((index) => !Number.isSafeInteger(index) || index < 0) + ) { + throw new Error('Invalid hunkIndices'); + } +} + +export function assertSnippetShapes(value: unknown): asserts value is SnippetDiff[] { + if (!Array.isArray(value) || value.length > MAX_REVIEW_SNIPPETS_PER_FILE) { + throw new Error('Invalid snippets array'); + } + for (const snippet of value) { + if (!snippet || typeof snippet !== 'object' || Array.isArray(snippet)) { + throw new Error('Invalid review snippet'); + } + const raw = snippet as Record; + for (const field of [ + 'toolUseId', + 'filePath', + 'toolName', + 'type', + 'oldString', + 'newString', + 'timestamp', + ]) { + if (typeof raw[field] !== 'string') { + throw new Error(`Invalid review snippet ${field}`); + } + } + if (typeof raw.replaceAll !== 'boolean' || typeof raw.isError !== 'boolean') { + throw new Error('Invalid review snippet flags'); + } + if (raw.ledger !== undefined) { + if (!raw.ledger || typeof raw.ledger !== 'object' || Array.isArray(raw.ledger)) { + throw new Error('Invalid review ledger metadata'); + } + const relation = (raw.ledger as Record).relation; + if (relation !== undefined) { + if (!relation || typeof relation !== 'object' || Array.isArray(relation)) { + throw new Error('Invalid review relation'); + } + const relationRaw = relation as Record; + if ( + (relationRaw.kind !== 'rename' && relationRaw.kind !== 'copy') || + typeof relationRaw.oldPath !== 'string' || + !relationRaw.oldPath || + typeof relationRaw.newPath !== 'string' || + !relationRaw.newPath + ) { + throw new Error('Invalid review relation'); + } + } + } + } +} diff --git a/src/features/change-review/index.ts b/src/features/change-review/index.ts new file mode 100644 index 0000000000..ef70edd976 --- /dev/null +++ b/src/features/change-review/index.ts @@ -0,0 +1 @@ +export { resolveChangeReviewFileHunkCount } from './core/domain/reviewHunkCountPolicy'; diff --git a/src/features/change-review/main/adapters/output/presenters/ReviewFileWatchEventPresenter.ts b/src/features/change-review/main/adapters/output/presenters/ReviewFileWatchEventPresenter.ts new file mode 100644 index 0000000000..4e1d73b3b0 --- /dev/null +++ b/src/features/change-review/main/adapters/output/presenters/ReviewFileWatchEventPresenter.ts @@ -0,0 +1,17 @@ +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; +import { REVIEW_FILE_CHANGE } from '@preload/constants/ipcChannels'; + +import type { EditorFileChangeEvent } from '@shared/types/editor'; +import type { BrowserWindow } from 'electron'; + +export class ReviewFileWatchEventPresenter { + private mainWindow: BrowserWindow | null = null; + + setMainWindow(window: BrowserWindow | null): void { + this.mainWindow = window; + } + + present(event: EditorFileChangeEvent): void { + safeSendToRenderer(this.mainWindow, REVIEW_FILE_CHANGE, event); + } +} diff --git a/src/features/change-review/main/application/ReviewDecisionPersistenceApplication.ts b/src/features/change-review/main/application/ReviewDecisionPersistenceApplication.ts new file mode 100644 index 0000000000..861e35a20e --- /dev/null +++ b/src/features/change-review/main/application/ReviewDecisionPersistenceApplication.ts @@ -0,0 +1,113 @@ +import { + assertReviewDecisionShape, + parseReviewDecisionPersistenceScope, + parseReviewHistoryScopeIdentity, +} from '../../core/domain/reviewDecisionPersistencePolicy'; + +import type { + ReviewDecisionHistoryScopeAuthorization, + ReviewDecisionPersistenceDependencies, + ReviewDraftHistoryScopeAuthorization, +} from './ReviewDecisionPersistencePorts'; +import type { ReviewPathAuthorization } from './ReviewScopeAuthorizationPorts'; +import type { + FileReviewDecision, + ReviewDecisionPersistenceScope, + ReviewFileScope, +} from '@shared/types/review'; + +export class ReviewDecisionPersistenceApplication { + private readonly queues = new Map>(); + + constructor(private readonly dependencies: ReviewDecisionPersistenceDependencies) {} + + assertDecisionShape(value: unknown): asserts value is FileReviewDecision { + assertReviewDecisionShape(value); + } + + parsePersistenceScope( + value: unknown, + scope: ReviewFileScope + ): ReviewDecisionPersistenceScope | null { + return parseReviewDecisionPersistenceScope(value, scope); + } + + async withLock( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope, + operation: () => Promise + ): Promise { + const key = `${teamName}:${persistenceScope.scopeKey}`; + const previous = this.queues.get(key) ?? Promise.resolve(); + let release = (): void => undefined; + const current = new Promise((resolve) => { + release = resolve; + }); + const queueTail = previous.then( + () => current, + () => current + ); + this.queues.set(key, queueTail); + + await previous.catch(() => undefined); + try { + return await this.dependencies.locks.withLogicalScopeLock( + teamName, + persistenceScope.scopeKey, + () => + this.dependencies.locks.withPersistenceScopeLock(teamName, persistenceScope, operation) + ); + } finally { + release(); + if (this.queues.get(key) === queueTail) { + this.queues.delete(key); + } + } + } + + async authorizeDraftHistoryScope( + teamName: string, + scopeKey: string + ): Promise { + const authorization = await this.resolveHistoryAuthorization(teamName, scopeKey); + return { + isCurrentReviewedFile: (filePath) => + this.dependencies.paths.isAbsoluteNormalized(filePath) && + Boolean( + authorization.reviewedFiles?.has(this.dependencies.scope.normalizeIdentityPath(filePath)) + ), + assertCurrentReviewedFile: async (filePath) => { + await this.dependencies.scope.validateFilePath(authorization, filePath, { + requireReviewedFile: true, + }); + }, + }; + } + + async authorizeDecisionHistoryScope( + teamName: string, + scopeKey: string + ): Promise { + const authorization = await this.resolveHistoryAuthorization(teamName, scopeKey); + return { + files: authorization.reviewedFiles ? [...authorization.reviewedFiles.values()] : null, + normalizePath: (filePath) => this.dependencies.scope.normalizeIdentityPath(filePath), + resolveFile: (filePath) => + this.dependencies.scope.getAuthoritativeFile(authorization, filePath), + }; + } + + private async resolveHistoryAuthorization( + teamName: string, + scopeKey: string + ): Promise { + const scope = this.dependencies.scope.parse({ + teamName, + ...parseReviewHistoryScopeIdentity(scopeKey), + }); + const { authorization } = await this.dependencies.scope.resolve(scope, { + requireIdentity: true, + }); + return authorization; + } +} diff --git a/src/features/change-review/main/application/ReviewDecisionPersistencePorts.ts b/src/features/change-review/main/application/ReviewDecisionPersistencePorts.ts new file mode 100644 index 0000000000..7713089e49 --- /dev/null +++ b/src/features/change-review/main/application/ReviewDecisionPersistencePorts.ts @@ -0,0 +1,55 @@ +import type { ReviewPathAuthorization } from './ReviewScopeAuthorizationPorts'; +import type { + FileChangeSummary, + ReviewDecisionPersistenceScope, + ReviewFileScope, +} from '@shared/types/review'; + +export interface ReviewDecisionPersistenceScopePort { + parse(value: unknown): ReviewFileScope; + resolve( + value: unknown, + options: { requireIdentity: true } + ): Promise<{ scope: ReviewFileScope; authorization: ReviewPathAuthorization }>; + normalizeIdentityPath(filePath: string): string; + validateFilePath( + authorization: ReviewPathAuthorization, + filePath: unknown, + options: { requireReviewedFile: true } + ): Promise; + getAuthoritativeFile(authorization: ReviewPathAuthorization, filePath: string): FileChangeSummary; +} + +export interface ReviewDecisionPersistencePathPort { + isAbsoluteNormalized(filePath: string): boolean; +} + +export interface ReviewDecisionPersistenceLockPort { + withLogicalScopeLock( + teamName: string, + scopeKey: string, + operation: () => Promise + ): Promise; + withPersistenceScopeLock( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope, + operation: () => Promise + ): Promise; +} + +export interface ReviewDecisionPersistenceDependencies { + scope: ReviewDecisionPersistenceScopePort; + paths: ReviewDecisionPersistencePathPort; + locks: ReviewDecisionPersistenceLockPort; +} + +export interface ReviewDraftHistoryScopeAuthorization { + isCurrentReviewedFile(filePath: string): boolean; + assertCurrentReviewedFile(filePath: string): Promise; +} + +export interface ReviewDecisionHistoryScopeAuthorization { + files: FileChangeSummary[] | null; + normalizePath(filePath: string): string; + resolveFile(filePath: string): FileChangeSummary; +} diff --git a/src/features/change-review/main/application/ReviewFileWatchApplication.ts b/src/features/change-review/main/application/ReviewFileWatchApplication.ts new file mode 100644 index 0000000000..a3b0e2b4fb --- /dev/null +++ b/src/features/change-review/main/application/ReviewFileWatchApplication.ts @@ -0,0 +1,70 @@ +import { normalizeReviewWatchedFiles } from '../../core/domain/reviewFileWatchPolicy'; + +import type { + ReviewFileWatchConfiguration, + ReviewFileWatchDependencies, + ReviewFileWatcherPort, + ReviewFileWatchOperation, + ReviewProjectPathValidator, +} from './ReviewFileWatchPorts'; + +export class ReviewFileWatchApplication { + private fileWatcher: ReviewFileWatcherPort; + private projectPathValidator: ReviewProjectPathValidator; + private projectRoot: string | null = null; + private requestGeneration = 0; + + constructor(private readonly dependencies: ReviewFileWatchDependencies) { + this.fileWatcher = dependencies.defaultWatcher; + this.projectPathValidator = dependencies.defaultProjectPathValidator; + } + + supersedePendingRequests(): void { + this.requestGeneration += 1; + } + + configure(configuration: ReviewFileWatchConfiguration): void { + const nextFileWatcher = configuration.fileWatcher ?? this.dependencies.defaultWatcher; + if (this.fileWatcher !== nextFileWatcher) { + this.fileWatcher.stop(); + this.projectRoot = null; + this.fileWatcher = nextFileWatcher; + } + this.projectPathValidator = + configuration.projectPathValidator ?? this.dependencies.defaultProjectPathValidator; + } + + prepareWatch(projectPath: string, filePaths: unknown): ReviewFileWatchOperation { + const requestGeneration = ++this.requestGeneration; + return async () => { + const normalizedProjectPath = await this.projectPathValidator(projectPath); + if (requestGeneration !== this.requestGeneration) return; + const shouldRestart = + this.projectRoot !== normalizedProjectPath || !this.fileWatcher.isWatching(); + + if (shouldRestart) { + this.fileWatcher.stop(); + this.projectRoot = normalizedProjectPath; + this.fileWatcher.start(normalizedProjectPath, (event) => { + this.dependencies.events.present(event); + }); + } + + this.fileWatcher.setWatchedFiles(normalizeReviewWatchedFiles(filePaths)); + }; + } + + prepareUnwatch(): ReviewFileWatchOperation { + this.requestGeneration += 1; + return async () => { + this.fileWatcher.stop(); + this.projectRoot = null; + }; + } + + dispose(): void { + this.fileWatcher.stop(); + this.projectRoot = null; + this.requestGeneration += 1; + } +} diff --git a/src/features/change-review/main/application/ReviewFileWatchPorts.ts b/src/features/change-review/main/application/ReviewFileWatchPorts.ts new file mode 100644 index 0000000000..301db94175 --- /dev/null +++ b/src/features/change-review/main/application/ReviewFileWatchPorts.ts @@ -0,0 +1,27 @@ +import type { EditorFileChangeEvent } from '@shared/types/editor'; + +export interface ReviewFileWatcherPort { + isWatching(): boolean; + setWatchedFiles(filePaths: string[]): void; + start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void; + stop(): void; +} + +export type ReviewProjectPathValidator = (projectPath: string) => Promise; + +export interface ReviewFileWatchEventPort { + present(event: EditorFileChangeEvent): void; +} + +export interface ReviewFileWatchDependencies { + defaultWatcher: ReviewFileWatcherPort; + defaultProjectPathValidator: ReviewProjectPathValidator; + events: ReviewFileWatchEventPort; +} + +export interface ReviewFileWatchConfiguration { + fileWatcher?: ReviewFileWatcherPort; + projectPathValidator?: ReviewProjectPathValidator; +} + +export type ReviewFileWatchOperation = () => Promise; diff --git a/src/features/change-review/main/application/ReviewQueryApplication.ts b/src/features/change-review/main/application/ReviewQueryApplication.ts new file mode 100644 index 0000000000..e2cdbf1269 --- /dev/null +++ b/src/features/change-review/main/application/ReviewQueryApplication.ts @@ -0,0 +1,75 @@ +import { assertOptionalString, assertSnippetShapes } from '../../core/domain/reviewScopePolicy'; + +import type { ReviewQueryDependencies, ReviewQueryGitLogEntry } from './ReviewQueryPorts'; +import type { + AgentChangeSet, + ChangeStats, + FileChangeWithContent, + TaskChangeRequestOptions, + TaskChangeSetV2, + TeamTaskChangeSummariesResponse, + TeamTaskChangeSummaryRequest, +} from '@shared/types/review'; + +export class ReviewQueryApplication { + constructor(private readonly dependencies: ReviewQueryDependencies) {} + + getAgentChanges(teamName: string, memberName: string): Promise { + return this.dependencies.changes.getAgentChanges(teamName, memberName); + } + + getTaskChanges( + teamName: string, + taskId: string, + options?: TaskChangeRequestOptions + ): Promise { + return this.dependencies.changes.getTaskChanges(teamName, taskId, options); + } + + getTeamTaskChangeSummaries( + teamName: string, + requests: TeamTaskChangeSummaryRequest[] + ): Promise { + return this.dependencies.changes.getTeamTaskChangeSummaries(teamName, requests); + } + + invalidateTaskChangeSummaries(teamName: string, taskIds: string[]): Promise { + return this.dependencies.changes.invalidateTaskChangeSummaries( + teamName, + Array.isArray(taskIds) ? taskIds.filter((taskId) => typeof taskId === 'string') : [] + ); + } + + getChangeStats(teamName: string, memberName: string): Promise { + return this.dependencies.changes.getChangeStats(teamName, memberName); + } + + async getFileContent( + teamNameValue: unknown, + memberNameValue: unknown, + filePathValue: unknown, + snippetsValue: unknown = [] + ): Promise { + assertOptionalString(memberNameValue, 'memberName'); + assertSnippetShapes(snippetsValue); + const { scope, authorization } = await this.dependencies.scope.resolve({ + teamName: teamNameValue, + memberName: this.dependencies.scope.normalizeIdentity(memberNameValue), + }); + const filePath = await this.dependencies.scope.validateFilePath(authorization, filePathValue, { + requireReviewedFile: false, + }); + await this.dependencies.scope.validateSnippets(authorization, snippetsValue); + const content = await this.dependencies.content.getFileContent( + scope.teamName, + scope.memberName ?? '', + filePath, + snippetsValue + ); + return this.dependencies.snapshots.register(scope.teamName, filePath, snippetsValue, content); + } + + getGitFileLog(projectPath: string, filePath: string): Promise { + return this.dependencies.gitHistory.getFileLog(projectPath, filePath); + } +} diff --git a/src/features/change-review/main/application/ReviewQueryPorts.ts b/src/features/change-review/main/application/ReviewQueryPorts.ts new file mode 100644 index 0000000000..380452e798 --- /dev/null +++ b/src/features/change-review/main/application/ReviewQueryPorts.ts @@ -0,0 +1,76 @@ +import type { ReviewPathAuthorization } from './ReviewScopeAuthorizationPorts'; +import type { + AgentChangeSet, + ChangeStats, + FileChangeWithContent, + ReviewFileScope, + SnippetDiff, + TaskChangeRequestOptions, + TaskChangeSetV2, + TeamTaskChangeSummariesResponse, + TeamTaskChangeSummaryRequest, +} from '@shared/types/review'; + +export interface ReviewQueryChangesPort { + getAgentChanges(teamName: string, memberName: string): Promise; + getTaskChanges( + teamName: string, + taskId: string, + options?: TaskChangeRequestOptions + ): Promise; + getTeamTaskChangeSummaries( + teamName: string, + requests: TeamTaskChangeSummaryRequest[] + ): Promise; + invalidateTaskChangeSummaries(teamName: string, taskIds: string[]): Promise; + getChangeStats(teamName: string, memberName: string): Promise; +} + +export interface ReviewQueryScopePort { + normalizeIdentity(value: string | undefined): string | undefined; + resolve( + value: unknown + ): Promise<{ scope: ReviewFileScope; authorization: ReviewPathAuthorization }>; + validateFilePath( + authorization: ReviewPathAuthorization, + filePath: unknown, + options: { requireReviewedFile: boolean } + ): Promise; + validateSnippets(authorization: ReviewPathAuthorization, snippets: SnippetDiff[]): Promise; +} + +export interface ReviewQueryContentPort { + getFileContent( + teamName: string, + memberName: string, + filePath: string, + snippets: SnippetDiff[] + ): Promise; +} + +export interface ReviewQuerySnapshotPort { + register( + teamName: string, + filePath: string, + snippets: SnippetDiff[], + content: FileChangeWithContent + ): FileChangeWithContent; +} + +export interface ReviewQueryGitLogEntry { + hash: string; + timestamp: string; + message: string; +} + +export interface ReviewQueryGitHistoryPort { + getFileLog(projectPath: string, filePath: string): Promise; +} + +export interface ReviewQueryDependencies { + changes: ReviewQueryChangesPort; + scope: ReviewQueryScopePort; + content: ReviewQueryContentPort; + snapshots: ReviewQuerySnapshotPort; + gitHistory: ReviewQueryGitHistoryPort; +} diff --git a/src/features/change-review/main/application/ReviewScopeAuthorizationApplication.ts b/src/features/change-review/main/application/ReviewScopeAuthorizationApplication.ts new file mode 100644 index 0000000000..9aff046995 --- /dev/null +++ b/src/features/change-review/main/application/ReviewScopeAuthorizationApplication.ts @@ -0,0 +1,384 @@ +import { + assertExpectedAuthoritativeRename, + assertNonEmptyString, + assertSnippetShapes, + collectReviewRootCandidates, + normalizeReviewIdentity, + parseReviewFileScope, + parseReviewRenameRecoveryExpectation, +} from '../../core/domain/reviewScopePolicy'; + +import type { + AuthorizedReviewRoot, + ReviewPathAuthorization, + ReviewScopeAuthorizationDependencies, +} from './ReviewScopeAuthorizationPorts'; +import type { + FileChangeSummary, + FileChangeWithContent, + ReviewFileScope, + ReviewRenameRecoveryExpectation, + SnippetDiff, +} from '@shared/types/review'; + +export class ReviewScopeAuthorizationApplication { + constructor(private readonly dependencies: ReviewScopeAuthorizationDependencies) {} + + normalizeReviewIdentity(value: string | undefined): string | undefined { + return normalizeReviewIdentity(value); + } + + parseReviewFileScope(value: unknown): ReviewFileScope { + return parseReviewFileScope(value, this.dependencies.validators); + } + + parseReviewRenameRecoveryExpectation(value: unknown): ReviewRenameRecoveryExpectation { + return parseReviewRenameRecoveryExpectation(value); + } + + normalizeReviewPathForIdentity(filePath: string): string { + return this.dependencies.paths.normalizeIdentity(filePath); + } + + async resolveReviewPathAuthorization( + scopeValue: unknown, + options: { requireIdentity?: boolean } = {} + ): Promise<{ scope: ReviewFileScope; authorization: ReviewPathAuthorization }> { + const scope = this.parseReviewFileScope(scopeValue); + if (options.requireIdentity && !scope.taskId && !scope.memberName) { + throw new Error('Review mutation requires taskId or memberName'); + } + const config = await this.dependencies.config.getConfig(scope.teamName); + if (!config) { + throw new Error(`Review team config is unavailable: ${scope.teamName}`); + } + + const rootCandidates = [ + ...new Set( + collectReviewRootCandidates(config).map((root) => this.dependencies.paths.normalize(root)) + ), + ]; + const roots = ( + await Promise.all(rootCandidates.map((root) => this.resolveAuthorizedReviewRoot(root))) + ).filter((root): root is AuthorizedReviewRoot => Boolean(root)); + if (roots.length === 0) { + throw new Error('Review project/worktree root is unavailable'); + } + + let reviewedFiles: Map | null = null; + let resolutionMemberName = scope.memberName ?? ''; + if (scope.taskId) { + const changeSet = await this.dependencies.changes.getTaskChanges( + scope.teamName, + scope.taskId + ); + reviewedFiles = this.collectAuthoritativeReviewedFiles(changeSet.files); + const authoritativeMemberName = normalizeReviewIdentity(changeSet.scope?.memberName); + if ( + scope.memberName && + authoritativeMemberName && + scope.memberName !== authoritativeMemberName + ) { + throw new Error('Review memberName does not match the authoritative task scope'); + } + resolutionMemberName = authoritativeMemberName ?? ''; + } else if (scope.memberName) { + const changeSet = await this.dependencies.changes.getAgentChanges( + scope.teamName, + scope.memberName + ); + reviewedFiles = this.collectAuthoritativeReviewedFiles(changeSet.files); + } + + return { scope, authorization: { roots, reviewedFiles, resolutionMemberName } }; + } + + async validateAuthorizedReviewFilePath( + authorization: ReviewPathAuthorization, + filePathValue: unknown, + options: { requireReviewedFile: boolean; rejectHardlinks?: boolean } + ): Promise { + assertNonEmptyString(filePathValue, 'filePath'); + if (!this.dependencies.paths.isAbsolute(filePathValue)) { + throw new Error('Review file path must be absolute'); + } + const normalizedPath = this.dependencies.paths.normalize(filePathValue); + if (this.dependencies.paths.isSensitive(normalizedPath)) { + throw new Error('Access to sensitive files is not allowed'); + } + if ( + options.requireReviewedFile && + !authorization.reviewedFiles?.has(this.normalizeReviewPathForIdentity(normalizedPath)) + ) { + throw new Error('File is not part of the reviewed scope'); + } + + let targetRealPath: string; + let targetStat: Awaited< + ReturnType + > | null = null; + let resolvedStat: Awaited< + ReturnType + > | null = null; + try { + targetStat = await this.dependencies.files.lstat(normalizedPath); + targetRealPath = this.dependencies.paths.normalize( + await this.dependencies.files.realpath(normalizedPath) + ); + resolvedStat = + targetStat.kind === 'symbolic-link' + ? await this.dependencies.files.stat(targetRealPath) + : targetStat; + if (resolvedStat.kind !== 'file') { + throw new Error('Review target must be a regular file'); + } + } catch (error) { + const code = this.getErrorCode(error); + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } + targetRealPath = await this.resolveNearestExistingRealPath( + this.dependencies.paths.dirname(normalizedPath) + ); + } + if (this.dependencies.paths.isSensitive(targetRealPath)) { + throw new Error('Access to sensitive files is not allowed'); + } + + if (!this.isAuthorizedPath(authorization, normalizedPath, targetRealPath)) { + throw new Error('Review file path is outside the authoritative project/worktree'); + } + if (options.rejectHardlinks && targetStat && resolvedStat) { + if (targetStat.kind !== 'symbolic-link' && resolvedStat.linkCount > 1) { + await this.dependencies.files.cleanupOwnedTemporaryLinks(normalizedPath); + targetStat = await this.dependencies.files.lstat(normalizedPath); + targetRealPath = this.dependencies.paths.normalize( + await this.dependencies.files.realpath(normalizedPath) + ); + resolvedStat = + targetStat.kind === 'symbolic-link' + ? await this.dependencies.files.stat(targetRealPath) + : targetStat; + const stillAllowed = + !this.dependencies.paths.isSensitive(targetRealPath) && + this.isAuthorizedPath(authorization, normalizedPath, targetRealPath); + if (!stillAllowed || resolvedStat.kind !== 'file') { + throw new Error('Review file path changed during authorization'); + } + } + const ownedReviewTransactionLink = + targetStat.kind !== 'symbolic-link' && + resolvedStat.linkCount > 1 && + (await this.dependencies.files.isOwnedTransactionHardlink(normalizedPath)); + if ( + targetStat.kind === 'symbolic-link' || + (resolvedStat.linkCount > 1 && !ownedReviewTransactionLink) + ) { + throw new Error('Review mutation refuses symbolic or multiply-linked files'); + } + } + return normalizedPath; + } + + getAuthoritativeReviewedFile( + authorization: ReviewPathAuthorization, + filePath: string + ): FileChangeSummary { + const file = authorization.reviewedFiles?.get(this.normalizeReviewPathForIdentity(filePath)); + if (!file) { + throw new Error('File is not part of the reviewed scope'); + } + return file; + } + + async resolveAuthoritativeFileContent( + scope: ReviewFileScope, + authorization: ReviewPathAuthorization, + filePath: string + ): Promise { + const authoritativeFile = this.getAuthoritativeReviewedFile(authorization, filePath); + assertSnippetShapes(authoritativeFile.snippets); + await this.validateSnippetPaths(authorization, authoritativeFile.snippets, { + requireReviewedFile: true, + }); + const resolved = await this.dependencies.content.getFileContent( + scope.teamName, + authorization.resolutionMemberName, + filePath, + authoritativeFile.snippets + ); + return { + ...resolved, + filePath, + snippets: authoritativeFile.snippets, + }; + } + + assertExpectedAuthoritativeRename( + content: FileChangeWithContent, + expectation: ReviewRenameRecoveryExpectation + ): void { + assertExpectedAuthoritativeRename(content, expectation); + } + + invalidateAuthoritativeReviewContent(content: FileChangeWithContent): void { + const paths = new Set([content.filePath]); + for (const snippet of content.snippets) { + paths.add(snippet.filePath); + const relation = snippet.ledger?.relation; + if (relation) { + paths.add(relation.oldPath); + paths.add(relation.newPath); + } + } + for (const filePath of paths) { + this.dependencies.content.invalidateFile(filePath); + } + } + + async validateSnippetPaths( + authorization: ReviewPathAuthorization, + snippets: SnippetDiff[], + options: { requireReviewedFile?: boolean; rejectHardlinks?: boolean } = {} + ): Promise { + const requireReviewedFile = options.requireReviewedFile === true; + await Promise.all( + snippets.map((snippet) => + this.validateAuthorizedReviewFilePath(authorization, snippet.filePath, { + requireReviewedFile, + rejectHardlinks: options.rejectHardlinks === true, + }) + ) + ); + + for (const snippet of snippets) { + const relation = snippet.ledger?.relation; + if (!relation) continue; + const slashFilePath = snippet.filePath.replace(/\\/g, '/'); + const relationPaths = [relation.oldPath, relation.newPath] as const; + if (relationPaths.every((relationPath) => this.dependencies.paths.isAbsolute(relationPath))) { + for (const relationPath of relationPaths) { + await this.validateAuthorizedReviewFilePath(authorization, relationPath, { + requireReviewedFile, + rejectHardlinks: options.rejectHardlinks === true, + }); + } + continue; + } + if (relationPaths.some((relationPath) => this.dependencies.paths.isAbsolute(relationPath))) { + throw new Error('Review relation paths must both be absolute or both be relative'); + } + + let resolvedRelationPaths: [string, string] | null = null; + for (const [anchorRelationPath, targetRelationPath] of [ + [relation.oldPath, relation.newPath], + [relation.newPath, relation.oldPath], + ] as const) { + const slashAnchor = anchorRelationPath.replace(/\\/g, '/'); + if ( + slashFilePath === slashAnchor || + slashFilePath.toLocaleLowerCase().endsWith(`/${slashAnchor.toLocaleLowerCase()}`) + ) { + const prefix = slashFilePath.slice(0, slashFilePath.length - slashAnchor.length); + const anchorPath = this.dependencies.paths.normalize(`${prefix}${slashAnchor}`); + const targetPath = this.dependencies.paths.normalize( + `${prefix}${targetRelationPath.replace(/\\/g, '/')}` + ); + resolvedRelationPaths = + anchorRelationPath === relation.oldPath + ? [anchorPath, targetPath] + : [targetPath, anchorPath]; + break; + } + } + if (!resolvedRelationPaths) { + throw new Error('Review relation is not anchored to an authoritative snippet path'); + } + for (const relationPath of resolvedRelationPaths) { + await this.validateAuthorizedReviewFilePath(authorization, relationPath, { + requireReviewedFile, + rejectHardlinks: options.rejectHardlinks === true, + }); + } + } + } + + private collectAuthoritativeReviewedFiles( + files: FileChangeSummary[] + ): Map { + const reviewedFiles = new Map(); + const add = (filePath: string | null, owner: FileChangeSummary): void => { + if (filePath && this.dependencies.paths.isAbsolute(filePath)) { + reviewedFiles.set(this.normalizeReviewPathForIdentity(filePath), owner); + } + }; + for (const file of files) { + add(file.filePath, file); + for (const snippet of file.snippets) { + add(snippet.filePath, file); + } + } + return reviewedFiles; + } + + private async resolveAuthorizedReviewRoot( + rootPath: string + ): Promise { + if (!this.dependencies.paths.isAbsolute(rootPath)) { + return null; + } + try { + const [rootStat, realPath] = await Promise.all([ + this.dependencies.files.stat(rootPath), + this.dependencies.files.realpath(rootPath), + ]); + if (rootStat.kind !== 'directory') { + return null; + } + return { + lexicalPath: this.dependencies.paths.normalize(rootPath), + realPath: this.dependencies.paths.normalize(realPath), + }; + } catch { + return null; + } + } + + private async resolveNearestExistingRealPath(filePath: string): Promise { + let current = filePath; + for (;;) { + try { + return this.dependencies.paths.normalize(await this.dependencies.files.realpath(current)); + } catch (error) { + const code = this.getErrorCode(error); + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } + const parent = this.dependencies.paths.dirname(current); + if (parent === current) { + throw new Error('No existing ancestor for review file path'); + } + current = parent; + } + } + } + + private isAuthorizedPath( + authorization: ReviewPathAuthorization, + normalizedPath: string, + targetRealPath: string + ): boolean { + return authorization.roots.some( + (root) => + (this.dependencies.paths.isWithinRoot(normalizedPath, root.lexicalPath) || + this.dependencies.paths.isWithinRoot(normalizedPath, root.realPath)) && + this.dependencies.paths.isWithinRoot(targetRealPath, root.realPath) + ); + } + + private getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String(error.code) + : undefined; + } +} diff --git a/src/features/change-review/main/application/ReviewScopeAuthorizationPorts.ts b/src/features/change-review/main/application/ReviewScopeAuthorizationPorts.ts new file mode 100644 index 0000000000..d6d95fc074 --- /dev/null +++ b/src/features/change-review/main/application/ReviewScopeAuthorizationPorts.ts @@ -0,0 +1,69 @@ +import type { + ReviewIdentityValidators, + ReviewRootConfig, +} from '../../core/domain/reviewScopePolicy'; +import type { FileChangeSummary, FileChangeWithContent, SnippetDiff } from '@shared/types/review'; + +export interface AuthorizedReviewRoot { + lexicalPath: string; + realPath: string; +} + +export interface ReviewPathAuthorization { + roots: AuthorizedReviewRoot[]; + reviewedFiles: Map | null; + resolutionMemberName: string; +} + +export interface ReviewScopeConfigPort { + getConfig(teamName: string): Promise; +} + +export interface ReviewScopeChangesPort { + getTaskChanges( + teamName: string, + taskId: string + ): Promise<{ files: FileChangeSummary[]; scope?: { memberName?: string } }>; + getAgentChanges(teamName: string, memberName: string): Promise<{ files: FileChangeSummary[] }>; +} + +export interface ReviewScopeContentPort { + getFileContent( + teamName: string, + memberName: string, + filePath: string, + snippets: SnippetDiff[] + ): Promise; + invalidateFile(filePath: string): void; +} + +export interface ReviewScopePathPort { + normalize(filePath: string): string; + dirname(filePath: string): string; + isAbsolute(filePath: string): boolean; + isWithinRoot(filePath: string, rootPath: string): boolean; + isSensitive(filePath: string): boolean; + normalizeIdentity(filePath: string): string; +} + +export interface ReviewScopeFileStat { + kind: 'directory' | 'file' | 'symbolic-link' | 'other'; + linkCount: number; +} + +export interface ReviewScopeFileSystemPort { + stat(filePath: string): Promise; + lstat(filePath: string): Promise; + realpath(filePath: string): Promise; + cleanupOwnedTemporaryLinks(filePath: string): Promise; + isOwnedTransactionHardlink(filePath: string): Promise; +} + +export interface ReviewScopeAuthorizationDependencies { + validators: ReviewIdentityValidators; + config: ReviewScopeConfigPort; + changes: ReviewScopeChangesPort; + content: ReviewScopeContentPort; + paths: ReviewScopePathPort; + files: ReviewScopeFileSystemPort; +} diff --git a/src/features/change-review/main/composition/createReviewDecisionPersistenceFeature.ts b/src/features/change-review/main/composition/createReviewDecisionPersistenceFeature.ts new file mode 100644 index 0000000000..69c7350adf --- /dev/null +++ b/src/features/change-review/main/composition/createReviewDecisionPersistenceFeature.ts @@ -0,0 +1,11 @@ +import { ReviewDecisionPersistenceApplication } from '../application/ReviewDecisionPersistenceApplication'; + +import type { ReviewDecisionPersistenceDependencies } from '../application/ReviewDecisionPersistencePorts'; + +export type ReviewDecisionPersistenceFeatureDependencies = ReviewDecisionPersistenceDependencies; + +export function createReviewDecisionPersistenceFeature( + dependencies: ReviewDecisionPersistenceFeatureDependencies +): ReviewDecisionPersistenceApplication { + return new ReviewDecisionPersistenceApplication(dependencies); +} diff --git a/src/features/change-review/main/composition/createReviewFileWatchFeature.ts b/src/features/change-review/main/composition/createReviewFileWatchFeature.ts new file mode 100644 index 0000000000..bba4be78cf --- /dev/null +++ b/src/features/change-review/main/composition/createReviewFileWatchFeature.ts @@ -0,0 +1,41 @@ +import { EditorFileWatcher } from '@main/services/editor'; + +import { ReviewFileWatchEventPresenter } from '../adapters/output/presenters/ReviewFileWatchEventPresenter'; +import { ReviewFileWatchApplication } from '../application/ReviewFileWatchApplication'; +import { validateReviewWatchProjectPath } from '../infrastructure/validateReviewWatchProjectPath'; + +import type { + ReviewFileWatchConfiguration, + ReviewFileWatchOperation, +} from '../application/ReviewFileWatchPorts'; +import type { BrowserWindow } from 'electron'; + +export interface ReviewFileWatchFeature { + supersedePendingRequests(): void; + configure(configuration: ReviewFileWatchConfiguration): void; + prepareWatch(projectPath: string, filePaths: unknown): ReviewFileWatchOperation; + prepareUnwatch(): ReviewFileWatchOperation; + dispose(): void; + setMainWindow(window: BrowserWindow | null): void; +} + +export function createReviewFileWatchFeature(): ReviewFileWatchFeature { + // Review is backed by a point-in-time diff. Ignoring startup changes can miss + // an external write and make Undo unsafe. + const defaultWatcher = new EditorFileWatcher({ ignoreStartupChanges: false }); + const presenter = new ReviewFileWatchEventPresenter(); + const application = new ReviewFileWatchApplication({ + defaultWatcher, + defaultProjectPathValidator: validateReviewWatchProjectPath, + events: presenter, + }); + + return { + supersedePendingRequests: () => application.supersedePendingRequests(), + configure: (configuration) => application.configure(configuration), + prepareWatch: (projectPath, filePaths) => application.prepareWatch(projectPath, filePaths), + prepareUnwatch: () => application.prepareUnwatch(), + dispose: () => application.dispose(), + setMainWindow: (window) => presenter.setMainWindow(window), + }; +} diff --git a/src/features/change-review/main/composition/createReviewQueryFeature.ts b/src/features/change-review/main/composition/createReviewQueryFeature.ts new file mode 100644 index 0000000000..9ce00b59c1 --- /dev/null +++ b/src/features/change-review/main/composition/createReviewQueryFeature.ts @@ -0,0 +1,9 @@ +import { ReviewQueryApplication } from '../application/ReviewQueryApplication'; + +import type { ReviewQueryDependencies } from '../application/ReviewQueryPorts'; + +export function createReviewQueryFeature( + dependencies: ReviewQueryDependencies +): ReviewQueryApplication { + return new ReviewQueryApplication(dependencies); +} diff --git a/src/features/change-review/main/composition/createReviewScopeAuthorizationFeature.ts b/src/features/change-review/main/composition/createReviewScopeAuthorizationFeature.ts new file mode 100644 index 0000000000..19e29fcd89 --- /dev/null +++ b/src/features/change-review/main/composition/createReviewScopeAuthorizationFeature.ts @@ -0,0 +1,22 @@ +import { ReviewScopeAuthorizationApplication } from '../application/ReviewScopeAuthorizationApplication'; +import { + nodeReviewScopeFileSystemPort, + nodeReviewScopePathPort, +} from '../infrastructure/nodeReviewScopeAuthorization'; + +import type { ReviewScopeAuthorizationDependencies } from '../application/ReviewScopeAuthorizationPorts'; + +export type ReviewScopeAuthorizationFeatureDependencies = Omit< + ReviewScopeAuthorizationDependencies, + 'files' | 'paths' +>; + +export function createReviewScopeAuthorizationFeature( + dependencies: ReviewScopeAuthorizationFeatureDependencies +): ReviewScopeAuthorizationApplication { + return new ReviewScopeAuthorizationApplication({ + ...dependencies, + files: nodeReviewScopeFileSystemPort, + paths: nodeReviewScopePathPort, + }); +} diff --git a/src/features/change-review/main/index.ts b/src/features/change-review/main/index.ts new file mode 100644 index 0000000000..13aabeaf1a --- /dev/null +++ b/src/features/change-review/main/index.ts @@ -0,0 +1,68 @@ +export { + assertReviewDecisionShape, + parseReviewDecisionPersistenceScope, + parseReviewHistoryScopeIdentity, + type ReviewHistoryScopeIdentity, +} from '../core/domain/reviewDecisionPersistencePolicy'; +export { normalizeReviewWatchedFiles } from '../core/domain/reviewFileWatchPolicy'; +export { + sanitizeTaskChangeOptions, + sanitizeTeamTaskChangeSummaryRequests, +} from '../core/domain/reviewQueryPolicy'; +export { + assertHunkIndices, + assertNonEmptyString, + assertOptionalString, + assertSnippetShapes, + MAX_REVIEW_HUNK_DECISIONS_PER_FILE, + MAX_REVIEW_SNIPPETS_PER_FILE, +} from '../core/domain/reviewScopePolicy'; +export { ReviewDecisionPersistenceApplication } from './application/ReviewDecisionPersistenceApplication'; +export type { + ReviewDecisionHistoryScopeAuthorization, + ReviewDecisionPersistenceDependencies, + ReviewDecisionPersistenceLockPort, + ReviewDecisionPersistencePathPort, + ReviewDecisionPersistenceScopePort, + ReviewDraftHistoryScopeAuthorization, +} from './application/ReviewDecisionPersistencePorts'; +export { ReviewFileWatchApplication } from './application/ReviewFileWatchApplication'; +export type { + ReviewFileWatchConfiguration, + ReviewFileWatchDependencies, + ReviewFileWatcherPort, + ReviewFileWatchEventPort, + ReviewFileWatchOperation, + ReviewProjectPathValidator, +} from './application/ReviewFileWatchPorts'; +export { ReviewQueryApplication } from './application/ReviewQueryApplication'; +export type { + ReviewQueryChangesPort, + ReviewQueryContentPort, + ReviewQueryDependencies, + ReviewQueryGitHistoryPort, + ReviewQueryGitLogEntry, + ReviewQueryScopePort, + ReviewQuerySnapshotPort, +} from './application/ReviewQueryPorts'; +export { ReviewScopeAuthorizationApplication } from './application/ReviewScopeAuthorizationApplication'; +export type { + AuthorizedReviewRoot, + ReviewPathAuthorization, + ReviewScopeAuthorizationDependencies, + ReviewScopeChangesPort, + ReviewScopeConfigPort, + ReviewScopeContentPort, + ReviewScopeFileStat, + ReviewScopeFileSystemPort, + ReviewScopePathPort, +} from './application/ReviewScopeAuthorizationPorts'; +export { + createReviewDecisionPersistenceFeature, + type ReviewDecisionPersistenceFeatureDependencies, +} from './composition/createReviewDecisionPersistenceFeature'; +export { createReviewQueryFeature } from './composition/createReviewQueryFeature'; +export { + createReviewScopeAuthorizationFeature, + type ReviewScopeAuthorizationFeatureDependencies, +} from './composition/createReviewScopeAuthorizationFeature'; diff --git a/src/features/change-review/main/infrastructure/nodeReviewScopeAuthorization.ts b/src/features/change-review/main/infrastructure/nodeReviewScopeAuthorization.ts new file mode 100644 index 0000000000..7c73cb8f61 --- /dev/null +++ b/src/features/change-review/main/infrastructure/nodeReviewScopeAuthorization.ts @@ -0,0 +1,46 @@ +import { + cleanupAtomicCreateTempLinks, + isOwnedReviewFileTransactionHardlink, +} from '@main/utils/atomicWrite'; +import { isPathWithinRoot, matchesSensitivePattern } from '@main/utils/pathValidation'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { + ReviewScopeFileStat, + ReviewScopeFileSystemPort, + ReviewScopePathPort, +} from '../application/ReviewScopeAuthorizationPorts'; + +function toReviewFileStat(stat: Awaited>): ReviewScopeFileStat { + return { + kind: stat.isSymbolicLink() + ? 'symbolic-link' + : stat.isDirectory() + ? 'directory' + : stat.isFile() + ? 'file' + : 'other', + linkCount: Number(stat.nlink), + }; +} + +export const nodeReviewScopePathPort: ReviewScopePathPort = { + normalize: (filePath) => path.resolve(path.normalize(filePath)), + dirname: (filePath) => path.dirname(filePath), + isAbsolute: (filePath) => path.isAbsolute(path.normalize(filePath)), + isWithinRoot: isPathWithinRoot, + isSensitive: matchesSensitivePattern, + normalizeIdentity: (filePath) => { + const normalized = path.resolve(path.normalize(filePath)); + return process.platform === 'win32' ? normalized.toLocaleLowerCase() : normalized; + }, +}; + +export const nodeReviewScopeFileSystemPort: ReviewScopeFileSystemPort = { + stat: async (filePath) => toReviewFileStat(await fs.stat(filePath)), + lstat: async (filePath) => toReviewFileStat(await fs.lstat(filePath)), + realpath: (filePath) => fs.realpath(filePath), + cleanupOwnedTemporaryLinks: cleanupAtomicCreateTempLinks, + isOwnedTransactionHardlink: isOwnedReviewFileTransactionHardlink, +}; diff --git a/src/features/change-review/main/infrastructure/validateReviewWatchProjectPath.ts b/src/features/change-review/main/infrastructure/validateReviewWatchProjectPath.ts new file mode 100644 index 0000000000..2635be00e6 --- /dev/null +++ b/src/features/change-review/main/infrastructure/validateReviewWatchProjectPath.ts @@ -0,0 +1,19 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export async function validateReviewWatchProjectPath(projectPath: string): Promise { + if (!projectPath || typeof projectPath !== 'string') { + throw new Error('Invalid project path'); + } + + if (!path.isAbsolute(projectPath)) { + throw new Error('Project path must be absolute'); + } + + const normalized = path.resolve(path.normalize(projectPath)); + const stat = await fs.stat(normalized); + if (!stat.isDirectory()) { + throw new Error('Project path is not a directory'); + } + return normalized; +} diff --git a/src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts b/src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts new file mode 100644 index 0000000000..f3bbe6c69e --- /dev/null +++ b/src/features/change-review/renderer/adapters/changeReviewDialogInteractionAdapters.ts @@ -0,0 +1,45 @@ +import type { + ChangeReviewCollapsedFilesStoragePort, + ChangeReviewExternalFileWatcherPort, +} from '../ports/changeReviewDialogInteractionPorts'; +import type { ReviewAPI } from '@shared/types/api'; + +type ExternalFileReviewApi = Pick< + ReviewAPI, + 'checkConflict' | 'onExternalFileChange' | 'unwatchFiles' | 'watchFiles' +>; + +export function createChangeReviewExternalFileWatcherPort( + getReviewApi: () => ExternalFileReviewApi +): ChangeReviewExternalFileWatcherPort { + return { + checkConflict: (scope, filePath, expectedModified) => + getReviewApi().checkConflict(scope, filePath, expectedModified), + subscribe: (callback) => getReviewApi().onExternalFileChange(callback), + watchFiles: (projectPath, filePaths) => getReviewApi().watchFiles(projectPath, filePaths), + unwatchFiles: () => getReviewApi().unwatchFiles(), + }; +} + +export const browserChangeReviewCollapsedFilesStorage: ChangeReviewCollapsedFilesStoragePort = { + read: (storageKey) => { + if (typeof window === 'undefined') return new Set(); + try { + const raw = window.localStorage.getItem(storageKey); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + return Array.isArray(parsed) + ? new Set(parsed.filter((value): value is string => typeof value === 'string')) + : new Set(); + } catch { + return new Set(); + } + }, + write: (storageKey, filePaths) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(storageKey, JSON.stringify([...filePaths])); + } catch { + // Collapsed state is best-effort presentation state. + } + }, +}; diff --git a/src/features/change-review/renderer/adapters/createChangeReviewActionHistoryPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewActionHistoryPorts.ts new file mode 100644 index 0000000000..b061be8c57 --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewActionHistoryPorts.ts @@ -0,0 +1,60 @@ +import type { + ChangeReviewActionHistoryStorePort, + ChangeReviewDecisionPersistencePort, + ChangeReviewDecisionPersistenceSnapshot, +} from '../ports/changeReviewActionHistoryPorts'; +import type { ReviewRedoAction, ReviewUndoAction } from '@shared/types'; + +interface ChangeReviewActionHistoryStore { + setReviewActionHistory(history: ReviewUndoAction[]): void; + setReviewRedoHistory(history: ReviewRedoAction[]): void; +} + +interface CreateChangeReviewActionHistoryStorePortInput { + getStore: () => ChangeReviewActionHistoryStore; + clearLegacyUndoStack: () => void; +} + +export function createChangeReviewActionHistoryStorePort({ + getStore, + clearLegacyUndoStack, +}: CreateChangeReviewActionHistoryStorePortInput): ChangeReviewActionHistoryStorePort { + return { + publishUndoHistory: (history) => getStore().setReviewActionHistory(history), + publishRedoHistory: (history) => getStore().setReviewRedoHistory(history), + clearLegacyUndoStack, + }; +} + +interface ChangeReviewDecisionPersistenceStore extends ChangeReviewDecisionPersistenceSnapshot { + loadDecisionsFromDisk(teamName: string, scopeKey: string, scopeToken: string): Promise; + persistDecisions(teamName: string, scopeKey: string, scopeToken: string): void; + flushDecisionsToDisk(teamName: string, scopeKey: string, scopeToken: string): Promise; + clearDecisionsFromDisk(teamName: string, scopeKey: string, scopeToken?: string): Promise; +} + +interface CreateChangeReviewDecisionPersistencePortInput { + getStore: () => ChangeReviewDecisionPersistenceStore; + setApplyError: (message: string | null) => void; +} + +export function createChangeReviewDecisionPersistencePort({ + getStore, + setApplyError, +}: CreateChangeReviewDecisionPersistencePortInput): ChangeReviewDecisionPersistencePort { + return { + getSnapshot: () => getStore(), + load: ({ teamName, scopeKey, scopeToken }) => + getStore().loadDecisionsFromDisk(teamName, scopeKey, scopeToken), + schedule: ({ teamName, scopeKey, scopeToken }) => + getStore().persistDecisions(teamName, scopeKey, scopeToken), + flush: ({ teamName, scopeKey, scopeToken }) => + getStore().flushDecisionsToDisk(teamName, scopeKey, scopeToken), + clear: ({ teamName, scopeKey, scopeToken }) => + getStore().clearDecisionsFromDisk(teamName, scopeKey, scopeToken), + reportError: setApplyError, + clearError: (expectedMessage) => { + if (getStore().applyError === expectedMessage) setApplyError(null); + }, + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewBulkDecisionPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewBulkDecisionPorts.ts new file mode 100644 index 0000000000..d83e1f93bd --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewBulkDecisionPorts.ts @@ -0,0 +1,59 @@ +import type { + ChangeReviewBulkDecisionCommandPort, + ChangeReviewBulkDecisionStatePort, + ChangeReviewBulkDecisionStateSnapshot, +} from '../ports/changeReviewBulkDecisionPorts'; +import type { ReviewDecisionSnapshot } from '@shared/types'; + +interface ChangeReviewBulkDecisionStore extends ChangeReviewBulkDecisionStateSnapshot { + acceptAllFile(filePath: string): boolean; + rejectAllFile(filePath: string): void; + invalidateResolvedFileContent(filePath: string): void; + applyReview( + teamName: string, + taskId?: string, + memberName?: string + ): ReturnType; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +interface CreateChangeReviewBulkDecisionStatePortInput { + getStore: () => ChangeReviewBulkDecisionStore; + restoreDecisionSnapshot: (snapshot: ReviewDecisionSnapshot) => void; +} + +export function createChangeReviewBulkDecisionStatePort({ + getStore, + restoreDecisionSnapshot, +}: CreateChangeReviewBulkDecisionStatePortInput): ChangeReviewBulkDecisionStatePort { + return { + getSnapshot: () => getStore(), + acceptAllFile: (filePath) => getStore().acceptAllFile(filePath), + rejectAllFile: (filePath) => getStore().rejectAllFile(filePath), + restoreDecisionSnapshot, + invalidateResolvedFileContent: (filePath) => getStore().invalidateResolvedFileContent(filePath), + }; +} + +interface CreateChangeReviewBulkDecisionCommandPortInput { + getStore: () => ChangeReviewBulkDecisionStore; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export function createChangeReviewBulkDecisionCommandPort({ + getStore, + readCurrentDiskContent, +}: CreateChangeReviewBulkDecisionCommandPortInput): ChangeReviewBulkDecisionCommandPort { + return { + applyReview: (teamName, taskId, memberName) => + getStore().applyReview(teamName, taskId, memberName), + fetchFileContent: (teamName, memberName, filePath) => { + void getStore().fetchFileContent(teamName, memberName, filePath); + }, + readCurrentDiskContent, + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts new file mode 100644 index 0000000000..352989282b --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewConflictPorts.ts @@ -0,0 +1,39 @@ +import type { + ChangeReviewConflictCommandPort, + ChangeReviewConflictQueryPort, +} from '../ports/changeReviewConflictPorts'; +import type { ReviewAPI } from '@shared/types/api'; + +type ReviewConflictQueryApi = Pick< + ReviewAPI, + 'loadDecisionConflictCandidates' | 'loadDraftHistoryConflictCandidates' +>; + +type ReviewConflictCommandApi = Pick; + +export function createChangeReviewConflictQueryPort( + getReviewApi: () => ReviewConflictQueryApi +): ChangeReviewConflictQueryPort { + return { + loadDecisionCandidates: ({ teamName, scopeKey, scopeToken }) => + getReviewApi().loadDecisionConflictCandidates(teamName, scopeKey, scopeToken), + loadDraftHistoryCandidates: ({ teamName, scopeKey, scopeToken }) => + getReviewApi().loadDraftHistoryConflictCandidates(teamName, scopeKey, scopeToken), + }; +} + +export function createChangeReviewConflictCommandPort( + getReviewApi: () => ReviewConflictCommandApi +): ChangeReviewConflictCommandPort { + return { + resolveDecisionCandidate: ({ scope, candidateId, resolution, observedCurrentRevision }) => + getReviewApi().resolveDecisionConflictCandidate( + scope.teamName, + scope.scopeKey, + scope.scopeToken, + candidateId, + resolution, + observedCurrentRevision + ), + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewConflictStateBridge.ts b/src/features/change-review/renderer/adapters/createChangeReviewConflictStateBridge.ts new file mode 100644 index 0000000000..da6ad5ed15 --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewConflictStateBridge.ts @@ -0,0 +1,41 @@ +import { CHANGE_REVIEW_CONFLICT_LOAD_ERROR_PREFIX } from '../utils/changeReviewConflicts'; + +interface ChangeReviewConflictStateSnapshot { + applyError: string | null; + decisionHydrationScopeKey: string | null; + decisionHydrationStatus: string; +} + +interface CreateChangeReviewConflictStateBridgeInput { + getSnapshot: () => ChangeReviewConflictStateSnapshot; + setApplyError: (message: string | null) => void; +} + +export interface ChangeReviewConflictStateBridge { + clearReportedLoadError: () => void; + reportError: (message: string) => void; + clearResolutionError: () => void; + isDecisionHydrationLoaded: (hydrationKey: string) => boolean; +} + +export function createChangeReviewConflictStateBridge({ + getSnapshot, + setApplyError, +}: CreateChangeReviewConflictStateBridgeInput): ChangeReviewConflictStateBridge { + return { + clearReportedLoadError: () => { + if (getSnapshot().applyError?.startsWith(CHANGE_REVIEW_CONFLICT_LOAD_ERROR_PREFIX)) { + setApplyError(null); + } + }, + reportError: (message) => setApplyError(message), + clearResolutionError: () => setApplyError(null), + isDecisionHydrationLoaded: (hydrationKey) => { + const snapshot = getSnapshot(); + return ( + snapshot.decisionHydrationScopeKey === hydrationKey && + snapshot.decisionHydrationStatus === 'loaded' + ); + }, + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewDialogLifecyclePorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewDialogLifecyclePorts.ts new file mode 100644 index 0000000000..cf777c121b --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewDialogLifecyclePorts.ts @@ -0,0 +1,106 @@ +import type { + ChangeReviewDialogLifecycleCommandPort, + ChangeReviewDialogLifecycleStatePort, + ChangeReviewDialogLifecycleStateSnapshot, +} from '../ports/changeReviewDialogLifecyclePorts'; +import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import type { ApplyReviewResult } from '@shared/types'; +import type { ReviewAPI } from '@shared/types/api'; + +interface ChangeReviewDialogLifecycleStore extends ChangeReviewDialogLifecycleStateSnapshot { + applyError: string | null; + resetAllReviewState(): void; + clearChangeReviewCache(): void; + fetchAgentChanges(teamName: string, memberName: string): Promise; + fetchTaskChanges( + teamName: string, + taskId: string, + options: TaskChangeRequestOptions + ): Promise; + clearDecisionsFromDisk( + teamName: string, + scopeKey: string, + scopeToken?: string, + forceDiscard?: boolean + ): Promise; + applyReview( + teamName: string, + taskId?: string, + memberName?: string + ): Promise; +} + +interface CreateChangeReviewDialogLifecycleStatePortInput { + getStore: () => ChangeReviewDialogLifecycleStore; + reportError: (message: string | null) => void; + completeSavedStateDiscard: (markDecisionHydrationLoaded: boolean) => void; +} + +export function createChangeReviewDialogLifecycleStatePort({ + getStore, + reportError, + completeSavedStateDiscard, +}: CreateChangeReviewDialogLifecycleStatePortInput): ChangeReviewDialogLifecycleStatePort { + return { + getSnapshot: () => { + const state = getStore(); + return { + editedContents: state.editedContents, + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + reviewActionHistory: state.reviewActionHistory, + reviewRedoHistory: state.reviewRedoHistory, + fileContents: state.fileContents, + fileChunkCounts: state.fileChunkCounts, + decisionHydrationScopeKey: state.decisionHydrationScopeKey, + decisionHydrationStatus: state.decisionHydrationStatus, + applying: state.applying, + }; + }, + reportError, + completeSavedStateDiscard, + }; +} + +type ChangeReviewDialogLifecycleReviewApi = Pick; + +interface CreateChangeReviewDialogLifecycleCommandPortInput { + getStore: () => ChangeReviewDialogLifecycleStore; + getReviewApi: () => ChangeReviewDialogLifecycleReviewApi; + hydrateDecisions: ChangeReviewDialogLifecycleCommandPort['hydrateDecisions']; +} + +export function createChangeReviewDialogLifecycleCommandPort({ + getStore, + getReviewApi, + hydrateDecisions, +}: CreateChangeReviewDialogLifecycleCommandPortInput): ChangeReviewDialogLifecycleCommandPort { + return { + resetAllReviewState: () => getStore().resetAllReviewState(), + clearChangeReviewCache: () => getStore().clearChangeReviewCache(), + fetchAgentChanges: (teamName, memberName) => { + void getStore().fetchAgentChanges(teamName, memberName); + }, + fetchTaskChanges: (teamName, taskId, options) => { + void getStore().fetchTaskChanges(teamName, taskId, options); + }, + hydrateDecisions, + clearDecisions: ({ teamName, scopeKey, scopeToken }, forceDiscard) => + getStore().clearDecisionsFromDisk(teamName, scopeKey, scopeToken, forceDiscard), + applyReview: async (teamName, taskId, memberName) => { + const result = await getStore().applyReview(teamName, taskId, memberName); + if (result?.errors.length === 0) { + return { status: 'applied', result }; + } + return { + status: 'failed', + result, + errorMessage: + result?.errors.map((entry) => entry.error).join('\n') || + getStore().applyError || + 'Unable to apply this review. Changes remains open; retry Apply.', + }; + }, + retryMutationRecovery: (request) => getReviewApi().retryMutationRecovery(request), + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewDialogViewPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewDialogViewPorts.ts new file mode 100644 index 0000000000..99d2555922 --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewDialogViewPorts.ts @@ -0,0 +1,371 @@ +import { Transaction } from '@codemirror/state'; +import { serializeReviewDraftEditorState } from '@features/change-review-history/renderer'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import { markChangeReviewMutationDiskPostimages } from '../utils/changeReviewWriteEvidence'; + +import type { + ChangeReviewBulkDecisionEditorPort, + ChangeReviewBulkDecisionStatusPort, + ChangeReviewBulkDecisionWriteEvidencePort, +} from '../ports/changeReviewBulkDecisionPorts'; +import type { + ChangeReviewDialogEditorActions, + ChangeReviewDialogKeyboardInteractionPort, + ChangeReviewRecentWrite, +} from '../ports/changeReviewDialogInteractionPorts'; +import type { + ChangeReviewDialogLifecycleEditorPort, + ChangeReviewDialogLifecycleSessionPort, + ChangeReviewDialogLifecycleStatusPort, + ChangeReviewDialogLifecycleWriteEvidencePort, +} from '../ports/changeReviewDialogLifecyclePorts'; +import type { + ChangeReviewFileDecisionEditorPort, + ChangeReviewFileDecisionStatusPort, + ChangeReviewFileDecisionWriteEvidencePort, +} from '../ports/changeReviewFileDecisionPorts'; +import type { + ChangeReviewFileDraftStatusPort, + ChangeReviewFileDraftWriteEvidencePort, +} from '../ports/changeReviewFileDraftPorts'; +import type { ChangeReviewHistoryMutationViewPort } from '../ports/changeReviewHistoryMutationPorts'; +import type { + ChangeReviewHunkDecisionEditorPort, + ChangeReviewHunkDecisionStatusPort, + ChangeReviewHunkDecisionWriteEvidencePort, +} from '../ports/changeReviewHunkDecisionPorts'; +import type { EditorView } from '@codemirror/view'; +import type { ReviewSerializedEditorState } from '@features/change-review-history/contracts'; +import type { + FileChangeSummary, + FileChangeWithContent, + ReviewMutationDiskPostimage, + ReviewUndoAction, +} from '@shared/types'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; + +interface FileMutationStatusDependencies { + fileApplyInFlightRef: MutableRefObject>; + setFilesApplying: Dispatch>>; + setDiscardCounters: Dispatch>>; +} + +interface LifecycleStatusDependencies { + undoInFlightRef: MutableRefObject; + closingRef: MutableRefObject; + pendingApplyCleanupKeyRef: MutableRefObject; + expectedDraftHistoryKeyRef: MutableRefObject; + setUndoing: Dispatch>; + setClosing: Dispatch>; +} + +interface CreateChangeReviewDialogViewPortsInput + extends FileMutationStatusDependencies, LifecycleStatusDependencies { + editorViewMapRef: MutableRefObject>; + editorActions: ChangeReviewDialogEditorActions; + subscribeToRejectCurrentHunk: (callback: () => void) => (() => void) | undefined; + recentReviewWritesRef: MutableRefObject>; + handleSerializedStateChanged: ( + filePath: string, + editorState: ReviewSerializedEditorState + ) => void; + addReviewFile: ( + file: FileChangeSummary, + options?: { index?: number; content?: FileChangeWithContent } + ) => void; + fetchFileContent: ( + teamName: string, + memberName: string | undefined, + filePath: string + ) => Promise; + navigateToHistoryAction: (action: ReviewUndoAction) => void; +} + +export interface ChangeReviewDialogViewPorts { + bulkDecision: { + editor: ChangeReviewBulkDecisionEditorPort; + status: ChangeReviewBulkDecisionStatusPort; + writeEvidence: ChangeReviewBulkDecisionWriteEvidencePort; + }; + fileDecision: { + editor: ChangeReviewFileDecisionEditorPort; + status: ChangeReviewFileDecisionStatusPort; + writeEvidence: ChangeReviewFileDecisionWriteEvidencePort; + }; + fileDraft: { + status: ChangeReviewFileDraftStatusPort; + writeEvidence: ChangeReviewFileDraftWriteEvidencePort; + }; + historyMutation: ChangeReviewHistoryMutationViewPort; + keyboardInteraction: ChangeReviewDialogKeyboardInteractionPort; + hunkDecision: { + editor: ChangeReviewHunkDecisionEditorPort; + status: ChangeReviewHunkDecisionStatusPort; + writeEvidence: ChangeReviewHunkDecisionWriteEvidencePort; + }; + lifecycle: { + editor: ChangeReviewDialogLifecycleEditorPort; + session: ChangeReviewDialogLifecycleSessionPort; + status: ChangeReviewDialogLifecycleStatusPort; + writeEvidence: ChangeReviewDialogLifecycleWriteEvidencePort; + }; +} + +export function createChangeReviewDialogViewPorts({ + editorViewMapRef, + editorActions, + subscribeToRejectCurrentHunk, + fileApplyInFlightRef, + undoInFlightRef, + closingRef, + pendingApplyCleanupKeyRef, + expectedDraftHistoryKeyRef, + recentReviewWritesRef, + setFilesApplying, + setDiscardCounters, + setUndoing, + setClosing, + handleSerializedStateChanged, + addReviewFile, + fetchFileContent, + navigateToHistoryAction, +}: CreateChangeReviewDialogViewPortsInput): ChangeReviewDialogViewPorts { + const scheduleEditorSync = (callback: () => void): void => { + requestAnimationFrame(callback); + }; + const rollbackEditorContent = (filePath: string, content: string): void => { + const view = editorViewMapRef.current.get(filePath); + if (!view?.dom.isConnected) return; + editorActions.ignoreNextDocChange(view); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: content }, + annotations: Transaction.addToHistory.of(false), + }); + }; + const markExpectedWrite = (filePath: string, expectedContent: string | null): void => { + recentReviewWritesRef.current.set(normalizePathForComparison(filePath), { + at: Date.now(), + expectedContent, + }); + }; + const clearExpectedWrite = (filePath: string): void => { + recentReviewWritesRef.current.delete(normalizePathForComparison(filePath)); + }; + const markCommittedPostimages = ( + postimages: readonly ReviewMutationDiskPostimage[] | undefined + ): void => { + markChangeReviewMutationDiskPostimages(postimages, markExpectedWrite); + }; + const setFileApplying = (filePath: string, value: boolean): void => { + setFilesApplying((previous) => { + const next = new Set(previous); + if (value) next.add(filePath); + else next.delete(filePath); + return next; + }); + }; + const beginFileMutation = (filePath: string): void => { + fileApplyInFlightRef.current.add(filePath); + setFileApplying(filePath, true); + }; + const finishFileMutation = (filePath: string): void => { + fileApplyInFlightRef.current.delete(filePath); + setFileApplying(filePath, false); + }; + const incrementDiscardCounter = (filePath: string): void => { + setDiscardCounters((previous) => ({ + ...previous, + [filePath]: (previous[filePath] ?? 0) + 1, + })); + }; + const setUndoInFlight = (value: boolean): void => { + undoInFlightRef.current = value; + setUndoing(value); + }; + + const writeEvidence = { + markExpectedWrite, + clearExpectedWrite, + markCommittedPostimages, + }; + const trackedFileStatus = { + beginFileMutation, + finishFileMutation, + incrementDiscardCounter, + }; + + return { + bulkDecision: { + editor: { + scheduleEditorSync, + acceptAllEditorChunks: (filePaths) => { + for (const [filePath, view] of editorViewMapRef.current.entries()) { + if (filePaths.has(filePath)) editorActions.acceptAllChunks(view); + } + }, + rejectAllEditorChunks: (filePaths) => { + for (const [filePath, view] of editorViewMapRef.current.entries()) { + if (filePaths.has(filePath)) editorActions.rejectAllChunks(view); + } + }, + rollbackEditorContent, + }, + writeEvidence, + status: { + beginFileMutation: (filePath) => fileApplyInFlightRef.current.add(filePath), + finishFileMutation: (filePath) => fileApplyInFlightRef.current.delete(filePath), + markFilesApplying: (filePaths) => { + setFilesApplying((previous) => { + const next = new Set(previous); + for (const filePath of filePaths) next.add(filePath); + return next; + }); + }, + clearFilesApplying: (filePaths) => { + setFilesApplying((previous) => { + const next = new Set(previous); + for (const filePath of filePaths) next.delete(filePath); + return next; + }); + }, + incrementDiscardCounter, + setUndoInFlight, + }, + }, + fileDecision: { + editor: { + getCurrentContent: (filePath) => + editorViewMapRef.current.get(filePath)?.state.doc.toString() ?? null, + scheduleEditorSync, + acceptAllEditorChunks: (filePath) => { + const view = editorViewMapRef.current.get(filePath); + if (view) editorActions.acceptAllChunks(view); + }, + rejectAllEditorChunks: (filePath) => { + const view = editorViewMapRef.current.get(filePath); + if (view) editorActions.rejectAllChunks(view); + }, + rollbackEditorContent, + }, + status: trackedFileStatus, + writeEvidence, + }, + fileDraft: { + status: trackedFileStatus, + writeEvidence: { markExpectedWrite }, + }, + historyMutation: { + addMissingFile: (file, index, content) => + addReviewFile(file, { + index, + content: { + ...file, + originalFullContent: '', + modifiedFullContent: content, + isNewFile: true, + contentSource: 'disk-current', + }, + }), + fetchFileContent: (teamName, memberName, filePath) => { + void fetchFileContent(teamName, memberName, filePath); + }, + incrementDiscardCounters: (filePaths) => { + setDiscardCounters((previous) => { + const next = { ...previous }; + for (const filePath of filePaths) { + next[filePath] = (next[filePath] ?? 0) + 1; + } + return next; + }); + }, + navigateToAction: navigateToHistoryAction, + markExpectedWrite, + clearExpectedWrite, + markCommittedPostimages, + setMutationInFlight: setUndoInFlight, + }, + keyboardInteraction: { + subscribeRejectCurrentHunk: subscribeToRejectCurrentHunk, + rejectCurrentChunk: (filePath) => { + const view = editorViewMapRef.current.get(filePath); + if (!view?.dom.isConnected) return null; + const cursorPosition = view.state.selection.main.head; + const hunkIndex = editorActions.computeChunkIndexAtPosition(view.state, cursorPosition); + const beforeContent = view.state.doc.toString(); + if (!editorActions.rejectChunk(view)) return null; + return { + hunkIndex, + beforeContent, + afterContent: view.state.doc.toString(), + }; + }, + rollbackContent: rollbackEditorContent, + }, + hunkDecision: { + editor: { + guardIgnoredMutation: (filePath) => { + const view = editorViewMapRef.current.get(filePath); + const guardedContent = view?.state.doc.toString(); + if (view && guardedContent !== undefined) { + queueMicrotask(() => { + if (view.dom.isConnected && view.state.doc.toString() !== guardedContent) { + rollbackEditorContent(filePath, guardedContent); + } + }); + } + }, + rejectChunk: (filePath) => { + const view = editorViewMapRef.current.get(filePath); + if (!view?.dom.isConnected) return null; + const beforeContent = view.state.doc.toString(); + if (!editorActions.rejectChunk(view)) return null; + return { + beforeContent, + afterContent: view.state.doc.toString(), + }; + }, + rollbackContent: rollbackEditorContent, + }, + status: trackedFileStatus, + writeEvidence, + }, + lifecycle: { + editor: { + captureDraftSnapshots: (shouldCapture) => { + for (const [filePath, view] of editorViewMapRef.current.entries()) { + if (shouldCapture(filePath)) { + handleSerializedStateChanged(filePath, serializeReviewDraftEditorState(view.state)); + } + } + }, + }, + session: { + getPendingApplyCleanupKey: () => pendingApplyCleanupKeyRef.current, + setPendingApplyCleanupKey: (key) => { + pendingApplyCleanupKeyRef.current = key; + }, + isExpectedHydrationKey: (hydrationKey) => + expectedDraftHistoryKeyRef.current === hydrationKey, + }, + status: { + getActionLockState: (applying) => ({ + applying, + fileApplyCount: fileApplyInFlightRef.current.size, + undoing: undoInFlightRef.current, + closing: closingRef.current, + }), + beginClosing: () => { + closingRef.current = true; + setClosing(true); + }, + finishClosing: () => { + closingRef.current = false; + setClosing(false); + }, + setRecoveryInFlight: setUndoInFlight, + }, + writeEvidence: { markCommittedPostimages }, + }, + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts b/src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts new file mode 100644 index 0000000000..9719ccdac6 --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewDraftHistoryPort.ts @@ -0,0 +1,61 @@ +import type { ChangeReviewDraftHistoryPort } from '../ports/changeReviewDraftHistoryPort'; +import type { ReviewAPI } from '@shared/types/api'; + +type ReviewDraftHistoryApi = Pick< + ReviewAPI, + | 'loadDraftHistory' + | 'saveDraftHistoryEntry' + | 'clearDraftHistory' + | 'checkConflict' + | 'replaceDraftHistoryConflictCandidate' + | 'resolveDraftHistoryConflictCandidate' +>; + +export function createChangeReviewDraftHistoryPort( + getReviewApi: () => ReviewDraftHistoryApi +): ChangeReviewDraftHistoryPort { + return { + load: ({ teamName, scopeKey, scopeToken }) => + getReviewApi().loadDraftHistory(teamName, scopeKey, scopeToken), + saveEntry: ({ scope, entry, expectedVersion }) => + getReviewApi().saveDraftHistoryEntry( + scope.teamName, + scope.scopeKey, + scope.scopeToken, + entry, + expectedVersion.revision, + expectedVersion.generation + ), + clear: ({ scope, filePath, expectedVersion }) => + getReviewApi().clearDraftHistory( + scope.teamName, + scope.scopeKey, + scope.scopeToken, + filePath, + expectedVersion?.revision, + expectedVersion?.generation + ), + checkConflict: ({ reviewScope, filePath, expectedModified }) => + getReviewApi().checkConflict(reviewScope, filePath, expectedModified), + replaceConflictCandidate: ({ scope, expectedEntry, replacementEntry, observedVersion }) => + getReviewApi().replaceDraftHistoryConflictCandidate( + scope.teamName, + scope.scopeKey, + scope.scopeToken, + expectedEntry, + replacementEntry, + observedVersion.revision, + observedVersion.generation + ), + resolveConflictCandidate: ({ scope, candidateId, resolution, observedVersion }) => + getReviewApi().resolveDraftHistoryConflictCandidate( + scope.teamName, + scope.scopeKey, + scope.scopeToken, + candidateId, + resolution, + observedVersion.revision, + observedVersion.generation + ), + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewFileDecisionPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewFileDecisionPorts.ts new file mode 100644 index 0000000000..3d781c807e --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewFileDecisionPorts.ts @@ -0,0 +1,103 @@ +import type { + ChangeReviewFileDecisionCommandPort, + ChangeReviewFileDecisionStatePort, + ChangeReviewFileDecisionStateSnapshot, +} from '../ports/changeReviewFileDecisionPorts'; +import type { FileChangeSummary, ReviewDecisionSnapshot, ReviewFileScope } from '@shared/types'; +import type { ReviewAPI } from '@shared/types/api'; + +interface ChangeReviewFileDecisionStore extends ChangeReviewFileDecisionStateSnapshot { + acceptAllFile(filePath: string): boolean; + rejectAllFile(filePath: string): void; + clearReviewFileExternalChange(filePath: string): void; + invalidateResolvedFileContent(filePath: string): void; + applySingleFileDecision( + teamName: string, + filePath: string, + taskId?: string, + memberName?: string + ): ReturnType; + quiesceDecisionPersistence( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise; + recordDecisionRevision( + teamName: string, + scopeKey: string, + scopeToken: string, + revision: number + ): void; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +interface CreateChangeReviewFileDecisionStatePortInput { + getStore: () => ChangeReviewFileDecisionStore; + applyRestoredDecisionState: (file: FileChangeSummary) => void; + restoreFileDecisions: (file: FileChangeSummary, snapshot: ReviewDecisionSnapshot) => void; + reportError: (message: string | null) => void; +} + +export function createChangeReviewFileDecisionStatePort({ + getStore, + applyRestoredDecisionState, + restoreFileDecisions, + reportError, +}: CreateChangeReviewFileDecisionStatePortInput): ChangeReviewFileDecisionStatePort { + return { + getSnapshot: () => { + const state = getStore(); + return { + fileContents: state.fileContents, + reviewExternalChangesByFile: state.reviewExternalChangesByFile, + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + hunkContextHashesByFile: state.hunkContextHashesByFile, + fileChunkCounts: state.fileChunkCounts, + decisionRevision: state.decisionRevision, + changeSetEpoch: state.changeSetEpoch, + }; + }, + acceptAllFile: (filePath) => getStore().acceptAllFile(filePath), + rejectAllFile: (filePath) => getStore().rejectAllFile(filePath), + applyRestoredDecisionState, + restoreFileDecisions, + clearExternalChange: (filePath) => getStore().clearReviewFileExternalChange(filePath), + invalidateResolvedFileContent: (filePath) => getStore().invalidateResolvedFileContent(filePath), + reportError, + }; +} + +type ChangeReviewFileDecisionReviewApi = Pick; + +interface CreateChangeReviewFileDecisionCommandPortInput { + getStore: () => ChangeReviewFileDecisionStore; + getReviewApi: () => ChangeReviewFileDecisionReviewApi; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export function createChangeReviewFileDecisionCommandPort({ + getStore, + getReviewApi, + readCurrentDiskContent, +}: CreateChangeReviewFileDecisionCommandPortInput): ChangeReviewFileDecisionCommandPort { + return { + checkConflict: (scope: ReviewFileScope, filePath, expectedContent) => + getReviewApi().checkConflict(scope, filePath, expectedContent), + executeMutation: (request) => getReviewApi().executeMutation(request), + applySingleFileDecision: (teamName, filePath, taskId, memberName) => + getStore().applySingleFileDecision(teamName, filePath, taskId, memberName), + quiescePersistence: ({ teamName, scopeKey, scopeToken }) => + getStore().quiesceDecisionPersistence(teamName, scopeKey, scopeToken), + recordDecisionRevision: ({ teamName, scopeKey, scopeToken }, revision) => + getStore().recordDecisionRevision(teamName, scopeKey, scopeToken, revision), + fetchFileContent: (teamName, memberName, filePath) => { + void getStore().fetchFileContent(teamName, memberName, filePath); + }, + readCurrentDiskContent, + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewFileDraftPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewFileDraftPorts.ts new file mode 100644 index 0000000000..2b046c7137 --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewFileDraftPorts.ts @@ -0,0 +1,145 @@ +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { + ChangeReviewFileDraftCommandPort, + ChangeReviewFileDraftStatePort, + ChangeReviewFileDraftStateSnapshot, + CommitChangeReviewExternalReloadInput, +} from '../ports/changeReviewFileDraftPorts'; +import type { ExecuteReviewMutationRequest, ReviewPersistedStateSnapshot } from '@shared/types'; +import type { ReviewAPI } from '@shared/types/api'; + +interface ChangeReviewFileDraftStore { + activeChangeSet: { + files: readonly ChangeReviewFileDraftStateSnapshot['activeFiles'][number][]; + } | null; + editedContents: ChangeReviewFileDraftStateSnapshot['editedContents']; + reviewExternalChangesByFile: ChangeReviewFileDraftStateSnapshot['reviewExternalChangesByFile']; + hunkDecisions: ChangeReviewFileDraftStateSnapshot['hunkDecisions']; + fileDecisions: ChangeReviewFileDraftStateSnapshot['fileDecisions']; + hunkContextHashesByFile: ChangeReviewFileDraftStateSnapshot['hunkContextHashesByFile']; + decisionRevision: number; + changeSetEpoch: number; + updateEditedContent(filePath: string, content: string): void; + discardFileEdits(filePath: string): void; + clearReviewFileExternalChange(filePath: string): void; + reloadReviewFileFromDisk(filePath: string): void; + saveEditedFile: ChangeReviewFileDraftCommandPort['saveEditedFile']; + quiesceDecisionPersistence( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise; + recordDecisionRevision( + teamName: string, + scopeKey: string, + scopeToken: string, + revision: number + ): void; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +interface CreateChangeReviewFileDraftStatePortInput { + getStore: () => ChangeReviewFileDraftStore; + applyReloadedReviewState: (state: ReviewPersistedStateSnapshot) => void; + reportError: (message: string | null) => void; +} + +function findExternalChange( + changes: Record, + filePath: string +): { filePath: string; value: object } | undefined { + const normalizedPath = normalizePathForComparison(filePath); + const entry = Object.entries(changes).find( + ([candidate]) => normalizePathForComparison(candidate) === normalizedPath + ); + return entry ? { filePath: entry[0], value: entry[1] } : undefined; +} + +export function createChangeReviewFileDraftStatePort({ + getStore, + applyReloadedReviewState, + reportError, +}: CreateChangeReviewFileDraftStatePortInput): ChangeReviewFileDraftStatePort { + return { + getSnapshot: () => { + const state = getStore(); + return { + activeFiles: state.activeChangeSet?.files ?? [], + editedContents: state.editedContents, + reviewExternalChangesByFile: state.reviewExternalChangesByFile, + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + hunkContextHashesByFile: state.hunkContextHashesByFile, + decisionRevision: state.decisionRevision, + changeSetEpoch: state.changeSetEpoch, + }; + }, + readExternalChange: (filePath) => + findExternalChange(getStore().reviewExternalChangesByFile, filePath)?.value, + updateEditedContent: (filePath, content) => getStore().updateEditedContent(filePath, content), + discardFileEdits: (filePath) => getStore().discardFileEdits(filePath), + clearExternalChange: (filePath, observedChange) => { + const store = getStore(); + const current = findExternalChange(store.reviewExternalChangesByFile, filePath); + if (current?.value !== observedChange) return false; + store.clearReviewFileExternalChange(current.filePath); + return true; + }, + reloadFileFromDisk: (filePath) => getStore().reloadReviewFileFromDisk(filePath), + applyReloadedReviewState, + reportError, + }; +} + +type ChangeReviewFileDraftReviewApi = Pick; + +interface CreateChangeReviewFileDraftCommandPortInput { + getStore: () => ChangeReviewFileDraftStore; + getReviewApi: () => ChangeReviewFileDraftReviewApi; +} + +function toExternalReloadRequest({ + reviewScope, + persistenceScope, + filePath, + persistedState, + expectedDecisionRevision, +}: CommitChangeReviewExternalReloadInput): ExecuteReviewMutationRequest { + return { + scope: reviewScope, + decisionPersistenceScope: { + scopeKey: persistenceScope.scopeKey, + scopeToken: persistenceScope.scopeToken, + }, + kind: 'reload-external' as const, + externalFilePath: filePath, + diskSteps: [], + persistedState, + expectedDecisionRevision, + }; +} + +export function createChangeReviewFileDraftCommandPort({ + getStore, + getReviewApi, +}: CreateChangeReviewFileDraftCommandPortInput): ChangeReviewFileDraftCommandPort { + return { + saveEditedFile: (filePath, reviewScope, expectedCurrentContent) => + getStore().saveEditedFile(filePath, reviewScope, expectedCurrentContent), + checkConflict: (reviewScope, filePath, expectedContent) => + getReviewApi().checkConflict(reviewScope, filePath, expectedContent), + commitExternalReload: (input) => getReviewApi().executeMutation(toExternalReloadRequest(input)), + quiescePersistence: ({ teamName, scopeKey, scopeToken }) => + getStore().quiesceDecisionPersistence(teamName, scopeKey, scopeToken), + recordDecisionRevision: ({ teamName, scopeKey, scopeToken }, revision) => + getStore().recordDecisionRevision(teamName, scopeKey, scopeToken, revision), + fetchFileContent: (teamName, memberName, filePath) => { + void getStore().fetchFileContent(teamName, memberName, filePath); + }, + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts new file mode 100644 index 0000000000..b134b7de9c --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewHistoryMutationPorts.ts @@ -0,0 +1,56 @@ +import type { + ChangeReviewHistoryMutationCommandPort, + ChangeReviewHistoryMutationStatePort, + ChangeReviewHistoryPersistenceScope, + ChangeReviewHistoryStateSnapshot, +} from '../ports/changeReviewHistoryMutationPorts'; +import type { ReviewPersistedStateSnapshot } from '@shared/types'; +import type { ReviewAPI } from '@shared/types/api'; + +type ChangeReviewHistoryMutationApi = Pick< + ReviewAPI, + 'executeMutation' | 'restoreHistory' | 'retryMutationRecovery' +>; + +export function createChangeReviewHistoryMutationCommandPort( + getReviewApi: () => ChangeReviewHistoryMutationApi +): ChangeReviewHistoryMutationCommandPort { + return { + executeMutation: (request) => getReviewApi().executeMutation(request), + restoreHistory: (request) => getReviewApi().restoreHistory(request), + retryRecovery: (request) => getReviewApi().retryMutationRecovery(request), + }; +} + +interface CreateChangeReviewHistoryMutationStatePortInput { + getSnapshot: () => ChangeReviewHistoryStateSnapshot; + quiesceDecisionPersistence: (scope: ChangeReviewHistoryPersistenceScope) => Promise; + recordDecisionRevision: (scope: ChangeReviewHistoryPersistenceScope, revision: number) => void; + applyDecisionState: ChangeReviewHistoryMutationStatePort['applyDecisionState']; + applyPersistedState: (state: ReviewPersistedStateSnapshot, applyError: string | null) => void; + reportError: (message: string) => void; + clearExternalChange: (filePath: string) => void; + invalidateResolvedFileContent: (filePath: string) => void; +} + +export function createChangeReviewHistoryMutationStatePort({ + getSnapshot, + quiesceDecisionPersistence, + recordDecisionRevision, + applyDecisionState, + applyPersistedState, + reportError, + clearExternalChange, + invalidateResolvedFileContent, +}: CreateChangeReviewHistoryMutationStatePortInput): ChangeReviewHistoryMutationStatePort { + return { + getSnapshot, + quiesceDecisionPersistence, + recordDecisionRevision, + applyDecisionState, + applyPersistedState, + reportError, + clearExternalChange, + invalidateResolvedFileContent, + }; +} diff --git a/src/features/change-review/renderer/adapters/createChangeReviewHunkDecisionPorts.ts b/src/features/change-review/renderer/adapters/createChangeReviewHunkDecisionPorts.ts new file mode 100644 index 0000000000..0ab5b773e6 --- /dev/null +++ b/src/features/change-review/renderer/adapters/createChangeReviewHunkDecisionPorts.ts @@ -0,0 +1,72 @@ +import type { + ChangeReviewHunkDecisionCommandPort, + ChangeReviewHunkDecisionStatePort, + ChangeReviewHunkDecisionStateSnapshot, +} from '../ports/changeReviewHunkDecisionPorts'; +import type { ApplyReviewResult, HunkDecision } from '@shared/types'; + +interface ChangeReviewHunkDecisionStore extends ChangeReviewHunkDecisionStateSnapshot { + setHunkDecision(filePath: string, hunkIndex: number, decision: HunkDecision): number; + clearHunkDecisionByOriginalIndex(filePath: string, originalIndex: number): void; + invalidateResolvedFileContent(filePath: string): void; + applySingleFileDecision( + teamName: string, + filePath: string, + taskId?: string, + memberName?: string + ): Promise; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +export function createChangeReviewHunkDecisionStatePort( + getStore: () => ChangeReviewHunkDecisionStore +): ChangeReviewHunkDecisionStatePort { + return { + getSnapshot: () => { + const state = getStore(); + return { + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + fileChunkCounts: state.fileChunkCounts, + changeSetEpoch: state.changeSetEpoch, + }; + }, + setDecision: (filePath, hunkIndex, decision) => + getStore().setHunkDecision(filePath, hunkIndex, decision), + clearDecision: (filePath, originalIndex) => + getStore().clearHunkDecisionByOriginalIndex(filePath, originalIndex), + invalidateResolvedFileContent: (filePath) => getStore().invalidateResolvedFileContent(filePath), + }; +} + +interface CreateChangeReviewHunkDecisionCommandPortInput { + getStore: () => ChangeReviewHunkDecisionStore; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export function createChangeReviewHunkDecisionCommandPort({ + getStore, + readCurrentDiskContent, +}: CreateChangeReviewHunkDecisionCommandPortInput): ChangeReviewHunkDecisionCommandPort { + return { + applySingleFileDecision: async (teamName, filePath, taskId, memberName) => { + const result = await getStore().applySingleFileDecision( + teamName, + filePath, + taskId, + memberName + ); + return result?.errors.length === 0 + ? { status: 'applied', result } + : { status: 'failed', result }; + }, + fetchFileContent: (teamName, memberName, filePath) => { + void getStore().fetchFileContent(teamName, memberName, filePath); + }, + readCurrentDiskContent, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewActionHistoryController.ts b/src/features/change-review/renderer/hooks/useChangeReviewActionHistoryController.ts new file mode 100644 index 0000000000..6aafcca108 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewActionHistoryController.ts @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + appendOrderedReviewAction, + createReviewUndoAction, + filterReviewActionHistoryForFile, + popOrderedReviewAction, + replaceLatestReviewAction, +} from '../utils/changeReviewActionHistory'; + +import type { ChangeReviewActionHistoryStorePort } from '../ports/changeReviewActionHistoryPorts'; +import type { ReviewUndoActionInput } from '../utils/changeReviewActionHistory'; +import type { ReviewDecisionHydrationStatus } from '../utils/changeReviewScope'; +import type { ReviewRedoAction, ReviewUndoAction } from '@shared/types'; + +interface UseChangeReviewActionHistoryControllerInput { + resetKey: string; + hydrationKey: string | null; + hydrationScopeKey: string | null; + hydrationStatus: ReviewDecisionHydrationStatus; + hydratedUndoHistory: ReviewUndoAction[]; + hydratedRedoHistory: ReviewRedoAction[]; + store: ChangeReviewActionHistoryStorePort; +} + +export interface ChangeReviewActionHistoryController { + undoDepth: number; + redoDepth: number; + getUndoHistory: () => ReviewUndoAction[]; + getRedoHistory: () => ReviewRedoAction[]; + getLatestUndoAction: () => ReviewUndoAction | undefined; + getLatestRedoAction: () => ReviewRedoAction | undefined; + pushUndoAction: (input: ReviewUndoActionInput) => ReviewUndoAction; + completeUndoAction: (action: ReviewUndoAction, redoAction: ReviewRedoAction) => boolean; + bindCommittedAction: ( + optimistic: ReviewUndoAction, + committed: ReviewUndoAction | undefined + ) => boolean; + completeRedoAction: (redoAction: ReviewRedoAction) => boolean; + discardLatestAction: (action: ReviewUndoAction) => boolean; + publishUndoHistory: () => void; + replaceHistories: (undoHistory: ReviewUndoAction[], redoHistory: ReviewRedoAction[]) => void; + clear: () => void; + clearForFile: (filePath: string) => void; +} + +export function useChangeReviewActionHistoryController({ + resetKey, + hydrationKey, + hydrationScopeKey, + hydrationStatus, + hydratedUndoHistory, + hydratedRedoHistory, + store, +}: UseChangeReviewActionHistoryControllerInput): ChangeReviewActionHistoryController { + const undoHistoryRef = useRef([]); + const redoHistoryRef = useRef([]); + const redoBeforePreparedActionRef = useRef<{ + action: ReviewUndoAction; + actionId: string; + history: ReviewRedoAction[]; + } | null>(null); + const [undoDepth, setUndoDepth] = useState(0); + const [redoDepth, setRedoDepth] = useState(0); + + useEffect(() => { + undoHistoryRef.current = []; + redoHistoryRef.current = []; + redoBeforePreparedActionRef.current = null; + setUndoDepth(0); + setRedoDepth(0); + }, [resetKey]); + + useEffect(() => { + if ( + hydrationKey === null || + hydrationScopeKey !== hydrationKey || + hydrationStatus !== 'loaded' + ) { + return; + } + undoHistoryRef.current = hydratedUndoHistory; + redoHistoryRef.current = hydratedRedoHistory; + setUndoDepth(hydratedUndoHistory.length); + setRedoDepth(hydratedRedoHistory.length); + }, [hydratedRedoHistory, hydratedUndoHistory, hydrationKey, hydrationScopeKey, hydrationStatus]); + + const getUndoHistory = useCallback((): ReviewUndoAction[] => undoHistoryRef.current, []); + const getRedoHistory = useCallback((): ReviewRedoAction[] => redoHistoryRef.current, []); + const getLatestUndoAction = useCallback( + (): ReviewUndoAction | undefined => undoHistoryRef.current.at(-1), + [] + ); + const getLatestRedoAction = useCallback( + (): ReviewRedoAction | undefined => redoHistoryRef.current.at(-1), + [] + ); + + const pushUndoAction = useCallback( + (input: ReviewUndoActionInput): ReviewUndoAction => { + const action = createReviewUndoAction(input); + const undoHistory = appendOrderedReviewAction(undoHistoryRef.current, action); + undoHistoryRef.current = undoHistory; + store.publishUndoHistory(undoHistory); + redoBeforePreparedActionRef.current = { + action, + actionId: action.id, + history: redoHistoryRef.current, + }; + redoHistoryRef.current = []; + store.publishRedoHistory([]); + setUndoDepth(undoHistory.length); + setRedoDepth(0); + return action; + }, + [store] + ); + + const completeUndoAction = useCallback( + (action: ReviewUndoAction, redoAction: ReviewRedoAction): boolean => { + const result = popOrderedReviewAction(undoHistoryRef.current, action); + if (!result.popped) return false; + const redoHistory = [...redoHistoryRef.current, redoAction]; + undoHistoryRef.current = result.stack; + redoHistoryRef.current = redoHistory; + redoBeforePreparedActionRef.current = null; + store.publishUndoHistory(result.stack); + store.publishRedoHistory(redoHistory); + setUndoDepth(result.stack.length); + setRedoDepth(redoHistory.length); + return true; + }, + [store] + ); + + const bindCommittedAction = useCallback( + (optimistic: ReviewUndoAction, committed: ReviewUndoAction | undefined): boolean => { + if (!committed) return false; + const result = replaceLatestReviewAction(undoHistoryRef.current, optimistic, committed); + if (!result.replaced) return false; + undoHistoryRef.current = result.stack; + store.publishUndoHistory(result.stack); + return true; + }, + [store] + ); + + const completeRedoAction = useCallback( + (redoAction: ReviewRedoAction): boolean => { + const latest = redoHistoryRef.current.at(-1); + if (latest?.action.id !== redoAction.action.id) return false; + const redoHistory = redoHistoryRef.current.slice(0, -1); + const undoHistory = appendOrderedReviewAction(undoHistoryRef.current, redoAction.action); + redoHistoryRef.current = redoHistory; + undoHistoryRef.current = undoHistory; + redoBeforePreparedActionRef.current = null; + store.publishRedoHistory(redoHistory); + store.publishUndoHistory(undoHistory); + setRedoDepth(redoHistory.length); + setUndoDepth(undoHistory.length); + return true; + }, + [store] + ); + + const discardLatestAction = useCallback( + (action: ReviewUndoAction): boolean => { + const latest = undoHistoryRef.current.at(-1); + if (latest?.id !== action.id) return false; + const result = popOrderedReviewAction(undoHistoryRef.current, latest); + if (!result.popped) return false; + undoHistoryRef.current = result.stack; + store.publishUndoHistory(result.stack); + const redoBackup = redoBeforePreparedActionRef.current; + if (redoBackup?.actionId === action.id) { + redoHistoryRef.current = redoBackup.history; + store.publishRedoHistory(redoBackup.history); + setRedoDepth(redoBackup.history.length); + redoBeforePreparedActionRef.current = null; + } + setUndoDepth(result.stack.length); + return true; + }, + [store] + ); + + const publishUndoHistory = useCallback((): void => { + store.publishUndoHistory([...undoHistoryRef.current]); + }, [store]); + + const replaceHistories = useCallback( + (undoHistory: ReviewUndoAction[], redoHistory: ReviewRedoAction[]): void => { + undoHistoryRef.current = undoHistory; + redoHistoryRef.current = redoHistory; + redoBeforePreparedActionRef.current = null; + store.publishUndoHistory(undoHistory); + store.publishRedoHistory(redoHistory); + setUndoDepth(undoHistory.length); + setRedoDepth(redoHistory.length); + }, + [store] + ); + + const clear = useCallback((): void => { + undoHistoryRef.current = []; + redoHistoryRef.current = []; + redoBeforePreparedActionRef.current = null; + store.publishUndoHistory([]); + store.publishRedoHistory([]); + store.clearLegacyUndoStack(); + setUndoDepth(0); + setRedoDepth(0); + }, [store]); + + const clearForFile = useCallback( + (filePath: string): void => { + const filtered = filterReviewActionHistoryForFile({ + undoHistory: undoHistoryRef.current, + redoHistory: redoHistoryRef.current, + filePath, + }); + if (filtered.clearAll) { + clear(); + return; + } + undoHistoryRef.current = filtered.undoHistory; + redoHistoryRef.current = filtered.redoHistory; + redoBeforePreparedActionRef.current = null; + store.publishUndoHistory(filtered.undoHistory); + store.publishRedoHistory(filtered.redoHistory); + setUndoDepth(filtered.undoHistory.length); + setRedoDepth(0); + }, + [clear, store] + ); + + return { + undoDepth, + redoDepth, + getUndoHistory, + getRedoHistory, + getLatestUndoAction, + getLatestRedoAction, + pushUndoAction, + completeUndoAction, + bindCommittedAction, + completeRedoAction, + discardLatestAction, + publishUndoHistory, + replaceHistories, + clear, + clearForFile, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewBulkDecisionController.ts b/src/features/change-review/renderer/hooks/useChangeReviewBulkDecisionController.ts new file mode 100644 index 0000000000..0283bce14b --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewBulkDecisionController.ts @@ -0,0 +1,343 @@ +import { useCallback } from 'react'; + +import { + alignReviewDiskUndoSnapshotWithAppliedContent, + isLedgerRenameReviewFile, + reconcileReviewDecisionRecordsAfterApply, +} from '@features/review-mutations'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { + BuildBulkRejectDiskSnapshot, + ChangeReviewBulkDecisionCommandPort, + ChangeReviewBulkDecisionEditorPort, + ChangeReviewBulkDecisionStatePort, + ChangeReviewBulkDecisionStatusPort, + ChangeReviewBulkDecisionWriteEvidencePort, +} from '../ports/changeReviewBulkDecisionPorts'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { ChangeReviewActionHistoryController } from './useChangeReviewActionHistoryController'; +import type { + ApplyReviewResult, + FileChangeSummary, + ReviewDecisionSnapshot, + ReviewUndoAction, +} from '@shared/types'; + +type ActionHistory = Pick< + ChangeReviewActionHistoryController, + | 'pushUndoAction' + | 'bindCommittedAction' + | 'discardLatestAction' + | 'getLatestUndoAction' + | 'publishUndoHistory' +>; + +interface UseChangeReviewBulkDecisionControllerInput { + active: boolean; + files: readonly FileChangeSummary[]; + rejectableFiles: readonly FileChangeSummary[]; + canAcceptAll: boolean; + changeSetEpoch: number; + instantApply: boolean; + teamName: string; + taskId: string | undefined; + memberName: string | undefined; + history: ActionHistory; + statePort: ChangeReviewBulkDecisionStatePort; + commandPort: ChangeReviewBulkDecisionCommandPort; + editorPort: ChangeReviewBulkDecisionEditorPort; + statusPort: ChangeReviewBulkDecisionStatusPort; + writeEvidencePort: ChangeReviewBulkDecisionWriteEvidencePort; + buildRejectDiskSnapshot: BuildBulkRejectDiskSnapshot; + persistLatestAcceptedAction: () => Promise; + ensureDurableScope: () => boolean; + hasActionInFlight: () => boolean; + blockForExternalChange: () => boolean; + captureOperationScope: () => ReviewOperationScopeToken | null; + isCurrentOperationScope: (scope: ReviewOperationScopeToken | null) => boolean; +} + +export interface ChangeReviewBulkDecisionController { + acceptAll: () => void; + rejectAll: () => void; +} + +function updateRetainedRejectAllDescriptor( + preparedAction: ReviewUndoAction, + retainedAction: ReviewUndoAction | undefined, + retainedSnapshotCount: number +): void { + if ( + retainedAction?.id !== preparedAction.id || + retainedAction.kind !== 'bulk' || + retainedAction.descriptor?.intent !== 'reject-all' + ) { + return; + } + retainedAction.descriptor = { intent: 'reject-all', fileCount: retainedSnapshotCount }; +} + +export function useChangeReviewBulkDecisionController({ + active, + files, + rejectableFiles, + canAcceptAll, + changeSetEpoch, + instantApply, + teamName, + taskId, + memberName, + history, + statePort, + commandPort, + editorPort, + statusPort, + writeEvidencePort, + buildRejectDiskSnapshot, + persistLatestAcceptedAction, + ensureDurableScope, + hasActionInFlight, + blockForExternalChange, + captureOperationScope, + isCurrentOperationScope, +}: UseChangeReviewBulkDecisionControllerInput): ChangeReviewBulkDecisionController { + const acceptAll = useCallback((): void => { + if (!active || !canAcceptAll || hasActionInFlight() || blockForExternalChange()) { + return; + } + const operationScope = captureOperationScope(); + if (!operationScope) return; + const state = statePort.getSnapshot(); + const decisionSnapshot: ReviewDecisionSnapshot = { + hunkDecisions: { ...state.hunkDecisions }, + fileDecisions: { ...state.fileDecisions }, + }; + const acceptedFiles = new Set(); + for (const file of files) { + if (file.filePath in state.editedContents) continue; + if (statePort.acceptAllFile(file.filePath)) acceptedFiles.add(file.filePath); + } + if (acceptedFiles.size === 0) return; + history.pushUndoAction({ + kind: 'bulk', + descriptor: { intent: 'accept-all', fileCount: acceptedFiles.size }, + decisionSnapshot, + diskSnapshots: [], + }); + void persistLatestAcceptedAction(); + editorPort.scheduleEditorSync(() => { + if (isCurrentOperationScope(operationScope)) { + editorPort.acceptAllEditorChunks(acceptedFiles); + } + }); + }, [ + active, + blockForExternalChange, + canAcceptAll, + captureOperationScope, + files, + hasActionInFlight, + history, + isCurrentOperationScope, + persistLatestAcceptedAction, + statePort, + editorPort, + ]); + + const rejectAll = useCallback((): void => { + if (!active || hasActionInFlight() || blockForExternalChange()) return; + const operationScope = captureOperationScope(); + if (!operationScope) return; + const initialState = statePort.getSnapshot(); + const requestedFiles = rejectableFiles.filter( + (file) => !(file.filePath in initialState.editedContents) + ); + const requestedPaths = new Set(requestedFiles.map((file) => file.filePath)); + if (requestedPaths.size === 0) return; + const decisionSnapshot: ReviewDecisionSnapshot = { + hunkDecisions: { ...initialState.hunkDecisions }, + fileDecisions: { ...initialState.fileDecisions }, + }; + const diskSnapshots = requestedFiles.flatMap((file) => { + const snapshot = buildRejectDiskSnapshot(file, decisionSnapshot); + return snapshot ? [snapshot] : []; + }); + for (const file of requestedFiles) { + statusPort.beginFileMutation(file.filePath); + statePort.rejectAllFile(file.filePath); + } + const preparedAction = history.pushUndoAction({ + kind: 'bulk', + descriptor: { intent: 'reject-all', fileCount: requestedFiles.length }, + decisionSnapshot, + diskSnapshots, + }); + statusPort.markFilesApplying(requestedPaths); + editorPort.scheduleEditorSync(() => { + if (isCurrentOperationScope(operationScope)) { + editorPort.rejectAllEditorChunks(requestedPaths); + } + }); + + if (!instantApply) { + for (const file of requestedFiles) { + statusPort.finishFileMutation(file.filePath); + } + statusPort.clearFilesApplying(requestedPaths); + return; + } + + void (async () => { + try { + if (!isCurrentOperationScope(operationScope)) return; + if (!ensureDurableScope()) { + statePort.restoreDecisionSnapshot(decisionSnapshot); + for (const snapshot of diskSnapshots) { + editorPort.rollbackEditorContent(snapshot.filePath, snapshot.beforeContent); + } + history.discardLatestAction(preparedAction); + return; + } + for (const snapshot of diskSnapshots) { + writeEvidencePort.markExpectedWrite( + snapshot.filePath, + isLedgerRenameReviewFile(snapshot.file) ? null : snapshot.afterContent + ); + } + let result: ApplyReviewResult | null = null; + try { + result = await commandPort.applyReview(teamName, taskId, memberName); + } catch { + // Treat transport/runtime failure like an unknown apply result. The + // store command owns user-visible error reporting; this controller + // must still roll back optimistic decisions and release busy state. + } + const currentState = statePort.getSnapshot(); + if ( + !isCurrentOperationScope(operationScope) || + currentState.changeSetEpoch !== changeSetEpoch + ) { + return; + } + writeEvidencePort.markCommittedPostimages(result?.diskPostimages); + history.bindCommittedAction(preparedAction, result?.committedReviewAction); + const reconciliation = reconcileReviewDecisionRecordsAfterApply( + requestedFiles, + result ? result.errors.map((entry) => entry.filePath) : null, + { + hunkDecisions: currentState.hunkDecisions, + fileDecisions: currentState.fileDecisions, + }, + decisionSnapshot + ); + statePort.restoreDecisionSnapshot(reconciliation); + const failedPaths = new Set( + reconciliation.failed.map((file) => normalizePathForComparison(file.filePath)) + ); + + for (const file of reconciliation.failed) { + const beforeContent = diskSnapshots.find( + (snapshot) => snapshot.filePath === file.filePath + )?.beforeContent; + if (beforeContent !== undefined) { + editorPort.rollbackEditorContent(file.filePath, beforeContent); + } + statePort.invalidateResolvedFileContent(file.filePath); + statusPort.incrementDiscardCounter(file.filePath); + commandPort.fetchFileContent(teamName, memberName, file.filePath); + } + + for (let index = diskSnapshots.length - 1; index >= 0; index--) { + if (failedPaths.has(normalizePathForComparison(diskSnapshots[index].filePath))) { + diskSnapshots.splice(index, 1); + } + } + + if (reconciliation.successful.length === 0) { + history.discardLatestAction(preparedAction); + return; + } + updateRetainedRejectAllDescriptor( + preparedAction, + history.getLatestUndoAction(), + diskSnapshots.length + ); + + statusPort.setUndoInFlight(true); + await Promise.all( + diskSnapshots.map(async (snapshot) => { + if ( + snapshot.afterContent === null || + snapshot.restoreMode === 'delete-file' || + isLedgerRenameReviewFile(snapshot.file) + ) { + return; + } + const appliedContent = await commandPort.readCurrentDiskContent( + snapshot.filePath, + snapshot.afterContent + ); + alignReviewDiskUndoSnapshotWithAppliedContent(snapshot, appliedContent); + }) + ); + + const refreshedState = statePort.getSnapshot(); + if ( + !isCurrentOperationScope(operationScope) || + refreshedState.changeSetEpoch !== changeSetEpoch + ) { + return; + } + for (const file of reconciliation.successful) { + const snapshot = diskSnapshots.find( + (candidate) => + normalizePathForComparison(candidate.filePath) === + normalizePathForComparison(file.filePath) + ); + if (snapshot) { + writeEvidencePort.markExpectedWrite( + file.filePath, + isLedgerRenameReviewFile(snapshot.file) ? null : snapshot.afterContent + ); + } + } + history.publishUndoHistory(); + } finally { + const currentState = statePort.getSnapshot(); + if ( + isCurrentOperationScope(operationScope) && + currentState.changeSetEpoch === changeSetEpoch + ) { + for (const file of requestedFiles) { + statusPort.finishFileMutation(file.filePath); + } + statusPort.clearFilesApplying(requestedPaths); + statusPort.setUndoInFlight(false); + } + } + })(); + }, [ + active, + blockForExternalChange, + buildRejectDiskSnapshot, + captureOperationScope, + changeSetEpoch, + commandPort, + editorPort, + ensureDurableScope, + hasActionInFlight, + history, + instantApply, + isCurrentOperationScope, + memberName, + rejectableFiles, + statePort, + statusPort, + taskId, + teamName, + writeEvidencePort, + ]); + + return { acceptAll, rejectAll }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewConflictDiscoveryController.ts b/src/features/change-review/renderer/hooks/useChangeReviewConflictDiscoveryController.ts new file mode 100644 index 0000000000..9cf2064d17 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewConflictDiscoveryController.ts @@ -0,0 +1,123 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import { CHANGE_REVIEW_CONFLICT_LOAD_ERROR_PREFIX } from '../utils/changeReviewConflicts'; + +import type { ChangeReviewConflictQueryPort } from '../ports/changeReviewConflictPorts'; +import type { ChangeReviewConflictScope } from '../ports/changeReviewConflictPorts'; +import type { ReviewDraftHistoryConflictCandidateSummary } from '@features/change-review-history/contracts'; +import type { ReviewDecisionConflictCandidateSummary } from '@shared/types'; + +interface UseChangeReviewConflictDiscoveryControllerInput { + active: boolean; + hydrationKey: string | null; + scope: ChangeReviewConflictScope | null; + isExpectedHydrationKey: (hydrationKey: string) => boolean; + hydrateDecisions: (scope: ChangeReviewConflictScope, hydrationKey: string) => Promise; + clearReportedLoadError: () => void; + reportLoadError: (message: string) => void; + port: ChangeReviewConflictQueryPort; +} + +export interface ChangeReviewConflictDiscoveryController { + decisionCandidates: ReviewDecisionConflictCandidateSummary[]; + draftHistoryCandidates: ReviewDraftHistoryConflictCandidateSummary[]; + candidateCount: number; + refreshPending: boolean; + loadError: string | null; + refresh: () => Promise; + reset: () => void; +} + +export function useChangeReviewConflictDiscoveryController({ + active, + hydrationKey, + scope, + isExpectedHydrationKey, + hydrateDecisions, + clearReportedLoadError, + reportLoadError, + port, +}: UseChangeReviewConflictDiscoveryControllerInput): ChangeReviewConflictDiscoveryController { + const [decisionCandidates, setDecisionCandidates] = useState< + ReviewDecisionConflictCandidateSummary[] + >([]); + const [draftHistoryCandidates, setDraftHistoryCandidates] = useState< + ReviewDraftHistoryConflictCandidateSummary[] + >([]); + const [refreshPending, setRefreshPending] = useState(false); + const [loadError, setLoadError] = useState(null); + const refreshGenerationRef = useRef(0); + + useLayoutEffect(() => { + refreshGenerationRef.current += 1; + return () => { + refreshGenerationRef.current += 1; + }; + }, [active, hydrationKey]); + + const reset = useCallback((): void => { + refreshGenerationRef.current += 1; + setDecisionCandidates([]); + setDraftHistoryCandidates([]); + setRefreshPending(false); + setLoadError(null); + }, []); + + const refresh = useCallback(async (): Promise => { + const refreshGeneration = ++refreshGenerationRef.current; + if (!active || !hydrationKey || !scope) { + setDecisionCandidates([]); + setDraftHistoryCandidates([]); + setRefreshPending(false); + setLoadError(null); + return; + } + const requestHydrationKey = hydrationKey; + const isCurrentRequest = (): boolean => + isExpectedHydrationKey(requestHydrationKey) && + refreshGenerationRef.current === refreshGeneration; + + setRefreshPending(true); + try { + const [nextDecisionCandidates, nextDraftHistoryCandidates] = await Promise.all([ + port.loadDecisionCandidates(scope), + port.loadDraftHistoryCandidates(scope), + ]); + if (!isCurrentRequest()) return; + if (nextDecisionCandidates.length > 0) { + await hydrateDecisions(scope, requestHydrationKey); + if (!isCurrentRequest()) return; + } + setDecisionCandidates(nextDecisionCandidates); + setDraftHistoryCandidates(nextDraftHistoryCandidates); + setLoadError(null); + clearReportedLoadError(); + } catch (error) { + if (!isCurrentRequest()) return; + const message = `${CHANGE_REVIEW_CONFLICT_LOAD_ERROR_PREFIX} ${String(error)}`; + setLoadError(message); + reportLoadError(message); + } finally { + if (isCurrentRequest()) setRefreshPending(false); + } + }, [ + active, + clearReportedLoadError, + hydrateDecisions, + hydrationKey, + isExpectedHydrationKey, + port, + reportLoadError, + scope, + ]); + + return { + decisionCandidates, + draftHistoryCandidates, + candidateCount: decisionCandidates.length + draftHistoryCandidates.length, + refreshPending, + loadError, + refresh, + reset, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewConflictInteractionController.ts b/src/features/change-review/renderer/hooks/useChangeReviewConflictInteractionController.ts new file mode 100644 index 0000000000..7232a9724a --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewConflictInteractionController.ts @@ -0,0 +1,207 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { selectLatestReviewConflictCandidate } from '../utils/changeReviewConflicts'; + +import type { ChangeReviewConflictCommandPort } from '../ports/changeReviewConflictPorts'; +import type { ChangeReviewConflictScope } from '../ports/changeReviewConflictPorts'; +import type { ReviewConflictCandidateSelection } from '../utils/changeReviewConflicts'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { ReviewDraftHistoryConflictCandidateSummary } from '@features/change-review-history/contracts'; +import type { + ReviewConflictResolution, + ReviewDecisionConflictCandidateSummary, +} from '@shared/types'; + +interface UseChangeReviewConflictInteractionControllerInput { + active: boolean; + hydrationKey: string | null; + scope: ChangeReviewConflictScope | null; + decisionCandidates: readonly ReviewDecisionConflictCandidateSummary[]; + draftHistoryCandidates: readonly ReviewDraftHistoryConflictCandidateSummary[]; + captureOperationScope: () => ReviewOperationScopeToken | null; + isCurrentOperationScope: ( + operationScope: ReviewOperationScopeToken | null + ) => operationScope is ReviewOperationScopeToken; + isExpectedHydrationKey: (hydrationKey: string) => boolean; + hydrateDecisions: (scope: ChangeReviewConflictScope, hydrationKey: string) => Promise; + isDecisionHydrationLoaded: (hydrationKey: string) => boolean; + publishDecisionPersistenceSaved: () => void; + resolveDraftHistoryCandidate: ( + candidate: ReviewDraftHistoryConflictCandidateSummary, + resolution: ReviewConflictResolution, + operationScope: ReviewOperationScopeToken + ) => Promise; + clearResolutionError: () => void; + reportResolutionError: (message: string) => void; + refreshCandidates: () => Promise; + port: ChangeReviewConflictCommandPort; +} + +export interface ChangeReviewConflictInteractionController { + activeCandidate: ReviewConflictCandidateSelection | null; + activeCandidateRecoverable: boolean; + resolvingCandidateId: string | null; + pendingDiscard: ReviewConflictCandidateSelection | null; + requestDiscard: (candidate: ReviewConflictCandidateSelection) => void; + onDiscardOpenChange: (open: boolean) => void; + confirmPendingDiscard: () => Promise; + resolveActiveCandidate: ( + resolution: ReviewConflictResolution, + expectedCandidateId?: string + ) => Promise; +} + +export function useChangeReviewConflictInteractionController({ + active, + hydrationKey, + scope, + decisionCandidates, + draftHistoryCandidates, + captureOperationScope, + isCurrentOperationScope, + isExpectedHydrationKey, + hydrateDecisions, + isDecisionHydrationLoaded, + publishDecisionPersistenceSaved, + resolveDraftHistoryCandidate, + clearResolutionError, + reportResolutionError, + refreshCandidates, + port, +}: UseChangeReviewConflictInteractionControllerInput): ChangeReviewConflictInteractionController { + const [resolvingCandidateId, setResolvingCandidateId] = useState(null); + const [pendingDiscard, setPendingDiscard] = useState( + null + ); + const resolutionOperationRef = useRef(null); + const activeCandidate = useMemo( + () => selectLatestReviewConflictCandidate(decisionCandidates, draftHistoryCandidates), + [decisionCandidates, draftHistoryCandidates] + ); + const activeCandidateRecoverable = activeCandidate?.value.recoverability === 'recoverable'; + + useLayoutEffect(() => { + resolutionOperationRef.current = null; + setResolvingCandidateId(null); + setPendingDiscard(null); + return () => { + resolutionOperationRef.current = null; + }; + }, [active, hydrationKey]); + + const resolveActiveCandidate = useCallback( + async (resolution: ReviewConflictResolution, expectedCandidateId?: string): Promise => { + if ( + !activeCandidate || + (resolution === 'recover-candidate' && !activeCandidateRecoverable) || + (expectedCandidateId !== undefined && activeCandidate.value.id !== expectedCandidateId) || + !hydrationKey || + !scope || + resolutionOperationRef.current !== null + ) { + return; + } + const selected = activeCandidate; + const resolutionHydrationKey = hydrationKey; + const operationScope = captureOperationScope(); + if (!operationScope) return; + const resolutionOperation = {}; + resolutionOperationRef.current = resolutionOperation; + setResolvingCandidateId(selected.value.id); + const isCurrentResolution = (): boolean => + isCurrentOperationScope(operationScope) && + isExpectedHydrationKey(resolutionHydrationKey) && + resolutionOperationRef.current === resolutionOperation; + + try { + if (selected.kind === 'decision') { + await port.resolveDecisionCandidate({ + scope, + candidateId: selected.value.id, + resolution, + observedCurrentRevision: selected.value.observedCurrentRevision, + }); + if (!isCurrentResolution()) return; + await hydrateDecisions(scope, resolutionHydrationKey); + if (!isCurrentResolution()) return; + if (!isDecisionHydrationLoaded(resolutionHydrationKey)) { + throw new Error('Resolved decisions could not be reloaded'); + } + publishDecisionPersistenceSaved(); + } else { + const resolved = await resolveDraftHistoryCandidate( + selected.value, + resolution, + operationScope + ); + if (!resolved) return; + } + if (!isCurrentResolution()) return; + clearResolutionError(); + await refreshCandidates(); + } catch (error) { + if (!isCurrentResolution()) return; + reportResolutionError(`Unable to resolve the durable recovery copy: ${String(error)}`); + await refreshCandidates(); + } finally { + if (isCurrentResolution()) { + resolutionOperationRef.current = null; + setResolvingCandidateId(null); + } + } + }, + [ + activeCandidate, + activeCandidateRecoverable, + captureOperationScope, + clearResolutionError, + hydrateDecisions, + hydrationKey, + isCurrentOperationScope, + isDecisionHydrationLoaded, + isExpectedHydrationKey, + port, + publishDecisionPersistenceSaved, + refreshCandidates, + reportResolutionError, + resolveDraftHistoryCandidate, + scope, + ] + ); + + const requestDiscard = useCallback((candidate: ReviewConflictCandidateSelection): void => { + setPendingDiscard(candidate); + }, []); + + const onDiscardOpenChange = useCallback( + (open: boolean): void => { + if (!open && resolvingCandidateId === null) setPendingDiscard(null); + }, + [resolvingCandidateId] + ); + + const confirmPendingDiscard = useCallback(async (): Promise => { + if (!pendingDiscard) return; + const operationScope = captureOperationScope(); + if (!operationScope) return; + const candidateId = pendingDiscard.value.id; + try { + await resolveActiveCandidate('keep-current', candidateId); + } finally { + if (isCurrentOperationScope(operationScope)) { + setPendingDiscard((current) => (current?.value.id === candidateId ? null : current)); + } + } + }, [captureOperationScope, isCurrentOperationScope, pendingDiscard, resolveActiveCandidate]); + + return { + activeCandidate, + activeCandidateRecoverable, + resolvingCandidateId, + pendingDiscard, + requestDiscard, + onDiscardOpenChange, + confirmPendingDiscard, + resolveActiveCandidate, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewDecisionAutoPersistence.ts b/src/features/change-review/renderer/hooks/useChangeReviewDecisionAutoPersistence.ts new file mode 100644 index 0000000000..de2d6b7264 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewDecisionAutoPersistence.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; + +import type { ChangeReviewDecisionPersistenceScope } from '../ports/changeReviewActionHistoryPorts'; +import type { ChangeReviewAutoClearResult } from './useChangeReviewDecisionPersistenceController'; + +interface UseChangeReviewDecisionAutoPersistenceInput { + active: boolean; + hydrationKey: string | null; + scope: ChangeReviewDecisionPersistenceScope | null; + hydrationReady: boolean; + blocked: boolean; + hasDurableReviewState: boolean; + hunkDecisions: object; + fileDecisions: object; + undoHistory: object; + redoHistory: object; + fileContents: object; + fileChunkCounts: object; + scheduleAutoPersistence: (scope: ChangeReviewDecisionPersistenceScope) => void; + clearAfterDurableStateEmptied: ( + scope: ChangeReviewDecisionPersistenceScope, + hydrationKey: string + ) => Promise; +} + +export function useChangeReviewDecisionAutoPersistence({ + active, + hydrationKey, + scope, + hydrationReady, + blocked, + hasDurableReviewState, + hunkDecisions, + fileDecisions, + undoHistory, + redoHistory, + fileContents, + fileChunkCounts, + scheduleAutoPersistence, + clearAfterDurableStateEmptied, +}: UseChangeReviewDecisionAutoPersistenceInput): void { + const hadDurableReviewStateRef = useRef(false); + const hasDurableReviewStateRef = useRef(hasDurableReviewState); + hasDurableReviewStateRef.current = hasDurableReviewState; + + useEffect(() => { + hadDurableReviewStateRef.current = false; + }, [scope?.scopeToken]); + + useEffect(() => { + if (!active || !scope || !hydrationKey || !hydrationReady || blocked) return; + if (hasDurableReviewState) { + hadDurableReviewStateRef.current = true; + scheduleAutoPersistence(scope); + return; + } + if (!hadDurableReviewStateRef.current) return; + void clearAfterDurableStateEmptied(scope, hydrationKey).then((result) => { + if (result === 'cleared' && !hasDurableReviewStateRef.current) { + hadDurableReviewStateRef.current = false; + } + }); + }, [ + active, + blocked, + clearAfterDurableStateEmptied, + fileChunkCounts, + fileContents, + fileDecisions, + hasDurableReviewState, + hunkDecisions, + hydrationKey, + hydrationReady, + redoHistory, + scheduleAutoPersistence, + scope, + undoHistory, + ]); +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewDecisionPersistenceController.ts b/src/features/change-review/renderer/hooks/useChangeReviewDecisionPersistenceController.ts new file mode 100644 index 0000000000..f694f53025 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewDecisionPersistenceController.ts @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { + ChangeReviewDecisionPersistencePort, + ChangeReviewDecisionPersistenceScope, + ChangeReviewDecisionPersistenceSnapshot, +} from '../ports/changeReviewActionHistoryPorts'; +import type { ReviewActionPersistenceStatus } from '../utils/changeReviewActionHistory'; + +export const CHANGE_REVIEW_PERSISTENCE_ERROR = + 'Latest review action is not saved. Retry from History before continuing.'; +const REVIEW_AUTO_CLEAR_ERROR = + 'Unable to clear saved review decisions. Retry from History or keep Changes open.'; + +interface ReviewPersistenceSnapshotIdentity { + scopeToken: string; + hunkDecisions: object; + fileDecisions: object; + reviewActionHistory: object; + reviewRedoHistory: object; + fileContents: object; + fileChunkCounts: object; +} + +interface PendingAutoClear { + hydrationKey: string; + generation: number; + operation: object; + promise: Promise; +} + +export type ChangeReviewAutoClearResult = 'cleared' | 'failed' | 'stale' | 'pending'; + +interface UseChangeReviewDecisionPersistenceControllerInput { + hydrationKey: string | null; + scope: ChangeReviewDecisionPersistenceScope | null; + hydrationReady: boolean; + isExpectedHydrationKey: (hydrationKey: string) => boolean; + refreshConflictCandidates: () => Promise; + port: ChangeReviewDecisionPersistencePort; +} + +export interface ChangeReviewDecisionPersistenceDiagnostics { + pendingDecisionClear: boolean; + persistenceStatus: ReviewActionPersistenceStatus; +} + +export interface ChangeReviewDecisionPersistenceController { + status: ReviewActionPersistenceStatus; + getStatus: () => ReviewActionPersistenceStatus; + publishSaved: () => void; + hydrate: (scope: ChangeReviewDecisionPersistenceScope, hydrationKey: string) => Promise; + persistLatest: () => Promise; + scheduleAutoPersistence: (scope: ChangeReviewDecisionPersistenceScope) => void; + clearAfterDurableStateEmptied: ( + scope: ChangeReviewDecisionPersistenceScope, + hydrationKey: string + ) => Promise; + flushForClose: () => Promise; + getDiagnostics: () => ChangeReviewDecisionPersistenceDiagnostics; +} + +function captureSnapshotIdentity( + scopeToken: string, + snapshot: ChangeReviewDecisionPersistenceSnapshot +): ReviewPersistenceSnapshotIdentity { + return { + scopeToken, + hunkDecisions: snapshot.hunkDecisions, + fileDecisions: snapshot.fileDecisions, + reviewActionHistory: snapshot.reviewActionHistory, + reviewRedoHistory: snapshot.reviewRedoHistory, + fileContents: snapshot.fileContents, + fileChunkCounts: snapshot.fileChunkCounts, + }; +} + +function isSameSnapshot( + left: ReviewPersistenceSnapshotIdentity | null, + right: ReviewPersistenceSnapshotIdentity +): boolean { + return ( + left?.scopeToken === right.scopeToken && + left.hunkDecisions === right.hunkDecisions && + left.fileDecisions === right.fileDecisions && + left.reviewActionHistory === right.reviewActionHistory && + left.reviewRedoHistory === right.reviewRedoHistory && + left.fileContents === right.fileContents && + left.fileChunkCounts === right.fileChunkCounts + ); +} + +function hasDurableReviewState(snapshot: ChangeReviewDecisionPersistenceSnapshot): boolean { + return ( + Object.keys(snapshot.hunkDecisions).length > 0 || + Object.keys(snapshot.fileDecisions).length > 0 || + Object.keys(snapshot.reviewActionHistory).length > 0 || + Object.keys(snapshot.reviewRedoHistory).length > 0 + ); +} + +export function useChangeReviewDecisionPersistenceController({ + hydrationKey, + scope, + hydrationReady, + isExpectedHydrationKey, + refreshConflictCandidates, + port, +}: UseChangeReviewDecisionPersistenceControllerInput): ChangeReviewDecisionPersistenceController { + const [status, setStatus] = useState('saved'); + const statusRef = useRef('saved'); + const generationRef = useRef(0); + const immediateSnapshotRef = useRef(null); + const pendingAutoClearRef = useRef(null); + + const publishStatus = useCallback((next: ReviewActionPersistenceStatus): void => { + statusRef.current = next; + setStatus(next); + }, []); + + useEffect(() => { + generationRef.current += 1; + immediateSnapshotRef.current = null; + if (pendingAutoClearRef.current?.hydrationKey !== hydrationKey) { + pendingAutoClearRef.current = null; + } + publishStatus('saved'); + }, [hydrationKey, publishStatus]); + + const getStatus = useCallback((): ReviewActionPersistenceStatus => statusRef.current, []); + const publishSaved = useCallback((): void => publishStatus('saved'), [publishStatus]); + + const hydrate = useCallback( + async ( + hydrationScope: ChangeReviewDecisionPersistenceScope, + targetHydrationKey: string + ): Promise => { + const generation = generationRef.current; + await port.load(hydrationScope); + if (generationRef.current !== generation || !isExpectedHydrationKey(targetHydrationKey)) { + return; + } + const hydrated = port.getSnapshot(); + if ( + hydrated.decisionHydrationScopeKey === targetHydrationKey && + hydrated.decisionHydrationStatus === 'loaded' + ) { + // Loading is already durable. Suppress only the exact reference snapshot + // produced by this hydration, never a structurally-equal later edit. + immediateSnapshotRef.current = captureSnapshotIdentity(hydrationScope.scopeToken, hydrated); + } + }, + [isExpectedHydrationKey, port] + ); + + const persistLatest = useCallback(async (): Promise => { + const generation = generationRef.current + 1; + generationRef.current = generation; + pendingAutoClearRef.current = null; + publishStatus('saving'); + + if (!scope || !hydrationReady) { + if (generationRef.current === generation) { + publishStatus('error'); + port.reportError(CHANGE_REVIEW_PERSISTENCE_ERROR); + } + return false; + } + + // The marker must precede both scheduling and flushing. Otherwise the + // post-ack render can enqueue a redundant revision before the marker exists. + immediateSnapshotRef.current = captureSnapshotIdentity(scope.scopeToken, port.getSnapshot()); + + let saved = false; + try { + port.schedule(scope); + saved = await port.flush(scope); + } catch { + saved = false; + } + + if (generationRef.current !== generation || !isExpectedHydrationKey(hydrationKey ?? '')) { + return saved; + } + if (saved) { + publishStatus('saved'); + port.clearError(CHANGE_REVIEW_PERSISTENCE_ERROR); + return true; + } + + publishStatus('error'); + port.reportError(CHANGE_REVIEW_PERSISTENCE_ERROR); + void refreshConflictCandidates(); + return false; + }, [ + hydrationKey, + hydrationReady, + isExpectedHydrationKey, + port, + publishStatus, + refreshConflictCandidates, + scope, + ]); + + const scheduleAutoPersistence = useCallback( + (autoScope: ChangeReviewDecisionPersistenceScope): void => { + const currentSnapshot = captureSnapshotIdentity(autoScope.scopeToken, port.getSnapshot()); + if (isSameSnapshot(immediateSnapshotRef.current, currentSnapshot)) { + immediateSnapshotRef.current = null; + return; + } + immediateSnapshotRef.current = null; + port.schedule(autoScope); + }, + [port] + ); + + const clearAfterDurableStateEmptied = useCallback( + ( + clearScope: ChangeReviewDecisionPersistenceScope, + targetHydrationKey: string + ): Promise => { + const existing = pendingAutoClearRef.current; + if ( + existing?.hydrationKey === targetHydrationKey && + existing.generation === generationRef.current + ) { + return existing.promise; + } + + const generation = generationRef.current + 1; + generationRef.current = generation; + const operation = {}; + const pending: PendingAutoClear = { + hydrationKey: targetHydrationKey, + generation, + operation, + promise: Promise.resolve('pending'), + }; + const isCurrent = (): boolean => + generationRef.current === generation && + pendingAutoClearRef.current?.operation === operation && + isExpectedHydrationKey(targetHydrationKey); + + pending.promise = (async (): Promise => { + let cleared = false; + try { + cleared = await port.clear(clearScope); + } catch { + cleared = false; + } + if (!isCurrent()) return 'stale'; + if (cleared) return 'cleared'; + + publishStatus('error'); + port.reportError(REVIEW_AUTO_CLEAR_ERROR); + void refreshConflictCandidates(); + return 'failed'; + })().finally(() => { + if (pendingAutoClearRef.current?.operation === operation) { + pendingAutoClearRef.current = null; + } + }); + pendingAutoClearRef.current = pending; + return pending.promise; + }, + [isExpectedHydrationKey, port, publishStatus, refreshConflictCandidates] + ); + + const flushForClose = useCallback(async (): Promise => { + if (!scope) return true; + if (hasDurableReviewState(port.getSnapshot())) return persistLatest(); + const pending = pendingAutoClearRef.current; + if (pending?.hydrationKey === hydrationKey) { + return (await pending.promise) === 'cleared'; + } + try { + return await port.clear(scope); + } catch { + return false; + } + }, [hydrationKey, persistLatest, port, scope]); + + const getDiagnostics = useCallback( + (): ChangeReviewDecisionPersistenceDiagnostics => ({ + pendingDecisionClear: pendingAutoClearRef.current !== null, + persistenceStatus: statusRef.current, + }), + [] + ); + + return { + status, + getStatus, + publishSaved, + hydrate, + persistLatest, + scheduleAutoPersistence, + clearAfterDurableStateEmptied, + flushForClose, + getDiagnostics, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewDialogKeyboardInteractions.ts b/src/features/change-review/renderer/hooks/useChangeReviewDialogKeyboardInteractions.ts new file mode 100644 index 0000000000..6714c743f7 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewDialogKeyboardInteractions.ts @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useMemo } from 'react'; + +import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; + +import { useChangeReviewHistoryKeyboardShortcuts } from './useChangeReviewHistoryKeyboardShortcuts'; + +import type { ChangeReviewDialogKeyboardInteractionPort } from '../ports/changeReviewDialogInteractionPorts'; +import type { EditorView } from '@codemirror/view'; +import type { FileChangeSummary, ReviewRedoAction, ReviewUndoAction } from '@shared/types'; +import type { RefObject } from 'react'; + +interface UseChangeReviewDialogKeyboardInteractionsInput { + open: boolean; + activeFilePath: string | null; + activeFilePathRef: RefObject; + activeEditorViewRef: RefObject; + editorViewMapRef: RefObject>; + sortedFiles: FileChangeSummary[]; + fileChunkCounts: Record; + editedCount: number; + scrollToFile: (filePath: string) => void; + saveFile: (filePath: string) => Promise; + requestClose: () => Promise; + acceptHunk: (filePath: string, hunkIndex: number) => boolean | void; + rejectHunk: ( + filePath: string, + hunkIndex: number, + beforeContent: string, + afterContent: string + ) => boolean | void; + hasDraft: (filePath: string) => boolean; + hasActionInFlight: () => boolean; + getEditorFilePathForTarget: (target: Element | null) => string | null; + getHunkCountForFile: ( + filePath: string, + fallbackSnippetsLength: number, + fileChunkCounts: Record + ) => number; + getUndoHistory: () => ReviewUndoAction[]; + getRedoHistory: () => ReviewRedoAction[]; + undoLatest: () => Promise; + redoLatest: () => Promise; + reportError: (message: string) => void; + keyboardPort: ChangeReviewDialogKeyboardInteractionPort; +} + +export interface ChangeReviewDialogKeyboardInteractions { + diffNav: ReturnType; + reviewHunkOrder: { + offsets: Record; + total: number; + }; +} + +export function useChangeReviewDialogKeyboardInteractions({ + open, + activeFilePath, + activeFilePathRef, + activeEditorViewRef, + editorViewMapRef, + sortedFiles, + fileChunkCounts, + editedCount, + scrollToFile, + saveFile, + requestClose, + acceptHunk, + rejectHunk, + hasDraft, + hasActionInFlight, + getEditorFilePathForTarget, + getHunkCountForFile, + getUndoHistory, + getRedoHistory, + undoLatest, + redoLatest, + reportError, + keyboardPort, +}: UseChangeReviewDialogKeyboardInteractionsInput): ChangeReviewDialogKeyboardInteractions { + const getHunkCount = useCallback( + (filePath: string, fallbackSnippetsLength: number): number => + getHunkCountForFile(filePath, fallbackSnippetsLength, fileChunkCounts), + [fileChunkCounts, getHunkCountForFile] + ); + const handleSaveActiveFile = useCallback((): void => { + if (!activeFilePath || hasActionInFlight()) return; + void saveFile(activeFilePath); + }, [activeFilePath, hasActionInFlight, saveFile]); + + const continuousOptions = useMemo( + () => ({ + editorViewMapRef, + activeFilePath, + scrollToFile, + enabled: true, + }), + [activeFilePath, editorViewMapRef, scrollToFile] + ); + + const diffNav = useDiffNavigation( + sortedFiles, + activeFilePath, + scrollToFile, + activeEditorViewRef, + open, + acceptHunk, + rejectHunk, + () => void requestClose(), + handleSaveActiveFile, + continuousOptions, + getHunkCount + ); + + const reviewHunkOrder = useMemo(() => { + const offsets: Record = {}; + let total = 0; + for (const file of sortedFiles) { + offsets[file.filePath] = total; + total += getHunkCount(file.filePath, file.snippets.length); + } + return { offsets, total }; + }, [getHunkCount, sortedFiles]); + + const resolveEditorContext = useCallback( + (target: Element | null) => { + const filePath = getEditorFilePathForTarget(target); + return { + editor: filePath ? (editorViewMapRef.current.get(filePath) ?? null) : null, + hasDraft: filePath ? hasDraft(filePath) : false, + }; + }, + [editorViewMapRef, getEditorFilePathForTarget, hasDraft] + ); + const getUndoCount = useCallback((): number => getUndoHistory().length, [getUndoHistory]); + const getRedoCount = useCallback((): number => getRedoHistory().length, [getRedoHistory]); + const reportManualDraftBlock = useCallback( + (): void => reportError('Save or discard manual edits before undoing a review action.'), + [reportError] + ); + + useChangeReviewHistoryKeyboardShortcuts({ + active: open, + editedCount, + resolveEditorContext, + hasActionInFlight, + getUndoCount, + getRedoCount, + undoLatest, + redoLatest, + reportManualDraftBlock, + }); + + useEffect(() => { + if (!open) return; + return keyboardPort.subscribeRejectCurrentHunk(() => { + const filePath = activeFilePathRef.current; + if (!filePath) return; + const rejected = keyboardPort.rejectCurrentChunk(filePath); + if (!rejected) return; + if ( + rejectHunk(filePath, rejected.hunkIndex, rejected.beforeContent, rejected.afterContent) === + false + ) { + keyboardPort.rollbackContent(filePath, rejected.beforeContent); + return; + } + requestAnimationFrame(() => diffNav.goToNextHunk()); + }); + }, [activeFilePathRef, diffNav, keyboardPort, open, rejectHunk]); + + return { diffNav, reviewHunkOrder }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewDialogLifecycleController.ts b/src/features/change-review/renderer/hooks/useChangeReviewDialogLifecycleController.ts new file mode 100644 index 0000000000..ddb79a3b06 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewDialogLifecycleController.ts @@ -0,0 +1,520 @@ +import { useCallback, useEffect, useMemo } from 'react'; + +import { + evaluateChangeReviewCloseReadiness, + shouldRequestReviewCloseForEscape, +} from '../utils/changeReviewDialogLifecycle'; + +import { useChangeReviewDecisionAutoPersistence } from './useChangeReviewDecisionAutoPersistence'; +import { useChangeReviewLifecycleRegistration } from './useChangeReviewLifecycleRegistration'; + +import type { + ChangeReviewDialogLifecycleCommandPort, + ChangeReviewDialogLifecycleDecisionPersistencePort, + ChangeReviewDialogLifecycleDraftHistoryPort, + ChangeReviewDialogLifecycleEditorPort, + ChangeReviewDialogLifecyclePersistenceScope, + ChangeReviewDialogLifecycleSessionPort, + ChangeReviewDialogLifecycleStatePort, + ChangeReviewDialogLifecycleStatusPort, + ChangeReviewDialogLifecycleWriteEvidencePort, +} from '../ports/changeReviewDialogLifecyclePorts'; +import type { + RegisterChangeReviewAppCloseParticipant, + RegisterChangeReviewLifecycleOwner, +} from '../ports/changeReviewLifecyclePorts'; +import type { ReviewDraftHistoryHydrationState } from '../utils/changeReviewScope'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import type { ReviewFileScope } from '@shared/types'; + +interface UseChangeReviewDialogLifecycleControllerInput { + open: boolean; + authorized: boolean; + setAuthorized: (authorized: boolean) => void; + hostId: string; + sessionId: string; + tabId: string | undefined; + focus: (() => void) | undefined; + teamName: string; + mode: 'agent' | 'task'; + memberName: string | undefined; + taskId: string | undefined; + taskChangeRequestOptions: TaskChangeRequestOptions | undefined; + scopeKey: string; + decisionScopeKey: string; + decisionScopeToken: string | null; + decisionHydrationKey: string | null; + decisionHydrationReady: boolean; + decisionHydrationFailed: boolean; + draftHistoryHydration: ReviewDraftHistoryHydrationState; + draftHistoryHydrationFailed: boolean; + reviewScope: ReviewFileScope; + reviewMutationBusy: boolean; + reviewActionsBusy: boolean; + onOpenChange: (open: boolean) => void; + statePort: ChangeReviewDialogLifecycleStatePort; + commandPort: ChangeReviewDialogLifecycleCommandPort; + editorPort: ChangeReviewDialogLifecycleEditorPort; + statusPort: ChangeReviewDialogLifecycleStatusPort; + sessionPort: ChangeReviewDialogLifecycleSessionPort; + writeEvidencePort: ChangeReviewDialogLifecycleWriteEvidencePort; + decisionPersistence: ChangeReviewDialogLifecycleDecisionPersistencePort; + draftHistory: ChangeReviewDialogLifecycleDraftHistoryPort; + hasActionInFlight: () => boolean; + blockForExternalChange: () => boolean; + captureOperationScope: () => ReviewOperationScopeToken | null; + isCurrentOperationScope: (scope: ReviewOperationScopeToken | null) => boolean; + registerOwner: RegisterChangeReviewLifecycleOwner; + registerAppCloseParticipant: RegisterChangeReviewAppCloseParticipant; +} + +export interface ChangeReviewDialogLifecycleController { + requestClose: () => Promise; + retrySavedReviewState: () => Promise; + discardSavedDecisionState: () => Promise; + apply: () => Promise; +} + +interface ReviewCloseFlushResult { + ok: boolean; + blocker?: string; +} + +export function useChangeReviewDialogLifecycleController({ + open, + authorized, + setAuthorized, + hostId, + sessionId, + tabId, + focus, + teamName, + mode, + memberName, + taskId, + taskChangeRequestOptions, + scopeKey, + decisionScopeKey, + decisionScopeToken, + decisionHydrationKey, + decisionHydrationReady, + decisionHydrationFailed, + draftHistoryHydration, + draftHistoryHydrationFailed, + reviewScope, + reviewMutationBusy, + reviewActionsBusy, + onOpenChange, + statePort, + commandPort, + editorPort, + statusPort, + sessionPort, + writeEvidencePort, + decisionPersistence, + draftHistory, + hasActionInFlight, + blockForExternalChange, + captureOperationScope, + isCurrentOperationScope, + registerOwner, + registerAppCloseParticipant, +}: UseChangeReviewDialogLifecycleControllerInput): ChangeReviewDialogLifecycleController { + const { + flushForClose: flushReviewDecisionsForClose, + getDiagnostics: getDecisionPersistenceDiagnostics, + scheduleAutoPersistence, + clearAfterDurableStateEmptied, + } = decisionPersistence; + const { + getEntry: getDraftHistoryEntry, + flushWrites: flushDraftHistoryWrites, + retryHydration: retryDraftHistoryHydration, + discardUnreadableScope: discardUnreadableDraftHistoryScope, + getDiagnostics: getDraftHistoryDiagnostics, + } = draftHistory; + const persistenceScope = useMemo( + () => + decisionScopeToken + ? { + teamName, + scopeKey: decisionScopeKey, + scopeToken: decisionScopeToken, + } + : null, + [decisionScopeKey, decisionScopeToken, teamName] + ); + + useEffect(() => { + if (!open || !authorized) return; + commandPort.resetAllReviewState(); + if (mode === 'agent' && memberName) { + commandPort.fetchAgentChanges(teamName, memberName); + } else if (mode === 'task' && taskId) { + commandPort.fetchTaskChanges(teamName, taskId, taskChangeRequestOptions ?? {}); + } + return () => commandPort.clearChangeReviewCache(); + }, [ + authorized, + commandPort, + decisionScopeKey, + memberName, + mode, + open, + taskChangeRequestOptions, + taskId, + teamName, + ]); + + useEffect(() => { + if (!open || !authorized || !persistenceScope || !decisionHydrationKey) return; + void commandPort.hydrateDecisions(persistenceScope, decisionHydrationKey); + }, [authorized, commandPort, decisionHydrationKey, open, persistenceScope]); + + const renderedState = statePort.getSnapshot(); + const hasDurableReviewState = + Object.keys(renderedState.hunkDecisions).length > 0 || + Object.keys(renderedState.fileDecisions).length > 0 || + renderedState.reviewActionHistory.length > 0 || + renderedState.reviewRedoHistory.length > 0; + useChangeReviewDecisionAutoPersistence({ + active: open && authorized, + hydrationKey: decisionHydrationKey, + scope: persistenceScope, + hydrationReady: decisionHydrationReady, + blocked: reviewActionsBusy, + hasDurableReviewState, + hunkDecisions: renderedState.hunkDecisions, + fileDecisions: renderedState.fileDecisions, + undoHistory: renderedState.reviewActionHistory, + redoHistory: renderedState.reviewRedoHistory, + fileContents: renderedState.fileContents, + fileChunkCounts: renderedState.fileChunkCounts, + scheduleAutoPersistence, + clearAfterDurableStateEmptied, + }); + + const flushReviewStateForClose = useCallback(async (): Promise => { + const operationScope = captureOperationScope(); + if (!operationScope) { + return { + ok: false, + blocker: 'Review scope changed before Changes could close.', + }; + } + const scopeChangedResult: ReviewCloseFlushResult = { + ok: false, + blocker: 'Review scope changed while Changes was closing.', + }; + const state = statePort.getSnapshot(); + const draftDiagnostics = getDraftHistoryDiagnostics(); + const decisionDiagnostics = getDecisionPersistenceDiagnostics(); + const hydrationHasError = + decisionHydrationKey !== null && + ((state.decisionHydrationScopeKey === decisionHydrationKey && + state.decisionHydrationStatus === 'error') || + (draftHistoryHydration.key === decisionHydrationKey && + draftHistoryHydration.status === 'error')); + const scopedDraftDiagnostics = hydrationHasError + ? getDraftHistoryDiagnostics(decisionHydrationKey) + : draftDiagnostics; + const readiness = evaluateChangeReviewCloseReadiness({ + hydrationKey: decisionHydrationKey, + decisionHydrationScopeKey: state.decisionHydrationScopeKey, + decisionHydrationStatus: state.decisionHydrationStatus, + draftHydrationKey: draftHistoryHydration.key, + draftHydrationStatus: draftHistoryHydration.status, + editedContentCount: Object.keys(state.editedContents).length, + hunkDecisionCount: Object.keys(state.hunkDecisions).length, + fileDecisionCount: Object.keys(state.fileDecisions).length, + undoHistoryCount: state.reviewActionHistory.length, + redoHistoryCount: state.reviewRedoHistory.length, + draftDiagnostics, + scopedDraftDiagnostics, + decisionDiagnostics, + pendingApplyCleanupKey: sessionPort.getPendingApplyCleanupKey(), + actionLockState: statusPort.getActionLockState(state.applying), + }); + if (readiness.disposition === 'block') { + statePort.reportError(readiness.blocker); + return { ok: false, blocker: readiness.blocker }; + } + if (readiness.disposition === 'close-without-flush') { + return { ok: true }; + } + + statusPort.beginClosing(); + try { + editorPort.captureDraftSnapshots( + (filePath) => filePath in state.editedContents || Boolean(getDraftHistoryEntry(filePath)) + ); + const currentState = statePort.getSnapshot(); + for (const filePath of Object.keys(currentState.editedContents)) { + if (!getDraftHistoryEntry(filePath)) { + const blocker = `Manual edits for ${filePath} are not durable yet. Keep Changes open and retry.`; + statePort.reportError(blocker); + return { ok: false, blocker }; + } + } + const draftsFlushed = await flushDraftHistoryWrites(); + if (!isCurrentOperationScope(operationScope)) return scopeChangedResult; + if (!draftsFlushed) { + const blocker = 'Unable to save manual edit history. Changes remains open.'; + statePort.reportError(blocker); + return { ok: false, blocker }; + } + if (persistenceScope && sessionPort.getPendingApplyCleanupKey() === decisionHydrationKey) { + const cleared = await commandPort.clearDecisions(persistenceScope); + if (!isCurrentOperationScope(operationScope)) return scopeChangedResult; + if (!cleared) { + const blocker = + 'Review was applied, but its saved state could not be cleared. Changes remains open.'; + statePort.reportError(blocker); + return { ok: false, blocker }; + } + sessionPort.setPendingApplyCleanupKey(null); + return { ok: true }; + } + if (persistenceScope) { + const flushed = await flushReviewDecisionsForClose(); + if (!isCurrentOperationScope(operationScope)) return scopeChangedResult; + if (!flushed) { + const blocker = 'Unable to save review decisions. Changes remains open.'; + statePort.reportError(blocker); + return { ok: false, blocker }; + } + } + return { ok: true }; + } finally { + if (isCurrentOperationScope(operationScope)) statusPort.finishClosing(); + } + }, [ + captureOperationScope, + commandPort, + decisionHydrationKey, + draftHistoryHydration.key, + draftHistoryHydration.status, + editorPort, + flushDraftHistoryWrites, + flushReviewDecisionsForClose, + getDecisionPersistenceDiagnostics, + getDraftHistoryDiagnostics, + getDraftHistoryEntry, + isCurrentOperationScope, + persistenceScope, + sessionPort, + statePort, + statusPort, + ]); + + const requestLifecycleClose = useCallback(async (): Promise => { + const operationScope = captureOperationScope(); + if (!operationScope) return false; + const result = await flushReviewStateForClose(); + if (!isCurrentOperationScope(operationScope)) return false; + if (result.ok) onOpenChange(false); + return result.ok; + }, [captureOperationScope, flushReviewStateForClose, isCurrentOperationScope, onOpenChange]); + + const requestClose = useCallback(async (): Promise => { + await requestLifecycleClose(); + }, [requestLifecycleClose]); + + const closeRejectedDialog = useCallback((): void => onOpenChange(false), [onOpenChange]); + useChangeReviewLifecycleRegistration({ + open, + authorized, + hostId, + sessionId, + tabId, + focus, + requestClose: requestLifecycleClose, + closeRejectedDialog, + setAuthorized, + appCloseParticipantId: `changes:${teamName}:${decisionHydrationKey ?? scopeKey}`, + flushForAppClose: flushReviewStateForClose, + registerOwner, + registerAppCloseParticipant, + }); + + const retrySavedReviewState = useCallback(async (): Promise => { + if (!persistenceScope || !decisionHydrationKey || reviewMutationBusy) { + return; + } + const operationScope = captureOperationScope(); + if (!operationScope) return; + statusPort.setRecoveryInFlight(true); + try { + if (decisionHydrationFailed) { + const recovered = await commandPort.retryMutationRecovery({ + scope: reviewScope, + decisionPersistenceScope: { + scopeKey: persistenceScope.scopeKey, + scopeToken: persistenceScope.scopeToken, + }, + }); + if (!isCurrentOperationScope(operationScope)) return; + writeEvidencePort.markCommittedPostimages(recovered.diskPostimages); + await commandPort.hydrateDecisions(persistenceScope, decisionHydrationKey); + if (!isCurrentOperationScope(operationScope)) return; + } + if (draftHistoryHydrationFailed) retryDraftHistoryHydration(); + } catch (error) { + if (!isCurrentOperationScope(operationScope)) return; + statePort.reportError(`Unable to resume the saved review update: ${String(error)}`); + } finally { + if (isCurrentOperationScope(operationScope)) { + statusPort.setRecoveryInFlight(false); + } + } + }, [ + captureOperationScope, + commandPort, + decisionHydrationFailed, + decisionHydrationKey, + draftHistoryHydrationFailed, + isCurrentOperationScope, + persistenceScope, + reviewMutationBusy, + reviewScope, + retryDraftHistoryHydration, + statePort, + statusPort, + writeEvidencePort, + ]); + + const discardSavedDecisionState = useCallback(async (): Promise => { + if (!persistenceScope || !decisionHydrationKey || reviewMutationBusy) { + throw new Error('Saved review state is not ready to be discarded.'); + } + const operationScope = captureOperationScope(); + if (!operationScope) { + throw new Error('Saved review scope is no longer active.'); + } + statusPort.beginClosing(); + try { + if (decisionHydrationFailed) { + const cleared = await commandPort.clearDecisions(persistenceScope, true); + if (!isCurrentOperationScope(operationScope)) return; + if (!cleared) { + const message = 'Unable to discard the unreadable saved review decisions.'; + statePort.reportError(message); + throw new Error(message); + } + } + if (draftHistoryHydrationFailed) { + try { + const discarded = await discardUnreadableDraftHistoryScope(operationScope); + if (!discarded) return; + } catch (error) { + if (!isCurrentOperationScope(operationScope)) return; + const message = `Unable to discard the unreadable manual edit history: ${String(error)}`; + statePort.reportError(message); + throw new Error(message, { cause: error }); + } + } + const state = statePort.getSnapshot(); + if (decisionHydrationFailed && state.decisionHydrationScopeKey !== decisionHydrationKey) { + throw new Error('Saved review scope changed before it could be discarded.'); + } + statePort.completeSavedStateDiscard(decisionHydrationFailed); + } finally { + if (isCurrentOperationScope(operationScope)) statusPort.finishClosing(); + } + }, [ + captureOperationScope, + commandPort, + decisionHydrationFailed, + decisionHydrationKey, + discardUnreadableDraftHistoryScope, + draftHistoryHydrationFailed, + isCurrentOperationScope, + persistenceScope, + reviewMutationBusy, + statePort, + statusPort, + ]); + + const apply = useCallback(async (): Promise => { + if (hasActionInFlight() || blockForExternalChange()) return; + if (!persistenceScope || !decisionHydrationKey) { + statePort.reportError('Durable review scope is unavailable. Reload Changes before applying.'); + return; + } + const operationScope = captureOperationScope(); + if (!operationScope) return; + + if (sessionPort.getPendingApplyCleanupKey() !== decisionHydrationKey) { + const outcome = await commandPort.applyReview(teamName, taskId, memberName); + if (!isCurrentOperationScope(operationScope)) return; + writeEvidencePort.markCommittedPostimages(outcome.result?.diskPostimages); + if (outcome.status === 'failed') { + statePort.reportError(outcome.errorMessage); + return; + } + if (!sessionPort.isExpectedHydrationKey(decisionHydrationKey)) return; + sessionPort.setPendingApplyCleanupKey(decisionHydrationKey); + } + + statusPort.beginClosing(); + try { + const cleared = await commandPort.clearDecisions(persistenceScope); + if (!isCurrentOperationScope(operationScope)) return; + if (!cleared) { + statePort.reportError( + 'Review was applied, but its saved state could not be cleared. Changes remains open; retry Apply to finish cleanup.' + ); + return; + } + sessionPort.setPendingApplyCleanupKey(null); + if (sessionPort.isExpectedHydrationKey(decisionHydrationKey)) { + commandPort.resetAllReviewState(); + } + } finally { + if (isCurrentOperationScope(operationScope)) statusPort.finishClosing(); + } + }, [ + blockForExternalChange, + captureOperationScope, + commandPort, + decisionHydrationKey, + hasActionInFlight, + isCurrentOperationScope, + memberName, + persistenceScope, + sessionPort, + statePort, + statusPort, + taskId, + teamName, + writeEvidencePort, + ]); + + useEffect(() => { + if (!open) return; + const handler = (event: KeyboardEvent): void => { + if ( + shouldRequestReviewCloseForEscape({ + key: event.key, + defaultPrevented: event.defaultPrevented, + hasOpenModalLayer: Boolean( + document.querySelector('[role="alertdialog"][data-state="open"]') + ), + }) + ) { + event.preventDefault(); + void requestClose(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, requestClose]); + + return { + requestClose, + retrySavedReviewState, + discardSavedDecisionState, + apply, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewDialogViewState.ts b/src/features/change-review/renderer/hooks/useChangeReviewDialogViewState.ts new file mode 100644 index 0000000000..c900bc1438 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewDialogViewState.ts @@ -0,0 +1,344 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav'; +import { useViewedFiles } from '@renderer/hooks/useViewedFiles'; +import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo'; + +import { + buildGlobalDiffLoadingState, + buildReviewFileLabels, + buildWatchedReviewFilePathsKey, + findActiveReviewFile, + resolveReviewFileLabel, + sortChangeReviewFiles, +} from '../view-models/changeReviewPresentation'; + +import type { ChangeReviewCollapsedFilesStoragePort } from '../ports/changeReviewDialogInteractionPorts'; +import type { + ChangeReviewChangeSet, + GlobalDiffLoadingState, +} from '../view-models/changeReviewPresentation'; +import type { EditorView } from '@codemirror/view'; +import type { FileChangeSummary, FileChangeWithContent, ReviewUndoAction } from '@shared/types'; +import type { EditorSelectionInfo } from '@shared/types/editor'; +import type { Dispatch, RefObject, SetStateAction } from 'react'; + +type ContinuousScrollNavigation = ReturnType; + +export interface ChangeReviewDialogViewStatePolicy { + buildInitialScrollKey: ( + changeSet: ChangeReviewChangeSet | null, + initialFilePath: string | undefined + ) => string | null; + getHistoryActionFilePath: (action: ReviewUndoAction) => string | null; + resolveFilePath: ( + files: readonly Pick[], + requestedPath: string | undefined + ) => string | null; +} + +interface UseChangeReviewDialogViewStateInput { + open: boolean; + hasData: boolean; + teamName: string; + scopeKey: string; + collapseStorageKey: string; + initialFilePath: string | undefined; + activeChangeSet: ChangeReviewChangeSet | null; + fileContents: Record; + fileContentsLoading: Record; + storage: ChangeReviewCollapsedFilesStoragePort; + policy: ChangeReviewDialogViewStatePolicy; + reportError: (message: string) => void; +} + +export interface ChangeReviewDialogViewState { + activeFile: FileChangeSummary | null; + activeFilePath: string | null; + activeFilePathRef: RefObject; + activeEditorViewRef: RefObject; + autoViewed: boolean; + clearSelection: () => void; + collapsedFiles: Set; + containerRect: DOMRect; + diffContentRef: RefObject; + editorViewMapRef: RefObject>; + getEditorFilePathForTarget: (target: Element | null) => string | null; + globalDiffLoadingState: GlobalDiffLoadingState | null; + handleFullyViewed: (filePath: string) => void; + handleHistoryActionNavigation: (action: ReviewUndoAction) => void; + handleSelectionChange: (info: EditorSelectionInfo | null) => void; + handleTreeFileClick: (filePath: string) => void; + handleVisibleFileChange: (filePath: string) => void; + isProgrammaticScroll: ContinuousScrollNavigation['isProgrammaticScroll']; + resolveReviewFileLabel: (filePath: string) => string; + scrollContainerRef: RefObject; + scrollToFile: ContinuousScrollNavigation['scrollToFile']; + selectionInfo: EditorSelectionInfo | null; + setAutoViewed: Dispatch>; + setTimelineOpen: Dispatch>; + sortedFiles: FileChangeSummary[]; + timelineOpen: boolean; + toggleCollapsedFile: (filePath: string) => void; + viewedCount: number; + viewedProgress: number; + viewedSet: Set; + viewedTotalCount: number; + watchedReviewFilePathsKey: string; +} + +export function useChangeReviewDialogViewState({ + open, + hasData, + teamName, + scopeKey, + collapseStorageKey, + initialFilePath, + activeChangeSet, + fileContents, + fileContentsLoading, + storage, + policy, + reportError, +}: UseChangeReviewDialogViewStateInput): ChangeReviewDialogViewState { + const [activeFilePath, setActiveFilePath] = useState(null); + const [autoViewed, setAutoViewed] = useState(true); + const [timelineOpen, setTimelineOpen] = useState(false); + const [collapsedFiles, setCollapsedFiles] = useState>(() => + storage.read(collapseStorageKey) + ); + const [selectionInfo, setSelectionInfo] = useState(null); + const [containerRect, setContainerRect] = useState(new DOMRect()); + + const diffContentRef = useRef(null); + const selectionTimerRef = useRef>(undefined); + const activeSelectionFileRef = useRef(null); + const editorViewMapRef = useRef(new Map()); + const scrollContainerRef = useRef(null); + const activeEditorViewRef = useRef(null); + const activeFilePathRef = useRef(null); + const initialScrollDoneKeyRef = useRef(null); + + const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({ + scrollContainerRef, + }); + const sortedFiles = useMemo( + () => sortChangeReviewFiles(activeChangeSet?.files ?? []), + [activeChangeSet] + ); + const reviewFileLabels = useMemo(() => buildReviewFileLabels(sortedFiles), [sortedFiles]); + const resolveFileLabel = useCallback( + (filePath: string): string => resolveReviewFileLabel(reviewFileLabels, filePath), + [reviewFileLabels] + ); + const watchedFilePathsKey = useMemo( + () => buildWatchedReviewFilePathsKey(sortedFiles), + [sortedFiles] + ); + const globalDiffLoadingState = useMemo( + () => + buildGlobalDiffLoadingState({ + files: sortedFiles, + activeFilePath, + fileContentsLoading, + fileContents, + }), + [activeFilePath, fileContents, fileContentsLoading, sortedFiles] + ); + const allFilePaths = useMemo(() => sortedFiles.map((file) => file.filePath), [sortedFiles]); + const viewed = useViewedFiles(teamName, scopeKey, allFilePaths); + const activeFile = useMemo( + () => findActiveReviewFile(activeChangeSet, activeFilePath), + [activeChangeSet, activeFilePath] + ); + + useEffect(() => { + activeFilePathRef.current = activeFilePath; + activeEditorViewRef.current = activeFilePath + ? (editorViewMapRef.current.get(activeFilePath) ?? null) + : null; + }, [activeFilePath]); + + const getEditorFilePathForTarget = useCallback((target: Element | null): string | null => { + if (!target) return null; + for (const [filePath, view] of editorViewMapRef.current.entries()) { + if (view.dom.contains(target)) return filePath; + } + return null; + }, []); + + const handleVisibleFileChange = useCallback((filePath: string): void => { + setActiveFilePath(filePath); + }, []); + + const handleTreeFileClick = useCallback( + (filePath: string): void => { + scrollToFile(filePath); + setActiveFilePath(filePath); + }, + [scrollToFile] + ); + + const handleHistoryActionNavigation = useCallback( + (action: ReviewUndoAction): void => { + const actionFilePath = policy.getHistoryActionFilePath(action); + if (!actionFilePath) return; + const resolvedPath = policy.resolveFilePath(sortedFiles, actionFilePath); + if (!resolvedPath) { + reportError('The file from this review action is no longer in the current change set.'); + return; + } + handleTreeFileClick(resolvedPath); + }, + [handleTreeFileClick, policy, reportError, sortedFiles] + ); + + const handleFullyViewed = useCallback( + (filePath: string): void => { + if (autoViewed && !viewed.isViewed(filePath)) viewed.markViewed(filePath); + }, + [autoViewed, viewed] + ); + + const handleSelectionChange = useCallback((info: EditorSelectionInfo | null): void => { + if (!info) { + if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current); + setSelectionInfo(null); + return; + } + activeSelectionFileRef.current = info.filePath; + if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current); + selectionTimerRef.current = setTimeout(() => { + setSelectionInfo(info); + }, SELECTION_DEBOUNCE_MS); + }, []); + + const clearSelection = useCallback((): void => { + setSelectionInfo(null); + }, []); + + useEffect(() => { + if (!hasData) return; + const container = scrollContainerRef.current; + if (!container) return; + + let animationFrameId = 0; + const onScroll = (): void => { + cancelAnimationFrame(animationFrameId); + animationFrameId = requestAnimationFrame(() => { + const filePath = activeSelectionFileRef.current; + if (!filePath) return; + const view = editorViewMapRef.current.get(filePath); + if (!view) return; + const selection = view.state.selection.main; + if (selection.empty) { + setSelectionInfo(null); + return; + } + const info = buildSelectionInfo(view, selection); + setSelectionInfo(info ? { ...info, filePath } : null); + }); + }; + + container.addEventListener('scroll', onScroll, { passive: true }); + return () => { + cancelAnimationFrame(animationFrameId); + container.removeEventListener('scroll', onScroll); + }; + }, [hasData]); + + useEffect(() => { + const element = diffContentRef.current; + if (!element) return; + const observer = new ResizeObserver(() => { + setContainerRect(element.getBoundingClientRect()); + }); + observer.observe(element); + setContainerRect(element.getBoundingClientRect()); + return () => observer.disconnect(); + }, [hasData]); + + const toggleCollapsedFile = useCallback((filePath: string): void => { + setCollapsedFiles((previous) => { + const next = new Set(previous); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }, []); + + useEffect(() => { + if (!open) return; + const timeoutId = setTimeout(() => { + storage.write(collapseStorageKey, collapsedFiles); + }, 200); + return () => clearTimeout(timeoutId); + }, [collapseStorageKey, collapsedFiles, open, storage]); + + useEffect(() => { + if (!activeChangeSet) return; + const allowed = new Set(activeChangeSet.files.map((file) => file.filePath)); + setCollapsedFiles((previous) => { + const next = new Set(); + for (const filePath of previous) { + if (allowed.has(filePath)) next.add(filePath); + } + return next.size === previous.size ? previous : next; + }); + }, [activeChangeSet]); + + useEffect(() => { + const scrollKey = policy.buildInitialScrollKey(activeChangeSet, initialFilePath); + if (!activeChangeSet || !initialFilePath || !scrollKey) return; + if (initialScrollDoneKeyRef.current === scrollKey) return; + const targetFilePath = policy.resolveFilePath(activeChangeSet.files, initialFilePath); + if (!targetFilePath) return; + initialScrollDoneKeyRef.current = scrollKey; + requestAnimationFrame(() => { + requestAnimationFrame(() => scrollToFile(targetFilePath)); + }); + }, [activeChangeSet, initialFilePath, policy, scrollToFile]); + + useEffect(() => { + if (!open) { + setSelectionInfo(null); + activeSelectionFileRef.current = null; + if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current); + } + }, [open]); + + return { + activeFile, + activeFilePath, + activeFilePathRef, + activeEditorViewRef, + autoViewed, + clearSelection, + collapsedFiles, + containerRect, + diffContentRef, + editorViewMapRef, + globalDiffLoadingState, + handleFullyViewed, + handleHistoryActionNavigation, + handleSelectionChange, + handleTreeFileClick, + handleVisibleFileChange, + isProgrammaticScroll, + resolveReviewFileLabel: resolveFileLabel, + scrollContainerRef, + scrollToFile, + setAutoViewed, + setTimelineOpen, + sortedFiles, + timelineOpen, + toggleCollapsedFile, + viewedCount: viewed.viewedCount, + viewedProgress: viewed.progress, + viewedSet: viewed.viewedSet, + viewedTotalCount: viewed.totalCount, + watchedReviewFilePathsKey: watchedFilePathsKey, + getEditorFilePathForTarget, + selectionInfo, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewDraftHistoryController.ts b/src/features/change-review/renderer/hooks/useChangeReviewDraftHistoryController.ts new file mode 100644 index 0000000000..e2bcc5335a --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewDraftHistoryController.ts @@ -0,0 +1,676 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { ReviewDraftHistoryWriteBuffer } from '@features/change-review-history/renderer'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { + ChangeReviewDraftHistoryPort, + ChangeReviewDraftHistoryScope, +} from '../ports/changeReviewDraftHistoryPort'; +import type { ReviewDraftHistoryHydrationState } from '../utils/changeReviewScope'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { ReviewDraftHistoryConflictCandidateSummary } from '@features/change-review-history/contracts'; +import type { + ReviewDraftHistoryEntry, + ReviewSerializedEditorState, +} from '@features/change-review-history/contracts'; +import type { ReviewChangeSetLike } from '@renderer/utils/reviewDecisionScope'; +import type { ReviewConflictResolution, ReviewFileScope } from '@shared/types'; + +interface PendingDraftHistoryWrite { + hydrationKey: string; + scope: ChangeReviewDraftHistoryScope; + entry: Omit; +} + +interface DraftHistoryVersion { + revision: number; + generation: string; +} + +interface HydratedDraftState { + scopeFilePaths: string[]; + recoveredDrafts: Record; + externalChanges: Record; + errorMessage?: string; +} + +interface UseChangeReviewDraftHistoryControllerInput { + open: boolean; + changeSetEpoch: number; + scopeKey: string; + teamName: string; + activeChangeSet: ReviewChangeSetLike | null | undefined; + decisionScopeKey: string; + decisionScopeToken: string | null; + decisionHydrationKey: string | null; + draftHistoryHydrationReady: boolean; + reviewScope: ReviewFileScope; + draftHistoryConflictCandidates: readonly ReviewDraftHistoryConflictCandidateSummary[]; + setHydration: (state: ReviewDraftHistoryHydrationState) => void; + isExpectedHydrationKey: (hydrationKey: string) => boolean; + refreshConflictCandidates: () => Promise; + captureOperationScope: () => ReviewOperationScopeToken | null; + isCurrentOperationScope: ( + operationScope: ReviewOperationScopeToken | null + ) => operationScope is ReviewOperationScopeToken; + commitHydratedDrafts: (state: HydratedDraftState) => void; + reportError: (message: string | null) => void; + port: ChangeReviewDraftHistoryPort; +} + +export interface ChangeReviewDraftHistoryDiagnostics { + pendingWriteCount: number; + writeChainCount: number; + writeErrorCount: number; +} + +export interface ChangeReviewDraftHistoryController { + entries: Record; + getEntry: (filePath: string) => ReviewDraftHistoryEntry | undefined; + hasBaseline: (filePath: string) => boolean; + getBaseline: (filePath: string) => string | null | undefined; + setBaseline: (filePath: string, baseline: string | null) => void; + deleteBaseline: (filePath: string) => void; + unsuppressFile: (filePath: string) => void; + publishCheckpoint: ( + filePath: string, + editorState: ReviewSerializedEditorState, + diskBaseline: string | null + ) => void; + handleSerializedStateChanged: ( + filePath: string, + editorState: ReviewSerializedEditorState + ) => void; + handleSerializedStateRestoreError: (filePath: string, error: unknown) => void; + flushWrites: () => Promise; + clearFile: (filePath: string) => Promise; + resolveConflictCandidate: ( + candidate: ReviewDraftHistoryConflictCandidateSummary, + resolution: ReviewConflictResolution, + operationScope: ReviewOperationScopeToken + ) => Promise; + retryHydration: () => void; + discardUnreadableScope: (operationScope: ReviewOperationScopeToken) => Promise; + getDiagnostics: (hydrationKey?: string | null) => ChangeReviewDraftHistoryDiagnostics; +} + +export function useChangeReviewDraftHistoryController({ + open, + changeSetEpoch, + scopeKey, + teamName, + activeChangeSet, + decisionScopeKey, + decisionScopeToken, + decisionHydrationKey, + draftHistoryHydrationReady, + reviewScope, + draftHistoryConflictCandidates, + setHydration, + isExpectedHydrationKey, + refreshConflictCandidates, + captureOperationScope, + isCurrentOperationScope, + commitHydratedDrafts, + reportError, + port, +}: UseChangeReviewDraftHistoryControllerInput): ChangeReviewDraftHistoryController { + const [entries, setEntries] = useState>({}); + const [retryNonce, setRetryNonce] = useState(0); + const [promotionNonce, setPromotionNonce] = useState(0); + const entriesRef = useRef>({}); + const baselinesRef = useRef(new Map()); + const writeChainsRef = useRef(new Map>()); + const promotionChainsRef = useRef(new Map>()); + const writeBufferRef = useRef(new ReviewDraftHistoryWriteBuffer()); + const writeErrorsRef = useRef(new Map()); + const persistedVersionsRef = useRef(new Map()); + const suppressedFilesRef = useRef(new Set()); + const stableReviewScope = useMemo( + () => ({ + teamName: reviewScope.teamName, + taskId: reviewScope.taskId, + memberName: reviewScope.memberName, + }), + [reviewScope.memberName, reviewScope.taskId, reviewScope.teamName] + ); + const persistenceScope = useMemo( + () => + decisionScopeToken + ? { teamName, scopeKey: decisionScopeKey, scopeToken: decisionScopeToken } + : null, + [decisionScopeKey, decisionScopeToken, teamName] + ); + + const replaceEntries = useCallback((next: Record): void => { + entriesRef.current = next; + setEntries(next); + }, []); + + const startDrain = useCallback( + (writeKey: string): Promise => { + const active = writeChainsRef.current.get(writeKey); + if (active) return active; + + const drain = (async () => { + while (true) { + const pending = writeBufferRef.current.takeNext(writeKey); + if (!pending) return; + try { + const expectedVersion = persistedVersionsRef.current.get(writeKey); + const saved = await port.saveEntry({ + scope: pending.scope, + entry: pending.entry, + expectedVersion: { + revision: expectedVersion?.revision ?? 0, + generation: expectedVersion?.generation ?? null, + }, + }); + persistedVersionsRef.current.set(writeKey, { + revision: saved.revision, + generation: saved.generation, + }); + writeErrorsRef.current.delete(writeKey); + const current = entriesRef.current[pending.entry.filePath]; + if ( + isExpectedHydrationKey(pending.hydrationKey) && + current?.revision === saved.revision + ) { + replaceEntries({ ...entriesRef.current, [pending.entry.filePath]: saved }); + } + } catch (error) { + writeBufferRef.current.markFailed(writeKey, pending); + writeErrorsRef.current.set(writeKey, error); + setPromotionNonce((nonce) => nonce + 1); + if (isExpectedHydrationKey(pending.hydrationKey)) { + reportError('Unable to save manual edit history. Retry Save or keep Changes open.'); + void refreshConflictCandidates(); + } + throw error; + } + } + })(); + writeChainsRef.current.set(writeKey, drain); + void drain + .catch(() => undefined) + .finally(() => { + if (writeChainsRef.current.get(writeKey) === drain) { + writeChainsRef.current.delete(writeKey); + } + }); + return drain; + }, + [isExpectedHydrationKey, port, refreshConflictCandidates, replaceEntries, reportError] + ); + + const enqueueWrite = useCallback( + (entry: Omit): void => { + if (!decisionHydrationKey || !persistenceScope) return; + const writeKey = `${decisionHydrationKey}\0${entry.filePath}`; + writeBufferRef.current.enqueue(writeKey, { + hydrationKey: decisionHydrationKey, + scope: persistenceScope, + entry, + }); + if (writeBufferRef.current.peekFailed(writeKey)) { + setPromotionNonce((nonce) => nonce + 1); + } + void startDrain(writeKey); + }, + [decisionHydrationKey, persistenceScope, startDrain] + ); + + useLayoutEffect(() => { + suppressedFilesRef.current.clear(); + }, [changeSetEpoch, decisionHydrationKey, open, scopeKey, teamName]); + + useEffect(() => { + baselinesRef.current.clear(); + replaceEntries({}); + setHydration({ key: null, status: 'idle' }); + }, [changeSetEpoch, replaceEntries, scopeKey, setHydration, teamName]); + + useEffect(() => { + if (!open || !decisionHydrationKey || !persistenceScope || !activeChangeSet) { + if (!decisionHydrationKey) setHydration({ key: null, status: 'idle' }); + return; + } + let cancelled = false; + const hydrationKey = decisionHydrationKey; + setHydration({ key: hydrationKey, status: 'loading' }); + + void (async () => { + try { + const snapshot = await port.load(persistenceScope); + if (cancelled || !isExpectedHydrationKey(hydrationKey)) return; + const writeKeyPrefix = `${hydrationKey}\0`; + for (const writeKey of persistedVersionsRef.current.keys()) { + if (writeKey.startsWith(writeKeyPrefix)) persistedVersionsRef.current.delete(writeKey); + } + + const allowedFiles = new Map( + activeChangeSet.files.map((file) => [normalizePathForComparison(file.filePath), file]) + ); + const recoveredEntries: Record = {}; + const recoveredDrafts: Record = {}; + const externalChanges: Record = {}; + + for (const entry of Object.values(snapshot?.entries ?? {})) { + const file = allowedFiles.get(normalizePathForComparison(entry.filePath)); + if (file?.filePath !== entry.filePath) continue; + const baselineKey = normalizePathForComparison(file.filePath); + const conflict = await port.checkConflict({ + reviewScope: stableReviewScope, + filePath: file.filePath, + expectedModified: entry.diskBaseline ?? '', + }); + if (cancelled || !isExpectedHydrationKey(hydrationKey)) return; + const diskMatchesBaseline = + entry.diskBaseline === null + ? conflict.hasConflict && conflict.conflictContent === null + : !conflict.hasConflict; + + recoveredEntries[file.filePath] = entry; + persistedVersionsRef.current.set(`${hydrationKey}\0${file.filePath}`, { + revision: entry.revision, + generation: entry.generation, + }); + baselinesRef.current.set(baselineKey, entry.diskBaseline); + if (!diskMatchesBaseline || entry.editorState.doc !== entry.diskBaseline) { + recoveredDrafts[file.filePath] = entry.editorState.doc; + } + if (!diskMatchesBaseline) externalChanges[file.filePath] = { type: 'change' }; + } + + replaceEntries(recoveredEntries); + commitHydratedDrafts({ + scopeFilePaths: [...allowedFiles.keys()], + recoveredDrafts, + externalChanges, + ...(Object.keys(externalChanges).length > 0 + ? { + errorMessage: + 'Recovered manual edits are based on files that changed on disk. Review each conflict before saving.', + } + : {}), + }); + setHydration({ key: hydrationKey, status: 'loaded' }); + } catch (error) { + if (cancelled || !isExpectedHydrationKey(hydrationKey)) return; + setHydration({ key: hydrationKey, status: 'error' }); + reportError(`Unable to load saved manual edit history: ${String(error)}`); + } + })(); + + return () => { + cancelled = true; + }; + }, [ + activeChangeSet, + changeSetEpoch, + commitHydratedDrafts, + decisionHydrationKey, + isExpectedHydrationKey, + open, + persistenceScope, + port, + replaceEntries, + reportError, + retryNonce, + stableReviewScope, + setHydration, + ]); + + useEffect(() => { + if (!decisionHydrationKey || !persistenceScope) return; + const hydrationKey = decisionHydrationKey; + for (const candidate of draftHistoryConflictCandidates) { + const writeKey = `${hydrationKey}\0${candidate.filePath}`; + if (promotionChainsRef.current.has(writeKey)) continue; + const failed = writeBufferRef.current.peekFailed(writeKey); + const pending = writeBufferRef.current.peekPending(writeKey); + if (!failed || !pending || failed.hydrationKey !== hydrationKey) continue; + const promotion = (async () => { + try { + await port.replaceConflictCandidate({ + scope: persistenceScope, + expectedEntry: failed.entry, + replacementEntry: pending.entry, + observedVersion: { + revision: candidate.observedCurrentRevision, + generation: candidate.observedCurrentGeneration, + }, + }); + if (!isExpectedHydrationKey(hydrationKey)) return; + writeBufferRef.current.promotePendingToFailed(writeKey, failed, pending); + await refreshConflictCandidates(); + } catch (error) { + if (!isExpectedHydrationKey(hydrationKey)) return; + reportError(`Unable to preserve the latest manual edit recovery copy: ${String(error)}`); + await refreshConflictCandidates(); + } + })(); + promotionChainsRef.current.set(writeKey, promotion); + void promotion.finally(() => { + if (promotionChainsRef.current.get(writeKey) === promotion) { + promotionChainsRef.current.delete(writeKey); + } + if ( + isExpectedHydrationKey(hydrationKey) && + writeBufferRef.current.peekFailed(writeKey) && + writeBufferRef.current.peekPending(writeKey) + ) { + setPromotionNonce((nonce) => nonce + 1); + } + }); + } + }, [ + decisionHydrationKey, + draftHistoryConflictCandidates, + isExpectedHydrationKey, + persistenceScope, + port, + promotionNonce, + refreshConflictCandidates, + reportError, + ]); + + const flushWrites = useCallback(async (): Promise => { + if (!decisionHydrationKey) return true; + const prefix = `${decisionHydrationKey}\0`; + for (const key of writeBufferRef.current.keys(prefix)) void startDrain(key); + while (true) { + const writes = [...writeChainsRef.current.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([, write]) => write); + if (writes.length === 0) break; + await Promise.allSettled(writes); + } + return ( + !writeBufferRef.current.hasPendingWithPrefix(prefix) && + !writeBufferRef.current.hasFailedWithPrefix(prefix) && + ![...writeErrorsRef.current.keys()].some((key) => key.startsWith(prefix)) + ); + }, [decisionHydrationKey, startDrain]); + + const clearFile = useCallback( + (filePath: string): Promise => { + const operationScope = captureOperationScope(); + if (!operationScope) { + return Promise.reject(new Error('Review scope changed before Undo history could clear.')); + } + const normalizedPath = normalizePathForComparison(filePath); + suppressedFilesRef.current.add(normalizedPath); + if (!decisionHydrationKey || !persistenceScope) { + if (isCurrentOperationScope(operationScope)) + suppressedFilesRef.current.delete(normalizedPath); + return Promise.reject( + new Error('Durable review scope is unavailable; refusing to discard Undo history.') + ); + } + + const writeKey = `${decisionHydrationKey}\0${filePath}`; + const previous = startDrain(writeKey); + let clearedVersion: DraftHistoryVersion | undefined; + const clear = previous + .then(() => { + if (!isCurrentOperationScope(operationScope)) { + throw new Error('Review scope changed before Undo history could clear.'); + } + clearedVersion = persistedVersionsRef.current.get(writeKey); + return port.clear({ + scope: persistenceScope, + filePath, + expectedVersion: { + revision: clearedVersion?.revision ?? 0, + generation: clearedVersion?.generation ?? null, + }, + }); + }) + .then(() => { + if (!isCurrentOperationScope(operationScope)) return; + const next = { ...entriesRef.current }; + const current = next[filePath]; + if (!current || current.revision <= (clearedVersion?.revision ?? 0)) + delete next[filePath]; + replaceEntries(next); + persistedVersionsRef.current.delete(writeKey); + writeErrorsRef.current.delete(writeKey); + }); + writeChainsRef.current.set(writeKey, clear); + void clear + .catch((error) => { + if (!isCurrentOperationScope(operationScope)) return; + suppressedFilesRef.current.delete(normalizedPath); + writeErrorsRef.current.set(writeKey, error); + if (isExpectedHydrationKey(decisionHydrationKey)) { + reportError(`Unable to discard saved manual edit history: ${String(error)}`); + } + }) + .finally(() => { + if (writeChainsRef.current.get(writeKey) === clear) + writeChainsRef.current.delete(writeKey); + if ( + isCurrentOperationScope(operationScope) && + writeBufferRef.current.hasPending(writeKey) + ) { + void startDrain(writeKey); + } + }); + return clear; + }, + [ + captureOperationScope, + decisionHydrationKey, + isCurrentOperationScope, + isExpectedHydrationKey, + persistenceScope, + port, + replaceEntries, + reportError, + startDrain, + ] + ); + + const publishCheckpoint = useCallback( + ( + filePath: string, + editorState: ReviewSerializedEditorState, + diskBaseline: string | null + ): void => { + if (!decisionHydrationKey || !draftHistoryHydrationReady) return; + const current = entriesRef.current[filePath]; + if ( + current?.diskBaseline === diskBaseline && + JSON.stringify(current.editorState) === JSON.stringify(editorState) + ) { + return; + } + const entry: ReviewDraftHistoryEntry = { + filePath, + codec: 'codemirror-history-v1', + revision: (current?.revision ?? 0) + 1, + generation: current?.generation ?? 'pending', + diskBaseline, + editorState, + updatedAt: new Date().toISOString(), + }; + replaceEntries({ ...entriesRef.current, [filePath]: entry }); + enqueueWrite({ + filePath, + codec: entry.codec, + revision: entry.revision, + diskBaseline, + editorState, + }); + }, + [decisionHydrationKey, draftHistoryHydrationReady, enqueueWrite, replaceEntries] + ); + + const handleSerializedStateChanged = useCallback( + (filePath: string, editorState: ReviewSerializedEditorState): void => { + const baselineKey = normalizePathForComparison(filePath); + if (suppressedFilesRef.current.has(baselineKey)) return; + const existing = entriesRef.current[filePath]; + if (!baselinesRef.current.has(baselineKey)) { + if (!existing) return; + baselinesRef.current.set(baselineKey, existing.diskBaseline); + } + publishCheckpoint(filePath, editorState, baselinesRef.current.get(baselineKey) ?? null); + }, + [publishCheckpoint] + ); + + const handleSerializedStateRestoreError = useCallback( + (filePath: string, error: unknown): void => { + reportError( + `Saved manual edit history for ${filePath} is incompatible and was not applied: ${String(error)}` + ); + }, + [reportError] + ); + + const resolveConflictCandidate = useCallback( + async ( + candidate: ReviewDraftHistoryConflictCandidateSummary, + resolution: ReviewConflictResolution, + operationScope: ReviewOperationScopeToken + ): Promise => { + if (!decisionHydrationKey || !persistenceScope) return false; + const hydrationKey = decisionHydrationKey; + const writeKey = `${hydrationKey}\0${candidate.filePath}`; + await writeChainsRef.current.get(writeKey)?.catch(() => undefined); + if (!isCurrentOperationScope(operationScope) || !isExpectedHydrationKey(hydrationKey)) { + return false; + } + const resolved = await port.resolveConflictCandidate({ + scope: persistenceScope, + candidateId: candidate.id, + resolution, + observedVersion: { + revision: candidate.observedCurrentRevision, + generation: candidate.observedCurrentGeneration, + }, + }); + if (!isCurrentOperationScope(operationScope) || !isExpectedHydrationKey(hydrationKey)) { + return false; + } + const pendingDescendant = writeBufferRef.current.resolveConflict( + writeKey, + resolution === 'recover-candidate' + ); + writeErrorsRef.current.delete(writeKey); + if (resolved) { + persistedVersionsRef.current.set(writeKey, { + revision: resolved.revision, + generation: resolved.generation, + }); + } else { + persistedVersionsRef.current.delete(writeKey); + } + if (pendingDescendant && resolved) { + const rebasedEntry = { + ...pendingDescendant, + entry: { ...pendingDescendant.entry, revision: resolved.revision + 1 }, + }; + replaceEntries({ + ...entriesRef.current, + [candidate.filePath]: { + ...rebasedEntry.entry, + generation: resolved.generation, + updatedAt: new Date().toISOString(), + }, + }); + writeBufferRef.current.enqueue(writeKey, rebasedEntry); + void startDrain(writeKey); + } else { + setRetryNonce((nonce) => nonce + 1); + } + return true; + }, + [ + decisionHydrationKey, + isCurrentOperationScope, + isExpectedHydrationKey, + persistenceScope, + port, + replaceEntries, + startDrain, + ] + ); + + const retryHydration = useCallback((): void => setRetryNonce((nonce) => nonce + 1), []); + + const discardUnreadableScope = useCallback( + async (operationScope: ReviewOperationScopeToken): Promise => { + if (!decisionHydrationKey || !persistenceScope) return false; + await port.clear({ scope: persistenceScope }); + if (!isCurrentOperationScope(operationScope)) return false; + baselinesRef.current.clear(); + replaceEntries({}); + setHydration({ key: decisionHydrationKey, status: 'loaded' }); + return true; + }, + [ + decisionHydrationKey, + isCurrentOperationScope, + persistenceScope, + port, + replaceEntries, + setHydration, + ] + ); + + const getEntry = useCallback((filePath: string) => entriesRef.current[filePath], []); + const hasBaseline = useCallback( + (filePath: string): boolean => baselinesRef.current.has(normalizePathForComparison(filePath)), + [] + ); + const getBaseline = useCallback( + (filePath: string): string | null | undefined => + baselinesRef.current.get(normalizePathForComparison(filePath)), + [] + ); + const setBaseline = useCallback((filePath: string, baseline: string | null): void => { + baselinesRef.current.set(normalizePathForComparison(filePath), baseline); + }, []); + const deleteBaseline = useCallback((filePath: string): void => { + baselinesRef.current.delete(normalizePathForComparison(filePath)); + }, []); + const unsuppressFile = useCallback((filePath: string): void => { + suppressedFilesRef.current.delete(normalizePathForComparison(filePath)); + }, []); + const getDiagnostics = useCallback( + (hydrationKey?: string | null): ChangeReviewDraftHistoryDiagnostics => { + const prefix = hydrationKey ? `${hydrationKey}\0` : ''; + return { + pendingWriteCount: writeBufferRef.current.keys(prefix).length, + writeChainCount: [...writeChainsRef.current.keys()].filter((key) => key.startsWith(prefix)) + .length, + writeErrorCount: [...writeErrorsRef.current.keys()].filter((key) => key.startsWith(prefix)) + .length, + }; + }, + [] + ); + + return { + entries, + getEntry, + hasBaseline, + getBaseline, + setBaseline, + deleteBaseline, + unsuppressFile, + publishCheckpoint, + handleSerializedStateChanged, + handleSerializedStateRestoreError, + flushWrites, + clearFile, + resolveConflictCandidate, + retryHydration, + discardUnreadableScope, + getDiagnostics, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewExternalChangeController.ts b/src/features/change-review/renderer/hooks/useChangeReviewExternalChangeController.ts new file mode 100644 index 0000000000..28b18a1bfd --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewExternalChangeController.ts @@ -0,0 +1,107 @@ +import { useCallback } from 'react'; + +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import { useChangeReviewExternalFileWatcher } from './useChangeReviewExternalFileWatcher'; + +import type { + ChangeReviewExternalFileWatcherPort, + ChangeReviewRecentWrite, +} from '../ports/changeReviewDialogInteractionPorts'; +import type { + ChangeReviewExternalChangePolicy, + ChangeReviewExternalChangeStatePort, +} from '../ports/changeReviewMutationSafetyPorts'; +import type { ReviewDraftHistoryEntry } from '@features/change-review-history/contracts'; +import type { EditorFileChangeEvent, ReviewFileScope } from '@shared/types'; +import type { RefObject } from 'react'; + +interface UseChangeReviewExternalChangeControllerInput { + open: boolean; + enabled: boolean; + projectPath: string | undefined; + watchedFilePathsKey: string; + reviewScope: ReviewFileScope; + externalChangesByFile: Readonly>; + recentWritesRef: RefObject>; + isMutationInFlight: (normalizedPath: string) => boolean; + getDraftHistoryEntry: (filePath: string) => ReviewDraftHistoryEntry | undefined; + statePort: ChangeReviewExternalChangeStatePort; + policy: ChangeReviewExternalChangePolicy; + watcherPort: ChangeReviewExternalFileWatcherPort; +} + +export interface ChangeReviewExternalChangeController { + reviewMutationBlockedByExternalChange: boolean; + blockReviewMutationForExternalChange: (filePath?: string) => boolean; +} + +export function useChangeReviewExternalChangeController({ + open, + enabled, + projectPath, + watchedFilePathsKey, + reviewScope, + externalChangesByFile, + recentWritesRef, + isMutationInFlight, + getDraftHistoryEntry, + statePort, + policy, + watcherPort, +}: UseChangeReviewExternalChangeControllerInput): ChangeReviewExternalChangeController { + const reviewMutationBlockedByExternalChange = Object.keys(externalChangesByFile).length > 0; + const blockReviewMutationForExternalChange = useCallback( + (filePath?: string): boolean => { + const externalChanges = statePort.getSnapshot().reviewExternalChangesByFile; + const blocked = filePath + ? policy.hasUnresolvedExternalChange(filePath, externalChanges) + : Object.keys(externalChanges).length > 0; + if (blocked) { + statePort.reportError( + 'Reload files changed outside Changes before continuing review actions.' + ); + } + return blocked; + }, + [policy, statePort] + ); + const processExternalChange = useCallback( + (event: EditorFileChangeEvent): void => { + const normalizedPath = normalizePathForComparison(event.path); + const state = statePort.getSnapshot(); + const file = state.activeChangeSet?.files.find( + (entry) => normalizePathForComparison(entry.filePath) === normalizedPath + ); + if (!file) return; + const durableDraftHistory = getDraftHistoryEntry(file.filePath); + if (!(file.filePath in state.editedContents) && durableDraftHistory) { + statePort.restoreDraft(file.filePath, durableDraftHistory.editorState.doc); + } + const changeType = + event.type === 'create' ? 'add' : event.type === 'delete' ? 'unlink' : 'change'; + statePort.markExternalChange(file.filePath, changeType); + statePort.reportError( + 'A reviewed file changed outside Changes. Reload it from disk before continuing review actions.' + ); + }, + [getDraftHistoryEntry, statePort] + ); + + useChangeReviewExternalFileWatcher({ + open, + enabled, + projectPath, + watchedFilePathsKey, + reviewScope, + recentWritesRef, + isMutationInFlight, + processExternalChange, + port: watcherPort, + }); + + return { + reviewMutationBlockedByExternalChange, + blockReviewMutationForExternalChange, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewExternalFileWatcher.ts b/src/features/change-review/renderer/hooks/useChangeReviewExternalFileWatcher.ts new file mode 100644 index 0000000000..26ad93fdf6 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewExternalFileWatcher.ts @@ -0,0 +1,132 @@ +import { useEffect, useRef } from 'react'; + +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { + ChangeReviewExternalFileWatcherPort, + ChangeReviewRecentWrite, +} from '../ports/changeReviewDialogInteractionPorts'; +import type { EditorFileChangeEvent, ReviewFileScope } from '@shared/types'; +import type { RefObject } from 'react'; + +const REVIEW_LOCAL_WRITE_COOLDOWN_MS = 2000; +const REVIEW_WRITE_SETTLE_RETRY_MS = 25; + +interface UseChangeReviewExternalFileWatcherInput { + open: boolean; + enabled: boolean; + projectPath: string | undefined; + watchedFilePathsKey: string; + reviewScope: ReviewFileScope; + recentWritesRef: RefObject>; + isMutationInFlight: (normalizedPath: string) => boolean; + processExternalChange: (event: EditorFileChangeEvent) => void; + port: ChangeReviewExternalFileWatcherPort; +} + +interface VerifyExpectedReviewWriteInput { + event: EditorFileChangeEvent; + normalizedPath: string; + reviewScope: ReviewFileScope; + recentWritesRef: RefObject>; + isDisposed: () => boolean; + isMutationInFlight: (normalizedPath: string) => boolean; + processIfCurrent: () => void; + port: ChangeReviewExternalFileWatcherPort; +} + +async function verifyExpectedReviewWrite(input: VerifyExpectedReviewWriteInput): Promise { + if (input.isDisposed()) return; + if (input.isMutationInFlight(input.normalizedPath)) { + setTimeout(() => void verifyExpectedReviewWrite(input), REVIEW_WRITE_SETTLE_RETRY_MS); + return; + } + const latest = input.recentWritesRef.current.get(input.normalizedPath); + if (!latest) return; + try { + const result = await input.port.checkConflict( + input.reviewScope, + input.event.path, + latest.expectedContent ?? '' + ); + const matchesExpected = + latest.expectedContent === null + ? result.hasConflict && result.conflictContent === null + : !result.hasConflict; + if (matchesExpected) return; + } catch { + // A failed verification is not evidence that this was our own event. + } + input.recentWritesRef.current.delete(input.normalizedPath); + input.processIfCurrent(); +} + +export function useChangeReviewExternalFileWatcher({ + open, + enabled, + projectPath, + watchedFilePathsKey, + reviewScope, + recentWritesRef, + isMutationInFlight, + processExternalChange, + port, +}: UseChangeReviewExternalFileWatcherInput): void { + const watchedFilePathsKeyRef = useRef(watchedFilePathsKey); + useEffect(() => { + watchedFilePathsKeyRef.current = watchedFilePathsKey; + }, [watchedFilePathsKey]); + + useEffect(() => { + if (!open || !projectPath || !enabled) return; + let disposed = false; + + const unsubscribe = port.subscribe((event) => { + const normalizedPath = normalizePathForComparison(event.path); + const processIfCurrent = (): void => { + if (!disposed) processExternalChange(event); + }; + const recentWrite = recentWritesRef.current.get(normalizedPath); + if (!recentWrite || Date.now() - recentWrite.at >= REVIEW_LOCAL_WRITE_COOLDOWN_MS) { + processIfCurrent(); + return; + } + void verifyExpectedReviewWrite({ + event, + normalizedPath, + reviewScope, + recentWritesRef, + isDisposed: () => disposed, + isMutationInFlight, + processIfCurrent, + port, + }); + }); + + const initialFilePaths = watchedFilePathsKeyRef.current + ? watchedFilePathsKeyRef.current.split('\0') + : []; + void port.watchFiles(projectPath, initialFilePaths); + + return () => { + disposed = true; + unsubscribe(); + void port.unwatchFiles(); + }; + }, [ + enabled, + isMutationInFlight, + open, + port, + processExternalChange, + projectPath, + recentWritesRef, + reviewScope, + ]); + + useEffect(() => { + if (!open || !projectPath || !enabled) return; + const filePaths = watchedFilePathsKey ? watchedFilePathsKey.split('\0') : []; + void port.watchFiles(projectPath, filePaths); + }, [enabled, open, port, projectPath, watchedFilePathsKey]); +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewFileDecisionController.ts b/src/features/change-review/renderer/hooks/useChangeReviewFileDecisionController.ts new file mode 100644 index 0000000000..945d9e44cd --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewFileDecisionController.ts @@ -0,0 +1,571 @@ +import { useCallback } from 'react'; + +import { + alignReviewDiskUndoSnapshotWithAppliedContent, + buildForwardDiskMutationSteps, + isLedgerRenameReviewFile, +} from '@features/review-mutations'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; +import { threeWayTextMerge } from '@shared/utils/threeWayTextMerge'; + +import type { + ChangeReviewFileDecisionCommandPort, + ChangeReviewFileDecisionEditorPort, + ChangeReviewFileDecisionHistoryPort, + ChangeReviewFileDecisionPersistenceScope, + ChangeReviewFileDecisionPolicy, + ChangeReviewFileDecisionStatePort, + ChangeReviewFileDecisionStatusPort, + ChangeReviewFileDecisionWriteEvidencePort, +} from '../ports/changeReviewFileDecisionPorts'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { + FileChangeSummary, + FileChangeWithContent, + ReviewDecisionSnapshot, + ReviewDiskUndoAction, + ReviewDiskUndoSnapshot, + ReviewFileScope, + ReviewUndoAction, +} from '@shared/types'; + +interface UseChangeReviewFileDecisionControllerInput { + files: readonly FileChangeSummary[]; + fileContents: Record; + changeSetEpoch: number; + instantApply: boolean; + teamName: string; + taskId: string | undefined; + memberName: string | undefined; + reviewScope: ReviewFileScope; + persistenceScope: ChangeReviewFileDecisionPersistenceScope | null; + history: ChangeReviewFileDecisionHistoryPort; + statePort: ChangeReviewFileDecisionStatePort; + commandPort: ChangeReviewFileDecisionCommandPort; + editorPort: ChangeReviewFileDecisionEditorPort; + statusPort: ChangeReviewFileDecisionStatusPort; + writeEvidencePort: ChangeReviewFileDecisionWriteEvidencePort; + policy: ChangeReviewFileDecisionPolicy; + persistLatestAcceptedAction: () => Promise; + ensureDurableScope: () => boolean; + hasDraft: (filePath: string) => boolean; + hasActionInFlight: () => boolean; + blockForExternalChange: (filePath: string) => boolean; + captureOperationScope: () => ReviewOperationScopeToken | null; + isCurrentOperationScope: (scope: ReviewOperationScopeToken | null) => boolean; +} + +export interface ChangeReviewFileDecisionController { + acceptFile: (filePath: string) => void; + rejectFile: (filePath: string) => Promise; +} + +function findLatestDiskSnapshots( + history: readonly ReviewUndoAction[], + filePath: string +): { + latest: ReviewDiskUndoSnapshot | undefined; + session: ReviewDiskUndoSnapshot | undefined; +} { + const normalizedFilePath = normalizePathForComparison(filePath); + const diskHistory = history.flatMap((action): ReviewDiskUndoAction[] => + action.kind === 'disk' + ? [action.action] + : action.kind === 'bulk' + ? action.diskSnapshots.map((snapshot) => ({ snapshot })) + : [] + ); + const matchesFile = (action: ReviewDiskUndoAction): boolean => + normalizePathForComparison(action.snapshot.filePath) === normalizedFilePath; + return { + latest: [...diskHistory].reverse().find(matchesFile)?.snapshot, + session: [...diskHistory] + .reverse() + .find((action) => action.originalIndex === undefined && matchesFile(action))?.snapshot, + }; +} + +function hasApplyErrorForFile( + filePath: string, + result: Awaited> +): boolean { + const normalizedFilePath = normalizePathForComparison(filePath); + return ( + !result || + result.errors.some((error) => normalizePathForComparison(error.filePath) === normalizedFilePath) + ); +} + +export function useChangeReviewFileDecisionController({ + files, + fileContents, + changeSetEpoch, + instantApply, + teamName, + taskId, + memberName, + reviewScope, + persistenceScope, + history, + statePort, + commandPort, + editorPort, + statusPort, + writeEvidencePort, + policy, + persistLatestAcceptedAction, + ensureDurableScope, + hasDraft, + hasActionInFlight, + blockForExternalChange, + captureOperationScope, + isCurrentOperationScope, +}: UseChangeReviewFileDecisionControllerInput): ChangeReviewFileDecisionController { + const restoreRejectedFileAsAccepted = useCallback( + async (filePath: string): Promise => { + if (hasDraft(filePath) || hasActionInFlight() || blockForExternalChange(filePath)) { + return; + } + const operationEpoch = changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) return; + const file = files.find((candidate) => candidate.filePath === filePath); + if (!file) return; + const content = fileContents[filePath] ?? null; + const isExpectedDeletion = policy.isExpectedDeletion(file); + const { latest: latestDiskSnapshot, session: sessionSnapshot } = findLatestDiskSnapshots( + history.getUndoHistory(), + filePath + ); + const hasAuthoritativeAgentContent = + content?.contentSource === 'ledger-exact' || content?.contentSource === 'ledger-snapshot'; + const canReconstructCreatedFile = policy.resolveFileIsNew(file, content); + const desiredContent = + sessionSnapshot?.beforeContent ?? + (hasAuthoritativeAgentContent || canReconstructCreatedFile + ? policy.resolveModifiedContent(file, content) + : null); + if (desiredContent === null) { + statePort.reportError( + 'Agent content is unavailable after reopen; restore it from Git or rerun the change.' + ); + return; + } + + const initialState = statePort.getSnapshot(); + const decisionSnapshot: ReviewDecisionSnapshot = { + hunkDecisions: { ...initialState.hunkDecisions }, + fileDecisions: { ...initialState.fileDecisions }, + }; + const rejectedHunkCount = policy.getHunkCount(file, initialState); + const rejectedNewFileWasRemoved = + canReconstructCreatedFile && + policy.isFileFullyRejected(file, rejectedHunkCount, decisionSnapshot); + statePort.reportError(null); + statusPort.beginFileMutation(filePath); + writeEvidencePort.markExpectedWrite(filePath, isExpectedDeletion ? null : desiredContent); + try { + if (!persistenceScope) { + throw new Error('Durable review scope is unavailable; refusing an unsafe restore.'); + } + let rejectedDiskContent = + sessionSnapshot?.afterContent ?? content?.originalFullContent ?? ''; + let restoredDiskContent: string | null = desiredContent; + let restoreMode: ReviewDiskUndoSnapshot['restoreMode'] = 'content'; + let renameExpectation = null; + + if (isLedgerRenameReviewFile(file)) { + renameExpectation = + sessionSnapshot?.renameExpectation ?? policy.getRenameRecoveryExpectation(file); + if (!renameExpectation) { + throw new Error('Rename recovery metadata is unavailable; refusing an unsafe restore.'); + } + restoreMode = 'reapply-rejected-rename'; + } else if (isExpectedDeletion) { + const expectedRejectedContent = + latestDiskSnapshot?.afterContent ?? + sessionSnapshot?.afterContent ?? + content?.originalFullContent; + if (expectedRejectedContent === null) { + throw new Error('Deleted file baseline is unavailable; refusing an unsafe restore.'); + } + rejectedDiskContent = expectedRejectedContent; + restoredDiskContent = null; + restoreMode = 'create-file'; + } else if (policy.resolveFileIsNew(file, content)) { + const current = await commandPort.checkConflict(reviewScope, filePath, ''); + const isMissing = current.hasConflict && current.conflictContent === null; + if (isMissing) { + rejectedDiskContent = ''; + restoreMode = 'delete-file'; + } else { + if (rejectedNewFileWasRemoved) { + throw new Error('A file now exists at this path; refusing to overwrite it.'); + } + if ( + policy.hasUnresolvedExternalChange(filePath, initialState.reviewExternalChangesByFile) + ) { + throw new Error( + 'Choose Reload from disk or Keep my draft before restoring this file.' + ); + } + rejectedDiskContent = current.currentContent; + restoredDiskContent = desiredContent; + } + } else { + const baseline = sessionSnapshot?.afterContent ?? content?.originalFullContent; + if (baseline === null) { + throw new Error('Original file content is unavailable; unable to restore safely.'); + } + const current = await commandPort.checkConflict(reviewScope, filePath, baseline); + if (current.hasConflict && current.conflictContent === null) { + throw new Error('File is missing on disk; unable to restore safely.'); + } + rejectedDiskContent = current.currentContent; + const merged = threeWayTextMerge(baseline, current.currentContent, desiredContent); + if (merged.hasConflicts) { + throw new Error('Agent changes conflict with edits made after rejection.'); + } + restoredDiskContent = merged.content; + } + + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + const quiesced = await commandPort.quiescePersistence(persistenceScope); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + if (!quiesced) { + throw new Error('Unable to finish saving the previous review state. Retry Restore.'); + } + statePort.applyRestoredDecisionState(file); + + const snapshot: ReviewDiskUndoSnapshot = { + filePath, + beforeContent: rejectedDiskContent, + afterContent: restoredDiskContent, + file, + restoreMode, + renameExpectation: renameExpectation ?? undefined, + }; + const preparedAction = history.pushUndoAction({ + kind: 'disk', + descriptor: { + intent: isLedgerRenameReviewFile(file) ? 'restore-rename' : 'restore-file', + filePath, + }, + action: { snapshot, file, decisionSnapshot }, + }); + try { + const state = statePort.getSnapshot(); + writeEvidencePort.markExpectedWrite(filePath, restoredDiskContent); + const committed = await commandPort.executeMutation({ + scope: reviewScope, + decisionPersistenceScope: { + scopeKey: persistenceScope.scopeKey, + scopeToken: persistenceScope.scopeToken, + }, + kind: isLedgerRenameReviewFile(file) ? 'rename' : 'restore', + diskSteps: buildForwardDiskMutationSteps(preparedAction.id, [snapshot]), + persistedState: { + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + hunkContextHashesByFile: state.hunkContextHashesByFile, + reviewActionHistory: history.getUndoHistory(), + reviewRedoHistory: history.getRedoHistory(), + }, + expectedDecisionRevision: state.decisionRevision, + }); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + writeEvidencePort.markCommittedPostimages(committed.diskPostimages); + history.bindCommittedAction(preparedAction, committed.committedReviewAction); + commandPort.recordDecisionRevision(persistenceScope, committed.decisionRevision); + } catch (error) { + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + statePort.restoreFileDecisions(file, decisionSnapshot); + history.discardLatestAction(preparedAction); + throw error; + } + writeEvidencePort.markExpectedWrite(filePath, restoredDiskContent); + statePort.clearExternalChange(filePath); + statePort.invalidateResolvedFileContent(filePath); + statusPort.incrementDiscardCounter(filePath); + commandPort.fetchFileContent(teamName, memberName, filePath); + } catch (error) { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statePort.reportError( + error instanceof Error ? error.message : 'Unable to restore the file.' + ); + statePort.invalidateResolvedFileContent(filePath); + statusPort.incrementDiscardCounter(filePath); + commandPort.fetchFileContent(teamName, memberName, filePath); + } + } finally { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statusPort.finishFileMutation(filePath); + } + } + }, + [ + blockForExternalChange, + captureOperationScope, + changeSetEpoch, + commandPort, + fileContents, + files, + hasActionInFlight, + hasDraft, + history, + isCurrentOperationScope, + memberName, + persistenceScope, + policy, + reviewScope, + statePort, + statusPort, + teamName, + writeEvidencePort, + ] + ); + + const acceptFile = useCallback( + (filePath: string): void => { + if (hasDraft(filePath) || hasActionInFlight() || blockForExternalChange(filePath)) { + return; + } + const file = files.find((candidate) => candidate.filePath === filePath); + if (!file) return; + const state = statePort.getSnapshot(); + const content = state.fileContents[file.filePath]; + const currentFileDecision = policy.getFileDecision(file, state); + if (!content || policy.isAcceptDisabled(file, content, currentFileDecision)) return; + const count = policy.getHunkCount(file, state); + const decisions = { + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + }; + if (policy.hasFileRejections(file, count, decisions)) { + void restoreRejectedFileAsAccepted(filePath); + return; + } + const decisionSnapshot: ReviewDecisionSnapshot = { + hunkDecisions: { ...state.hunkDecisions }, + fileDecisions: { ...state.fileDecisions }, + }; + if (!statePort.acceptAllFile(filePath)) return; + history.pushUndoAction({ + kind: 'bulk', + descriptor: { intent: 'accept-file', filePath }, + decisionSnapshot, + diskSnapshots: [], + }); + void persistLatestAcceptedAction(); + const operationScope = captureOperationScope(); + editorPort.scheduleEditorSync(() => { + if (!operationScope || isCurrentOperationScope(operationScope)) { + editorPort.acceptAllEditorChunks(filePath); + } + }); + }, + [ + blockForExternalChange, + captureOperationScope, + editorPort, + files, + hasActionInFlight, + hasDraft, + history, + isCurrentOperationScope, + persistLatestAcceptedAction, + policy, + restoreRejectedFileAsAccepted, + statePort, + ] + ); + + const rejectFile = useCallback( + async (filePath: string): Promise => { + if (hasDraft(filePath) || hasActionInFlight() || blockForExternalChange(filePath)) { + return; + } + statusPort.beginFileMutation(filePath); + const operationEpoch = changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) { + statusPort.finishFileMutation(filePath); + return; + } + try { + const file = files.find((candidate) => candidate.filePath === filePath); + if (!file) return; + const state = statePort.getSnapshot(); + if (!policy.isRejectable(file, state.fileContents[file.filePath] ?? null)) return; + const count = policy.getHunkCount(file, state); + const decisions = { + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + }; + if (policy.isFileFullyRejected(file, count, decisions)) return; + const decisionSnapshot: ReviewDecisionSnapshot = { + hunkDecisions: { ...state.hunkDecisions }, + fileDecisions: { ...state.fileDecisions }, + }; + const content = fileContents[filePath] ?? null; + const isNew = policy.resolveFileIsNew(file, content); + const shouldDeleteOnUndo = policy.shouldDeleteWhenUndoingReject( + file, + count, + decisionSnapshot + ); + const beforeContent = + editorPort.getCurrentContent(filePath) ?? policy.resolveModifiedContent(file, content); + const afterContent = isNew ? null : (content?.originalFullContent ?? null); + const restoreContent = beforeContent ?? policy.resolveModifiedContent(file, content); + if (restoreContent === null || (!isNew && afterContent === null)) { + statePort.reportError( + 'Exact disk contents are unavailable; refusing a reject without Undo.' + ); + return; + } + const snapshot: ReviewDiskUndoSnapshot = { + filePath, + beforeContent: restoreContent, + afterContent, + file, + fileIndex: isNew + ? Math.max( + 0, + files.findIndex((candidate) => candidate.filePath === filePath) + ) + : undefined, + restoreMode: isNew ? 'create-file' : shouldDeleteOnUndo ? 'delete-file' : undefined, + renameExpectation: policy.getRenameRecoveryExpectation(file) ?? undefined, + }; + + statePort.rejectAllFile(filePath); + editorPort.rejectAllEditorChunks(filePath); + const preparedAction = history.pushUndoAction({ + kind: 'disk', + descriptor: { intent: 'reject-file', filePath }, + action: { snapshot, file, decisionSnapshot }, + }); + if (!instantApply) return; + + writeEvidencePort.markExpectedWrite( + filePath, + isNew || isLedgerRenameReviewFile(file) ? null : afterContent + ); + if (!ensureDurableScope()) { + statePort.restoreFileDecisions(file, decisionSnapshot); + editorPort.rollbackEditorContent(filePath, restoreContent); + history.discardLatestAction(preparedAction); + return; + } + const result = await commandPort.applySingleFileDecision( + teamName, + filePath, + taskId, + memberName + ); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + writeEvidencePort.markCommittedPostimages(result?.diskPostimages); + history.bindCommittedAction(preparedAction, result?.committedReviewAction); + + if (hasApplyErrorForFile(filePath, result)) { + history.discardLatestAction(preparedAction); + statePort.restoreFileDecisions(file, decisionSnapshot); + editorPort.rollbackEditorContent(filePath, restoreContent); + statePort.invalidateResolvedFileContent(filePath); + statusPort.incrementDiscardCounter(filePath); + commandPort.fetchFileContent(teamName, memberName, filePath); + return; + } + if (isNew) { + writeEvidencePort.markExpectedWrite(filePath, null); + statePort.invalidateResolvedFileContent(filePath); + commandPort.fetchFileContent(teamName, memberName, filePath); + return; + } + if (beforeContent !== null && afterContent !== null) { + const actualAfterContent = await commandPort.readCurrentDiskContent( + filePath, + afterContent + ); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + if (snapshot.restoreMode !== 'delete-file' && !isLedgerRenameReviewFile(file)) { + alignReviewDiskUndoSnapshotWithAppliedContent(snapshot, actualAfterContent); + } + history.publishUndoHistory(); + } + writeEvidencePort.markExpectedWrite( + filePath, + isLedgerRenameReviewFile(file) ? null : afterContent + ); + } finally { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statusPort.finishFileMutation(filePath); + } + } + }, + [ + blockForExternalChange, + captureOperationScope, + changeSetEpoch, + commandPort, + editorPort, + ensureDurableScope, + fileContents, + files, + hasActionInFlight, + hasDraft, + history, + instantApply, + isCurrentOperationScope, + memberName, + policy, + statePort, + statusPort, + taskId, + teamName, + writeEvidencePort, + ] + ); + + return { acceptFile, rejectFile }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewFileDraftController.ts b/src/features/change-review/renderer/hooks/useChangeReviewFileDraftController.ts new file mode 100644 index 0000000000..7905f37784 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewFileDraftController.ts @@ -0,0 +1,485 @@ +import { useCallback } from 'react'; + +import { buildReviewExternalReloadState } from '@features/review-mutations'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { + ChangeReviewFileDraftActionHistoryPort, + ChangeReviewFileDraftCommandPort, + ChangeReviewFileDraftHistoryPort, + ChangeReviewFileDraftPersistenceScope, + ChangeReviewFileDraftStatePort, + ChangeReviewFileDraftStatusPort, + ChangeReviewFileDraftWriteEvidencePort, +} from '../ports/changeReviewFileDraftPorts'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { FileChangeSummary, FileChangeWithContent, ReviewFileScope } from '@shared/types'; + +const KEEP_DRAFT_MAX_ATTEMPTS = 3; + +interface UseChangeReviewFileDraftControllerInput { + files: readonly FileChangeSummary[]; + fileContents: Record; + teamName: string; + memberName: string | undefined; + reviewScope: ReviewFileScope; + persistenceScope: ChangeReviewFileDraftPersistenceScope | null; + actionHistory: ChangeReviewFileDraftActionHistoryPort; + draftHistory: ChangeReviewFileDraftHistoryPort; + statePort: ChangeReviewFileDraftStatePort; + commandPort: ChangeReviewFileDraftCommandPort; + statusPort: ChangeReviewFileDraftStatusPort; + writeEvidencePort: ChangeReviewFileDraftWriteEvidencePort; + hasActionInFlight: () => boolean; + captureOperationScope: () => ReviewOperationScopeToken | null; + isCurrentOperationScope: (scope: ReviewOperationScopeToken | null) => boolean; + resolveModifiedContent: ( + file: FileChangeSummary, + content: FileChangeWithContent | null + ) => string | null; + isFileMissingOnDisk: (content: FileChangeWithContent | null) => boolean; + hasUnresolvedExternalChange: (filePath: string, changes: Record) => boolean; +} + +function toErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +export interface ChangeReviewFileDraftController { + contentChanged: (filePath: string, content: string, previousContent?: string) => void; + saveFile: (filePath: string) => Promise; + restoreMissingFile: (filePath: string, content: string) => void; + reloadFromDisk: (filePath: string) => void; + keepDraft: (filePath: string) => void; + discardFile: (filePath: string) => void; +} + +export function useChangeReviewFileDraftController({ + files, + fileContents, + teamName, + memberName, + reviewScope, + persistenceScope, + actionHistory, + draftHistory, + statePort, + commandPort, + statusPort, + writeEvidencePort, + hasActionInFlight, + captureOperationScope, + isCurrentOperationScope, + resolveModifiedContent, + isFileMissingOnDisk, + hasUnresolvedExternalChange, +}: UseChangeReviewFileDraftControllerInput): ChangeReviewFileDraftController { + const contentChanged = useCallback( + (filePath: string, content: string, previousContent?: string): void => { + const baselineKey = normalizePathForComparison(filePath); + draftHistory.unsuppressFile(baselineKey); + if (!draftHistory.hasBaseline(baselineKey)) { + const fileContent = fileContents[filePath] ?? null; + if (isFileMissingOnDisk(fileContent)) { + draftHistory.setBaseline(baselineKey, null); + } else { + const baseline = + previousContent ?? + resolveModifiedContent( + files.find((file) => file.filePath === filePath) ?? { + filePath, + relativePath: filePath, + snippets: [], + linesAdded: 0, + linesRemoved: 0, + isNewFile: false, + }, + fileContent + ); + if (baseline != null) draftHistory.setBaseline(baselineKey, baseline); + } + } + const diskBaseline = draftHistory.getBaseline(baselineKey); + if (diskBaseline !== null && diskBaseline !== undefined && content === diskBaseline) { + statePort.discardFileEdits(filePath); + } else { + statePort.updateEditedContent(filePath, content); + } + }, + [draftHistory, fileContents, files, isFileMissingOnDisk, resolveModifiedContent, statePort] + ); + + const saveFile = useCallback( + async (filePath: string): Promise => { + if (hasActionInFlight()) return; + const initialState = statePort.getSnapshot(); + const contentToSave = initialState.editedContents[filePath]; + if (contentToSave === undefined) return; + if (hasUnresolvedExternalChange(filePath, initialState.reviewExternalChangesByFile)) { + statePort.reportError('Choose Reload from disk or Keep my draft before saving this file.'); + return; + } + const baselineKey = normalizePathForComparison(filePath); + if (!draftHistory.hasBaseline(baselineKey)) { + statePort.reportError( + 'The draft disk baseline is unavailable. Reload the file before saving.' + ); + return; + } + const expectedCurrentContent = draftHistory.getBaseline(baselineKey) ?? null; + const operationEpoch = initialState.changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) return; + writeEvidencePort.markExpectedWrite(filePath, contentToSave); + try { + const result = await commandPort.saveEditedFile( + filePath, + reviewScope, + expectedCurrentContent + ); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + if (!result.ok) { + statePort.reportError(result.error); + return; + } + draftHistory.setBaseline(baselineKey, contentToSave); + const serializedState = draftHistory.getEntry(filePath)?.editorState; + if (serializedState) { + draftHistory.publishCheckpoint(filePath, serializedState, contentToSave); + const flushed = await draftHistory.flushWrites(); + if (!isCurrentOperationScope(operationScope)) return; + if (!flushed) { + statePort.reportError( + 'The file was saved, but its durable Undo history could not be updated.' + ); + } + } + actionHistory.clearForFile(filePath); + writeEvidencePort.markExpectedWrite(filePath, contentToSave); + } catch (error) { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statePort.reportError(toErrorMessage(error, 'Unable to save the edited file.')); + } + } + }, + [ + actionHistory, + captureOperationScope, + commandPort, + draftHistory, + hasActionInFlight, + hasUnresolvedExternalChange, + isCurrentOperationScope, + reviewScope, + statePort, + writeEvidencePort, + ] + ); + + const restoreMissingFile = useCallback( + (filePath: string, content: string): void => { + if (hasActionInFlight()) return; + const operationEpoch = statePort.getSnapshot().changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) return; + const baselineKey = normalizePathForComparison(filePath); + draftHistory.setBaseline(baselineKey, null); + writeEvidencePort.markExpectedWrite(filePath, content); + statePort.updateEditedContent(filePath, content); + void Promise.resolve().then(async () => { + if (!isCurrentOperationScope(operationScope)) return; + try { + const result = await commandPort.saveEditedFile(filePath, reviewScope, null); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + if (!result.ok) { + statePort.reportError(result.error); + return; + } + draftHistory.setBaseline(baselineKey, content); + const serializedState = draftHistory.getEntry(filePath)?.editorState; + if (serializedState) { + draftHistory.publishCheckpoint(filePath, serializedState, content); + const flushed = await draftHistory.flushWrites(); + if (!isCurrentOperationScope(operationScope)) return; + if (!flushed) { + statePort.reportError( + 'The file was restored, but its durable Undo history could not be updated.' + ); + } + } + actionHistory.clearForFile(filePath); + writeEvidencePort.markExpectedWrite(filePath, content); + } catch (error) { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statePort.reportError(toErrorMessage(error, 'Unable to restore the missing file.')); + } + } + }); + }, + [ + actionHistory, + captureOperationScope, + commandPort, + draftHistory, + hasActionInFlight, + isCurrentOperationScope, + reviewScope, + statePort, + writeEvidencePort, + ] + ); + + const reloadFromDisk = useCallback( + (filePath: string): void => { + if (hasActionInFlight()) return; + const operationEpoch = statePort.getSnapshot().changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) return; + statusPort.beginFileMutation(filePath); + void (async () => { + try { + if (!persistenceScope) { + throw new Error('Durable review scope is unavailable; refusing an unsafe reload.'); + } + const quiesced = await commandPort.quiescePersistence(persistenceScope); + if (!isCurrentOperationScope(operationScope)) return; + if (!quiesced) { + throw new Error('Unable to finish saving the previous review state. Retry Reload.'); + } + const state = statePort.getSnapshot(); + const file = state.activeFiles.find( + (candidate) => + normalizePathForComparison(candidate.filePath) === + normalizePathForComparison(filePath) + ); + if (!file) throw new Error('Reviewed file is unavailable for Reload.'); + const next = buildReviewExternalReloadState(file, { + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + hunkContextHashesByFile: state.hunkContextHashesByFile, + reviewActionHistory: actionHistory.getUndoHistory(), + reviewRedoHistory: actionHistory.getRedoHistory(), + }); + const committed = await commandPort.commitExternalReload({ + reviewScope, + persistenceScope, + filePath, + persistedState: next, + expectedDecisionRevision: state.decisionRevision, + }); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + actionHistory.replaceHistories(next.reviewActionHistory, next.reviewRedoHistory); + commandPort.recordDecisionRevision(persistenceScope, committed.decisionRevision); + draftHistory.deleteBaseline(filePath); + statePort.applyReloadedReviewState(next); + await draftHistory.clearFile(filePath); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + statePort.reloadFileFromDisk(filePath); + statusPort.incrementDiscardCounter(filePath); + commandPort.fetchFileContent(teamName, memberName, filePath); + } catch (error) { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statePort.reportError( + error instanceof Error ? error.message : 'Unable to reload the external file.' + ); + } + } finally { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statusPort.finishFileMutation(filePath); + } + } + })(); + }, + [ + actionHistory, + captureOperationScope, + commandPort, + draftHistory, + hasActionInFlight, + isCurrentOperationScope, + memberName, + persistenceScope, + reviewScope, + statePort, + statusPort, + teamName, + ] + ); + + const keepDraft = useCallback( + (filePath: string): void => { + if (hasActionInFlight()) return; + const baselineKey = normalizePathForComparison(filePath); + if (!draftHistory.hasBaseline(baselineKey)) { + statePort.reportError( + 'The draft disk baseline is unavailable. Reload the file before continuing.' + ); + return; + } + let expected = draftHistory.getBaseline(baselineKey) ?? ''; + const operationEpoch = statePort.getSnapshot().changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) return; + statusPort.beginFileMutation(filePath); + void (async () => { + try { + for (let attempt = 0; attempt < KEEP_DRAFT_MAX_ATTEMPTS; attempt += 1) { + const observedChange = statePort.readExternalChange(filePath); + if (observedChange === undefined) return; + const current = await commandPort.checkConflict(reviewScope, filePath, expected); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + const nextBaseline = + current.hasConflict && current.conflictContent === null + ? null + : current.currentContent; + if (statePort.readExternalChange(filePath) !== observedChange) { + expected = nextBaseline ?? ''; + continue; + } + const serializedState = draftHistory.getEntry(filePath)?.editorState; + if (serializedState) { + draftHistory.publishCheckpoint(filePath, serializedState, nextBaseline); + const flushed = await draftHistory.flushWrites(); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + if (!flushed) { + throw new Error('Unable to persist the rebased manual edit history'); + } + } + if (!statePort.clearExternalChange(filePath, observedChange)) { + expected = nextBaseline ?? ''; + continue; + } + draftHistory.setBaseline(baselineKey, nextBaseline); + return; + } + throw new Error( + 'The file kept changing while the draft was rebased. Retry Keep my draft.' + ); + } catch (error) { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statePort.reportError(toErrorMessage(error, 'Unable to keep the manual file draft.')); + } + } finally { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statusPort.finishFileMutation(filePath); + } + } + })(); + }, + [ + captureOperationScope, + commandPort, + draftHistory, + hasActionInFlight, + isCurrentOperationScope, + reviewScope, + statePort, + statusPort, + ] + ); + + const discardFile = useCallback( + (filePath: string): void => { + if (hasActionInFlight()) return; + const state = statePort.getSnapshot(); + if (hasUnresolvedExternalChange(filePath, state.reviewExternalChangesByFile)) { + reloadFromDisk(filePath); + return; + } + const operationEpoch = state.changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) return; + statusPort.beginFileMutation(filePath); + void (async () => { + try { + await draftHistory.clearFile(filePath); + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + draftHistory.deleteBaseline(filePath); + statePort.discardFileEdits(filePath); + statusPort.incrementDiscardCounter(filePath); + } catch (error) { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statePort.reportError( + toErrorMessage(error, 'Unable to discard the saved manual edit history.') + ); + } + } finally { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statusPort.finishFileMutation(filePath); + } + } + })(); + }, + [ + captureOperationScope, + draftHistory, + hasActionInFlight, + hasUnresolvedExternalChange, + isCurrentOperationScope, + reloadFromDisk, + statePort, + statusPort, + ] + ); + + return { contentChanged, saveFile, restoreMissingFile, reloadFromDisk, keepDraft, discardFile }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewHistoryKeyboardShortcuts.ts b/src/features/change-review/renderer/hooks/useChangeReviewHistoryKeyboardShortcuts.ts new file mode 100644 index 0000000000..fa8c2be9bf --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewHistoryKeyboardShortcuts.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react'; + +import { redoDepth, undoDepth } from '@codemirror/commands'; + +import type { EditorView } from '@codemirror/view'; + +export interface ChangeReviewKeyboardEditorContext { + editor: EditorView | null; + hasDraft: boolean; +} + +interface UseChangeReviewHistoryKeyboardShortcutsInput { + active: boolean; + editedCount: number; + resolveEditorContext: (target: Element | null) => ChangeReviewKeyboardEditorContext; + hasActionInFlight: () => boolean; + getUndoCount: () => number; + getRedoCount: () => number; + undoLatest: () => Promise; + redoLatest: () => Promise; + reportManualDraftBlock: () => void; +} + +export function useChangeReviewHistoryKeyboardShortcuts({ + active, + editedCount, + resolveEditorContext, + hasActionInFlight, + getUndoCount, + getRedoCount, + undoLatest, + redoLatest, + reportManualDraftBlock, +}: UseChangeReviewHistoryKeyboardShortcutsInput): void { + useEffect(() => { + if (!active) return; + const handler = (event: KeyboardEvent): void => { + if (!(event.metaKey || event.ctrlKey)) return; + const isRedoShortcut = + (event.code === 'KeyZ' && event.shiftKey) || (event.code === 'KeyY' && !event.shiftKey); + const isUndoShortcut = event.code === 'KeyZ' && !event.shiftKey; + if (!isUndoShortcut && !isRedoShortcut) return; + + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + const { editor, hasDraft } = resolveEditorContext(document.activeElement); + + if (isRedoShortcut) { + if (editor && redoDepth(editor.state) > 0) return; + if (hasDraft) return; + event.preventDefault(); + event.stopPropagation(); + if (hasActionInFlight() || editedCount > 0) return; + if (getRedoCount() > 0) void redoLatest(); + return; + } + + if (editor && undoDepth(editor.state) > 0 && (hasDraft || getUndoCount() === 0)) return; + if (hasDraft) return; + if (hasActionInFlight()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + if (getUndoCount() > 0) { + event.preventDefault(); + event.stopPropagation(); + if (editedCount > 0) { + reportManualDraftBlock(); + return; + } + void undoLatest(); + return; + } + + // Native CodeMirror Undo would mutate only the visual document and + // desynchronize it from the durable decision timeline. + event.preventDefault(); + event.stopPropagation(); + }; + document.addEventListener('keydown', handler, true); + return () => document.removeEventListener('keydown', handler, true); + }, [ + active, + editedCount, + getRedoCount, + getUndoCount, + hasActionInFlight, + redoLatest, + reportManualDraftBlock, + resolveEditorContext, + undoLatest, + ]); +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewHistoryMutationController.ts b/src/features/change-review/renderer/hooks/useChangeReviewHistoryMutationController.ts new file mode 100644 index 0000000000..21be6d7321 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewHistoryMutationController.ts @@ -0,0 +1,589 @@ +import { useCallback } from 'react'; + +import { + buildRedoDiskMutationSteps, + buildReviewHistoryRestoreDiskImpact, + buildReviewHistoryRestoreDiskSteps, + buildReviewHistoryRestorePlan, + buildReviewUndoDecisionState, + buildUndoDiskMutationSteps, + getReviewActionDiskSnapshots, +} from '@features/review-mutations'; + +import { + classifyReviewHistoryRecovery, + createReviewRedoAction, + getReviewActionAffectedPaths, + getReviewActionsAffectedPaths, + getReviewDiskMutationExpectedContent, + resolveReviewFile, +} from '../utils/changeReviewHistoryMutation'; + +import { CHANGE_REVIEW_PERSISTENCE_ERROR } from './useChangeReviewDecisionPersistenceController'; + +import type { + ChangeReviewHistoryMutationCommandPort, + ChangeReviewHistoryMutationScope, + ChangeReviewHistoryMutationStatePort, + ChangeReviewHistoryMutationViewPort, + ChangeReviewHistoryPersistenceScope, +} from '../ports/changeReviewHistoryMutationPorts'; +import type { ReviewActionPersistenceStatus } from '../utils/changeReviewActionHistory'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { ChangeReviewActionHistoryController } from './useChangeReviewActionHistoryController'; +import type { + FileChangeSummary, + RetryReviewMutationRecoveryResult, + ReviewDecisionPersistenceScope, + ReviewDiskUndoSnapshot, + ReviewHistoryRestoreTarget, + ReviewPersistedStateSnapshot, + ReviewUndoAction, +} from '@shared/types'; + +type ActionHistory = Pick< + ChangeReviewActionHistoryController, + | 'getUndoHistory' + | 'getRedoHistory' + | 'getLatestUndoAction' + | 'getLatestRedoAction' + | 'completeUndoAction' + | 'completeRedoAction' + | 'replaceHistories' +>; + +interface UseChangeReviewHistoryMutationControllerInput { + teamName: string; + memberName: string | undefined; + files: readonly FileChangeSummary[]; + editedCount: number; + decisionHydrationReady: boolean; + scope: ChangeReviewHistoryMutationScope | null; + history: ActionHistory; + commandPort: ChangeReviewHistoryMutationCommandPort; + statePort: ChangeReviewHistoryMutationStatePort; + viewPort: ChangeReviewHistoryMutationViewPort; + captureOperationScope: () => ReviewOperationScopeToken | null; + isCurrentOperationScope: (scope: ReviewOperationScopeToken | null) => boolean; + hasActionInFlight: () => boolean; + isFileMutationInFlight: (filePath: string) => boolean; + blockForExternalChange: () => boolean; + getPersistenceStatus: () => ReviewActionPersistenceStatus; +} + +export interface ChangeReviewHistoryRestorePreview { + direction: 'undo' | 'redo'; + actions: ReviewUndoAction[]; + diskTransitions: ReturnType; +} + +export interface ChangeReviewHistoryMutationController { + undoLatest: () => Promise; + redoLatest: () => Promise; + getRestorePreview: (target: ReviewHistoryRestoreTarget) => ChangeReviewHistoryRestorePreview; + restoreHistory: (target: ReviewHistoryRestoreTarget) => Promise; + recoverFailedHistory: (target: ReviewHistoryRestoreTarget) => Promise; +} + +function getRestoreDirection(direction: 'undo' | 'redo' | 'none'): 'undo' | 'redo' { + if (direction === 'none') throw new Error('Review history restore plan is inconsistent.'); + return direction; +} + +function toDecisionPersistenceScope( + scope: ChangeReviewHistoryPersistenceScope +): ReviewDecisionPersistenceScope { + return { scopeKey: scope.scopeKey, scopeToken: scope.scopeToken }; +} + +export function useChangeReviewHistoryMutationController({ + teamName, + memberName, + files, + editedCount, + decisionHydrationReady, + scope, + history, + commandPort, + statePort, + viewPort, + captureOperationScope, + isCurrentOperationScope, + hasActionInFlight, + isFileMutationInFlight, + blockForExternalChange, + getPersistenceStatus, +}: UseChangeReviewHistoryMutationControllerInput): ChangeReviewHistoryMutationController { + const executeWithPreparedExpectations = useCallback( + async ( + snapshots: readonly ReviewDiskUndoSnapshot[], + direction: 'undo' | 'redo', + execute: () => Promise + ): Promise => { + for (const snapshot of snapshots) { + viewPort.markExpectedWrite( + snapshot.filePath, + getReviewDiskMutationExpectedContent(snapshot, direction) + ); + } + return execute(); + }, + [viewPort] + ); + + const refreshAfterUndo = useCallback( + ( + snapshots: readonly ReviewDiskUndoSnapshot[], + affectedPaths = snapshots.map((snapshot) => snapshot.filePath) + ): void => { + for (const snapshot of snapshots) { + const restoreMode = + snapshot.restoreMode ?? + (snapshot.renameExpectation ? 'restore-rejected-rename' : 'content'); + if (snapshot.afterContent === null && snapshot.file && restoreMode !== 'create-file') { + viewPort.addMissingFile(snapshot.file, snapshot.fileIndex, snapshot.beforeContent); + } + statePort.clearExternalChange(snapshot.filePath); + statePort.invalidateResolvedFileContent(snapshot.filePath); + viewPort.fetchFileContent(teamName, memberName, snapshot.filePath); + } + viewPort.incrementDiscardCounters(affectedPaths); + }, + [memberName, statePort, teamName, viewPort] + ); + + const refreshAfterRedo = useCallback( + (action: ReviewUndoAction): void => { + const snapshots = getReviewActionDiskSnapshots(action); + for (const snapshot of snapshots) { + statePort.clearExternalChange(snapshot.filePath); + statePort.invalidateResolvedFileContent(snapshot.filePath); + viewPort.fetchFileContent(teamName, memberName, snapshot.filePath); + } + viewPort.incrementDiscardCounters(getReviewActionAffectedPaths(action, files)); + }, + [files, memberName, statePort, teamName, viewPort] + ); + + const applyCommittedState = useCallback( + ( + persistedState: ReviewPersistedStateSnapshot, + decisionRevision: number, + applyError: string | null + ): void => { + if (!scope) throw new Error('Durable review history scope is unavailable.'); + statePort.recordDecisionRevision(scope.persistence, decisionRevision); + history.replaceHistories( + persistedState.reviewActionHistory, + persistedState.reviewRedoHistory + ); + statePort.applyPersistedState(persistedState, applyError); + }, + [history, scope, statePort] + ); + + const applyRestoredHistory = useCallback( + ( + persistedState: ReviewPersistedStateSnapshot, + decisionRevision: number, + direction: 'undo' | 'redo', + diskSnapshots: readonly ReviewDiskUndoSnapshot[], + orderedActions: readonly ReviewUndoAction[], + target: ReviewHistoryRestoreTarget + ): void => { + applyCommittedState(persistedState, decisionRevision, null); + if (direction === 'undo') { + refreshAfterUndo(diskSnapshots, getReviewActionsAffectedPaths(orderedActions, files)); + } else { + for (const action of orderedActions) refreshAfterRedo(action); + } + if (target.kind !== 'after-action') return; + const targetAction = + persistedState.reviewActionHistory.find((action) => action.id === target.actionId) ?? + persistedState.reviewRedoHistory.find((entry) => entry.action.id === target.actionId) + ?.action; + if (targetAction) viewPort.navigateToAction(targetAction); + }, + [applyCommittedState, files, refreshAfterRedo, refreshAfterUndo, viewPort] + ); + + const synchronizeRecoveredState = useCallback( + ( + persistedState: ReviewPersistedStateSnapshot, + decisionRevision: number, + message: string + ): void => { + applyCommittedState(persistedState, decisionRevision, message); + const affectedPaths = files.map((file) => file.filePath); + for (const filePath of affectedPaths) { + statePort.clearExternalChange(filePath); + statePort.invalidateResolvedFileContent(filePath); + viewPort.fetchFileContent(teamName, memberName, filePath); + } + viewPort.incrementDiscardCounters(affectedPaths); + }, + [applyCommittedState, files, memberName, statePort, teamName, viewPort] + ); + + const buildCurrentRestorePlan = useCallback( + (target: ReviewHistoryRestoreTarget) => { + const state = statePort.getSnapshot(); + const plan = buildReviewHistoryRestorePlan( + { + hunkDecisions: state.hunkDecisions, + fileDecisions: state.fileDecisions, + hunkContextHashesByFile: state.hunkContextHashesByFile, + reviewActionHistory: history.getUndoHistory(), + reviewRedoHistory: history.getRedoHistory(), + }, + target, + (filePath) => resolveReviewFile(files, filePath) + ); + return { state, plan }; + }, + [files, history, statePort] + ); + + const getRestorePreview = useCallback( + (target: ReviewHistoryRestoreTarget): ChangeReviewHistoryRestorePreview => { + const { plan } = buildCurrentRestorePlan(target); + if (plan.direction === 'none') throw new Error('This review checkpoint is already current.'); + const direction = getRestoreDirection(plan.direction); + return { + direction, + actions: plan.orderedActions, + diskTransitions: buildReviewHistoryRestoreDiskImpact( + plan.orderedActions.map((action) => ({ direction, action })) + ), + }; + }, + [buildCurrentRestorePlan] + ); + + const undoLatest = useCallback(async (): Promise => { + if (hasActionInFlight() || editedCount > 0 || blockForExternalChange()) return; + const action = history.getLatestUndoAction(); + if (!action) return; + if (!scope) { + statePort.reportError('Durable review scope is unavailable; refusing an unsafe Undo.'); + return; + } + if (action.kind === 'disk' && isFileMutationInFlight(action.action.snapshot.filePath)) return; + const operationScope = captureOperationScope(); + if (!operationScope) return; + const state = statePort.getSnapshot(); + const decisionState = buildReviewUndoDecisionState(action, state, (filePath) => + resolveReviewFile(files, filePath) + ); + if (!decisionState) { + statePort.reportError('Reviewed file is unavailable for Undo.'); + return; + } + + viewPort.setMutationInFlight(true); + try { + const quiesced = await statePort.quiesceDecisionPersistence(scope.persistence); + if (!isCurrentOperationScope(operationScope)) return; + if (!quiesced) + throw new Error('Unable to finish saving the previous review state. Retry Undo.'); + const current = statePort.getSnapshot(); + const redoAction = createReviewRedoAction(action, current); + const redoHistory = [...history.getRedoHistory(), redoAction]; + const diskSnapshots = getReviewActionDiskSnapshots(action); + const committed = await executeWithPreparedExpectations(diskSnapshots, 'undo', () => + commandPort.executeMutation({ + scope: scope.review, + decisionPersistenceScope: toDecisionPersistenceScope(scope.persistence), + kind: 'undo', + diskSteps: buildUndoDiskMutationSteps(action.id, diskSnapshots), + persistedState: { + hunkDecisions: decisionState.hunkDecisions, + fileDecisions: decisionState.fileDecisions, + hunkContextHashesByFile: current.hunkContextHashesByFile, + reviewActionHistory: history.getUndoHistory().slice(0, -1), + reviewRedoHistory: redoHistory, + }, + expectedTopActionId: action.id, + expectedDecisionRevision: current.decisionRevision, + }) + ); + if (!isCurrentOperationScope(operationScope)) return; + viewPort.markCommittedPostimages(committed.diskPostimages); + statePort.recordDecisionRevision(scope.persistence, committed.decisionRevision); + statePort.applyDecisionState(decisionState); + if (diskSnapshots.length > 0) refreshAfterUndo(diskSnapshots); + if (!history.completeUndoAction(action, redoAction)) return; + if (diskSnapshots.length === 0) { + viewPort.incrementDiscardCounters(getReviewActionAffectedPaths(action, files)); + } + } catch (error) { + if (!isCurrentOperationScope(operationScope)) return; + statePort.reportError( + error instanceof Error ? error.message : 'Unable to undo because the file changed on disk.' + ); + } finally { + if (isCurrentOperationScope(operationScope)) viewPort.setMutationInFlight(false); + } + }, [ + blockForExternalChange, + captureOperationScope, + commandPort, + editedCount, + executeWithPreparedExpectations, + files, + hasActionInFlight, + history, + isCurrentOperationScope, + isFileMutationInFlight, + refreshAfterUndo, + scope, + statePort, + viewPort, + ]); + + const redoLatest = useCallback(async (): Promise => { + if (hasActionInFlight() || editedCount > 0 || blockForExternalChange()) return; + const redoAction = history.getLatestRedoAction(); + if (!redoAction || !scope) return; + const operationScope = captureOperationScope(); + if (!operationScope) return; + viewPort.setMutationInFlight(true); + try { + const quiesced = await statePort.quiesceDecisionPersistence(scope.persistence); + if (!isCurrentOperationScope(operationScope)) return; + if (!quiesced) + throw new Error('Unable to finish saving the previous review state. Retry Redo.'); + const state = statePort.getSnapshot(); + const action = redoAction.action; + const diskSnapshots = getReviewActionDiskSnapshots(action); + const committed = await executeWithPreparedExpectations(diskSnapshots, 'redo', () => + commandPort.executeMutation({ + scope: scope.review, + decisionPersistenceScope: toDecisionPersistenceScope(scope.persistence), + kind: 'redo', + diskSteps: buildRedoDiskMutationSteps(action.id, diskSnapshots), + persistedState: { + hunkDecisions: redoAction.decisionSnapshot.hunkDecisions, + fileDecisions: redoAction.decisionSnapshot.fileDecisions, + hunkContextHashesByFile: + redoAction.hunkContextHashesByFile ?? state.hunkContextHashesByFile, + reviewActionHistory: [...history.getUndoHistory(), action], + reviewRedoHistory: history.getRedoHistory().slice(0, -1), + }, + expectedTopRedoActionId: action.id, + expectedDecisionRevision: state.decisionRevision, + }) + ); + if (!isCurrentOperationScope(operationScope)) return; + viewPort.markCommittedPostimages(committed.diskPostimages); + statePort.recordDecisionRevision(scope.persistence, committed.decisionRevision); + statePort.applyDecisionState({ + hunkDecisions: { ...redoAction.decisionSnapshot.hunkDecisions }, + fileDecisions: { ...redoAction.decisionSnapshot.fileDecisions }, + hunkContextHashesByFile: + redoAction.hunkContextHashesByFile ?? state.hunkContextHashesByFile, + }); + refreshAfterRedo(action); + history.completeRedoAction(redoAction); + } catch (error) { + if (!isCurrentOperationScope(operationScope)) return; + statePort.reportError( + error instanceof Error ? error.message : 'Unable to redo because the file changed on disk.' + ); + } finally { + if (isCurrentOperationScope(operationScope)) viewPort.setMutationInFlight(false); + } + }, [ + blockForExternalChange, + captureOperationScope, + commandPort, + editedCount, + executeWithPreparedExpectations, + hasActionInFlight, + history, + isCurrentOperationScope, + refreshAfterRedo, + scope, + statePort, + viewPort, + ]); + + const restoreHistory = useCallback( + async (target: ReviewHistoryRestoreTarget): Promise => { + if (hasActionInFlight()) throw new Error('Another review action is still running.'); + if (editedCount > 0) + throw new Error('Save or discard manual edits before restoring review history.'); + if (blockForExternalChange()) { + throw new Error('Reload files changed outside Changes before restoring review history.'); + } + if (!scope || !decisionHydrationReady) + throw new Error('Durable review history is not ready yet.'); + if (getPersistenceStatus() !== 'saved') throw new Error(CHANGE_REVIEW_PERSISTENCE_ERROR); + const operationScope = captureOperationScope(); + if (!operationScope) throw new Error('Durable review history scope is no longer active.'); + const { state, plan } = buildCurrentRestorePlan(target); + if (plan.actionCount === 0) return; + const direction = getRestoreDirection(plan.direction); + const diskSnapshots = plan.orderedActions.flatMap(getReviewActionDiskSnapshots); + viewPort.setMutationInFlight(true); + try { + const quiesced = await statePort.quiesceDecisionPersistence(scope.persistence); + if (!isCurrentOperationScope(operationScope)) return; + if (!quiesced) + throw new Error('Unable to finish saving the previous review state. Retry Restore.'); + const committed = await executeWithPreparedExpectations(diskSnapshots, direction, () => + commandPort.restoreHistory({ + scope: scope.review, + decisionPersistenceScope: toDecisionPersistenceScope(scope.persistence), + target, + expectedDecisionRevision: state.decisionRevision, + }) + ); + if (!isCurrentOperationScope(operationScope)) return; + viewPort.markCommittedPostimages(committed.diskPostimages); + applyRestoredHistory( + committed.persistedState, + committed.decisionRevision, + direction, + diskSnapshots, + plan.orderedActions, + target + ); + } catch (error) { + if (!isCurrentOperationScope(operationScope)) return; + const message = + error instanceof Error ? error.message : 'Unable to restore the selected review history.'; + statePort.reportError(message); + throw error instanceof Error ? error : new Error(message); + } finally { + if (isCurrentOperationScope(operationScope)) viewPort.setMutationInFlight(false); + } + }, + [ + applyRestoredHistory, + blockForExternalChange, + buildCurrentRestorePlan, + captureOperationScope, + commandPort, + decisionHydrationReady, + editedCount, + executeWithPreparedExpectations, + getPersistenceStatus, + hasActionInFlight, + isCurrentOperationScope, + scope, + statePort, + viewPort, + ] + ); + + const recoverFailedHistory = useCallback( + async (target: ReviewHistoryRestoreTarget): Promise => { + if (!scope || !decisionHydrationReady) { + throw new Error('Durable review history is not ready for recovery.'); + } + const operationScope = captureOperationScope(); + if (!operationScope) throw new Error('Durable review history scope is no longer active.'); + const currentRevision = statePort.getSnapshot().decisionRevision; + const { plan } = buildCurrentRestorePlan(target); + const direction = plan.direction; + const diskSteps = + direction === 'undo' || direction === 'redo' + ? buildReviewHistoryRestoreDiskSteps( + plan.orderedActions.map((action) => ({ direction, action })) + ) + : []; + const diskSnapshots = plan.orderedActions.flatMap(getReviewActionDiskSnapshots); + const retryRecovery = (): Promise => + commandPort.retryRecovery({ + scope: scope.review, + decisionPersistenceScope: toDecisionPersistenceScope(scope.persistence), + expectedRestore: { + expectedDecisionRevision: currentRevision, + persistedState: plan.persistedState, + diskSteps, + }, + }); + let retryOriginalRestore = false; + viewPort.setMutationInFlight(true); + try { + const recovered = + direction === 'undo' || direction === 'redo' + ? await executeWithPreparedExpectations(diskSnapshots, direction, retryRecovery) + : await retryRecovery(); + if (!isCurrentOperationScope(operationScope)) return; + const disposition = classifyReviewHistoryRecovery( + recovered, + currentRevision, + plan.persistedState + ); + if (disposition === 'retry-restore') { + for (const snapshot of diskSnapshots) viewPort.clearExpectedWrite(snapshot.filePath); + retryOriginalRestore = true; + } else if (disposition === 'different-mutation-pending') { + for (const snapshot of diskSnapshots) viewPort.clearExpectedWrite(snapshot.filePath); + throw new Error( + 'A different interrupted review update must be recovered first. Close Restore and retry the saved review state.' + ); + } else if (disposition === 'apply-selected-restore') { + if (!recovered.persistedState) { + throw new Error('Recovered checkpoint state is unavailable. Reload Changes.'); + } + if (direction !== 'undo' && direction !== 'redo') { + throw new Error('Recovered review history no longer matches this checkpoint.'); + } + viewPort.markCommittedPostimages(recovered.diskPostimages); + applyRestoredHistory( + recovered.persistedState, + recovered.decisionRevision, + direction, + diskSnapshots, + plan.orderedActions, + target + ); + } else { + if (!recovered.persistedState) { + throw new Error( + 'Recovered review state is unavailable. Reload Changes before retrying.' + ); + } + for (const snapshot of diskSnapshots) viewPort.clearExpectedWrite(snapshot.filePath); + synchronizeRecoveredState( + recovered.persistedState, + recovered.decisionRevision, + recovered.recoveredMutation + ? 'A different interrupted review action was recovered. Latest durable state was loaded; select the checkpoint again.' + : 'Review history changed while Restore was finishing. Latest durable state was loaded; verify it before continuing.' + ); + } + } catch (error) { + if (!isCurrentOperationScope(operationScope)) return; + const message = + error instanceof Error ? error.message : 'Unable to recover the interrupted Restore.'; + statePort.reportError(message); + throw error instanceof Error ? error : new Error(message); + } finally { + if (isCurrentOperationScope(operationScope)) viewPort.setMutationInFlight(false); + } + if (retryOriginalRestore && isCurrentOperationScope(operationScope)) { + await restoreHistory(target); + } + }, + [ + applyRestoredHistory, + buildCurrentRestorePlan, + captureOperationScope, + commandPort, + decisionHydrationReady, + executeWithPreparedExpectations, + isCurrentOperationScope, + restoreHistory, + scope, + statePort, + synchronizeRecoveredState, + viewPort, + ] + ); + + return { undoLatest, redoLatest, getRestorePreview, restoreHistory, recoverFailedHistory }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewHunkDecisionController.ts b/src/features/change-review/renderer/hooks/useChangeReviewHunkDecisionController.ts new file mode 100644 index 0000000000..2da3d2af42 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewHunkDecisionController.ts @@ -0,0 +1,284 @@ +import { useCallback } from 'react'; + +import { + alignReviewDiskUndoSnapshotWithAppliedContent, + isLedgerRenameReviewFile, +} from '@features/review-mutations'; + +import type { + CaptureChangeReviewHunkOperationScope, + ChangeReviewHunkDecisionCommandPort, + ChangeReviewHunkDecisionEditorPort, + ChangeReviewHunkDecisionHistoryPort, + ChangeReviewHunkDecisionPolicy, + ChangeReviewHunkDecisionStatePort, + ChangeReviewHunkDecisionStatusPort, + ChangeReviewHunkDecisionWriteEvidencePort, +} from '../ports/changeReviewHunkDecisionPorts'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { + FileChangeSummary, + FileChangeWithContent, + ReviewDiskUndoSnapshot, +} from '@shared/types'; + +interface UseChangeReviewHunkDecisionControllerInput { + files: readonly FileChangeSummary[]; + fileContents: Record; + changeSetEpoch: number; + instantApply: boolean; + teamName: string; + taskId: string | undefined; + memberName: string | undefined; + statePort: ChangeReviewHunkDecisionStatePort; + commandPort: ChangeReviewHunkDecisionCommandPort; + editorPort: ChangeReviewHunkDecisionEditorPort; + statusPort: ChangeReviewHunkDecisionStatusPort; + historyPort: ChangeReviewHunkDecisionHistoryPort; + writeEvidencePort: ChangeReviewHunkDecisionWriteEvidencePort; + policy: ChangeReviewHunkDecisionPolicy; + persistLatestAcceptedAction: () => Promise; + ensureDurableScope: () => boolean; + hasDraft: (filePath: string) => boolean; + hasActionInFlight: () => boolean; + blockForExternalChange: (filePath: string) => boolean; + captureOperationScope: CaptureChangeReviewHunkOperationScope; + isCurrentOperationScope: (scope: ReviewOperationScopeToken | null) => boolean; +} + +export interface ChangeReviewHunkDecisionController { + acceptHunk: (filePath: string, hunkIndex: number) => boolean; + rejectHunk: ( + filePath: string, + hunkIndex: number, + beforeContent?: string, + afterContent?: string + ) => boolean; +} + +export function useChangeReviewHunkDecisionController({ + files, + fileContents, + changeSetEpoch, + instantApply, + teamName, + taskId, + memberName, + statePort, + commandPort, + editorPort, + statusPort, + historyPort, + writeEvidencePort, + policy, + persistLatestAcceptedAction, + ensureDurableScope, + hasDraft, + hasActionInFlight, + blockForExternalChange, + captureOperationScope, + isCurrentOperationScope, +}: UseChangeReviewHunkDecisionControllerInput): ChangeReviewHunkDecisionController { + const acceptHunk = useCallback( + (filePath: string, hunkIndex: number): boolean => { + if (hasDraft(filePath) || hasActionInFlight() || blockForExternalChange(filePath)) { + editorPort.guardIgnoredMutation(filePath); + return false; + } + const originalIndex = statePort.setDecision(filePath, hunkIndex, 'accepted'); + historyPort.pushUndoAction({ + kind: 'hunk', + descriptor: { intent: 'accept-hunk', filePath, hunkIndex: originalIndex }, + action: { filePath, originalIndex }, + }); + void persistLatestAcceptedAction(); + return true; + }, + [ + blockForExternalChange, + editorPort, + hasActionInFlight, + hasDraft, + historyPort, + persistLatestAcceptedAction, + statePort, + ] + ); + + const rejectHunk = useCallback( + ( + filePath: string, + hunkIndex: number, + suppliedBeforeContent?: string, + suppliedAfterContent?: string + ): boolean => { + if (hasDraft(filePath) || hasActionInFlight() || blockForExternalChange(filePath)) { + return false; + } + + let beforeContent = suppliedBeforeContent; + let afterContent = suppliedAfterContent; + if (beforeContent === undefined || afterContent === undefined) { + const rejected = editorPort.rejectChunk(filePath); + if (!rejected) return false; + beforeContent = rejected.beforeContent; + afterContent = rejected.afterContent; + } + + const operationEpoch = changeSetEpoch; + const operationScope = captureOperationScope(); + if (!operationScope) { + editorPort.rollbackContent(filePath, beforeContent); + return false; + } + + statusPort.beginFileMutation(filePath); + const decisionState = statePort.getSnapshot(); + const file = files.find((candidate) => candidate.filePath === filePath); + const hunkCount = file ? policy.getHunkCount(file, decisionState) : 0; + const shouldDeleteOnUndo = policy.shouldDeleteWhenUndoingReject( + file, + hunkCount, + decisionState + ); + const originalIndex = statePort.setDecision(filePath, hunkIndex, 'rejected'); + const isNewFileFullyRejected = policy.shouldCreateWhenUndoingReject( + file, + Boolean(file && policy.resolveFileIsNew(file, fileContents[filePath])), + hunkCount, + statePort.getSnapshot() + ); + + if (instantApply) { + const snapshot: ReviewDiskUndoSnapshot = { + filePath, + beforeContent, + afterContent: isNewFileFullyRejected ? null : afterContent, + file, + restoreMode: isNewFileFullyRejected + ? 'create-file' + : shouldDeleteOnUndo + ? 'delete-file' + : undefined, + renameExpectation: policy.getRenameRecoveryExpectation(file) ?? undefined, + }; + const preparedAction = historyPort.pushUndoAction({ + kind: 'disk', + descriptor: { intent: 'reject-hunk', filePath, hunkIndex: originalIndex }, + action: { snapshot, originalIndex }, + }); + void (async () => { + try { + if (!ensureDurableScope()) { + editorPort.rollbackContent(filePath, beforeContent); + statePort.clearDecision(filePath, originalIndex); + historyPort.discardLatestAction(preparedAction); + return; + } + writeEvidencePort.markExpectedWrite( + filePath, + isNewFileFullyRejected ? null : afterContent + ); + let outcome: Awaited< + ReturnType + >; + try { + outcome = await commandPort.applySingleFileDecision( + teamName, + filePath, + taskId, + memberName + ); + } catch { + outcome = { status: 'failed', result: null }; + } + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + writeEvidencePort.clearExpectedWrite(filePath); + writeEvidencePort.markCommittedPostimages(outcome.result?.diskPostimages); + if (outcome.status === 'applied') { + historyPort.bindCommittedAction(preparedAction, outcome.result.committedReviewAction); + let actualAfterContent: string | null = null; + if (!isNewFileFullyRejected) { + try { + actualAfterContent = await commandPort.readCurrentDiskContent( + filePath, + afterContent + ); + } catch { + actualAfterContent = afterContent; + } + } + if ( + !isCurrentOperationScope(operationScope) || + statePort.getSnapshot().changeSetEpoch !== operationEpoch + ) { + return; + } + if ( + actualAfterContent !== null && + snapshot.restoreMode !== 'delete-file' && + !isLedgerRenameReviewFile(snapshot.file) + ) { + alignReviewDiskUndoSnapshotWithAppliedContent(snapshot, actualAfterContent); + } + historyPort.publishUndoHistory(); + writeEvidencePort.markExpectedWrite(filePath, snapshot.afterContent); + return; + } + + editorPort.rollbackContent(filePath, beforeContent); + statePort.clearDecision(filePath, originalIndex); + historyPort.discardLatestAction(preparedAction); + statePort.invalidateResolvedFileContent(filePath); + statusPort.incrementDiscardCounter(filePath); + commandPort.fetchFileContent(teamName, memberName, filePath); + } finally { + if ( + isCurrentOperationScope(operationScope) && + statePort.getSnapshot().changeSetEpoch === operationEpoch + ) { + statusPort.finishFileMutation(filePath); + } + } + })(); + } else { + statusPort.finishFileMutation(filePath); + historyPort.pushUndoAction({ + kind: 'hunk', + descriptor: { intent: 'reject-hunk', filePath, hunkIndex: originalIndex }, + action: { filePath, originalIndex }, + }); + } + return true; + }, + [ + blockForExternalChange, + captureOperationScope, + changeSetEpoch, + commandPort, + editorPort, + ensureDurableScope, + fileContents, + files, + hasActionInFlight, + hasDraft, + historyPort, + instantApply, + isCurrentOperationScope, + memberName, + policy, + statePort, + statusPort, + taskId, + teamName, + writeEvidencePort, + ] + ); + + return { acceptHunk, rejectHunk }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewLifecycleRegistration.ts b/src/features/change-review/renderer/hooks/useChangeReviewLifecycleRegistration.ts new file mode 100644 index 0000000000..58572c49e3 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewLifecycleRegistration.ts @@ -0,0 +1,74 @@ +import { useEffect, useLayoutEffect } from 'react'; + +import type { + RegisterChangeReviewAppCloseParticipant, + RegisterChangeReviewLifecycleOwner, +} from '../ports/changeReviewLifecyclePorts'; +import type { AppCloseParticipant } from '@features/app-close-coordination/renderer'; + +interface UseChangeReviewLifecycleRegistrationInput { + open: boolean; + authorized: boolean; + hostId: string; + sessionId: string; + tabId?: string; + focus?: () => void; + requestClose: () => Promise; + closeRejectedDialog: () => void; + setAuthorized: (authorized: boolean) => void; + appCloseParticipantId: string; + flushForAppClose: AppCloseParticipant; + registerOwner: RegisterChangeReviewLifecycleOwner; + registerAppCloseParticipant: RegisterChangeReviewAppCloseParticipant; +} + +export function useChangeReviewLifecycleRegistration({ + open, + authorized, + hostId, + sessionId, + tabId, + focus, + requestClose, + closeRejectedDialog, + setAuthorized, + appCloseParticipantId, + flushForAppClose, + registerOwner, + registerAppCloseParticipant, +}: UseChangeReviewLifecycleRegistrationInput): void { + useLayoutEffect(() => { + if (!open) { + setAuthorized(false); + return; + } + const registration = registerOwner({ + hostId, + sessionId, + tabId, + requestClose, + focus, + }); + setAuthorized(registration.accepted); + if (!registration.accepted) closeRejectedDialog(); + return () => { + registration.unregister(); + setAuthorized(false); + }; + }, [ + closeRejectedDialog, + focus, + hostId, + open, + registerOwner, + requestClose, + sessionId, + setAuthorized, + tabId, + ]); + + useEffect(() => { + if (!open || !authorized) return; + return registerAppCloseParticipant(appCloseParticipantId, flushForAppClose); + }, [appCloseParticipantId, authorized, flushForAppClose, open, registerAppCloseParticipant]); +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewMutationGuards.ts b/src/features/change-review/renderer/hooks/useChangeReviewMutationGuards.ts new file mode 100644 index 0000000000..a9b90dd6c9 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewMutationGuards.ts @@ -0,0 +1,124 @@ +import { useCallback } from 'react'; + +import { isReviewActionPersistenceBlocking } from '../utils/changeReviewActionHistory'; +import { isReviewActionLocked } from '../utils/changeReviewDialogLifecycle'; + +import type { ChangeReviewOperationStatePort } from '../ports/changeReviewMutationSafetyPorts'; +import type { ReviewActionPersistenceStatus } from '../utils/changeReviewActionHistory'; +import type { ReviewDraftHistoryHydrationState } from '../utils/changeReviewScope'; +import type { ChangeReviewOperationState } from './useChangeReviewOperationState'; + +interface ChangeReviewConflictGuardState { + refreshPending: boolean; + loadError: string | null; + candidateCount: number; + resolvingCandidateId: string | null; +} + +interface UseChangeReviewMutationGuardsInput { + applying: boolean; + operation: ChangeReviewOperationState; + decisionScopeToken: string | null; + decisionHydrationKey: string | null; + decisionHydrationReady: boolean; + draftHistoryHydration: ReviewDraftHistoryHydrationState; + draftHistoryHydrationReady: boolean; + conflict: ChangeReviewConflictGuardState; + persistenceStatus: ReviewActionPersistenceStatus; + getPersistenceStatus: () => ReviewActionPersistenceStatus; + port: ChangeReviewOperationStatePort; +} + +export interface ChangeReviewMutationGuards { + reviewMutationBusy: boolean; + reviewActionsBusy: boolean; + reviewCloseBusy: boolean; + hasReviewActionInFlight: () => boolean; + ensureDurableReviewScope: () => boolean; +} + +const DURABLE_SCOPE_ERROR = + 'Durable review scope is unavailable; refusing an unsafe disk mutation.'; + +export function useChangeReviewMutationGuards({ + applying, + operation, + decisionScopeToken, + decisionHydrationKey, + decisionHydrationReady, + draftHistoryHydration, + draftHistoryHydrationReady, + conflict, + persistenceStatus, + getPersistenceStatus, + port, +}: UseChangeReviewMutationGuardsInput): ChangeReviewMutationGuards { + const reviewMutationBusy = isReviewActionLocked({ + applying, + fileApplyCount: operation.filesApplying.size, + undoing: operation.undoing, + closing: operation.closing, + }); + const reviewActionsBusy = + reviewMutationBusy || + conflict.refreshPending || + conflict.loadError !== null || + conflict.candidateCount > 0 || + conflict.resolvingCandidateId !== null || + isReviewActionPersistenceBlocking(persistenceStatus) || + (decisionHydrationKey !== null && (!decisionHydrationReady || !draftHistoryHydrationReady)); + // Discovery and persistence drains may finish during close flushing. Only + // active mutation or conflict resolution keeps the close control locked. + const reviewCloseBusy = reviewMutationBusy || conflict.resolvingCandidateId !== null; + + const hasReviewActionInFlight = useCallback((): boolean => { + const state = port.getSnapshot(); + const hydrationReady = + decisionHydrationKey === null || + (state.decisionHydrationScopeKey === decisionHydrationKey && + state.decisionHydrationStatus === 'loaded' && + draftHistoryHydration.key === decisionHydrationKey && + draftHistoryHydration.status === 'loaded'); + return ( + !hydrationReady || + conflict.refreshPending || + conflict.loadError !== null || + conflict.candidateCount > 0 || + conflict.resolvingCandidateId !== null || + isReviewActionPersistenceBlocking(getPersistenceStatus()) || + isReviewActionLocked({ + applying: state.applying, + fileApplyCount: operation.viewPortBindings.fileApplyInFlightRef.current.size, + undoing: operation.viewPortBindings.undoInFlightRef.current, + closing: operation.viewPortBindings.closingRef.current, + }) + ); + }, [ + conflict.candidateCount, + conflict.loadError, + conflict.refreshPending, + conflict.resolvingCandidateId, + decisionHydrationKey, + draftHistoryHydration.key, + draftHistoryHydration.status, + getPersistenceStatus, + operation.viewPortBindings.closingRef, + operation.viewPortBindings.fileApplyInFlightRef, + operation.viewPortBindings.undoInFlightRef, + port, + ]); + + const ensureDurableReviewScope = useCallback((): boolean => { + if (decisionScopeToken) return true; + port.reportError(DURABLE_SCOPE_ERROR); + return false; + }, [decisionScopeToken, port]); + + return { + reviewMutationBusy, + reviewActionsBusy, + reviewCloseBusy, + hasReviewActionInFlight, + ensureDurableReviewScope, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewOperationGeneration.ts b/src/features/change-review/renderer/hooks/useChangeReviewOperationGeneration.ts new file mode 100644 index 0000000000..930cc46a53 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewOperationGeneration.ts @@ -0,0 +1,58 @@ +import { useCallback, useLayoutEffect, useRef } from 'react'; + +import { + createReviewOperationScopeToken, + isReviewOperationScopeCurrent, + type ReviewOperationScopeToken, +} from '../utils/reviewOperationGeneration'; + +interface UseChangeReviewOperationGenerationInput { + active: boolean; + decisionHydrationKey: string | null; + fallbackScopeKey: string; + changeSetEpoch: number; + resetGenerationState: () => void; +} + +interface ChangeReviewOperationGeneration { + captureReviewOperationScope: () => ReviewOperationScopeToken | null; + isCurrentReviewOperationScope: ( + operationScope: ReviewOperationScopeToken | null + ) => operationScope is ReviewOperationScopeToken; +} + +export function useChangeReviewOperationGeneration({ + active, + decisionHydrationKey, + fallbackScopeKey, + changeSetEpoch, + resetGenerationState, +}: UseChangeReviewOperationGenerationInput): ChangeReviewOperationGeneration { + const operationScopeRef = useRef(null); + + useLayoutEffect(() => { + const activeScopeKey = active ? (decisionHydrationKey ?? fallbackScopeKey) : null; + const operationScope = activeScopeKey ? createReviewOperationScopeToken(activeScopeKey) : null; + operationScopeRef.current = operationScope; + resetGenerationState(); + return () => { + if (operationScopeRef.current === operationScope) { + operationScopeRef.current = null; + } + }; + }, [active, changeSetEpoch, decisionHydrationKey, fallbackScopeKey, resetGenerationState]); + + const captureReviewOperationScope = useCallback((): ReviewOperationScopeToken | null => { + return operationScopeRef.current; + }, []); + + const isCurrentReviewOperationScope = useCallback( + ( + operationScope: ReviewOperationScopeToken | null + ): operationScope is ReviewOperationScopeToken => + isReviewOperationScopeCurrent(operationScopeRef.current, operationScope), + [] + ); + + return { captureReviewOperationScope, isCurrentReviewOperationScope }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewOperationState.ts b/src/features/change-review/renderer/hooks/useChangeReviewOperationState.ts new file mode 100644 index 0000000000..bd06c7b7c4 --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewOperationState.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import { useChangeReviewOperationGeneration } from './useChangeReviewOperationGeneration'; + +import type { ChangeReviewRecentWrite } from '../ports/changeReviewDialogInteractionPorts'; +import type { ChangeReviewOperationStatePort } from '../ports/changeReviewMutationSafetyPorts'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; + +export interface ChangeReviewOperationViewPortBindings { + fileApplyInFlightRef: MutableRefObject>; + undoInFlightRef: MutableRefObject; + closingRef: MutableRefObject; + pendingApplyCleanupKeyRef: MutableRefObject; + recentReviewWritesRef: MutableRefObject>; + setFilesApplying: Dispatch>>; + setUndoing: Dispatch>; + setClosing: Dispatch>; +} + +interface UseChangeReviewOperationStateInput { + active: boolean; + decisionHydrationKey: string | null; + fallbackScopeKey: string; + changeSetEpoch: number; + resetKey: string; + port: ChangeReviewOperationStatePort; +} + +export interface ChangeReviewOperationState { + filesApplying: Set; + undoing: boolean; + closing: boolean; + viewPortBindings: ChangeReviewOperationViewPortBindings; + captureReviewOperationScope: () => ReviewOperationScopeToken | null; + isCurrentReviewOperationScope: ( + operationScope: ReviewOperationScopeToken | null + ) => operationScope is ReviewOperationScopeToken; + isFileMutationInFlight: (filePath: string) => boolean; + isPathMutationInFlight: (normalizedPath: string) => boolean; +} + +export function useChangeReviewOperationState({ + active, + decisionHydrationKey, + fallbackScopeKey, + changeSetEpoch, + resetKey, + port, +}: UseChangeReviewOperationStateInput): ChangeReviewOperationState { + const [filesApplying, setFilesApplying] = useState>(() => new Set()); + const [undoing, setUndoing] = useState(false); + const [closing, setClosing] = useState(false); + const fileApplyInFlightRef = useRef(new Set()); + const undoInFlightRef = useRef(false); + const closingRef = useRef(false); + const pendingApplyCleanupKeyRef = useRef(null); + const recentReviewWritesRef = useRef(new Map()); + + const resetGenerationState = useCallback((): void => { + // Busy state belongs to one operation generation. Keep recent-write evidence + // so late filesystem events from committed mutations remain suppressible. + fileApplyInFlightRef.current.clear(); + undoInFlightRef.current = false; + closingRef.current = false; + setFilesApplying(new Set()); + setUndoing(false); + setClosing(false); + }, []); + + const { captureReviewOperationScope, isCurrentReviewOperationScope } = + useChangeReviewOperationGeneration({ + active, + decisionHydrationKey, + fallbackScopeKey, + changeSetEpoch, + resetGenerationState, + }); + + useEffect(() => { + if (pendingApplyCleanupKeyRef.current !== decisionHydrationKey) { + pendingApplyCleanupKeyRef.current = null; + } + }, [decisionHydrationKey]); + + useEffect(() => { + fileApplyInFlightRef.current.clear(); + recentReviewWritesRef.current.clear(); + undoInFlightRef.current = false; + closingRef.current = false; + setUndoing(false); + setClosing(false); + setFilesApplying(new Set()); + }, [resetKey]); + + const isFileMutationInFlight = useCallback( + (filePath: string): boolean => fileApplyInFlightRef.current.has(filePath), + [] + ); + const isPathMutationInFlight = useCallback( + (normalizedPath: string): boolean => { + const pathBusy = [...fileApplyInFlightRef.current].some( + (filePath) => normalizePathForComparison(filePath) === normalizedPath + ); + return pathBusy || undoInFlightRef.current || port.getSnapshot().applying; + }, + [port] + ); + const viewPortBindings = useMemo( + () => ({ + fileApplyInFlightRef, + undoInFlightRef, + closingRef, + pendingApplyCleanupKeyRef, + recentReviewWritesRef, + setFilesApplying, + setUndoing, + setClosing, + }), + [] + ); + + return { + filesApplying, + undoing, + closing, + viewPortBindings, + captureReviewOperationScope, + isCurrentReviewOperationScope, + isFileMutationInFlight, + isPathMutationInFlight, + }; +} diff --git a/src/features/change-review/renderer/hooks/useChangeReviewScopeIdentity.ts b/src/features/change-review/renderer/hooks/useChangeReviewScopeIdentity.ts new file mode 100644 index 0000000000..ea49f896ce --- /dev/null +++ b/src/features/change-review/renderer/hooks/useChangeReviewScopeIdentity.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; + +import { + buildChangeReviewScopeProjection, + type BuildChangeReviewScopeProjectionInput, + type ChangeReviewScopeProjection, +} from '../utils/changeReviewScope'; + +export function useChangeReviewScopeIdentity({ + activeChangeSet, + decisionHydrationScopeKey, + decisionHydrationStatus, + draftHistoryHydration, + memberName, + mode, + taskChangeRequestOptions, + taskId, + teamName, +}: BuildChangeReviewScopeProjectionInput): ChangeReviewScopeProjection { + const reviewScope = useMemo( + () => ({ teamName, taskId, memberName }), + [memberName, taskId, teamName] + ); + + return useMemo( + () => ({ + ...buildChangeReviewScopeProjection({ + activeChangeSet, + decisionHydrationScopeKey, + decisionHydrationStatus, + draftHistoryHydration, + memberName, + mode, + taskChangeRequestOptions, + taskId, + teamName, + }), + reviewScope, + }), + [ + activeChangeSet, + decisionHydrationScopeKey, + decisionHydrationStatus, + draftHistoryHydration, + memberName, + mode, + reviewScope, + taskChangeRequestOptions, + taskId, + teamName, + ] + ); +} diff --git a/src/features/change-review/renderer/index.ts b/src/features/change-review/renderer/index.ts new file mode 100644 index 0000000000..1217a508fa --- /dev/null +++ b/src/features/change-review/renderer/index.ts @@ -0,0 +1,780 @@ +import * as interactionAdapters from './adapters/changeReviewDialogInteractionAdapters'; +import * as actionHistoryAdapters from './adapters/createChangeReviewActionHistoryPorts'; +import * as bulkDecisionAdapters from './adapters/createChangeReviewBulkDecisionPorts'; +import * as conflictAdapters from './adapters/createChangeReviewConflictPorts'; +import * as conflictStateAdapters from './adapters/createChangeReviewConflictStateBridge'; +import * as lifecycleAdapters from './adapters/createChangeReviewDialogLifecyclePorts'; +import * as dialogViewAdapters from './adapters/createChangeReviewDialogViewPorts'; +import * as draftHistoryAdapters from './adapters/createChangeReviewDraftHistoryPort'; +import * as fileDecisionAdapters from './adapters/createChangeReviewFileDecisionPorts'; +import * as fileDraftAdapters from './adapters/createChangeReviewFileDraftPorts'; +import * as historyMutationAdapters from './adapters/createChangeReviewHistoryMutationPorts'; +import * as hunkDecisionAdapters from './adapters/createChangeReviewHunkDecisionPorts'; + +import type { + ChangeReviewActionHistoryStorePort, + ChangeReviewDecisionPersistencePort, + ChangeReviewDecisionPersistenceSnapshot, +} from './ports/changeReviewActionHistoryPorts'; +import type { + ChangeReviewBulkDecisionCommandPort, + ChangeReviewBulkDecisionEditorPort, + ChangeReviewBulkDecisionStatePort, + ChangeReviewBulkDecisionStateSnapshot, + ChangeReviewBulkDecisionStatusPort, + ChangeReviewBulkDecisionWriteEvidencePort, +} from './ports/changeReviewBulkDecisionPorts'; +import type { + ChangeReviewConflictCommandPort, + ChangeReviewConflictQueryPort, +} from './ports/changeReviewConflictPorts'; +import type { + ChangeReviewCollapsedFilesStoragePort, + ChangeReviewDialogEditorActions, + ChangeReviewDialogKeyboardInteractionPort, + ChangeReviewExternalFileWatcherPort, + ChangeReviewRecentWrite, +} from './ports/changeReviewDialogInteractionPorts'; +import type { + ChangeReviewDialogLifecycleCommandPort, + ChangeReviewDialogLifecycleEditorPort, + ChangeReviewDialogLifecycleSessionPort, + ChangeReviewDialogLifecycleStatePort, + ChangeReviewDialogLifecycleStateSnapshot, + ChangeReviewDialogLifecycleStatusPort, + ChangeReviewDialogLifecycleWriteEvidencePort, +} from './ports/changeReviewDialogLifecyclePorts'; +import type { ChangeReviewDraftHistoryPort } from './ports/changeReviewDraftHistoryPort'; +import type { + ChangeReviewFileDecisionCommandPort, + ChangeReviewFileDecisionEditorPort, + ChangeReviewFileDecisionStatePort, + ChangeReviewFileDecisionStateSnapshot, + ChangeReviewFileDecisionStatusPort, + ChangeReviewFileDecisionWriteEvidencePort, +} from './ports/changeReviewFileDecisionPorts'; +import type { + ChangeReviewFileDraftCommandPort, + ChangeReviewFileDraftStatePort, + ChangeReviewFileDraftStateSnapshot, + ChangeReviewFileDraftStatusPort, + ChangeReviewFileDraftWriteEvidencePort, +} from './ports/changeReviewFileDraftPorts'; +import type { + ChangeReviewHistoryMutationCommandPort, + ChangeReviewHistoryMutationStatePort, + ChangeReviewHistoryMutationViewPort, + ChangeReviewHistoryPersistenceScope, + ChangeReviewHistoryStateSnapshot, +} from './ports/changeReviewHistoryMutationPorts'; +import type { + ChangeReviewHunkDecisionCommandPort, + ChangeReviewHunkDecisionEditorPort, + ChangeReviewHunkDecisionStatePort, + ChangeReviewHunkDecisionStateSnapshot, + ChangeReviewHunkDecisionStatusPort, + ChangeReviewHunkDecisionWriteEvidencePort, +} from './ports/changeReviewHunkDecisionPorts'; +import type { EditorView } from '@codemirror/view'; +import type { ReviewSerializedEditorState } from '@features/change-review-history/contracts'; +import type { + ApplyReviewResult, + FileChangeSummary, + FileChangeWithContent, + HunkDecision, + ReviewDecisionSnapshot, + ReviewPersistedStateSnapshot, + ReviewUndoAction, +} from '@shared/types'; +import type { ReviewAPI } from '@shared/types/api'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; + +type ExternalFileReviewApi = Pick< + ReviewAPI, + 'checkConflict' | 'onExternalFileChange' | 'unwatchFiles' | 'watchFiles' +>; + +export function createChangeReviewExternalFileWatcherPort( + getReviewApi: () => ExternalFileReviewApi +): ChangeReviewExternalFileWatcherPort { + return interactionAdapters.createChangeReviewExternalFileWatcherPort(getReviewApi); +} + +export const browserChangeReviewCollapsedFilesStorage: ChangeReviewCollapsedFilesStoragePort = { + read: (storageKey) => + interactionAdapters.browserChangeReviewCollapsedFilesStorage.read(storageKey), + write: (storageKey, filePaths) => + interactionAdapters.browserChangeReviewCollapsedFilesStorage.write(storageKey, filePaths), +}; + +interface ChangeReviewActionHistoryStore { + setReviewActionHistory(history: ReviewUndoAction[]): void; + setReviewRedoHistory(history: import('@shared/types').ReviewRedoAction[]): void; +} + +interface CreateChangeReviewActionHistoryStorePortInput { + getStore: () => ChangeReviewActionHistoryStore; + clearLegacyUndoStack: () => void; +} + +export function createChangeReviewActionHistoryStorePort( + input: CreateChangeReviewActionHistoryStorePortInput +): ChangeReviewActionHistoryStorePort { + return actionHistoryAdapters.createChangeReviewActionHistoryStorePort(input); +} + +interface ChangeReviewDecisionPersistenceStore extends ChangeReviewDecisionPersistenceSnapshot { + loadDecisionsFromDisk(teamName: string, scopeKey: string, scopeToken: string): Promise; + persistDecisions(teamName: string, scopeKey: string, scopeToken: string): void; + flushDecisionsToDisk(teamName: string, scopeKey: string, scopeToken: string): Promise; + clearDecisionsFromDisk(teamName: string, scopeKey: string, scopeToken?: string): Promise; +} + +interface CreateChangeReviewDecisionPersistencePortInput { + getStore: () => ChangeReviewDecisionPersistenceStore; + setApplyError: (message: string | null) => void; +} + +export function createChangeReviewDecisionPersistencePort( + input: CreateChangeReviewDecisionPersistencePortInput +): ChangeReviewDecisionPersistencePort { + return actionHistoryAdapters.createChangeReviewDecisionPersistencePort(input); +} + +interface ChangeReviewBulkDecisionStore extends ChangeReviewBulkDecisionStateSnapshot { + acceptAllFile(filePath: string): boolean; + rejectAllFile(filePath: string): void; + invalidateResolvedFileContent(filePath: string): void; + applyReview( + teamName: string, + taskId?: string, + memberName?: string + ): ReturnType; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +interface CreateChangeReviewBulkDecisionStatePortInput { + getStore: () => ChangeReviewBulkDecisionStore; + restoreDecisionSnapshot: (snapshot: ReviewDecisionSnapshot) => void; +} + +export function createChangeReviewBulkDecisionStatePort( + input: CreateChangeReviewBulkDecisionStatePortInput +): ChangeReviewBulkDecisionStatePort { + return bulkDecisionAdapters.createChangeReviewBulkDecisionStatePort(input); +} + +interface CreateChangeReviewBulkDecisionCommandPortInput { + getStore: () => ChangeReviewBulkDecisionStore; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export function createChangeReviewBulkDecisionCommandPort( + input: CreateChangeReviewBulkDecisionCommandPortInput +): ChangeReviewBulkDecisionCommandPort { + return bulkDecisionAdapters.createChangeReviewBulkDecisionCommandPort(input); +} + +type ReviewConflictQueryApi = Pick< + ReviewAPI, + 'loadDecisionConflictCandidates' | 'loadDraftHistoryConflictCandidates' +>; +type ReviewConflictCommandApi = Pick; + +export function createChangeReviewConflictQueryPort( + getReviewApi: () => ReviewConflictQueryApi +): ChangeReviewConflictQueryPort { + return conflictAdapters.createChangeReviewConflictQueryPort(getReviewApi); +} + +export function createChangeReviewConflictCommandPort( + getReviewApi: () => ReviewConflictCommandApi +): ChangeReviewConflictCommandPort { + return conflictAdapters.createChangeReviewConflictCommandPort(getReviewApi); +} + +interface ChangeReviewConflictStateSnapshot { + applyError: string | null; + decisionHydrationScopeKey: string | null; + decisionHydrationStatus: string; +} + +interface CreateChangeReviewConflictStateBridgeInput { + getSnapshot: () => ChangeReviewConflictStateSnapshot; + setApplyError: (message: string | null) => void; +} + +export interface ChangeReviewConflictStateBridge { + clearReportedLoadError(): void; + reportError(message: string): void; + clearResolutionError(): void; + isDecisionHydrationLoaded(hydrationKey: string): boolean; +} + +export function createChangeReviewConflictStateBridge( + input: CreateChangeReviewConflictStateBridgeInput +): ChangeReviewConflictStateBridge { + return conflictStateAdapters.createChangeReviewConflictStateBridge(input); +} + +interface ChangeReviewDialogLifecycleStore extends ChangeReviewDialogLifecycleStateSnapshot { + applyError: string | null; + resetAllReviewState(): void; + clearChangeReviewCache(): void; + fetchAgentChanges(teamName: string, memberName: string): Promise; + fetchTaskChanges( + teamName: string, + taskId: string, + options: NonNullable[2]> + ): Promise; + clearDecisionsFromDisk( + teamName: string, + scopeKey: string, + scopeToken?: string, + forceDiscard?: boolean + ): Promise; + applyReview( + teamName: string, + taskId?: string, + memberName?: string + ): Promise; +} + +interface CreateChangeReviewDialogLifecycleStatePortInput { + getStore: () => ChangeReviewDialogLifecycleStore; + reportError: (message: string | null) => void; + completeSavedStateDiscard: (markDecisionHydrationLoaded: boolean) => void; +} + +export function createChangeReviewDialogLifecycleStatePort( + input: CreateChangeReviewDialogLifecycleStatePortInput +): ChangeReviewDialogLifecycleStatePort { + return lifecycleAdapters.createChangeReviewDialogLifecycleStatePort(input); +} + +interface CreateChangeReviewDialogLifecycleCommandPortInput { + getStore: () => ChangeReviewDialogLifecycleStore; + getReviewApi: () => Pick; + hydrateDecisions: ChangeReviewDialogLifecycleCommandPort['hydrateDecisions']; +} + +export function createChangeReviewDialogLifecycleCommandPort( + input: CreateChangeReviewDialogLifecycleCommandPortInput +): ChangeReviewDialogLifecycleCommandPort { + return lifecycleAdapters.createChangeReviewDialogLifecycleCommandPort(input); +} + +interface FileMutationStatusDependencies { + fileApplyInFlightRef: MutableRefObject>; + setFilesApplying: Dispatch>>; + setDiscardCounters: Dispatch>>; +} + +interface LifecycleStatusDependencies { + undoInFlightRef: MutableRefObject; + closingRef: MutableRefObject; + pendingApplyCleanupKeyRef: MutableRefObject; + expectedDraftHistoryKeyRef: MutableRefObject; + setUndoing: Dispatch>; + setClosing: Dispatch>; +} + +interface CreateChangeReviewDialogViewPortsInput + extends FileMutationStatusDependencies, LifecycleStatusDependencies { + editorViewMapRef: MutableRefObject>; + editorActions: ChangeReviewDialogEditorActions; + subscribeToRejectCurrentHunk: (callback: () => void) => (() => void) | undefined; + recentReviewWritesRef: MutableRefObject>; + handleSerializedStateChanged: ( + filePath: string, + editorState: ReviewSerializedEditorState + ) => void; + addReviewFile( + file: FileChangeSummary, + options?: { index?: number; content?: FileChangeWithContent } + ): void; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; + navigateToHistoryAction(action: ReviewUndoAction): void; +} + +export interface ChangeReviewDialogViewPorts { + bulkDecision: { + editor: ChangeReviewBulkDecisionEditorPort; + status: ChangeReviewBulkDecisionStatusPort; + writeEvidence: ChangeReviewBulkDecisionWriteEvidencePort; + }; + fileDecision: { + editor: ChangeReviewFileDecisionEditorPort; + status: ChangeReviewFileDecisionStatusPort; + writeEvidence: ChangeReviewFileDecisionWriteEvidencePort; + }; + fileDraft: { + status: ChangeReviewFileDraftStatusPort; + writeEvidence: ChangeReviewFileDraftWriteEvidencePort; + }; + historyMutation: ChangeReviewHistoryMutationViewPort; + keyboardInteraction: ChangeReviewDialogKeyboardInteractionPort; + hunkDecision: { + editor: ChangeReviewHunkDecisionEditorPort; + status: ChangeReviewHunkDecisionStatusPort; + writeEvidence: ChangeReviewHunkDecisionWriteEvidencePort; + }; + lifecycle: { + editor: ChangeReviewDialogLifecycleEditorPort; + session: ChangeReviewDialogLifecycleSessionPort; + status: ChangeReviewDialogLifecycleStatusPort; + writeEvidence: ChangeReviewDialogLifecycleWriteEvidencePort; + }; +} + +export function createChangeReviewDialogViewPorts( + input: CreateChangeReviewDialogViewPortsInput +): ChangeReviewDialogViewPorts { + return dialogViewAdapters.createChangeReviewDialogViewPorts(input); +} + +type ReviewDraftHistoryApi = Pick< + ReviewAPI, + | 'loadDraftHistory' + | 'saveDraftHistoryEntry' + | 'clearDraftHistory' + | 'checkConflict' + | 'replaceDraftHistoryConflictCandidate' + | 'resolveDraftHistoryConflictCandidate' +>; + +export function createChangeReviewDraftHistoryPort( + getReviewApi: () => ReviewDraftHistoryApi +): ChangeReviewDraftHistoryPort { + return draftHistoryAdapters.createChangeReviewDraftHistoryPort(getReviewApi); +} + +interface ChangeReviewFileDecisionStore extends ChangeReviewFileDecisionStateSnapshot { + acceptAllFile(filePath: string): boolean; + rejectAllFile(filePath: string): void; + clearReviewFileExternalChange(filePath: string): void; + invalidateResolvedFileContent(filePath: string): void; + applySingleFileDecision( + teamName: string, + filePath: string, + taskId?: string, + memberName?: string + ): ReturnType; + quiesceDecisionPersistence( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise; + recordDecisionRevision( + teamName: string, + scopeKey: string, + scopeToken: string, + revision: number + ): void; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +interface CreateChangeReviewFileDecisionStatePortInput { + getStore: () => ChangeReviewFileDecisionStore; + applyRestoredDecisionState: (file: FileChangeSummary) => void; + restoreFileDecisions: (file: FileChangeSummary, snapshot: ReviewDecisionSnapshot) => void; + reportError: (message: string | null) => void; +} + +export function createChangeReviewFileDecisionStatePort( + input: CreateChangeReviewFileDecisionStatePortInput +): ChangeReviewFileDecisionStatePort { + return fileDecisionAdapters.createChangeReviewFileDecisionStatePort(input); +} + +interface CreateChangeReviewFileDecisionCommandPortInput { + getStore: () => ChangeReviewFileDecisionStore; + getReviewApi: () => Pick; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export function createChangeReviewFileDecisionCommandPort( + input: CreateChangeReviewFileDecisionCommandPortInput +): ChangeReviewFileDecisionCommandPort { + return fileDecisionAdapters.createChangeReviewFileDecisionCommandPort(input); +} + +interface ChangeReviewFileDraftStore { + activeChangeSet: { + files: readonly ChangeReviewFileDraftStateSnapshot['activeFiles'][number][]; + } | null; + editedContents: ChangeReviewFileDraftStateSnapshot['editedContents']; + reviewExternalChangesByFile: ChangeReviewFileDraftStateSnapshot['reviewExternalChangesByFile']; + hunkDecisions: ChangeReviewFileDraftStateSnapshot['hunkDecisions']; + fileDecisions: ChangeReviewFileDraftStateSnapshot['fileDecisions']; + hunkContextHashesByFile: ChangeReviewFileDraftStateSnapshot['hunkContextHashesByFile']; + decisionRevision: number; + changeSetEpoch: number; + updateEditedContent(filePath: string, content: string): void; + discardFileEdits(filePath: string): void; + clearReviewFileExternalChange(filePath: string): void; + reloadReviewFileFromDisk(filePath: string): void; + saveEditedFile: ChangeReviewFileDraftCommandPort['saveEditedFile']; + quiesceDecisionPersistence( + teamName: string, + scopeKey: string, + scopeToken: string + ): Promise; + recordDecisionRevision( + teamName: string, + scopeKey: string, + scopeToken: string, + revision: number + ): void; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +interface CreateChangeReviewFileDraftStatePortInput { + getStore: () => ChangeReviewFileDraftStore; + applyReloadedReviewState: (state: ReviewPersistedStateSnapshot) => void; + reportError: (message: string | null) => void; +} + +export function createChangeReviewFileDraftStatePort( + input: CreateChangeReviewFileDraftStatePortInput +): ChangeReviewFileDraftStatePort { + return fileDraftAdapters.createChangeReviewFileDraftStatePort(input); +} + +interface CreateChangeReviewFileDraftCommandPortInput { + getStore: () => ChangeReviewFileDraftStore; + getReviewApi: () => Pick; +} + +export function createChangeReviewFileDraftCommandPort( + input: CreateChangeReviewFileDraftCommandPortInput +): ChangeReviewFileDraftCommandPort { + return fileDraftAdapters.createChangeReviewFileDraftCommandPort(input); +} + +type ChangeReviewHistoryMutationApi = Pick< + ReviewAPI, + 'executeMutation' | 'restoreHistory' | 'retryMutationRecovery' +>; + +export function createChangeReviewHistoryMutationCommandPort( + getReviewApi: () => ChangeReviewHistoryMutationApi +): ChangeReviewHistoryMutationCommandPort { + return historyMutationAdapters.createChangeReviewHistoryMutationCommandPort(getReviewApi); +} + +interface CreateChangeReviewHistoryMutationStatePortInput { + getSnapshot: () => ChangeReviewHistoryStateSnapshot; + quiesceDecisionPersistence: (scope: ChangeReviewHistoryPersistenceScope) => Promise; + recordDecisionRevision(scope: ChangeReviewHistoryPersistenceScope, revision: number): void; + applyDecisionState: ChangeReviewHistoryMutationStatePort['applyDecisionState']; + applyPersistedState(state: ReviewPersistedStateSnapshot, applyError: string | null): void; + reportError(message: string): void; + clearExternalChange(filePath: string): void; + invalidateResolvedFileContent(filePath: string): void; +} + +export function createChangeReviewHistoryMutationStatePort( + input: CreateChangeReviewHistoryMutationStatePortInput +): ChangeReviewHistoryMutationStatePort { + return historyMutationAdapters.createChangeReviewHistoryMutationStatePort(input); +} + +interface ChangeReviewHunkDecisionStore extends ChangeReviewHunkDecisionStateSnapshot { + setHunkDecision(filePath: string, hunkIndex: number, decision: HunkDecision): number; + clearHunkDecisionByOriginalIndex(filePath: string, originalIndex: number): void; + invalidateResolvedFileContent(filePath: string): void; + applySingleFileDecision( + teamName: string, + filePath: string, + taskId?: string, + memberName?: string + ): Promise; + fetchFileContent( + teamName: string, + memberName: string | undefined, + filePath: string + ): Promise; +} + +export function createChangeReviewHunkDecisionStatePort( + getStore: () => ChangeReviewHunkDecisionStore +): ChangeReviewHunkDecisionStatePort { + return hunkDecisionAdapters.createChangeReviewHunkDecisionStatePort(getStore); +} + +interface CreateChangeReviewHunkDecisionCommandPortInput { + getStore: () => ChangeReviewHunkDecisionStore; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export function createChangeReviewHunkDecisionCommandPort( + input: CreateChangeReviewHunkDecisionCommandPortInput +): ChangeReviewHunkDecisionCommandPort { + return hunkDecisionAdapters.createChangeReviewHunkDecisionCommandPort(input); +} +export type { ChangeReviewActionHistoryController } from './hooks/useChangeReviewActionHistoryController'; +export { useChangeReviewActionHistoryController } from './hooks/useChangeReviewActionHistoryController'; +export type { ChangeReviewBulkDecisionController } from './hooks/useChangeReviewBulkDecisionController'; +export { useChangeReviewBulkDecisionController } from './hooks/useChangeReviewBulkDecisionController'; +export type { ChangeReviewConflictDiscoveryController } from './hooks/useChangeReviewConflictDiscoveryController'; +export { useChangeReviewConflictDiscoveryController } from './hooks/useChangeReviewConflictDiscoveryController'; +export type { ChangeReviewConflictInteractionController } from './hooks/useChangeReviewConflictInteractionController'; +export { useChangeReviewConflictInteractionController } from './hooks/useChangeReviewConflictInteractionController'; +export { useChangeReviewDecisionAutoPersistence } from './hooks/useChangeReviewDecisionAutoPersistence'; +export type { + ChangeReviewAutoClearResult, + ChangeReviewDecisionPersistenceController, + ChangeReviewDecisionPersistenceDiagnostics, +} from './hooks/useChangeReviewDecisionPersistenceController'; +export { + CHANGE_REVIEW_PERSISTENCE_ERROR, + useChangeReviewDecisionPersistenceController, +} from './hooks/useChangeReviewDecisionPersistenceController'; +export type { ChangeReviewDialogKeyboardInteractions } from './hooks/useChangeReviewDialogKeyboardInteractions'; +export { useChangeReviewDialogKeyboardInteractions } from './hooks/useChangeReviewDialogKeyboardInteractions'; +export type { ChangeReviewDialogLifecycleController } from './hooks/useChangeReviewDialogLifecycleController'; +export { useChangeReviewDialogLifecycleController } from './hooks/useChangeReviewDialogLifecycleController'; +export type { + ChangeReviewDialogViewState, + ChangeReviewDialogViewStatePolicy, +} from './hooks/useChangeReviewDialogViewState'; +export { useChangeReviewDialogViewState } from './hooks/useChangeReviewDialogViewState'; +export type { + ChangeReviewDraftHistoryController, + ChangeReviewDraftHistoryDiagnostics, +} from './hooks/useChangeReviewDraftHistoryController'; +export { useChangeReviewDraftHistoryController } from './hooks/useChangeReviewDraftHistoryController'; +export type { ChangeReviewExternalChangeController } from './hooks/useChangeReviewExternalChangeController'; +export { useChangeReviewExternalChangeController } from './hooks/useChangeReviewExternalChangeController'; +export { useChangeReviewExternalFileWatcher } from './hooks/useChangeReviewExternalFileWatcher'; +export type { ChangeReviewFileDecisionController } from './hooks/useChangeReviewFileDecisionController'; +export { useChangeReviewFileDecisionController } from './hooks/useChangeReviewFileDecisionController'; +export type { ChangeReviewFileDraftController } from './hooks/useChangeReviewFileDraftController'; +export { useChangeReviewFileDraftController } from './hooks/useChangeReviewFileDraftController'; +export type { ChangeReviewKeyboardEditorContext } from './hooks/useChangeReviewHistoryKeyboardShortcuts'; +export { useChangeReviewHistoryKeyboardShortcuts } from './hooks/useChangeReviewHistoryKeyboardShortcuts'; +export type { + ChangeReviewHistoryMutationController, + ChangeReviewHistoryRestorePreview, +} from './hooks/useChangeReviewHistoryMutationController'; +export { useChangeReviewHistoryMutationController } from './hooks/useChangeReviewHistoryMutationController'; +export type { ChangeReviewHunkDecisionController } from './hooks/useChangeReviewHunkDecisionController'; +export { useChangeReviewHunkDecisionController } from './hooks/useChangeReviewHunkDecisionController'; +export { useChangeReviewLifecycleRegistration } from './hooks/useChangeReviewLifecycleRegistration'; +export type { ChangeReviewMutationGuards } from './hooks/useChangeReviewMutationGuards'; +export { useChangeReviewMutationGuards } from './hooks/useChangeReviewMutationGuards'; +export { useChangeReviewOperationGeneration } from './hooks/useChangeReviewOperationGeneration'; +export type { + ChangeReviewOperationState, + ChangeReviewOperationViewPortBindings, +} from './hooks/useChangeReviewOperationState'; +export { useChangeReviewOperationState } from './hooks/useChangeReviewOperationState'; +export { useChangeReviewScopeIdentity } from './hooks/useChangeReviewScopeIdentity'; +export type { + ChangeReviewActionHistoryStorePort, + ChangeReviewDecisionPersistencePort, + ChangeReviewDecisionPersistenceScope, + ChangeReviewDecisionPersistenceSnapshot, +} from './ports/changeReviewActionHistoryPorts'; +export type { + BuildBulkRejectDiskSnapshot, + ChangeReviewBulkDecisionCommandPort, + ChangeReviewBulkDecisionEditorPort, + ChangeReviewBulkDecisionStatePort, + ChangeReviewBulkDecisionStateSnapshot, + ChangeReviewBulkDecisionStatusPort, + ChangeReviewBulkDecisionWriteEvidencePort, +} from './ports/changeReviewBulkDecisionPorts'; +export type { + ChangeReviewConflictCommandPort, + ChangeReviewConflictQueryPort, + ChangeReviewConflictScope, +} from './ports/changeReviewConflictPorts'; +export type { + ChangeReviewCollapsedFilesStoragePort, + ChangeReviewDialogEditorActions, + ChangeReviewDialogKeyboardInteractionPort, + ChangeReviewExternalFileWatcherPort, + ChangeReviewRecentWrite, +} from './ports/changeReviewDialogInteractionPorts'; +export type { + ChangeReviewDialogLifecycleApplyOutcome, + ChangeReviewDialogLifecycleAutoClearResult, + ChangeReviewDialogLifecycleCommandPort, + ChangeReviewDialogLifecycleDecisionPersistencePort, + ChangeReviewDialogLifecycleDraftHistoryPort, + ChangeReviewDialogLifecycleEditorPort, + ChangeReviewDialogLifecyclePersistenceScope, + ChangeReviewDialogLifecycleSessionPort, + ChangeReviewDialogLifecycleStatePort, + ChangeReviewDialogLifecycleStateSnapshot, + ChangeReviewDialogLifecycleStatusPort, + ChangeReviewDialogLifecycleWriteEvidencePort, +} from './ports/changeReviewDialogLifecyclePorts'; +export type { + ChangeReviewDraftHistoryEntryInput, + ChangeReviewDraftHistoryPort, + ChangeReviewDraftHistoryScope, + ChangeReviewDraftHistoryVersion, +} from './ports/changeReviewDraftHistoryPort'; +export type { + ChangeReviewFileDecisionCommandPort, + ChangeReviewFileDecisionEditorPort, + ChangeReviewFileDecisionHistoryPort, + ChangeReviewFileDecisionPersistenceScope, + ChangeReviewFileDecisionPolicy, + ChangeReviewFileDecisionStatePort, + ChangeReviewFileDecisionStateSnapshot, + ChangeReviewFileDecisionStatusPort, + ChangeReviewFileDecisionWriteEvidencePort, +} from './ports/changeReviewFileDecisionPorts'; +export type { + ChangeReviewFileDraftActionHistoryPort, + ChangeReviewFileDraftCommandPort, + ChangeReviewFileDraftHistoryPort, + ChangeReviewFileDraftPersistenceScope, + ChangeReviewFileDraftStatePort, + ChangeReviewFileDraftStateSnapshot, + ChangeReviewFileDraftStatusPort, + ChangeReviewFileDraftWriteEvidencePort, + ChangeReviewSaveEditedFileResult, + CommitChangeReviewExternalReloadInput, +} from './ports/changeReviewFileDraftPorts'; +export type { + ChangeReviewHistoryMutationCommandPort, + ChangeReviewHistoryMutationScope, + ChangeReviewHistoryMutationStatePort, + ChangeReviewHistoryMutationViewPort, + ChangeReviewHistoryPersistenceScope, + ChangeReviewHistoryStateSnapshot, +} from './ports/changeReviewHistoryMutationPorts'; +export type { + CaptureChangeReviewHunkOperationScope, + ChangeReviewHunkDecisionApplyOutcome, + ChangeReviewHunkDecisionCommandPort, + ChangeReviewHunkDecisionEditorPort, + ChangeReviewHunkDecisionHistoryPort, + ChangeReviewHunkDecisionPolicy, + ChangeReviewHunkDecisionStatePort, + ChangeReviewHunkDecisionStateSnapshot, + ChangeReviewHunkDecisionStatusPort, + ChangeReviewHunkDecisionWriteEvidencePort, +} from './ports/changeReviewHunkDecisionPorts'; +export type { + RegisterChangeReviewAppCloseParticipant, + RegisterChangeReviewLifecycleOwner, +} from './ports/changeReviewLifecyclePorts'; +export type { + ChangeReviewExternalChangePolicy, + ChangeReviewExternalChangeStatePort, + ChangeReviewExternalChangeStateSnapshot, + ChangeReviewExternalChangeType, + ChangeReviewOperationStatePort, + ChangeReviewOperationStateSnapshot, +} from './ports/changeReviewMutationSafetyPorts'; +export { + ChangeReviewConflictDiscardDialog, + ChangeReviewConflictNotices, +} from './ui/ChangeReviewConflictNotices'; +export type { ChangeReviewSidebarProps } from './ui/ChangeReviewSidebar'; +export { ChangeReviewSidebar } from './ui/ChangeReviewSidebar'; +export type { TaskChangesEmptyStateProps } from './ui/TaskChangesEmptyState'; +export { TaskChangesEmptyState } from './ui/TaskChangesEmptyState'; +export type { + ReviewActionPersistenceStatus, + ReviewUndoActionInput, +} from './utils/changeReviewActionHistory'; +export { + appendOrderedReviewAction, + createReviewUndoAction, + filterReviewActionHistoryForFile, + isReviewActionPersistenceBlocking, + popOrderedReviewAction, + replaceLatestReviewAction, +} from './utils/changeReviewActionHistory'; +export type { ReviewConflictCandidateSelection } from './utils/changeReviewConflicts'; +export { + CHANGE_REVIEW_CONFLICT_LOAD_ERROR_PREFIX, + describeReviewConflictCandidate, + describeReviewConflictDiscard, + selectLatestReviewConflictCandidate, +} from './utils/changeReviewConflicts'; +export type { + ChangeReviewActionLockState, + ChangeReviewCloseReadiness, + ChangeReviewCloseReadinessInput, + ChangeReviewDecisionWriteDiagnostics, + ChangeReviewDraftWriteDiagnostics, +} from './utils/changeReviewDialogLifecycle'; +export { + evaluateChangeReviewCloseReadiness, + getReviewCloseBlockReason, + hasUnscopedLocalReviewState, + isReviewActionLocked, + shouldRequestReviewCloseForEscape, +} from './utils/changeReviewDialogLifecycle'; +export type { ReviewHistoryRecoveryDisposition } from './utils/changeReviewHistoryMutation'; +export { + areReviewPersistedStatesEqual, + classifyReviewHistoryRecovery, + createReviewRedoAction, + getReviewActionAffectedPaths, + getReviewDiskMutationExpectedContent, + resolveReviewFile, +} from './utils/changeReviewHistoryMutation'; +export type { + BuildChangeReviewScopeProjectionInput, + ChangeReviewScopeProjection, + ReviewDecisionHydrationGuard, + ReviewDecisionHydrationStatus, + ReviewDraftHistoryHydrationState, +} from './utils/changeReviewScope'; +export { + buildChangeReviewScopeProjection, + getReviewDecisionHydrationGuard, +} from './utils/changeReviewScope'; +export { markChangeReviewMutationDiskPostimages } from './utils/changeReviewWriteEvidence'; +export type { ReviewOperationScopeToken } from './utils/reviewOperationGeneration'; +export { + createReviewOperationScopeToken, + isReviewOperationScopeCurrent, +} from './utils/reviewOperationGeneration'; +export type { + ChangeReviewChangeSet, + GlobalDiffLoadingState, + ReviewChangeStats, + ReviewStats, + TaskChangesEmptyStatePresentation, +} from './view-models/changeReviewPresentation'; +export { + buildChangeReviewTitle, + buildGlobalDiffLoadingState, + buildReviewChangeStats, + buildReviewFileLabels, + buildReviewStats, + buildTaskChangesEmptyStatePresentation, + buildWatchedReviewFilePathsKey, + findActiveReviewFile, + isTaskChangeSetV2, + resolveReviewFileLabel, + shouldShowTaskScopeBanner, + sortChangeReviewFiles, + toTaskChangeSetV2, +} from './view-models/changeReviewPresentation'; diff --git a/src/features/change-review/renderer/ports/changeReviewActionHistoryPorts.ts b/src/features/change-review/renderer/ports/changeReviewActionHistoryPorts.ts new file mode 100644 index 0000000000..ebb78a1c9b --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewActionHistoryPorts.ts @@ -0,0 +1,41 @@ +import type { ReviewDecisionHydrationStatus } from '../utils/changeReviewScope'; +import type { + FileChangeWithContent, + HunkDecision, + ReviewRedoAction, + ReviewUndoAction, +} from '@shared/types'; + +export interface ChangeReviewActionHistoryStorePort { + publishUndoHistory(history: ReviewUndoAction[]): void; + publishRedoHistory(history: ReviewRedoAction[]): void; + clearLegacyUndoStack(): void; +} + +export interface ChangeReviewDecisionPersistenceScope { + teamName: string; + scopeKey: string; + scopeToken: string; +} + +export interface ChangeReviewDecisionPersistenceSnapshot { + hunkDecisions: Record; + fileDecisions: Record; + reviewActionHistory: ReviewUndoAction[]; + reviewRedoHistory: ReviewRedoAction[]; + fileContents: Record; + fileChunkCounts: Record; + decisionHydrationScopeKey: string | null; + decisionHydrationStatus: ReviewDecisionHydrationStatus; + applyError: string | null; +} + +export interface ChangeReviewDecisionPersistencePort { + getSnapshot(): ChangeReviewDecisionPersistenceSnapshot; + load(scope: ChangeReviewDecisionPersistenceScope): Promise; + schedule(scope: ChangeReviewDecisionPersistenceScope): void; + flush(scope: ChangeReviewDecisionPersistenceScope): Promise; + clear(scope: ChangeReviewDecisionPersistenceScope): Promise; + reportError(message: string): void; + clearError(expectedMessage: string): void; +} diff --git a/src/features/change-review/renderer/ports/changeReviewBulkDecisionPorts.ts b/src/features/change-review/renderer/ports/changeReviewBulkDecisionPorts.ts new file mode 100644 index 0000000000..51db4c12b2 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewBulkDecisionPorts.ts @@ -0,0 +1,59 @@ +import type { + ApplyReviewResult, + FileChangeSummary, + HunkDecision, + ReviewDecisionSnapshot, + ReviewDiskUndoSnapshot, + ReviewMutationDiskPostimage, +} from '@shared/types'; + +export interface ChangeReviewBulkDecisionStateSnapshot { + editedContents: Record; + hunkDecisions: Record; + fileDecisions: Record; + changeSetEpoch: number; +} + +export interface ChangeReviewBulkDecisionStatePort { + getSnapshot: () => ChangeReviewBulkDecisionStateSnapshot; + acceptAllFile: (filePath: string) => boolean; + rejectAllFile: (filePath: string) => void; + restoreDecisionSnapshot: (snapshot: ReviewDecisionSnapshot) => void; + invalidateResolvedFileContent: (filePath: string) => void; +} + +export interface ChangeReviewBulkDecisionCommandPort { + applyReview: ( + teamName: string, + taskId?: string, + memberName?: string + ) => Promise; + fetchFileContent: (teamName: string, memberName: string | undefined, filePath: string) => void; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export interface ChangeReviewBulkDecisionEditorPort { + scheduleEditorSync: (callback: () => void) => void; + acceptAllEditorChunks: (filePaths: ReadonlySet) => void; + rejectAllEditorChunks: (filePaths: ReadonlySet) => void; + rollbackEditorContent: (filePath: string, content: string) => void; +} + +export interface ChangeReviewBulkDecisionWriteEvidencePort { + markExpectedWrite: (filePath: string, expectedContent: string | null) => void; + markCommittedPostimages: (postimages: readonly ReviewMutationDiskPostimage[] | undefined) => void; +} + +export interface ChangeReviewBulkDecisionStatusPort { + beginFileMutation: (filePath: string) => void; + finishFileMutation: (filePath: string) => void; + markFilesApplying: (filePaths: ReadonlySet) => void; + clearFilesApplying: (filePaths: ReadonlySet) => void; + incrementDiscardCounter: (filePath: string) => void; + setUndoInFlight: (value: boolean) => void; +} + +export type BuildBulkRejectDiskSnapshot = ( + file: FileChangeSummary, + decisionSnapshot: ReviewDecisionSnapshot +) => ReviewDiskUndoSnapshot | null; diff --git a/src/features/change-review/renderer/ports/changeReviewConflictPorts.ts b/src/features/change-review/renderer/ports/changeReviewConflictPorts.ts new file mode 100644 index 0000000000..12ec151ca9 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewConflictPorts.ts @@ -0,0 +1,29 @@ +import type { ReviewDraftHistoryConflictCandidateSummary } from '@features/change-review-history/contracts'; +import type { + ReviewConflictResolution, + ReviewDecisionConflictCandidateSummary, +} from '@shared/types'; + +export interface ChangeReviewConflictScope { + teamName: string; + scopeKey: string; + scopeToken: string; +} + +export interface ChangeReviewConflictQueryPort { + loadDecisionCandidates( + scope: ChangeReviewConflictScope + ): Promise; + loadDraftHistoryCandidates( + scope: ChangeReviewConflictScope + ): Promise; +} + +export interface ChangeReviewConflictCommandPort { + resolveDecisionCandidate(input: { + scope: ChangeReviewConflictScope; + candidateId: string; + resolution: ReviewConflictResolution; + observedCurrentRevision: number; + }): Promise<{ revision: number }>; +} diff --git a/src/features/change-review/renderer/ports/changeReviewDialogInteractionPorts.ts b/src/features/change-review/renderer/ports/changeReviewDialogInteractionPorts.ts new file mode 100644 index 0000000000..ff0ebf2e8c --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewDialogInteractionPorts.ts @@ -0,0 +1,40 @@ +import type { EditorState } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import type { ConflictCheckResult, EditorFileChangeEvent, ReviewFileScope } from '@shared/types'; + +export interface ChangeReviewCollapsedFilesStoragePort { + read: (storageKey: string) => Set; + write: (storageKey: string, filePaths: ReadonlySet) => void; +} + +export interface ChangeReviewRecentWrite { + at: number; + expectedContent: string | null; +} + +export interface ChangeReviewExternalFileWatcherPort { + checkConflict: ( + scope: ReviewFileScope, + filePath: string, + expectedModified: string + ) => Promise; + subscribe: (callback: (event: EditorFileChangeEvent) => void) => () => void; + watchFiles: (projectPath: string, filePaths: string[]) => Promise; + unwatchFiles: () => Promise; +} + +export interface ChangeReviewDialogKeyboardInteractionPort { + subscribeRejectCurrentHunk: (callback: () => void) => (() => void) | undefined; + rejectCurrentChunk: ( + filePath: string + ) => { hunkIndex: number; beforeContent: string; afterContent: string } | null; + rollbackContent: (filePath: string, content: string) => void; +} + +export interface ChangeReviewDialogEditorActions { + acceptAllChunks: (view: EditorView) => boolean; + computeChunkIndexAtPosition: (state: EditorState, position: number) => number; + ignoreNextDocChange: (view: EditorView) => void; + rejectAllChunks: (view: EditorView) => boolean; + rejectChunk: (view: EditorView) => boolean; +} diff --git a/src/features/change-review/renderer/ports/changeReviewDialogLifecyclePorts.ts b/src/features/change-review/renderer/ports/changeReviewDialogLifecyclePorts.ts new file mode 100644 index 0000000000..183d723238 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewDialogLifecyclePorts.ts @@ -0,0 +1,111 @@ +import type { + ChangeReviewActionLockState, + ChangeReviewDecisionWriteDiagnostics, + ChangeReviewDraftWriteDiagnostics, +} from '../utils/changeReviewDialogLifecycle'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { ReviewDraftHistoryEntry } from '@features/change-review-history/contracts'; +import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import type { + ApplyReviewResult, + FileChangeWithContent, + HunkDecision, + RetryReviewMutationRecoveryRequest, + RetryReviewMutationRecoveryResult, + ReviewMutationDiskPostimage, + ReviewRedoAction, + ReviewUndoAction, +} from '@shared/types'; + +export interface ChangeReviewDialogLifecyclePersistenceScope { + teamName: string; + scopeKey: string; + scopeToken: string; +} + +export type ChangeReviewDialogLifecycleAutoClearResult = 'cleared' | 'failed' | 'stale' | 'pending'; + +export type ChangeReviewDialogLifecycleApplyOutcome = + | { status: 'applied'; result: ApplyReviewResult } + | { status: 'failed'; result: ApplyReviewResult | null; errorMessage: string }; + +export interface ChangeReviewDialogLifecycleDecisionPersistencePort { + scheduleAutoPersistence: (scope: ChangeReviewDialogLifecyclePersistenceScope) => void; + clearAfterDurableStateEmptied: ( + scope: ChangeReviewDialogLifecyclePersistenceScope, + hydrationKey: string + ) => Promise; + flushForClose: () => Promise; + getDiagnostics: () => ChangeReviewDecisionWriteDiagnostics; +} + +export interface ChangeReviewDialogLifecycleDraftHistoryPort { + getEntry: (filePath: string) => ReviewDraftHistoryEntry | undefined; + flushWrites: () => Promise; + retryHydration: () => void; + discardUnreadableScope: (operationScope: ReviewOperationScopeToken) => Promise; + getDiagnostics: (hydrationKey?: string | null) => ChangeReviewDraftWriteDiagnostics; +} + +export interface ChangeReviewDialogLifecycleStateSnapshot { + editedContents: Record; + hunkDecisions: Record; + fileDecisions: Record; + reviewActionHistory: ReviewUndoAction[]; + reviewRedoHistory: ReviewRedoAction[]; + fileContents: Record; + fileChunkCounts: Record; + decisionHydrationScopeKey: string | null; + decisionHydrationStatus: 'idle' | 'loading' | 'loaded' | 'error'; + applying: boolean; +} + +export interface ChangeReviewDialogLifecycleStatePort { + getSnapshot: () => ChangeReviewDialogLifecycleStateSnapshot; + reportError: (message: string | null) => void; + completeSavedStateDiscard: (markDecisionHydrationLoaded: boolean) => void; +} + +export interface ChangeReviewDialogLifecycleCommandPort { + resetAllReviewState: () => void; + clearChangeReviewCache: () => void; + fetchAgentChanges: (teamName: string, memberName: string) => void; + fetchTaskChanges: (teamName: string, taskId: string, options: TaskChangeRequestOptions) => void; + hydrateDecisions: ( + scope: ChangeReviewDialogLifecyclePersistenceScope, + hydrationKey: string + ) => Promise; + clearDecisions: ( + scope: ChangeReviewDialogLifecyclePersistenceScope, + forceDiscard?: boolean + ) => Promise; + applyReview: ( + teamName: string, + taskId: string | undefined, + memberName: string | undefined + ) => Promise; + retryMutationRecovery: ( + request: RetryReviewMutationRecoveryRequest + ) => Promise; +} + +export interface ChangeReviewDialogLifecycleEditorPort { + captureDraftSnapshots: (shouldCapture: (filePath: string) => boolean) => void; +} + +export interface ChangeReviewDialogLifecycleStatusPort { + getActionLockState: (applying: boolean) => ChangeReviewActionLockState; + beginClosing: () => void; + finishClosing: () => void; + setRecoveryInFlight: (value: boolean) => void; +} + +export interface ChangeReviewDialogLifecycleSessionPort { + getPendingApplyCleanupKey: () => string | null; + setPendingApplyCleanupKey: (key: string | null) => void; + isExpectedHydrationKey: (hydrationKey: string) => boolean; +} + +export interface ChangeReviewDialogLifecycleWriteEvidencePort { + markCommittedPostimages: (postimages: readonly ReviewMutationDiskPostimage[] | undefined) => void; +} diff --git a/src/features/change-review/renderer/ports/changeReviewDraftHistoryPort.ts b/src/features/change-review/renderer/ports/changeReviewDraftHistoryPort.ts new file mode 100644 index 0000000000..b01d59c594 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewDraftHistoryPort.ts @@ -0,0 +1,53 @@ +import type { + ReviewDraftHistoryConflictCandidateSummary, + ReviewDraftHistoryEntry, + ReviewDraftHistorySnapshot, +} from '@features/change-review-history/contracts'; +import type { ConflictCheckResult, ReviewConflictResolution, ReviewFileScope } from '@shared/types'; + +export interface ChangeReviewDraftHistoryScope { + teamName: string; + scopeKey: string; + scopeToken: string; +} + +export interface ChangeReviewDraftHistoryVersion { + revision: number; + generation: string | null; +} + +export type ChangeReviewDraftHistoryEntryInput = Omit< + ReviewDraftHistoryEntry, + 'updatedAt' | 'generation' +>; + +export interface ChangeReviewDraftHistoryPort { + load(scope: ChangeReviewDraftHistoryScope): Promise; + saveEntry(input: { + scope: ChangeReviewDraftHistoryScope; + entry: ChangeReviewDraftHistoryEntryInput; + expectedVersion: ChangeReviewDraftHistoryVersion; + }): Promise; + clear(input: { + scope: ChangeReviewDraftHistoryScope; + filePath?: string; + expectedVersion?: ChangeReviewDraftHistoryVersion; + }): Promise; + checkConflict(input: { + reviewScope: ReviewFileScope; + filePath: string; + expectedModified: string; + }): Promise; + replaceConflictCandidate(input: { + scope: ChangeReviewDraftHistoryScope; + expectedEntry: ChangeReviewDraftHistoryEntryInput; + replacementEntry: ChangeReviewDraftHistoryEntryInput; + observedVersion: ChangeReviewDraftHistoryVersion; + }): Promise; + resolveConflictCandidate(input: { + scope: ChangeReviewDraftHistoryScope; + candidateId: string; + resolution: ReviewConflictResolution; + observedVersion: ChangeReviewDraftHistoryVersion; + }): Promise; +} diff --git a/src/features/change-review/renderer/ports/changeReviewFileDecisionPorts.ts b/src/features/change-review/renderer/ports/changeReviewFileDecisionPorts.ts new file mode 100644 index 0000000000..ce9ca95901 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewFileDecisionPorts.ts @@ -0,0 +1,133 @@ +import type { ReviewUndoActionInput } from '../utils/changeReviewActionHistory'; +import type { + ApplyReviewResult, + ConflictCheckResult, + ExecuteReviewMutationRequest, + ExecuteReviewMutationResult, + FileChangeSummary, + FileChangeWithContent, + HunkDecision, + ReviewDecisionSnapshot, + ReviewMutationDiskPostimage, + ReviewRedoAction, + ReviewRenameRecoveryExpectation, + ReviewUndoAction, +} from '@shared/types'; + +export interface ChangeReviewFileDecisionPersistenceScope { + teamName: string; + scopeKey: string; + scopeToken: string; +} + +export interface ChangeReviewFileDecisionStateSnapshot { + fileContents: Record; + reviewExternalChangesByFile: Record; + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile: Record>; + fileChunkCounts: Record; + decisionRevision: number; + changeSetEpoch: number; +} + +export interface ChangeReviewFileDecisionStatePort { + getSnapshot: () => ChangeReviewFileDecisionStateSnapshot; + acceptAllFile: (filePath: string) => boolean; + rejectAllFile: (filePath: string) => void; + applyRestoredDecisionState: (file: FileChangeSummary) => void; + restoreFileDecisions: (file: FileChangeSummary, snapshot: ReviewDecisionSnapshot) => void; + clearExternalChange: (filePath: string) => void; + invalidateResolvedFileContent: (filePath: string) => void; + reportError: (message: string | null) => void; +} + +export interface ChangeReviewFileDecisionHistoryPort { + pushUndoAction: (input: ReviewUndoActionInput) => ReviewUndoAction; + bindCommittedAction: ( + optimistic: ReviewUndoAction, + committed: ReviewUndoAction | undefined + ) => boolean; + discardLatestAction: (action: ReviewUndoAction) => boolean; + getUndoHistory: () => ReviewUndoAction[]; + getRedoHistory: () => ReviewRedoAction[]; + publishUndoHistory: () => void; +} + +export interface ChangeReviewFileDecisionCommandPort { + checkConflict: ( + scope: ExecuteReviewMutationRequest['scope'], + filePath: string, + expectedContent: string + ) => Promise; + executeMutation: (request: ExecuteReviewMutationRequest) => Promise; + applySingleFileDecision: ( + teamName: string, + filePath: string, + taskId?: string, + memberName?: string + ) => Promise; + quiescePersistence: (scope: ChangeReviewFileDecisionPersistenceScope) => Promise; + recordDecisionRevision: ( + scope: ChangeReviewFileDecisionPersistenceScope, + revision: number + ) => void; + fetchFileContent: (teamName: string, memberName: string | undefined, filePath: string) => void; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export interface ChangeReviewFileDecisionEditorPort { + getCurrentContent: (filePath: string) => string | null; + scheduleEditorSync: (callback: () => void) => void; + acceptAllEditorChunks: (filePath: string) => void; + rejectAllEditorChunks: (filePath: string) => void; + rollbackEditorContent: (filePath: string, content: string) => void; +} + +export interface ChangeReviewFileDecisionStatusPort { + beginFileMutation: (filePath: string) => void; + finishFileMutation: (filePath: string) => void; + incrementDiscardCounter: (filePath: string) => void; +} + +export interface ChangeReviewFileDecisionWriteEvidencePort { + markExpectedWrite: (filePath: string, expectedContent: string | null) => void; + markCommittedPostimages: (postimages: readonly ReviewMutationDiskPostimage[] | undefined) => void; +} + +export interface ChangeReviewFileDecisionPolicy { + getHunkCount: (file: FileChangeSummary, state: ChangeReviewFileDecisionStateSnapshot) => number; + getFileDecision: ( + file: FileChangeSummary, + state: ChangeReviewFileDecisionStateSnapshot + ) => HunkDecision | undefined; + resolveModifiedContent: ( + file: FileChangeSummary, + content: FileChangeWithContent | null + ) => string | null; + resolveFileIsNew: (file: FileChangeSummary, content: FileChangeWithContent | null) => boolean; + isExpectedDeletion: (file: FileChangeSummary) => boolean; + isAcceptDisabled: ( + file: FileChangeSummary, + content: FileChangeWithContent, + fileDecision: HunkDecision | undefined + ) => boolean; + isRejectable: (file: FileChangeSummary, content: FileChangeWithContent | null) => boolean; + hasFileRejections: ( + file: FileChangeSummary, + hunkCount: number, + decisions: ReviewDecisionSnapshot + ) => boolean; + isFileFullyRejected: ( + file: FileChangeSummary, + hunkCount: number, + decisions: ReviewDecisionSnapshot + ) => boolean; + shouldDeleteWhenUndoingReject: ( + file: FileChangeSummary, + hunkCount: number, + decisions: ReviewDecisionSnapshot + ) => boolean; + hasUnresolvedExternalChange: (filePath: string, changes: Record) => boolean; + getRenameRecoveryExpectation: (file: FileChangeSummary) => ReviewRenameRecoveryExpectation | null; +} diff --git a/src/features/change-review/renderer/ports/changeReviewFileDraftPorts.ts b/src/features/change-review/renderer/ports/changeReviewFileDraftPorts.ts new file mode 100644 index 0000000000..64c0709f5c --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewFileDraftPorts.ts @@ -0,0 +1,109 @@ +import type { + ReviewDraftHistoryEntry, + ReviewSerializedEditorState, +} from '@features/change-review-history/contracts'; +import type { + ExecuteReviewMutationResult, + FileChangeSummary, + HunkDecision, + ReviewFileScope, + ReviewPersistedStateSnapshot, + ReviewRedoAction, + ReviewUndoAction, +} from '@shared/types'; + +export interface ChangeReviewFileDraftPersistenceScope { + teamName: string; + scopeKey: string; + scopeToken: string; +} + +export interface ChangeReviewFileDraftStateSnapshot { + activeFiles: readonly FileChangeSummary[]; + editedContents: Partial>; + reviewExternalChangesByFile: Record; + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile: Record>; + decisionRevision: number; + changeSetEpoch: number; +} + +export interface ChangeReviewFileDraftStatePort { + getSnapshot: () => ChangeReviewFileDraftStateSnapshot; + readExternalChange: (filePath: string) => object | undefined; + updateEditedContent: (filePath: string, content: string) => void; + discardFileEdits: (filePath: string) => void; + clearExternalChange: (filePath: string, observedChange: object) => boolean; + reloadFileFromDisk: (filePath: string) => void; + applyReloadedReviewState: (state: ReviewPersistedStateSnapshot) => void; + reportError: (message: string | null) => void; +} + +export interface ChangeReviewFileDraftActionHistoryPort { + clearForFile: (filePath: string) => void; + getUndoHistory: () => ReviewUndoAction[]; + getRedoHistory: () => ReviewRedoAction[]; + replaceHistories: (undoHistory: ReviewUndoAction[], redoHistory: ReviewRedoAction[]) => void; +} + +export interface ChangeReviewFileDraftHistoryPort { + getEntry: (filePath: string) => ReviewDraftHistoryEntry | undefined; + hasBaseline: (filePath: string) => boolean; + getBaseline: (filePath: string) => string | null | undefined; + setBaseline: (filePath: string, baseline: string | null) => void; + deleteBaseline: (filePath: string) => void; + unsuppressFile: (filePath: string) => void; + publishCheckpoint: ( + filePath: string, + editorState: ReviewSerializedEditorState, + diskBaseline: string | null + ) => void; + flushWrites: () => Promise; + clearFile: (filePath: string) => Promise; +} + +export type ChangeReviewSaveEditedFileResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; + +export interface CommitChangeReviewExternalReloadInput { + reviewScope: ReviewFileScope; + persistenceScope: ChangeReviewFileDraftPersistenceScope; + filePath: string; + persistedState: ReviewPersistedStateSnapshot; + expectedDecisionRevision: number; +} + +export interface ChangeReviewFileDraftCommandPort { + saveEditedFile: ( + filePath: string, + reviewScope: ReviewFileScope, + expectedCurrentContent: string | null + ) => Promise; + checkConflict: ( + reviewScope: ReviewFileScope, + filePath: string, + expectedContent: string + ) => Promise<{ + hasConflict: boolean; + conflictContent: string | null; + currentContent: string; + }>; + commitExternalReload: ( + input: CommitChangeReviewExternalReloadInput + ) => Promise; + quiescePersistence: (scope: ChangeReviewFileDraftPersistenceScope) => Promise; + recordDecisionRevision: (scope: ChangeReviewFileDraftPersistenceScope, revision: number) => void; + fetchFileContent: (teamName: string, memberName: string | undefined, filePath: string) => void; +} + +export interface ChangeReviewFileDraftStatusPort { + beginFileMutation: (filePath: string) => void; + finishFileMutation: (filePath: string) => void; + incrementDiscardCounter: (filePath: string) => void; +} + +export interface ChangeReviewFileDraftWriteEvidencePort { + markExpectedWrite: (filePath: string, expectedContent: string | null) => void; +} diff --git a/src/features/change-review/renderer/ports/changeReviewHistoryMutationPorts.ts b/src/features/change-review/renderer/ports/changeReviewHistoryMutationPorts.ts new file mode 100644 index 0000000000..36d70fa050 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewHistoryMutationPorts.ts @@ -0,0 +1,65 @@ +import type { + ExecuteReviewMutationRequest, + ExecuteReviewMutationResult, + FileChangeSummary, + HunkDecision, + RestoreReviewHistoryRequest, + RestoreReviewHistoryResult, + RetryReviewMutationRecoveryRequest, + RetryReviewMutationRecoveryResult, + ReviewDecisionPersistenceScope, + ReviewFileScope, + ReviewMutationDiskPostimage, + ReviewPersistedStateSnapshot, + ReviewUndoAction, +} from '@shared/types'; + +export interface ChangeReviewHistoryStateSnapshot { + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile: Record>; + decisionRevision: number; +} + +export interface ChangeReviewHistoryMutationCommandPort { + executeMutation(request: ExecuteReviewMutationRequest): Promise; + restoreHistory(request: RestoreReviewHistoryRequest): Promise; + retryRecovery( + request: RetryReviewMutationRecoveryRequest + ): Promise; +} + +export interface ChangeReviewHistoryMutationStatePort { + getSnapshot(): ChangeReviewHistoryStateSnapshot; + quiesceDecisionPersistence(scope: ChangeReviewHistoryPersistenceScope): Promise; + recordDecisionRevision(scope: ChangeReviewHistoryPersistenceScope, revision: number): void; + applyDecisionState(state: { + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + }): void; + applyPersistedState(state: ReviewPersistedStateSnapshot, applyError: string | null): void; + reportError(message: string): void; + clearExternalChange(filePath: string): void; + invalidateResolvedFileContent(filePath: string): void; +} + +export interface ChangeReviewHistoryPersistenceScope extends ReviewDecisionPersistenceScope { + teamName: string; +} + +export interface ChangeReviewHistoryMutationViewPort { + addMissingFile(file: FileChangeSummary, index: number | undefined, content: string): void; + fetchFileContent(teamName: string, memberName: string | undefined, filePath: string): void; + incrementDiscardCounters(filePaths: readonly string[]): void; + navigateToAction(action: ReviewUndoAction): void; + markExpectedWrite(filePath: string, expectedContent: string | null): void; + clearExpectedWrite(filePath: string): void; + markCommittedPostimages(postimages: readonly ReviewMutationDiskPostimage[] | undefined): void; + setMutationInFlight(value: boolean): void; +} + +export interface ChangeReviewHistoryMutationScope { + review: ReviewFileScope; + persistence: ChangeReviewHistoryPersistenceScope; +} diff --git a/src/features/change-review/renderer/ports/changeReviewHunkDecisionPorts.ts b/src/features/change-review/renderer/ports/changeReviewHunkDecisionPorts.ts new file mode 100644 index 0000000000..dfb68c1c68 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewHunkDecisionPorts.ts @@ -0,0 +1,94 @@ +import type { ReviewUndoActionInput } from '../utils/changeReviewActionHistory'; +import type { ReviewOperationScopeToken } from '../utils/reviewOperationGeneration'; +import type { + ApplyReviewResult, + FileChangeSummary, + FileChangeWithContent, + HunkDecision, + ReviewDecisionSnapshot, + ReviewMutationDiskPostimage, + ReviewRenameRecoveryExpectation, + ReviewUndoAction, +} from '@shared/types'; + +export interface ChangeReviewHunkDecisionStateSnapshot extends ReviewDecisionSnapshot { + fileChunkCounts: Record; + changeSetEpoch: number; +} + +export interface ChangeReviewHunkDecisionStatePort { + getSnapshot: () => ChangeReviewHunkDecisionStateSnapshot; + setDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => number; + clearDecision: (filePath: string, originalIndex: number) => void; + invalidateResolvedFileContent: (filePath: string) => void; +} + +export type ChangeReviewHunkDecisionApplyOutcome = + | { status: 'applied'; result: ApplyReviewResult } + | { status: 'failed'; result: ApplyReviewResult | null }; + +export interface ChangeReviewHunkDecisionCommandPort { + applySingleFileDecision: ( + teamName: string, + filePath: string, + taskId: string | undefined, + memberName: string | undefined + ) => Promise; + fetchFileContent: (teamName: string, memberName: string | undefined, filePath: string) => void; + readCurrentDiskContent: (filePath: string, fallback: string) => Promise; +} + +export interface ChangeReviewHunkDecisionEditorPort { + guardIgnoredMutation: (filePath: string) => void; + rejectChunk: (filePath: string) => { beforeContent: string; afterContent: string } | null; + rollbackContent: (filePath: string, content: string) => void; +} + +export interface ChangeReviewHunkDecisionStatusPort { + beginFileMutation: (filePath: string) => void; + finishFileMutation: (filePath: string) => void; + incrementDiscardCounter: (filePath: string) => void; +} + +export interface ChangeReviewHunkDecisionWriteEvidencePort { + markExpectedWrite: (filePath: string, expectedContent: string | null) => void; + clearExpectedWrite: (filePath: string) => void; + markCommittedPostimages: (postimages: readonly ReviewMutationDiskPostimage[] | undefined) => void; +} + +export interface ChangeReviewHunkDecisionHistoryPort { + pushUndoAction: (input: ReviewUndoActionInput) => ReviewUndoAction; + bindCommittedAction: ( + optimistic: ReviewUndoAction, + committed: ReviewUndoAction | undefined + ) => boolean; + discardLatestAction: (action: ReviewUndoAction) => boolean; + publishUndoHistory: () => void; +} + +export interface ChangeReviewHunkDecisionPolicy { + getHunkCount: ( + file: FileChangeSummary, + snapshot: ChangeReviewHunkDecisionStateSnapshot + ) => number; + resolveFileIsNew: ( + file: FileChangeSummary, + content: FileChangeWithContent | null | undefined + ) => boolean; + shouldDeleteWhenUndoingReject: ( + file: FileChangeSummary | undefined, + hunkCount: number, + decisions: ReviewDecisionSnapshot + ) => boolean; + shouldCreateWhenUndoingReject: ( + file: FileChangeSummary | undefined, + isNewFile: boolean, + hunkCount: number, + decisions: ReviewDecisionSnapshot + ) => boolean; + getRenameRecoveryExpectation: ( + file: FileChangeSummary | undefined + ) => ReviewRenameRecoveryExpectation | null; +} + +export type CaptureChangeReviewHunkOperationScope = () => ReviewOperationScopeToken | null; diff --git a/src/features/change-review/renderer/ports/changeReviewLifecyclePorts.ts b/src/features/change-review/renderer/ports/changeReviewLifecyclePorts.ts new file mode 100644 index 0000000000..f719ee076e --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewLifecyclePorts.ts @@ -0,0 +1,22 @@ +export interface ChangeReviewLifecycleOwnerRegistration { + hostId: string; + sessionId: string; + tabId?: string; + requestClose: () => Promise; + focus?: () => void; +} + +export interface ChangeReviewLifecycleOwnerRegistrationResult { + accepted: boolean; + unregister: () => void; +} + +export type RegisterChangeReviewLifecycleOwner = ( + owner: ChangeReviewLifecycleOwnerRegistration +) => ChangeReviewLifecycleOwnerRegistrationResult; + +export type RegisterChangeReviewAppCloseParticipant = ( + participantId: string, + flush: AppCloseParticipant +) => () => void; +import type { AppCloseParticipant } from '@features/app-close-coordination/renderer'; diff --git a/src/features/change-review/renderer/ports/changeReviewMutationSafetyPorts.ts b/src/features/change-review/renderer/ports/changeReviewMutationSafetyPorts.ts new file mode 100644 index 0000000000..de5fb2cf88 --- /dev/null +++ b/src/features/change-review/renderer/ports/changeReviewMutationSafetyPorts.ts @@ -0,0 +1,34 @@ +import type { ReviewDecisionHydrationStatus } from '../utils/changeReviewScope'; + +export interface ChangeReviewOperationStateSnapshot { + applying: boolean; + decisionHydrationScopeKey: string | null; + decisionHydrationStatus: ReviewDecisionHydrationStatus; +} + +export interface ChangeReviewOperationStatePort { + getSnapshot: () => ChangeReviewOperationStateSnapshot; + reportError: (message: string) => void; +} + +export type ChangeReviewExternalChangeType = 'change' | 'add' | 'unlink'; + +export interface ChangeReviewExternalChangeStateSnapshot { + activeChangeSet: { files: readonly { filePath: string }[] } | null; + editedContents: Readonly>; + reviewExternalChangesByFile: Readonly>; +} + +export interface ChangeReviewExternalChangeStatePort { + getSnapshot: () => ChangeReviewExternalChangeStateSnapshot; + restoreDraft: (filePath: string, content: string) => void; + markExternalChange: (filePath: string, changeType: ChangeReviewExternalChangeType) => void; + reportError: (message: string) => void; +} + +export interface ChangeReviewExternalChangePolicy { + hasUnresolvedExternalChange: ( + filePath: string, + changes: Readonly> + ) => boolean; +} diff --git a/src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx b/src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx new file mode 100644 index 0000000000..9bdd4aef29 --- /dev/null +++ b/src/features/change-review/renderer/ui/ChangeReviewConflictNotices.tsx @@ -0,0 +1,145 @@ +import React from 'react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@renderer/components/ui/alert-dialog'; +import { AlertTriangle } from 'lucide-react'; + +import { + describeReviewConflictCandidate, + describeReviewConflictDiscard, +} from '../utils/changeReviewConflicts'; + +import type { ReviewConflictCandidateSelection } from '../utils/changeReviewConflicts'; + +interface ChangeReviewConflictDiscardDialogProps { + pendingDiscard: ReviewConflictCandidateSelection | null; + resolvingCandidateId: string | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => Promise; +} + +export const ChangeReviewConflictDiscardDialog = ({ + pendingDiscard, + resolvingCandidateId, + onOpenChange, + onConfirm, +}: ChangeReviewConflictDiscardDialogProps): React.JSX.Element => ( + + + + Discard this recovery branch? + + {describeReviewConflictDiscard(pendingDiscard)} Your current branch stays saved. The + selected recovery copy will be permanently deleted and cannot be restored later. + + + + Cancel + void onConfirm()} + > + Discard recovery branch + + + + +); + +interface ChangeReviewConflictNoticesProps { + loadError: string | null; + refreshPending: boolean; + activeCandidate: ReviewConflictCandidateSelection | null; + activeCandidateRecoverable: boolean; + candidateCount: number; + resolvingCandidateId: string | null; + onRetry: () => Promise; + onRequestDiscard: (candidate: ReviewConflictCandidateSelection) => void; + onRecover: () => Promise; +} + +export const ChangeReviewConflictNotices = ({ + loadError, + refreshPending, + activeCandidate, + activeCandidateRecoverable, + candidateCount, + resolvingCandidateId, + onRetry, + onRequestDiscard, + onRecover, +}: ChangeReviewConflictNoticesProps): React.JSX.Element => ( + <> + {loadError && ( +
+ +
+ Recovery copies could not be verified. Review actions stay locked to prevent data loss. +
+ +
+ )} + + {activeCandidate && ( +
+ +
+
A conflicting recovery branch is safe on disk
+
+ {describeReviewConflictCandidate(activeCandidate)} + {candidateCount > 1 + ? ` ${candidateCount - 1} more recovery ${candidateCount === 2 ? 'copy is' : 'copies are'} queued.` + : ''} + {activeCandidateRecoverable + ? ' Switching branches first preserves the current branch as another recovery copy.' + : ' Review actions remain locked until this incompatible copy is explicitly discarded.'} +
+
+ + +
+ )} + +); diff --git a/src/features/change-review/renderer/ui/ChangeReviewSidebar.tsx b/src/features/change-review/renderer/ui/ChangeReviewSidebar.tsx new file mode 100644 index 0000000000..db84e6d70b --- /dev/null +++ b/src/features/change-review/renderer/ui/ChangeReviewSidebar.tsx @@ -0,0 +1,88 @@ +import React from 'react'; + +import { useAppTranslation } from '@features/localization/renderer'; +import { cn } from '@renderer/lib/utils'; +import { ChevronDown, Clock } from 'lucide-react'; + +import { FileEditTimeline } from './FileEditTimeline'; +import { ReviewFileTree } from './ReviewFileTree'; + +import type { + ChangeReviewFileTreeDecisionState, + ChangeReviewPathChangeLabels, +} from './ReviewFileTree'; +import type { + FileChangeSummary, + FileEditTimeline as FileEditTimelineType, +} from '@shared/types/review'; + +export interface ChangeReviewSidebarProps { + files: FileChangeSummary[]; + pathChangeLabels: ChangeReviewPathChangeLabels; + decisionState: ChangeReviewFileTreeDecisionState; + activeFilePath: string | null; + viewedSet: Set; + onSelectFile: (filePath: string) => void; + timeline: FileEditTimelineType | null; + timelineOpen: boolean; + onToggleTimeline: () => void; + onTimelineEventClick: (snippetIndex: number) => void; + activeSnippetIndex: number; +} + +export const ChangeReviewSidebar = ({ + files, + pathChangeLabels, + decisionState, + activeFilePath, + viewedSet, + onSelectFile, + timeline, + timelineOpen, + onToggleTimeline, + onTimelineEventClick, + activeSnippetIndex, +}: ChangeReviewSidebarProps): React.ReactElement => { + const { t } = useAppTranslation('team'); + + return ( +
+ + + {/* Edit Timeline for active file */} + {timeline && timeline.events.length > 0 && ( +
+ + {timelineOpen && ( + + )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/team/review/FileEditTimeline.tsx b/src/features/change-review/renderer/ui/FileEditTimeline.tsx similarity index 97% rename from src/renderer/components/team/review/FileEditTimeline.tsx rename to src/features/change-review/renderer/ui/FileEditTimeline.tsx index 06bfc266f3..481e0d70ea 100644 --- a/src/renderer/components/team/review/FileEditTimeline.tsx +++ b/src/features/change-review/renderer/ui/FileEditTimeline.tsx @@ -2,6 +2,7 @@ import { useAppTranslation } from '@features/localization/renderer'; import { cn } from '@renderer/lib/utils'; import type { FileEditTimeline as FileEditTimelineType } from '@shared/types/review'; +import type { JSX } from 'react'; interface FileEditTimelineProps { timeline: FileEditTimelineType; @@ -13,7 +14,7 @@ export const FileEditTimeline = ({ timeline, onEventClick, activeSnippetIndex, -}: FileEditTimelineProps) => { +}: FileEditTimelineProps): JSX.Element => { const { t } = useAppTranslation('team'); if (timeline.events.length === 0) { diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/features/change-review/renderer/ui/ReviewFileTree.tsx similarity index 94% rename from src/renderer/components/team/review/ReviewFileTree.tsx rename to src/features/change-review/renderer/ui/ReviewFileTree.tsx index 748d452f4a..40f3ed4292 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/features/change-review/renderer/ui/ReviewFileTree.tsx @@ -4,8 +4,6 @@ import { useAppTranslation } from '@features/localization/renderer'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { useStore } from '@renderer/store'; -import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice'; import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; import { @@ -20,24 +18,31 @@ import { X as XIcon, } from 'lucide-react'; +import { resolveChangeReviewFileHunkCount } from '../../core/domain/reviewHunkCountPolicy'; + import type { TreeNode } from '@renderer/utils/fileTreeBuilder'; import type { HunkDecision } from '@shared/types'; -import type { FileChangeWithContent } from '@shared/types'; import type { FileChangeSummary } from '@shared/types/review'; +export interface ChangeReviewFileTreeDecisionState { + hunkDecisions: Record; + fileDecisions: Record; + fileChunkCounts: Record; +} + +export type ChangeReviewPathChangeLabels = Record< + string, + | { kind: 'deleted' } + | { kind: 'copied' | 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string } +>; + interface ReviewFileTreeProps { files: FileChangeSummary[]; - fileContents?: Record; - pathChangeLabels?: Record< - string, - | { kind: 'deleted' } - | { kind: 'copied' | 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string } - >; + decisionState: ChangeReviewFileTreeDecisionState; + pathChangeLabels?: ChangeReviewPathChangeLabels; selectedFilePath: string | null; onSelectFile: (filePath: string) => void; viewedSet?: Set; - onMarkViewed?: (filePath: string) => void; - onUnmarkViewed?: (filePath: string) => void; activeFilePath?: string; } @@ -55,7 +60,11 @@ function getFileStatus( if (fileDec === 'accepted') return 'accepted'; if (fileDec === 'rejected') return 'rejected'; - const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); + const count = resolveChangeReviewFileHunkCount( + file.filePath, + file.snippets.length, + fileChunkCounts + ); if (count === 0) return 'pending'; const decisions: HunkDecision[] = []; @@ -277,6 +286,7 @@ function getAncestorFolderPaths(tree: TreeNode[], filePath: s export const ReviewFileTree = ({ files, + decisionState: { hunkDecisions, fileDecisions, fileChunkCounts }, pathChangeLabels, selectedFilePath, onSelectFile, @@ -284,9 +294,6 @@ export const ReviewFileTree = ({ activeFilePath, }: ReviewFileTreeProps): JSX.Element => { const { t } = useAppTranslation('team'); - const hunkDecisions = useStore((state) => state.hunkDecisions); - const fileDecisions = useStore((state) => state.fileDecisions); - const fileChunkCounts = useStore((state) => state.fileChunkCounts); const [query, setQuery] = useState(''); const [filterUnresolved, setFilterUnresolved] = useState(false); const [filterRejected, setFilterRejected] = useState(false); @@ -314,7 +321,11 @@ export const ReviewFileTree = ({ if (fileDecisions[reviewKey] === 'rejected' || fileDecisions[f.filePath] === 'rejected') { return true; } - const count = getFileHunkCount(f.filePath, f.snippets.length, fileChunkCounts); + const count = resolveChangeReviewFileHunkCount( + f.filePath, + f.snippets.length, + fileChunkCounts + ); for (let i = 0; i < count; i++) { if ( hunkDecisions[buildHunkDecisionKey(reviewKey, i)] === 'rejected' || diff --git a/src/features/change-review/renderer/ui/TaskChangesEmptyState.tsx b/src/features/change-review/renderer/ui/TaskChangesEmptyState.tsx new file mode 100644 index 0000000000..2daebc34cd --- /dev/null +++ b/src/features/change-review/renderer/ui/TaskChangesEmptyState.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { useAppTranslation } from '@features/localization/renderer'; +import { cn } from '@renderer/lib/utils'; +import { AlertTriangle, FileSearch, Info } from 'lucide-react'; + +import { buildTaskChangesEmptyStatePresentation } from '../view-models/changeReviewPresentation'; + +import type { TaskChangeSetV2 } from '@shared/types'; + +export interface TaskChangesEmptyStateProps { + changeSet: TaskChangeSetV2 | null; +} + +const ICONS = { + alert: AlertTriangle, + info: Info, + 'file-search': FileSearch, +} as const; + +export const TaskChangesEmptyState = ({ + changeSet, +}: TaskChangesEmptyStateProps): React.ReactElement => { + const { t } = useAppTranslation('team'); + const presentation = buildTaskChangesEmptyStatePresentation(changeSet); + const Icon = ICONS[presentation.icon]; + const isAttention = presentation.tone === 'attention'; + + return ( +
+
+ +
{t(presentation.titleKey)}
+

{t(presentation.descriptionKey)}

+ {presentation.messages.length > 0 && ( +
+ {presentation.messages.map((message, index) => ( +
{message}
+ ))} +
+ )} +
+
+ ); +}; diff --git a/src/features/change-review/renderer/utils/changeReviewActionHistory.ts b/src/features/change-review/renderer/utils/changeReviewActionHistory.ts new file mode 100644 index 0000000000..5bc5f5be95 --- /dev/null +++ b/src/features/change-review/renderer/utils/changeReviewActionHistory.ts @@ -0,0 +1,75 @@ +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { ReviewRedoAction, ReviewUndoAction } from '@shared/types'; + +export type ReviewActionPersistenceStatus = 'saved' | 'saving' | 'error'; + +export function isReviewActionPersistenceBlocking(status: ReviewActionPersistenceStatus): boolean { + return status !== 'saved'; +} + +export type ReviewUndoActionInput = + | Omit, 'id' | 'createdAt'> + | Omit, 'id' | 'createdAt'> + | Omit, 'id' | 'createdAt'>; + +let reviewActionIdSequence = 0; + +export function createReviewUndoAction(input: ReviewUndoActionInput): ReviewUndoAction { + reviewActionIdSequence += 1; + const randomId = globalThis.crypto?.randomUUID?.(); + return { + ...input, + id: randomId ?? `${Date.now().toString(36)}-${reviewActionIdSequence.toString(36)}`, + createdAt: new Date().toISOString(), + } as ReviewUndoAction; +} + +export function appendOrderedReviewAction(stack: readonly T[], action: T): T[] { + return [...stack, action]; +} + +export function popOrderedReviewAction( + stack: readonly T[], + expected: T +): { stack: T[]; popped: boolean } { + if (stack.at(-1) !== expected) return { stack: [...stack], popped: false }; + return { stack: stack.slice(0, -1), popped: true }; +} + +export function replaceLatestReviewAction( + stack: readonly ReviewUndoAction[], + optimistic: ReviewUndoAction, + committed: ReviewUndoAction +): { stack: ReviewUndoAction[]; replaced: boolean } { + if (optimistic.id !== committed.id || stack.at(-1)?.id !== optimistic.id) { + return { stack: [...stack], replaced: false }; + } + return { stack: [...stack.slice(0, -1), committed], replaced: true }; +} + +export function filterReviewActionHistoryForFile(input: { + undoHistory: readonly ReviewUndoAction[]; + redoHistory: readonly ReviewRedoAction[]; + filePath: string; +}): { clearAll: boolean; undoHistory: ReviewUndoAction[]; redoHistory: ReviewRedoAction[] } { + if ( + input.undoHistory.some((action) => action.kind === 'bulk') || + input.redoHistory.some((entry) => entry.action.kind === 'bulk') + ) { + return { clearAll: true, undoHistory: [], redoHistory: [] }; + } + const normalizedPath = normalizePathForComparison(input.filePath); + const undoHistory = input.undoHistory.filter((action) => { + const actionPath = + action.kind === 'disk' + ? action.action.snapshot.filePath + : action.kind === 'hunk' + ? action.action.filePath + : null; + return actionPath === null || normalizePathForComparison(actionPath) !== normalizedPath; + }); + // Redo entries contain full-scope post-action snapshots. Retaining even an + // apparently unrelated entry could replay stale decisions for this file. + return { clearAll: false, undoHistory, redoHistory: [] }; +} diff --git a/src/features/change-review/renderer/utils/changeReviewConflicts.ts b/src/features/change-review/renderer/utils/changeReviewConflicts.ts new file mode 100644 index 0000000000..985941ebb6 --- /dev/null +++ b/src/features/change-review/renderer/utils/changeReviewConflicts.ts @@ -0,0 +1,46 @@ +import type { ReviewDraftHistoryConflictCandidateSummary } from '@features/change-review-history/contracts'; +import type { ReviewDecisionConflictCandidateSummary } from '@shared/types'; + +export const CHANGE_REVIEW_CONFLICT_LOAD_ERROR_PREFIX = 'Unable to load durable recovery copies:'; + +export type ReviewConflictCandidateSelection = + | { kind: 'decision'; value: ReviewDecisionConflictCandidateSummary } + | { kind: 'draft'; value: ReviewDraftHistoryConflictCandidateSummary }; + +export function selectLatestReviewConflictCandidate( + decisions: readonly ReviewDecisionConflictCandidateSummary[], + drafts: readonly ReviewDraftHistoryConflictCandidateSummary[] +): ReviewConflictCandidateSelection | null { + const decision = decisions[0]; + const draft = drafts[0]; + if (!decision) return draft ? { kind: 'draft', value: draft } : null; + if (!draft) return { kind: 'decision', value: decision }; + return Date.parse(decision.capturedAt) >= Date.parse(draft.capturedAt) + ? { kind: 'decision', value: decision } + : { kind: 'draft', value: draft }; +} + +export function describeReviewConflictCandidate( + selected: ReviewConflictCandidateSelection +): string { + if (selected.kind === 'decision') { + return selected.value.origin === 'prior-snapshot' + ? `An earlier review snapshot has a saved branch with ${selected.value.undoDepth} Undo and ${selected.value.redoDepth} Redo actions. It cannot be applied to this changed diff.` + : `Another window saved a different review branch. Local copy: ${selected.value.undoDepth} Undo and ${selected.value.redoDepth} Redo actions.`; + } + if (selected.value.recoverability === 'file-not-in-current-review') { + return `An earlier manual-edit branch targets ${selected.value.filePath}, which is not part of the current review.`; + } + return selected.value.entryRevision === null + ? `The recovery branch has no saved manual edits for ${selected.value.filePath}.` + : `Another window saved different manual edit history for ${selected.value.filePath}.`; +} + +export function describeReviewConflictDiscard( + selected: ReviewConflictCandidateSelection | null +): string { + if (!selected) return ''; + return selected.kind === 'decision' + ? `Captured ${new Date(selected.value.capturedAt).toLocaleString()} with ${selected.value.undoDepth} Undo and ${selected.value.redoDepth} Redo actions.` + : `Captured ${new Date(selected.value.capturedAt).toLocaleString()} for ${selected.value.filePath}.`; +} diff --git a/src/features/change-review/renderer/utils/changeReviewDialogLifecycle.ts b/src/features/change-review/renderer/utils/changeReviewDialogLifecycle.ts new file mode 100644 index 0000000000..b5db60f898 --- /dev/null +++ b/src/features/change-review/renderer/utils/changeReviewDialogLifecycle.ts @@ -0,0 +1,195 @@ +import type { ReviewActionPersistenceStatus } from './changeReviewActionHistory'; + +export interface ChangeReviewActionLockState { + applying: boolean; + fileApplyCount: number; + undoing: boolean; + closing: boolean; +} + +export interface ChangeReviewDraftWriteDiagnostics { + pendingWriteCount: number; + writeChainCount: number; + writeErrorCount: number; +} + +export interface ChangeReviewDecisionWriteDiagnostics { + pendingDecisionClear: boolean; + persistenceStatus: ReviewActionPersistenceStatus; +} + +export interface ChangeReviewCloseReadinessInput { + hydrationKey: string | null; + decisionHydrationScopeKey: string | null; + decisionHydrationStatus: 'idle' | 'loading' | 'loaded' | 'error'; + draftHydrationKey: string | null; + draftHydrationStatus: 'idle' | 'loading' | 'loaded' | 'error'; + editedContentCount: number; + hunkDecisionCount: number; + fileDecisionCount: number; + undoHistoryCount: number; + redoHistoryCount: number; + draftDiagnostics: ChangeReviewDraftWriteDiagnostics; + scopedDraftDiagnostics: ChangeReviewDraftWriteDiagnostics; + decisionDiagnostics: ChangeReviewDecisionWriteDiagnostics; + pendingApplyCleanupKey: string | null; + actionLockState: ChangeReviewActionLockState; +} + +export type ChangeReviewCloseReadiness = + | { disposition: 'flush' } + | { disposition: 'close-without-flush' } + | { disposition: 'block'; blocker: string }; + +export function shouldRequestReviewCloseForEscape(input: { + key: string; + defaultPrevented: boolean; + hasOpenModalLayer: boolean; +}): boolean { + return input.key === 'Escape' && !input.defaultPrevented && !input.hasOpenModalLayer; +} + +export function isReviewActionLocked(state: ChangeReviewActionLockState): boolean { + return state.applying || state.fileApplyCount > 0 || state.undoing || state.closing; +} + +export function getReviewCloseBlockReason(input: { + busy: boolean; + draftCount: number; +}): string | null { + if (input.busy) return 'Wait for the current review action to finish.'; + if (input.draftCount > 0) return 'Save or discard manual edits before closing Changes.'; + return null; +} + +interface LocalReviewStateInput { + editedContentCount: number; + hunkDecisionCount: number; + fileDecisionCount: number; + undoHistoryCount: number; + redoHistoryCount: number; + draftDiagnostics: ChangeReviewDraftWriteDiagnostics; + pendingApplyCleanup: boolean; + decisionDiagnostics: ChangeReviewDecisionWriteDiagnostics; +} + +function hasLocalReviewState(input: LocalReviewStateInput): boolean { + return ( + input.editedContentCount > 0 || + input.hunkDecisionCount > 0 || + input.fileDecisionCount > 0 || + input.undoHistoryCount > 0 || + input.redoHistoryCount > 0 || + input.draftDiagnostics.pendingWriteCount > 0 || + input.draftDiagnostics.writeChainCount > 0 || + input.draftDiagnostics.writeErrorCount > 0 || + input.pendingApplyCleanup || + input.decisionDiagnostics.pendingDecisionClear || + input.decisionDiagnostics.persistenceStatus !== 'saved' + ); +} + +export function hasUnscopedLocalReviewState(input: { + editedContentCount: number; + hunkDecisionCount: number; + fileDecisionCount: number; + undoHistoryCount: number; + redoHistoryCount: number; + pendingDraftWriteCount: number; + draftWriteChainCount: number; + draftWriteErrorCount: number; + pendingApplyCleanup: boolean; + pendingDecisionClear: boolean; + persistenceStatus: ReviewActionPersistenceStatus; +}): boolean { + return hasLocalReviewState({ + editedContentCount: input.editedContentCount, + hunkDecisionCount: input.hunkDecisionCount, + fileDecisionCount: input.fileDecisionCount, + undoHistoryCount: input.undoHistoryCount, + redoHistoryCount: input.redoHistoryCount, + draftDiagnostics: { + pendingWriteCount: input.pendingDraftWriteCount, + writeChainCount: input.draftWriteChainCount, + writeErrorCount: input.draftWriteErrorCount, + }, + pendingApplyCleanup: input.pendingApplyCleanup, + decisionDiagnostics: { + pendingDecisionClear: input.pendingDecisionClear, + persistenceStatus: input.persistenceStatus, + }, + }); +} + +function hasLocalReviewBranch(input: ChangeReviewCloseReadinessInput): boolean { + return hasLocalReviewState({ + editedContentCount: input.editedContentCount, + hunkDecisionCount: input.hunkDecisionCount, + fileDecisionCount: input.fileDecisionCount, + undoHistoryCount: input.undoHistoryCount, + redoHistoryCount: input.redoHistoryCount, + draftDiagnostics: input.scopedDraftDiagnostics, + pendingApplyCleanup: input.pendingApplyCleanupKey === input.hydrationKey, + decisionDiagnostics: input.decisionDiagnostics, + }); +} + +export function evaluateChangeReviewCloseReadiness( + input: ChangeReviewCloseReadinessInput +): ChangeReviewCloseReadiness { + const localStateRequiresScope = hasUnscopedLocalReviewState({ + editedContentCount: input.editedContentCount, + hunkDecisionCount: input.hunkDecisionCount, + fileDecisionCount: input.fileDecisionCount, + undoHistoryCount: input.undoHistoryCount, + redoHistoryCount: input.redoHistoryCount, + pendingDraftWriteCount: input.draftDiagnostics.pendingWriteCount, + draftWriteChainCount: input.draftDiagnostics.writeChainCount, + draftWriteErrorCount: input.draftDiagnostics.writeErrorCount, + pendingApplyCleanup: input.pendingApplyCleanupKey !== null, + pendingDecisionClear: input.decisionDiagnostics.pendingDecisionClear, + persistenceStatus: input.decisionDiagnostics.persistenceStatus, + }); + if (!input.hydrationKey && localStateRequiresScope) { + return { + disposition: 'block', + blocker: + 'Manual edit history lost its saved review scope. Keep Changes open and retry recovery.', + }; + } + + if (input.hydrationKey) { + const matchesCurrentHydration = input.decisionHydrationScopeKey === input.hydrationKey; + const matchesDraftHydration = input.draftHydrationKey === input.hydrationKey; + if ( + (matchesCurrentHydration && input.decisionHydrationStatus === 'error') || + (matchesDraftHydration && input.draftHydrationStatus === 'error') + ) { + if (hasLocalReviewBranch(input)) { + return { + disposition: 'block', + blocker: + 'Saved review state could not be reconciled with local changes. Retry recovery before closing Changes.', + }; + } + return { disposition: 'close-without-flush' }; + } + if ( + !matchesCurrentHydration || + input.decisionHydrationStatus !== 'loaded' || + !matchesDraftHydration || + input.draftHydrationStatus !== 'loaded' + ) { + return { + disposition: 'block', + blocker: 'Wait for saved review state to finish loading before closing Changes.', + }; + } + } + + const blockReason = getReviewCloseBlockReason({ + busy: isReviewActionLocked(input.actionLockState), + draftCount: 0, + }); + return blockReason ? { disposition: 'block', blocker: blockReason } : { disposition: 'flush' }; +} diff --git a/src/features/change-review/renderer/utils/changeReviewHistoryMutation.ts b/src/features/change-review/renderer/utils/changeReviewHistoryMutation.ts new file mode 100644 index 0000000000..53392cb892 --- /dev/null +++ b/src/features/change-review/renderer/utils/changeReviewHistoryMutation.ts @@ -0,0 +1,133 @@ +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { + FileChangeSummary, + HunkDecision, + RetryReviewMutationRecoveryResult, + ReviewDiskUndoSnapshot, + ReviewPersistedStateSnapshot, + ReviewRedoAction, + ReviewUndoAction, +} from '@shared/types'; + +function toCanonicalReviewValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(toCanonicalReviewValue); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, toCanonicalReviewValue(entry)]) + ); +} + +export function areReviewPersistedStatesEqual( + left: ReviewPersistedStateSnapshot, + right: ReviewPersistedStateSnapshot +): boolean { + return ( + JSON.stringify(toCanonicalReviewValue(left)) === JSON.stringify(toCanonicalReviewValue(right)) + ); +} + +export type ReviewHistoryRecoveryDisposition = + | 'retry-restore' + | 'apply-selected-restore' + | 'different-mutation-pending' + | 'synchronize-latest'; + +export function classifyReviewHistoryRecovery( + recovery: RetryReviewMutationRecoveryResult, + currentRevision: number, + plannedState: ReviewPersistedStateSnapshot +): ReviewHistoryRecoveryDisposition { + if (recovery.differentMutationPending) return 'different-mutation-pending'; + if (!recovery.recoveredMutation && recovery.decisionRevision === currentRevision) { + return 'retry-restore'; + } + if ( + recovery.expectedRestoreCompleted && + recovery.persistedState && + areReviewPersistedStatesEqual(recovery.persistedState, plannedState) + ) { + return 'apply-selected-restore'; + } + return 'synchronize-latest'; +} + +export function createReviewRedoAction( + action: ReviewUndoAction, + state: { + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile: Record>; + } +): ReviewRedoAction { + return { + action: structuredClone(action), + decisionSnapshot: { + hunkDecisions: { ...state.hunkDecisions }, + fileDecisions: { ...state.fileDecisions }, + }, + hunkContextHashesByFile: structuredClone(state.hunkContextHashesByFile), + }; +} + +export function getReviewDiskMutationExpectedContent( + snapshot: ReviewDiskUndoSnapshot, + direction: 'undo' | 'redo' +): string | null { + const restoreMode = + snapshot.restoreMode ?? (snapshot.renameExpectation ? 'restore-rejected-rename' : 'content'); + if (direction === 'undo') { + return restoreMode === 'delete-file' || restoreMode === 'reapply-rejected-rename' + ? null + : snapshot.beforeContent; + } + return restoreMode === 'create-file' || restoreMode === 'restore-rejected-rename' + ? null + : snapshot.afterContent; +} + +export function getReviewActionAffectedPaths( + action: ReviewUndoAction, + files: readonly FileChangeSummary[] +): string[] { + if (action.kind === 'bulk') { + return action.descriptor && 'filePath' in action.descriptor + ? [action.descriptor.filePath] + : files.map((file) => file.filePath); + } + return [action.kind === 'disk' ? action.action.snapshot.filePath : action.action.filePath]; +} + +export function getReviewActionsAffectedPaths( + actions: readonly ReviewUndoAction[], + files: readonly FileChangeSummary[] +): string[] { + const seenPaths = new Set(); + const affectedPaths: string[] = []; + for (const action of actions) { + for (const filePath of getReviewActionAffectedPaths(action, files)) { + const normalizedPath = normalizePathForComparison(filePath); + if (seenPaths.has(normalizedPath)) continue; + seenPaths.add(normalizedPath); + const currentFile = files.find( + (file) => normalizePathForComparison(file.filePath) === normalizedPath + ); + affectedPaths.push(currentFile?.filePath ?? filePath); + } + } + return affectedPaths; +} + +export function resolveReviewFile( + files: readonly FileChangeSummary[], + filePath: string +): FileChangeSummary | null { + const normalizedPath = normalizePathForComparison(filePath); + return ( + files.find((candidate) => normalizePathForComparison(candidate.filePath) === normalizedPath) ?? + null + ); +} diff --git a/src/features/change-review/renderer/utils/changeReviewScope.ts b/src/features/change-review/renderer/utils/changeReviewScope.ts new file mode 100644 index 0000000000..01fb99ff9a --- /dev/null +++ b/src/features/change-review/renderer/utils/changeReviewScope.ts @@ -0,0 +1,121 @@ +import { + buildReviewDecisionScopeToken, + reviewChangeSetMatchesScope, +} from '@renderer/utils/reviewDecisionScope'; +import { buildTaskChangeSignature } from '@renderer/utils/taskChangeRequest'; + +import type { ReviewChangeSetLike } from '@renderer/utils/reviewDecisionScope'; +import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import type { ReviewFileScope } from '@shared/types'; + +export type ReviewDecisionHydrationStatus = 'idle' | 'loading' | 'loaded' | 'error'; +export type ReviewDecisionHydrationGuard = 'not-required' | 'pending' | 'ready' | 'error'; + +export interface ReviewDraftHistoryHydrationState { + key: string | null; + status: ReviewDecisionHydrationStatus; +} + +export interface ChangeReviewScopeProjection { + scopeKey: string; + decisionScopeKey: string; + decisionScopeToken: string | null; + decisionHydrationKey: string | null; + decisionHydrationReady: boolean; + decisionHydrationFailed: boolean; + decisionHydrationPending: boolean; + draftHistoryHydrationReady: boolean; + draftHistoryHydrationPending: boolean; + draftHistoryHydrationFailed: boolean; + reviewScope: ReviewFileScope; + collapseStorageKey: string; +} + +export interface BuildChangeReviewScopeProjectionInput { + teamName: string; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + taskChangeRequestOptions?: TaskChangeRequestOptions; + activeChangeSet: ReviewChangeSetLike | null | undefined; + decisionHydrationScopeKey: string | null; + decisionHydrationStatus: ReviewDecisionHydrationStatus; + draftHistoryHydration: ReviewDraftHistoryHydrationState; +} + +export function getReviewDecisionHydrationGuard(input: { + expectedScopeKey: string | null; + hydratedScopeKey: string | null; + status: ReviewDecisionHydrationStatus; +}): ReviewDecisionHydrationGuard { + if (input.expectedScopeKey === null) return 'not-required'; + if (input.hydratedScopeKey !== input.expectedScopeKey) return 'pending'; + if (input.status === 'loaded') return 'ready'; + if (input.status === 'error') return 'error'; + return 'pending'; +} + +export function buildChangeReviewScopeProjection( + input: BuildChangeReviewScopeProjectionInput +): ChangeReviewScopeProjection { + const scopeTarget = input.mode === 'task' ? (input.taskId ?? '') : (input.memberName ?? ''); + const scopeKey = `${input.mode}:${scopeTarget}`; + const decisionScopeKey = `${input.mode}-${scopeTarget}`; + const changeSetMatchesScope = reviewChangeSetMatchesScope(input.activeChangeSet, { + teamName: input.teamName, + taskId: input.mode === 'task' ? input.taskId : undefined, + memberName: input.mode === 'agent' ? input.memberName : undefined, + }); + const decisionScopeToken = changeSetMatchesScope + ? buildReviewDecisionScopeToken({ + mode: input.mode, + taskId: input.taskId, + memberName: input.memberName, + requestSignature: + input.mode === 'task' + ? buildTaskChangeSignature(input.taskChangeRequestOptions ?? {}) + : undefined, + changeSet: input.activeChangeSet, + }) + : null; + const decisionHydrationKey = decisionScopeToken + ? `${input.teamName}:${decisionScopeKey}:${decisionScopeToken}` + : null; + const decisionHydrationGuard = getReviewDecisionHydrationGuard({ + expectedScopeKey: decisionHydrationKey, + hydratedScopeKey: input.decisionHydrationScopeKey, + status: input.decisionHydrationStatus, + }); + const draftHistoryHydrationReady = + decisionHydrationKey === null || + (input.draftHistoryHydration.key === decisionHydrationKey && + input.draftHistoryHydration.status === 'loaded'); + const draftHistoryHydrationPending = + decisionHydrationKey !== null && + (input.draftHistoryHydration.key !== decisionHydrationKey || + input.draftHistoryHydration.status === 'idle' || + input.draftHistoryHydration.status === 'loading'); + const draftHistoryHydrationFailed = + decisionHydrationKey !== null && + input.draftHistoryHydration.key === decisionHydrationKey && + input.draftHistoryHydration.status === 'error'; + + return { + scopeKey, + decisionScopeKey, + decisionScopeToken, + decisionHydrationKey, + decisionHydrationReady: decisionHydrationGuard === 'ready', + decisionHydrationFailed: decisionHydrationGuard === 'error', + decisionHydrationPending: decisionHydrationGuard === 'pending', + draftHistoryHydrationReady, + draftHistoryHydrationPending, + draftHistoryHydrationFailed, + reviewScope: { + teamName: input.teamName, + taskId: input.taskId, + memberName: input.memberName, + }, + collapseStorageKey: `review:collapsed:${input.teamName}:${decisionScopeKey}`, + }; +} diff --git a/src/features/change-review/renderer/utils/changeReviewWriteEvidence.ts b/src/features/change-review/renderer/utils/changeReviewWriteEvidence.ts new file mode 100644 index 0000000000..b51709c48e --- /dev/null +++ b/src/features/change-review/renderer/utils/changeReviewWriteEvidence.ts @@ -0,0 +1,10 @@ +import type { ReviewMutationDiskPostimage } from '@shared/types'; + +export function markChangeReviewMutationDiskPostimages( + postimages: readonly ReviewMutationDiskPostimage[] | undefined, + markExpectedWrite: (filePath: string, expectedContent: string | null) => void +): void { + for (const postimage of postimages ?? []) { + markExpectedWrite(postimage.filePath, postimage.content); + } +} diff --git a/src/features/change-review/renderer/utils/reviewOperationGeneration.ts b/src/features/change-review/renderer/utils/reviewOperationGeneration.ts new file mode 100644 index 0000000000..d0925e6c84 --- /dev/null +++ b/src/features/change-review/renderer/utils/reviewOperationGeneration.ts @@ -0,0 +1,16 @@ +export interface ReviewOperationScopeToken { + readonly hydrationKey: string; + readonly generation: symbol; +} + +/** Object identity prevents an A -> B -> A scope transition from reviving stale work. */ +export function createReviewOperationScopeToken(hydrationKey: string): ReviewOperationScopeToken { + return Object.freeze({ hydrationKey, generation: Symbol(hydrationKey) }); +} + +export function isReviewOperationScopeCurrent( + current: ReviewOperationScopeToken | null, + operation: ReviewOperationScopeToken | null +): operation is ReviewOperationScopeToken { + return current !== null && current === operation; +} diff --git a/src/features/change-review/renderer/view-models/changeReviewPresentation.ts b/src/features/change-review/renderer/view-models/changeReviewPresentation.ts new file mode 100644 index 0000000000..24f5e1c407 --- /dev/null +++ b/src/features/change-review/renderer/view-models/changeReviewPresentation.ts @@ -0,0 +1,228 @@ +import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder'; +import { displayMemberName } from '@renderer/utils/memberHelpers'; +import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; +import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability'; + +import { resolveChangeReviewFileHunkCount } from '../../core/domain/reviewHunkCountPolicy'; + +import type { + AgentChangeSet, + FileChangeSummary, + GlobalTask, + HunkDecision, + TaskChangeSet, + TaskChangeSetV2, +} from '@shared/types'; + +export type ChangeReviewChangeSet = AgentChangeSet | TaskChangeSet | TaskChangeSetV2; + +export interface GlobalDiffLoadingState { + totalFilesCount: number; + readyFilesCount: number; + loadingFilesCount: number; + snippetCount: number; + activeFileName: string | undefined; +} + +export interface ReviewStats { + pending: number; + accepted: number; + rejected: number; +} + +export interface ReviewChangeStats { + linesAdded: number; + linesRemoved: number; + filesChanged: number; +} + +export interface TaskChangesEmptyStatePresentation { + icon: 'alert' | 'info' | 'file-search'; + tone: 'attention' | 'neutral'; + titleKey: + | 'review.empty.noSafeDiff' + | 'review.continuousScroll.empty' + | 'review.empty.noFileChangesRecorded'; + descriptionKey: + | 'review.empty.noSafeDiffDescription' + | 'review.empty.noSafeDiffDiagnosticsDescription' + | 'review.empty.noFileEventsYet' + | 'review.empty.noFileEvents'; + messages: string[]; +} + +export function isTaskChangeSetV2(changeSet: { teamName: string }): changeSet is TaskChangeSetV2 { + return 'scope' in changeSet; +} + +export function sortChangeReviewFiles(files: readonly FileChangeSummary[]): FileChangeSummary[] { + return sortItemsAsTree([...files], (file) => file.relativePath); +} + +export function buildReviewFileLabels( + files: readonly FileChangeSummary[] +): ReadonlyMap { + return new Map( + files.map((file) => [ + normalizePathForComparison(file.filePath), + file.relativePath || file.filePath, + ]) + ); +} + +export function resolveReviewFileLabel( + labels: ReadonlyMap, + filePath: string +): string { + return labels.get(normalizePathForComparison(filePath)) ?? filePath; +} + +export function buildWatchedReviewFilePathsKey(files: readonly FileChangeSummary[]): string { + return files.map((file) => file.filePath).join('\0'); +} + +export function buildGlobalDiffLoadingState(input: { + files: readonly FileChangeSummary[]; + activeFilePath: string | null; + fileContentsLoading: Readonly>; + fileContents: Readonly>; +}): GlobalDiffLoadingState | null { + const loadingFiles = input.files.filter((file) => input.fileContentsLoading[file.filePath]); + if (loadingFiles.length === 0) return null; + + const preferredFile = + (input.activeFilePath + ? loadingFiles.find((file) => file.filePath === input.activeFilePath) + : undefined) ?? loadingFiles[0]; + + return { + totalFilesCount: input.files.length, + readyFilesCount: input.files.filter((file) => file.filePath in input.fileContents).length, + loadingFilesCount: loadingFiles.length, + snippetCount: loadingFiles.reduce( + (sum, file) => sum + file.snippets.filter((snippet) => !snippet.isError).length, + 0 + ), + activeFileName: preferredFile?.relativePath ?? preferredFile?.filePath, + }; +} + +export function buildReviewStats(input: { + changeSet: ChangeReviewChangeSet | null; + hunkDecisions: Readonly>; + fileDecisions: Readonly>; + fileChunkCounts: Readonly>; +}): ReviewStats { + if (!input.changeSet) return { pending: 0, accepted: 0, rejected: 0 }; + + const stats: ReviewStats = { pending: 0, accepted: 0, rejected: 0 }; + for (const file of input.changeSet.files) { + const reviewKey = getFileReviewKey(file); + const fileDecision = input.fileDecisions[reviewKey] ?? input.fileDecisions[file.filePath]; + const count = resolveChangeReviewFileHunkCount( + file.filePath, + file.snippets.length, + input.fileChunkCounts + ); + + if (fileDecision === 'accepted' || fileDecision === 'rejected') { + stats[fileDecision] += count; + continue; + } + + for (let index = 0; index < count; index += 1) { + const decision = + input.hunkDecisions[buildHunkDecisionKey(reviewKey, index)] ?? + input.hunkDecisions[`${file.filePath}:${index}`] ?? + 'pending'; + stats[decision] += 1; + } + } + return stats; +} + +export function buildReviewChangeStats(changeSet: ChangeReviewChangeSet | null): ReviewChangeStats { + if (!changeSet) return { linesAdded: 0, linesRemoved: 0, filesChanged: 0 }; + return { + linesAdded: changeSet.totalLinesAdded, + linesRemoved: changeSet.totalLinesRemoved, + filesChanged: changeSet.totalFiles, + }; +} + +export function toTaskChangeSetV2(changeSet: ChangeReviewChangeSet | null): TaskChangeSetV2 | null { + return changeSet && isTaskChangeSetV2(changeSet) ? changeSet : null; +} + +export function shouldShowTaskScopeBanner(input: { + mode: 'agent' | 'task'; + changeSet: TaskChangeSetV2 | null; +}): boolean { + return ( + input.mode === 'task' && + !!input.changeSet && + (input.changeSet.provenance?.sourceKind !== 'ledger' || + classifyTaskChangeReviewability(input.changeSet).reviewability === 'attention_required' || + input.changeSet.scope.confidence.tier > 1) + ); +} + +export function findActiveReviewFile( + changeSet: ChangeReviewChangeSet | null, + activeFilePath: string | null +): FileChangeSummary | null { + if (!changeSet || !activeFilePath) return null; + return changeSet.files.find((file) => file.filePath === activeFilePath) ?? null; +} + +export function buildChangeReviewTitle(input: { + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + globalTasks: readonly GlobalTask[]; +}): string { + if (input.mode === 'agent') { + return `Changes by ${displayMemberName(input.memberName ?? 'unknown')}`; + } + const task = input.taskId + ? input.globalTasks.find((candidate) => candidate.id === input.taskId) + : undefined; + const shortId = task?.displayId ?? input.taskId?.slice(0, 8) ?? '?'; + return task?.subject + ? `Changes for task #${shortId} - ${task.subject}` + : `Changes for task #${shortId}`; +} + +export function buildTaskChangesEmptyStatePresentation( + changeSet: TaskChangeSetV2 | null +): TaskChangesEmptyStatePresentation { + const status = changeSet ? classifyTaskChangeReviewability(changeSet) : null; + const diagnosticMessages = + status && status.diagnostics.length > 0 + ? status.diagnostics.map((diagnostic) => diagnostic.message) + : (changeSet?.warnings ?? []); + const messages = [...new Set(diagnosticMessages.filter((message) => message.trim().length > 0))]; + const isAttention = status?.reviewability === 'attention_required'; + const isDiagnosticOnly = status?.reviewability === 'diagnostic_only'; + const hasDiagnosticContext = messages.length > 0; + + return { + icon: isAttention ? 'alert' : hasDiagnosticContext ? 'info' : 'file-search', + tone: isAttention ? 'attention' : 'neutral', + titleKey: isDiagnosticOnly + ? 'review.empty.noSafeDiff' + : isAttention + ? 'review.continuousScroll.empty' + : 'review.empty.noFileChangesRecorded', + descriptionKey: + isAttention || isDiagnosticOnly + ? isDiagnosticOnly + ? 'review.empty.noSafeDiffDescription' + : 'review.empty.noSafeDiffDiagnosticsDescription' + : hasDiagnosticContext + ? 'review.empty.noFileEventsYet' + : 'review.empty.noFileEvents', + messages, + }; +} diff --git a/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts index f3078a3753..627e9ecb0e 100644 --- a/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts +++ b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts @@ -33,8 +33,13 @@ const CURRENT_MANIFEST_SCHEMA_VERSION = 1; const MAX_TARBALL_BYTES = 160 * 1024 * 1024; const MAX_UNPACKED_BYTES = 650 * 1024 * 1024; const FETCH_TIMEOUT_MS = 60_000; +const DOWNLOAD_TIMEOUT_MS = 180_000; +const NETWORK_MAX_ATTEMPTS = 3; +const NETWORK_RETRY_BASE_DELAY_MS = 1_000; const LATEST_VERSION_TIMEOUT_MS = 8_000; const VERSION_TIMEOUT_MS = 10_000; +const TRANSIENT_ERROR_CODE = + /^(?:ECONNRESET|EAI_AGAIN|ETIMEDOUT|UND_ERR_(?:BODY|CONNECT|HEADERS)_TIMEOUT)$/; interface NpmPackageMetadata { name?: string; @@ -234,7 +239,23 @@ export function getCodexRuntimePlatformCandidates( throw new Error(`Codex app install is not supported on ${platform}/${arch}`); } -async function fetchText(url: string, timeoutMs = FETCH_TIMEOUT_MS): Promise { +function isTransientNetworkError(error: unknown): boolean { + let current = error; + while (current && typeof current === 'object') { + const candidate = current as { name?: unknown; code?: unknown; cause?: unknown }; + if ( + candidate.name === 'AbortError' || + candidate.name === 'TimeoutError' || + (typeof candidate.code === 'string' && TRANSIENT_ERROR_CODE.test(candidate.code)) + ) { + return true; + } + current = candidate.cause; + } + return false; +} + +async function fetchText(url: string, timeoutMs = FETCH_TIMEOUT_MS, attempt = 1): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -243,6 +264,11 @@ async function fetchText(url: string, timeoutMs = FETCH_TIMEOUT_MS): Promise= NETWORK_MAX_ATTEMPTS || !isTransientNetworkError(error)) throw error; + const delayMs = NETWORK_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return fetchText(url, timeoutMs, attempt + 1); } finally { clearTimeout(timer); } @@ -288,10 +314,11 @@ export function verifyCodexRuntimePackageIntegrity(buffer: Buffer, integrity: st async function downloadTarball( url: string, - onProgress: (progress: CodexRuntimeInstallProgress) => void + onProgress: (progress: CodexRuntimeInstallProgress) => void, + attempt = 1 ): Promise { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok || !response.body) { @@ -330,6 +357,11 @@ async function downloadTarball( }); } return Buffer.concat(chunks, downloadedBytes); + } catch (error) { + if (attempt >= NETWORK_MAX_ATTEMPTS || !isTransientNetworkError(error)) throw error; + const delayMs = NETWORK_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return downloadTarball(url, onProgress, attempt + 1); } finally { clearTimeout(timer); } @@ -459,10 +491,7 @@ function parsePlatformVersion(value: string | undefined, fallback: string): stri return fallback; } const aliasMatch = /^npm:@openai\/codex@(.+)$/.exec(normalized); - if (aliasMatch?.[1]) { - return aliasMatch[1]; - } - return normalized.replace(/^[~^]/, ''); + return aliasMatch?.[1] ?? normalized.replace(/^[~^]/, ''); } async function writePackageFiles( diff --git a/src/features/coordination-backup/contracts/coordinationBackupContracts.ts b/src/features/coordination-backup/contracts/coordinationBackupContracts.ts new file mode 100644 index 0000000000..c91a54ac6a --- /dev/null +++ b/src/features/coordination-backup/contracts/coordinationBackupContracts.ts @@ -0,0 +1,421 @@ +import type { DeploymentId, TeamId, WorkspaceId } from '@shared/contracts/hosted/identifiers'; + +declare const backupRunIdBrand: unique symbol; +declare const sha256DigestBrand: unique symbol; + +export type BackupRunId = string & { readonly [backupRunIdBrand]: 'BackupRunId' }; +export type Sha256Digest = string & { readonly [sha256DigestBrand]: 'Sha256Digest' }; + +const BACKUP_RUN_ID_PATTERN = /^backup_[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; + +export function parseBackupRunId(value: unknown): BackupRunId { + if (typeof value !== 'string' || !BACKUP_RUN_ID_PATTERN.test(value)) { + throw new TypeError('coordination-backup-run-id-invalid'); + } + return value as BackupRunId; +} + +export function parseSha256Digest(value: unknown): Sha256Digest { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + throw new TypeError('coordination-backup-sha256-invalid'); + } + return value as Sha256Digest; +} + +export const COORDINATION_BACKUP_FORMAT = 'coordination-backup/v2' as const; +export const COORDINATION_BACKUP_COMMIT_MARKER_FORMAT = + 'coordination-backup-commit-marker/v1' as const; +export const SQLITE_ONLINE_BACKUP_METHOD = 'sqlite_online_backup_api' as const; +export const COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION = 1 as const; +export const COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION = 1 as const; +export const COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION = 3 as const; +export const COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION = 1 as const; + +export const BACKUP_RUN_STATES = Object.freeze([ + 'requested', + 'fencing', + 'quiescing', + 'sqlite_snapshot', + 'file_stage', + 'verifying', + 'committed', + 'failed', + 'operator_required', + 'artifact_source', +] as const); + +export type BackupRunState = (typeof BACKUP_RUN_STATES)[number]; +export type ActiveBackupRunState = Exclude< + BackupRunState, + 'committed' | 'failed' | 'operator_required' | 'artifact_source' +>; +export type TerminalBackupRunState = Extract< + BackupRunState, + 'committed' | 'failed' | 'operator_required' | 'artifact_source' +>; + +export type BackupProductKind = 'coordination_backup'; +export type CoordinationBackupPurpose = 'app_migration' | 'coordination_repair'; + +export interface BackupParticipantDescriptor< + TParticipantId extends string = string, + TKind extends string = string, +> { + readonly participantId: TParticipantId; + readonly kind: TKind; + readonly contractVersion: typeof COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION; + readonly schemaVersion: typeof COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION; + readonly required: boolean; +} + +export interface PreparedBackupParticipant< + TParticipantId extends string = string, + TKind extends string = string, +> { + readonly descriptor: BackupParticipantDescriptor; + readonly sourceGeneration: string; +} + +export interface FlushedBackupParticipant< + TParticipantId extends string = string, + TKind extends string = string, +> extends PreparedBackupParticipant { + readonly durableBarrier: string; +} + +export interface StateCompatibilityManifestRef { + readonly manifestId: string; + readonly schemaVersion: typeof COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION; + readonly sha256: Sha256Digest; +} + +export interface BackupFenceEvidence { + readonly generation: number; + readonly admittedRunId: BackupRunId; +} + +export type BackupFenceCompletionDisposition = 'committed' | 'aborted' | 'operator_required'; + +export type BackupFenceCompletion = + | { + readonly generation: number; + readonly disposition: BackupFenceCompletionDisposition; + readonly status: 'pending'; + readonly completedAt: null; + } + | { + readonly generation: number; + readonly disposition: BackupFenceCompletionDisposition; + readonly status: 'completed'; + readonly completedAt: string; + }; + +export type PendingBackupFenceCompletion = Extract; + +export interface BackupAcceptedCommandDrain { + readonly admittedRunId: BackupRunId; + readonly fenceGeneration: number; + readonly throughCommandCursor: string; + readonly durableBarrier: string; +} + +export interface BackupParticipantRecoveryPoint { + readonly participantId: string; + readonly sourceGeneration: string; + readonly durableBarrier: string; +} + +export interface BackupCoordinationBarrier { + readonly stateCompatibilityManifest: StateCompatibilityManifestRef; + readonly acceptedCommandDrain: BackupAcceptedCommandDrain; + readonly participantRecoveryPoints: readonly BackupParticipantRecoveryPoint[]; + readonly eventCursor: string; + readonly eventEpoch: string; + readonly journalCursors: Readonly>; +} + +export type BackupIdentityKind = 'deployment' | 'team' | 'member'; +export type BackupIdentityState = 'active' | 'tombstoned'; + +export interface BackupIdentityInventoryEntry { + readonly kind: BackupIdentityKind; + readonly identityId: string; + readonly parentIdentityId: string | null; + readonly state: BackupIdentityState; + readonly checksum: Sha256Digest; + /** Active identities require an anchor; row-only tombstones intentionally use null. */ + readonly fileEntryId: string | null; +} + +export interface BackupWorkspaceRegistrationEntry { + readonly workspaceId: WorkspaceId; + readonly registrationKey: string; + readonly state: 'registered' | 'disabled'; +} + +export interface BackupIdentityInventory { + readonly schemaVersion: typeof COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION; + readonly deploymentId: DeploymentId; + readonly identities: readonly BackupIdentityInventoryEntry[]; + /** Mount generation is intentionally excluded: restore rotates mount authority. */ + readonly workspaceRegistrations: readonly BackupWorkspaceRegistrationEntry[]; +} + +export type BackupManifestEntryKind = 'sqlite_snapshot' | 'participant_file' | 'identity_anchor'; + +export interface BackupManifestEntry { + readonly entryId: string; + readonly participantId: string; + readonly kind: BackupManifestEntryKind; + readonly logicalOwner: string; + readonly logicalType: string; + readonly schemaVersion: number; + readonly byteLength: number; + readonly mode: number; + readonly sha256: Sha256Digest; + readonly sourceGeneration: string; +} + +export interface BackupExclusion { + readonly participantId: string; + readonly logicalType: string; + readonly reason: + | 'credential' + | 'session_or_ticket' + | 'ephemeral_runtime' + | 'rebuildable_cache' + | 'secret_diagnostic' + | 'outside_coordination_scope'; +} + +export interface OnlineBackupSnapshot { + readonly method: typeof SQLITE_ONLINE_BACKUP_METHOD; + readonly entry: BackupManifestEntry & { readonly kind: 'sqlite_snapshot' }; + readonly applicationId: number; + readonly userVersion: number; + readonly sourceRunId: BackupRunId; +} + +export interface SqliteIntegrityEvidence { + readonly integrityCheck: 'ok'; + readonly applicationId: number; + readonly userVersion: number; + readonly requiredInvariants: Readonly>; +} + +export interface BackupManifestBody { + readonly format: typeof COORDINATION_BACKUP_FORMAT; + readonly backupRunId: BackupRunId; + readonly sourceBackupRunId: BackupRunId; + readonly productKind: BackupProductKind; + readonly purpose: CoordinationBackupPurpose; + readonly deploymentId: DeploymentId; + readonly requestedAt: string; + readonly sealedAt: string; + readonly fenceGeneration: number; + readonly coordinationBarrier: BackupCoordinationBarrier; + readonly identityInventory: BackupIdentityInventory; + readonly participants: readonly FlushedBackupParticipant[]; + readonly sqliteSnapshot: OnlineBackupSnapshot; + readonly sqliteIntegrity: SqliteIntegrityEvidence; + readonly entries: readonly BackupManifestEntry[]; + readonly exclusions: readonly BackupExclusion[]; +} + +export interface BackupManifest extends BackupManifestBody { + readonly manifestHash: Sha256Digest; +} + +export interface BackupCommitMarker { + readonly format: typeof COORDINATION_BACKUP_COMMIT_MARKER_FORMAT; + readonly backupRunId: BackupRunId; + readonly deploymentId: DeploymentId; + readonly manifestHash: Sha256Digest; + readonly sealedAt: string; +} + +export interface BackupVerificationPlan { + readonly manifest: BackupManifest; + readonly marker: BackupCommitMarker; +} + +export interface CommittedBackupPublication { + readonly backupRunId: BackupRunId; + readonly manifestHash: Sha256Digest; + readonly immutableGeneration: string; +} + +export interface BackupRunFailure { + readonly code: string; + readonly phase: ActiveBackupRunState; + readonly safeMessage: string; +} + +export interface BackupRunRecord { + readonly backupRunId: BackupRunId; + readonly deploymentId: DeploymentId; + readonly productKind: BackupProductKind; + readonly purpose: CoordinationBackupPurpose; + readonly state: BackupRunState; + readonly revision: number; + readonly requestedAt: string; + readonly updatedAt: string; + readonly participantDescriptors: readonly BackupParticipantDescriptor[]; + readonly fence: BackupFenceEvidence | null; + readonly fenceLeaseId: string | null; + readonly fenceCompletion: BackupFenceCompletion | null; + readonly preparedParticipants: readonly PreparedBackupParticipant[] | null; + readonly flushedParticipants: readonly FlushedBackupParticipant[] | null; + readonly coordinationBarrier: BackupCoordinationBarrier | null; + readonly identityInventory: BackupIdentityInventory | null; + readonly sqliteSnapshot: OnlineBackupSnapshot | null; + readonly stagedEntries: readonly BackupManifestEntry[] | null; + readonly exclusions: readonly BackupExclusion[] | null; + readonly verificationPlan: BackupVerificationPlan | null; + readonly publication: CommittedBackupPublication | null; + readonly failure: BackupRunFailure | null; +} + +export interface RequestCoordinationBackup { + readonly backupRunId: BackupRunId; + readonly deploymentId: DeploymentId; + readonly purpose: CoordinationBackupPurpose; +} + +export type BackupRunTransitionRequest = + | { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly from: 'requested'; + readonly to: 'fencing'; + readonly at: string; + } + | { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly from: 'fencing'; + readonly to: 'quiescing'; + readonly at: string; + readonly fence: BackupFenceEvidence; + readonly fenceLeaseId: string; + } + | { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly from: 'quiescing'; + readonly to: 'sqlite_snapshot'; + readonly at: string; + readonly preparedParticipants: readonly PreparedBackupParticipant[]; + readonly flushedParticipants: readonly FlushedBackupParticipant[]; + readonly coordinationBarrier: BackupCoordinationBarrier; + readonly identityInventory: BackupIdentityInventory; + } + | { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly from: 'sqlite_snapshot'; + readonly to: 'file_stage'; + readonly at: string; + readonly sqliteSnapshot: OnlineBackupSnapshot; + } + | { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly from: 'file_stage'; + readonly to: 'verifying'; + readonly at: string; + readonly stagedEntries: readonly BackupManifestEntry[]; + readonly exclusions: readonly BackupExclusion[]; + } + | { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly from: 'verifying'; + readonly to: 'committed'; + readonly at: string; + readonly publication: CommittedBackupPublication; + readonly fenceCompletion: PendingBackupFenceCompletion; + } + | { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly from: ActiveBackupRunState; + readonly to: 'failed' | 'operator_required'; + readonly at: string; + readonly failure: BackupRunFailure; + readonly fence: BackupFenceEvidence | null; + readonly fenceLeaseId: string | null; + readonly fenceCompletion: PendingBackupFenceCompletion | null; + }; + +export type BackupPublicationInspection = + | { readonly status: 'absent' } + | { readonly status: 'staging_unsealed' } + | { readonly status: 'staging_sealed' } + | { readonly status: 'committed'; readonly publication: CommittedBackupPublication } + | { readonly status: 'ambiguous' }; + +export interface MeasuredBackupEntry { + readonly entryId: string; + readonly byteLength: number; + readonly mode: number; + readonly sha256: Sha256Digest; +} + +export interface CopiedSourceBackupRun { + readonly backupRunId: BackupRunId; + readonly deploymentId: DeploymentId; + readonly productKind: BackupProductKind; + readonly purpose: CoordinationBackupPurpose; + readonly state: BackupRunState; + readonly fenceGeneration: number; + readonly coordinationBarrier: BackupCoordinationBarrier; + readonly participants: readonly FlushedBackupParticipant[]; + readonly identityInventory: BackupIdentityInventory; +} + +export interface ImmutableBackupInspection { + readonly manifest: BackupManifest; + readonly marker: BackupCommitMarker; + readonly computedManifestHash: Sha256Digest; + readonly measuredEntries: readonly MeasuredBackupEntry[]; + readonly observedIdentityInventory: BackupIdentityInventory; + readonly copiedSourceRun: CopiedSourceBackupRun; +} + +export type ImmutableBackupVerification = + | { readonly status: 'verified'; readonly inspection: ImmutableBackupInspection } + | { readonly status: 'invalid'; readonly reasons: readonly string[] }; + +export type BackupArtifactClassification = 'committed_v2' | 'legacy_unverified' | 'partial'; +export type CoordinationBackupRestorePurpose = CoordinationBackupPurpose | 'replace_deployment'; + +export interface RestoreSetValidationRequest { + readonly classification: BackupArtifactClassification; + readonly purpose: CoordinationBackupRestorePurpose; + readonly expectedDeploymentId: DeploymentId; + readonly inspection: ImmutableBackupInspection | null; +} + +export interface ValidatedRestoreIdentityMapping { + readonly deploymentId: DeploymentId; + readonly activeTeamIds: readonly TeamId[]; + readonly tombstonedIdentityIds: readonly string[]; + readonly workspaceRegistrations: Readonly>; + readonly sourceRunFinalization: { + readonly backupRunId: BackupRunId; + readonly from: 'sqlite_snapshot'; + readonly to: 'artifact_source'; + }; +} + +export type RestoreSetValidationResult = + | { + readonly status: 'valid'; + readonly mapping: ValidatedRestoreIdentityMapping; + } + | { + readonly status: 'invalid'; + readonly reasons: readonly string[]; + }; diff --git a/src/features/coordination-backup/contracts/index.ts b/src/features/coordination-backup/contracts/index.ts new file mode 100644 index 0000000000..21a8040369 --- /dev/null +++ b/src/features/coordination-backup/contracts/index.ts @@ -0,0 +1 @@ +export * from './coordinationBackupContracts'; diff --git a/src/features/coordination-backup/core/application/CoordinationBackupService.ts b/src/features/coordination-backup/core/application/CoordinationBackupService.ts new file mode 100644 index 0000000000..2e6f671c42 --- /dev/null +++ b/src/features/coordination-backup/core/application/CoordinationBackupService.ts @@ -0,0 +1,791 @@ +import { + type BackupCommitMarker, + type BackupExclusion, + type BackupManifest, + type BackupManifestBody, + type BackupManifestEntry, + type BackupRunId, + type BackupRunRecord, + type BackupVerificationPlan, + COORDINATION_BACKUP_COMMIT_MARKER_FORMAT, + COORDINATION_BACKUP_FORMAT, + COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION, + type FlushedBackupParticipant, + type ImmutableBackupInspection, + type PreparedBackupParticipant, + type RequestCoordinationBackup, + type RestoreSetValidationRequest, + type RestoreSetValidationResult, + SQLITE_ONLINE_BACKUP_METHOD, + type SqliteIntegrityEvidence, +} from '../../contracts'; +import { + assertBackupRunRecord, + isActiveBackupRunState, + validateCoordinationBackupRestoreSet, + validateImmutableBackupInspection, +} from '../domain'; + +import { + assertAcceptedCommandDrain, + assertCompletedFenceRecord, + assertCoordinationBarrierEvidence, + assertFlushedParticipant, + assertParticipantContract, + assertPreparedParticipant, + assertRequestedRun, + assertRunState, + assertVerificationPlanMatchesRun, + asServiceError, + classifyExecutionFault, + contractFault, + normalizeParticipants, + pendingFenceCompletion, + pendingFenceCompletionFor, + replaceTerminalRecord, + requireBackupRun, + requireParticipantEvidence, + sortEntries, + sortExclusions, + sortFlushedParticipants, + transitionBackupRun, +} from './coordinationBackupServiceSupport'; +import { + BackupExecutionFault, + type CoordinationBackupServiceDependencies, + CoordinationBackupServiceError, +} from './coordinationBackupServiceTypes'; + +import type { + BackupRunRepository, + BackupWriterFenceLease, + BackupWriterFencePort, + CoordinationBackupParticipant, +} from './ports'; + +export { + type CoordinationBackupServiceDependencies, + CoordinationBackupServiceError, + type CoordinationBackupServiceErrorCode, +} from './coordinationBackupServiceTypes'; + +export class CoordinationBackupService { + private readonly participants: readonly CoordinationBackupParticipant[]; + constructor(private readonly dependencies: CoordinationBackupServiceDependencies) { + this.participants = normalizeParticipants(dependencies.participants); + } + + async createCoordinationBackup(request: RequestCoordinationBackup): Promise { + const requestedAt = this.dependencies.clock.nowIso(); + const run = await this.dependencies.runs.create({ + ...request, + requestedAt, + participantDescriptors: this.participants.map((participant) => participant.descriptor), + }); + assertRequestedRun(run, request, this.participants); + return this.resume(run); + } + + async recoverBackupRun(backupRunId: BackupRunId): Promise { + const run = await this.dependencies.runs.get(backupRunId); + if (!run) { + throw new CoordinationBackupServiceError('run_not_found', 'BackupRun was not found'); + } + assertBackupRunRecord(run); + if (isActiveBackupRunState(run.state)) assertParticipantContract(run, this.participants); + return this.resume(run); + } + + async recoverAllBackupRuns(): Promise { + const recoverable = await this.dependencies.runs.listRecoverable(); + const recovered: BackupRunRecord[] = []; + for (const run of recoverable) { + assertBackupRunRecord(run); + if (isActiveBackupRunState(run.state)) assertParticipantContract(run, this.participants); + recovered.push(await this.resume(run)); + } + return Object.freeze(recovered); + } + + async verifyCommittedBackup(backupRunId: BackupRunId): Promise { + const run = await this.requireRun(backupRunId); + if (run.state !== 'committed' || !run.verificationPlan || !run.publication) { + throw new CoordinationBackupServiceError( + 'run_contract_invalid', + 'Only a durably committed BackupRun can be verified', + run + ); + } + try { + return await this.verifyImmutable(run, 'committed'); + } catch (error) { + throw asServiceError(error, run); + } + } + + validateRestoreSet(request: RestoreSetValidationRequest): RestoreSetValidationResult { + return validateCoordinationBackupRestoreSet(request); + } + + private async resume(initial: BackupRunRecord): Promise { + let run = initial; + let lease: BackupWriterFenceLease | null = null; + let result: BackupRunRecord | null = null; + let pendingError: Error | null = null; + + try { + if (run.state === 'committed') { + await this.verifyImmutable(run, 'committed'); + result = run; + } else if (run.state === 'failed' || run.state === 'operator_required') { + result = run; + } else if (run.state === 'artifact_source') { + throw new CoordinationBackupServiceError( + 'run_contract_invalid', + 'An artifact_source record cannot execute on the source deployment', + run + ); + } else { + while (isActiveBackupRunState(run.state)) { + if (run.state === 'requested') { + run = await this.transition(run, { + backupRunId: run.backupRunId, + expectedRevision: run.revision, + from: 'requested', + to: 'fencing', + at: this.dependencies.clock.nowIso(), + }); + continue; + } + + lease ??= await this.acquireFence(run); + if (run.state === 'fencing') { + run = await this.performFencing(run, lease); + } else if (run.state === 'quiescing') { + run = await this.performQuiescing(run, lease); + } else if (run.state === 'sqlite_snapshot') { + run = await this.performSqliteSnapshot(run, lease); + } else if (run.state === 'file_stage') { + run = await this.performFileStage(run, lease); + } else { + run = await this.performVerificationAndPublication(run); + } + } + result = run; + } + } catch (error) { + if (error instanceof CoordinationBackupServiceError) { + pendingError = error; + } else { + try { + run = await this.reconcileFailure(run.backupRunId, error, lease); + if (run.state === 'committed') result = run; + else { + pendingError = new CoordinationBackupServiceError( + run.state === 'operator_required' + ? 'backup_run_operator_required' + : 'backup_run_failed', + run.state === 'operator_required' + ? 'BackupRun requires operator review' + : 'BackupRun failed closed', + run, + { cause: error } + ); + } + } catch (reconciliationError) { + pendingError = asServiceError(reconciliationError, run); + } + } + } + + if (run.fenceCompletion?.status === 'pending') { + try { + run = await this.completeFence(run, lease); + if (result) result = run; + if (pendingError) pendingError = replaceTerminalRecord(pendingError, run); + } catch (error) { + pendingError = new CoordinationBackupServiceError( + 'fence_completion_failed', + 'Backup writer fence completion failed; mutation admission must remain closed', + run, + { cause: error } + ); + } + } + + if (pendingError) throw pendingError; + if (!result) throw contractFault('backup_run_result_missing'); + return result; + } + + private async performFencing( + run: BackupRunRecord, + lease: BackupWriterFenceLease + ): Promise { + assertRunState(run, 'fencing'); + return this.transition(run, { + backupRunId: run.backupRunId, + expectedRevision: run.revision, + from: 'fencing', + to: 'quiescing', + at: this.dependencies.clock.nowIso(), + fence: lease.evidence, + fenceLeaseId: lease.leaseId, + }); + } + + private async performQuiescing( + run: BackupRunRecord, + lease: BackupWriterFenceLease + ): Promise { + assertRunState(run, 'quiescing'); + const acceptedCommandDrain = await this.dependencies.coordinationFlush.drainAcceptedCommands({ + backupRunId: run.backupRunId, + fence: lease.evidence, + }); + assertAcceptedCommandDrain(run, lease, acceptedCommandDrain); + + const prepared: PreparedBackupParticipant[] = []; + const flushed: FlushedBackupParticipant[] = []; + for (const participant of this.participants) { + const preparedEvidence = await participant.prepare({ + backupRunId: run.backupRunId, + fence: lease.evidence, + }); + assertPreparedParticipant(participant.descriptor, preparedEvidence); + prepared.push(preparedEvidence); + const evidence = await participant.flush({ + backupRunId: run.backupRunId, + fence: lease.evidence, + prepared: preparedEvidence, + }); + assertFlushedParticipant(participant.descriptor, preparedEvidence, evidence); + flushed.push(evidence); + } + + const coordinationBarrier = await this.dependencies.coordinationFlush.captureBarrier({ + backupRunId: run.backupRunId, + fence: lease.evidence, + acceptedCommandDrain, + participants: flushed, + }); + assertCoordinationBarrierEvidence( + run, + lease, + acceptedCommandDrain, + flushed, + coordinationBarrier + ); + const identityInventory = await this.dependencies.identityInventory.capture({ + backupRunId: run.backupRunId, + fence: lease.evidence, + barrier: coordinationBarrier, + }); + if ( + identityInventory.schemaVersion !== COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION || + identityInventory.deploymentId !== run.deploymentId + ) { + throw contractFault('identity_inventory_deployment_mismatch'); + } + + return this.transition(run, { + backupRunId: run.backupRunId, + expectedRevision: run.revision, + from: 'quiescing', + to: 'sqlite_snapshot', + at: this.dependencies.clock.nowIso(), + preparedParticipants: Object.freeze(prepared), + flushedParticipants: Object.freeze(flushed), + coordinationBarrier, + identityInventory, + }); + } + + private async performSqliteSnapshot( + run: BackupRunRecord, + lease: BackupWriterFenceLease + ): Promise { + assertRunState(run, 'sqlite_snapshot'); + if (!run.coordinationBarrier || !run.flushedParticipants) { + throw contractFault('sqlite_recovery_point_evidence_missing'); + } + await this.dependencies.publication.preparePrivateStage(run.backupRunId); + const result = await this.dependencies.onlineBackup.createOnlineSnapshot({ + backupRunId: run.backupRunId, + fence: lease.evidence, + coordinationBarrier: run.coordinationBarrier, + participants: run.flushedParticipants, + }); + if (result.status === 'failed') { + throw new BackupExecutionFault( + `online_backup_${result.reason}`, + 'failed', + 'SQLite Online Backup API snapshot failed' + ); + } + if ( + result.snapshot.method !== SQLITE_ONLINE_BACKUP_METHOD || + result.snapshot.sourceRunId !== run.backupRunId || + result.snapshot.entry.kind !== 'sqlite_snapshot' + ) { + throw contractFault('online_backup_evidence_invalid'); + } + return this.transition(run, { + backupRunId: run.backupRunId, + expectedRevision: run.revision, + from: 'sqlite_snapshot', + to: 'file_stage', + at: this.dependencies.clock.nowIso(), + sqliteSnapshot: result.snapshot, + }); + } + + private async performFileStage( + run: BackupRunRecord, + lease: BackupWriterFenceLease + ): Promise { + assertRunState(run, 'file_stage'); + if (!run.flushedParticipants || !run.sqliteSnapshot) { + throw contractFault('file_stage_evidence_missing'); + } + const entries: BackupManifestEntry[] = []; + const exclusions: BackupExclusion[] = []; + const entryIds = new Set([run.sqliteSnapshot.entry.entryId]); + + for (const participant of this.participants) { + const flushed = requireParticipantEvidence( + run.flushedParticipants, + participant.descriptor.participantId + ); + const staged = await participant.stage({ + backupRunId: run.backupRunId, + fence: lease.evidence, + flushed, + }); + if (staged.participantId !== participant.descriptor.participantId) { + throw contractFault('staged_participant_id_mismatch'); + } + for (const entry of staged.entries) { + if ( + entry.participantId !== participant.descriptor.participantId || + entry.kind === 'sqlite_snapshot' || + entry.sourceGeneration !== flushed.sourceGeneration || + entryIds.has(entry.entryId) + ) { + throw contractFault('staged_entry_invalid'); + } + entryIds.add(entry.entryId); + entries.push(entry); + } + for (const exclusion of staged.exclusions) { + if (exclusion.participantId !== participant.descriptor.participantId) { + throw contractFault('staged_exclusion_invalid'); + } + exclusions.push(exclusion); + } + } + + return this.transition(run, { + backupRunId: run.backupRunId, + expectedRevision: run.revision, + from: 'file_stage', + to: 'verifying', + at: this.dependencies.clock.nowIso(), + stagedEntries: Object.freeze(sortEntries(entries)), + exclusions: Object.freeze(sortExclusions(exclusions)), + }); + } + + private async performVerificationAndPublication( + initial: BackupRunRecord + ): Promise { + assertRunState(initial, 'verifying'); + let run: BackupRunRecord = initial; + const publication = await this.dependencies.publication.inspect(run.backupRunId); + if (publication.status === 'ambiguous') { + throw new BackupExecutionFault( + 'publication_ambiguous', + 'operator_required', + 'Backup publication state is ambiguous' + ); + } + if (publication.status === 'committed') { + return this.commitRecoveredPublication(run, publication.publication); + } + if (publication.status === 'absent') { + throw new BackupExecutionFault( + 'publication_missing', + 'failed', + 'Backup private stage is missing' + ); + } + if (publication.status === 'staging_sealed') { + if (!run.verificationPlan) throw contractFault('sealed_stage_plan_missing'); + return this.commitSealedStage(run); + } + + const integrity = await this.checkSqliteIntegrity(run); + await this.verifyParticipants(run); + if (!run.verificationPlan) { + const plan = await this.buildVerificationPlan(run, integrity); + run = await this.dependencies.runs.saveVerificationPlan({ + backupRunId: run.backupRunId, + expectedRevision: run.revision, + plan, + at: this.dependencies.clock.nowIso(), + }); + assertBackupRunRecord(run); + if (run.state !== 'verifying' || !run.verificationPlan) { + throw contractFault('verification_plan_not_durable'); + } + } else { + assertVerificationPlanMatchesRun(run); + } + + await this.dependencies.publication.writeRootManifest({ + backupRunId: run.backupRunId, + manifest: run.verificationPlan.manifest, + }); + await this.dependencies.publication.writeCommitMarkerLast({ + backupRunId: run.backupRunId, + marker: run.verificationPlan.marker, + }); + return this.commitSealedStage(run); + } + + private async checkSqliteIntegrity(run: BackupRunRecord): Promise { + if (!run.sqliteSnapshot) throw contractFault('sqlite_snapshot_missing'); + const result = await this.dependencies.sqliteIntegrity.reopenAndCheck({ + backupRunId: run.backupRunId, + snapshot: run.sqliteSnapshot, + }); + if (result.status === 'invalid') { + throw new BackupExecutionFault( + `sqlite_${result.reason}`, + 'failed', + 'Independently reopened SQLite snapshot failed integrity validation' + ); + } + if ( + result.evidence.applicationId !== run.sqliteSnapshot.applicationId || + result.evidence.userVersion !== run.sqliteSnapshot.userVersion + ) { + throw contractFault('sqlite_integrity_evidence_mismatch'); + } + return result.evidence; + } + + private async verifyParticipants(run: BackupRunRecord): Promise { + if (!run.flushedParticipants || !run.stagedEntries || !run.fence) { + throw contractFault('participant_verification_evidence_missing'); + } + for (const participant of this.participants) { + const flushed = requireParticipantEvidence( + run.flushedParticipants, + participant.descriptor.participantId + ); + const stagedEntries = run.stagedEntries.filter( + (entry) => entry.participantId === participant.descriptor.participantId + ); + const result = await participant.verify({ + backupRunId: run.backupRunId, + fence: run.fence, + flushed, + stagedEntries, + }); + if (result.status !== 'verified') { + throw new BackupExecutionFault( + 'participant_verification_failed', + 'failed', + 'A required backup participant failed immutable verification' + ); + } + } + } + + private async buildVerificationPlan( + run: BackupRunRecord, + sqliteIntegrity: BackupManifestBody['sqliteIntegrity'] + ): Promise { + assertRunState(run, 'verifying'); + if ( + !run.fence || + !run.coordinationBarrier || + !run.identityInventory || + !run.flushedParticipants || + !run.sqliteSnapshot || + !run.stagedEntries || + !run.exclusions + ) { + throw contractFault('manifest_evidence_missing'); + } + const sealedAt = this.dependencies.clock.nowIso(); + const body: BackupManifestBody = Object.freeze({ + format: COORDINATION_BACKUP_FORMAT, + backupRunId: run.backupRunId, + sourceBackupRunId: run.backupRunId, + productKind: run.productKind, + purpose: run.purpose, + deploymentId: run.deploymentId, + requestedAt: run.requestedAt, + sealedAt, + fenceGeneration: run.fence.generation, + coordinationBarrier: run.coordinationBarrier, + identityInventory: run.identityInventory, + participants: Object.freeze(sortFlushedParticipants(run.flushedParticipants)), + sqliteSnapshot: run.sqliteSnapshot, + sqliteIntegrity, + entries: Object.freeze(sortEntries([run.sqliteSnapshot.entry, ...run.stagedEntries])), + exclusions: Object.freeze(sortExclusions(run.exclusions)), + }); + const manifestHash = await this.dependencies.manifestHash.hashCanonicalManifest(body); + const manifest: BackupManifest = Object.freeze({ ...body, manifestHash }); + const marker: BackupCommitMarker = Object.freeze({ + format: COORDINATION_BACKUP_COMMIT_MARKER_FORMAT, + backupRunId: run.backupRunId, + deploymentId: run.deploymentId, + manifestHash, + sealedAt, + }); + return Object.freeze({ manifest, marker }); + } + + private async commitSealedStage(run: BackupRunRecord): Promise { + assertRunState(run, 'verifying'); + if (!run.verificationPlan) throw contractFault('verification_plan_missing'); + await this.verifyImmutable(run, 'staging'); + const publication = await this.dependencies.publication.commitSealedStage({ + backupRunId: run.backupRunId, + manifestHash: run.verificationPlan.manifest.manifestHash, + }); + await this.verifyImmutable(run, 'committed'); + return this.completeCommittedTransition(run, publication); + } + + private async commitRecoveredPublication( + run: BackupRunRecord, + publication: NonNullable + ): Promise { + assertRunState(run, 'verifying'); + if (!run.verificationPlan) throw contractFault('committed_publication_plan_missing'); + await this.verifyImmutable(run, 'committed'); + return this.completeCommittedTransition(run, publication); + } + + private async completeCommittedTransition( + run: BackupRunRecord, + publication: NonNullable + ): Promise { + assertRunState(run, 'verifying'); + if ( + !run.verificationPlan || + publication.backupRunId !== run.backupRunId || + publication.manifestHash !== run.verificationPlan.manifest.manifestHash + ) { + throw contractFault('committed_publication_mismatch'); + } + return this.transition(run, { + backupRunId: run.backupRunId, + expectedRevision: run.revision, + from: 'verifying', + to: 'committed', + at: this.dependencies.clock.nowIso(), + publication, + fenceCompletion: pendingFenceCompletion(run, 'committed'), + }); + } + + private async verifyImmutable( + run: BackupRunRecord, + location: 'staging' | 'committed' + ): Promise { + if (!run.verificationPlan) throw contractFault('verification_plan_missing'); + const result = await this.dependencies.immutableVerifier.verify({ + backupRunId: run.backupRunId, + location, + expectedPlan: run.verificationPlan, + }); + if (result.status === 'invalid') { + throw new BackupExecutionFault( + 'immutable_verification_failed', + location === 'committed' ? 'operator_required' : 'failed', + 'Immutable backup verification failed' + ); + } + const domainValidation = validateImmutableBackupInspection(result.inspection); + if ( + domainValidation.status === 'invalid' || + result.inspection.manifest.manifestHash !== run.verificationPlan.manifest.manifestHash || + result.inspection.marker.manifestHash !== run.verificationPlan.marker.manifestHash + ) { + throw new BackupExecutionFault( + 'immutable_verification_failed', + location === 'committed' ? 'operator_required' : 'failed', + 'Immutable backup evidence disagrees with the durable verification plan' + ); + } + return result.inspection; + } + + private async reconcileFailure( + backupRunId: BackupRunId, + error: unknown, + lease: BackupWriterFenceLease | null + ): Promise { + let run = await this.requireRun(backupRunId); + if (run.state === 'committed') { + await this.verifyImmutable(run, 'committed'); + return run; + } + if (run.state === 'failed' || run.state === 'operator_required') return run; + if (!isActiveBackupRunState(run.state)) throw error; + + const fault = classifyExecutionFault(error); + let disposition = fault.disposition; + if (run.state === 'verifying') { + try { + const inspection = await this.dependencies.publication.inspect(run.backupRunId); + if (inspection.status === 'committed' && run.verificationPlan) { + return await this.commitRecoveredPublication(run, inspection.publication); + } + if (inspection.status === 'staging_sealed' && run.verificationPlan) { + return await this.commitSealedStage(run); + } + if (inspection.status === 'ambiguous') disposition = 'operator_required'; + else await this.dependencies.publication.abortUncommittedStage(run.backupRunId); + } catch { + disposition = 'operator_required'; + } + } else { + try { + await this.dependencies.publication.abortUncommittedStage(run.backupRunId); + } catch { + disposition = 'operator_required'; + } + } + + run = await this.requireRun(backupRunId); + if (!isActiveBackupRunState(run.state)) return run; + const durableFence = run.fence ?? lease?.evidence ?? null; + const durableFenceLeaseId = run.fenceLeaseId ?? lease?.leaseId ?? null; + return this.transition(run, { + backupRunId: run.backupRunId, + expectedRevision: run.revision, + from: run.state, + to: disposition, + at: this.dependencies.clock.nowIso(), + failure: { + code: fault.code, + phase: run.state, + safeMessage: fault.safeMessage, + }, + fence: durableFence, + fenceLeaseId: durableFenceLeaseId, + fenceCompletion: durableFence + ? pendingFenceCompletionFor( + durableFence.generation, + disposition === 'failed' ? 'aborted' : 'operator_required' + ) + : null, + }); + } + + private async acquireFence(run: BackupRunRecord): Promise { + let result: Awaited>; + try { + result = await this.dependencies.writerFence.acquire({ + backupRunId: run.backupRunId, + expectedGeneration: run.fence?.generation ?? null, + }); + } catch (error) { + throw new BackupExecutionFault( + 'writer_fence_acquire_failed', + 'operator_required', + 'Writer fence acquisition outcome is unknown', + { cause: error } + ); + } + if (result.status === 'busy') { + throw new CoordinationBackupServiceError( + 'backup_fence_busy', + 'Another BackupRun currently owns the deployment writer fence', + run + ); + } + const { lease } = result; + if (!lease.leaseId) throw contractFault('fence_lease_id_missing'); + if (lease.evidence.admittedRunId !== run.backupRunId) { + throw contractFault('fence_run_mismatch'); + } + if (run.fence && lease.evidence.generation !== run.fence.generation) { + throw new BackupExecutionFault( + 'fence_generation_mismatch', + 'operator_required', + 'Recovered writer fence generation does not match durable BackupRun evidence' + ); + } + if (run.fenceLeaseId && lease.leaseId !== run.fenceLeaseId) { + throw new BackupExecutionFault( + 'fence_lease_mismatch', + 'operator_required', + 'Recovered writer fence lease does not match durable BackupRun evidence' + ); + } + return lease; + } + + private async completeFence( + run: BackupRunRecord, + acquiredLease: BackupWriterFenceLease | null + ): Promise { + const completion = run.fenceCompletion; + if (!completion || completion.status === 'completed') return run; + if (!run.fence || !run.fenceLeaseId) throw contractFault('fence_completion_evidence_missing'); + if ( + acquiredLease && + (acquiredLease.leaseId !== run.fenceLeaseId || + acquiredLease.evidence.generation !== run.fence.generation || + acquiredLease.evidence.admittedRunId !== run.backupRunId) + ) { + throw contractFault('fence_completion_lease_mismatch'); + } + + await this.dependencies.writerFence.complete({ + lease: { leaseId: run.fenceLeaseId, evidence: run.fence }, + disposition: completion.disposition, + }); + + try { + const completed = await this.dependencies.runs.markFenceCompleted({ + backupRunId: run.backupRunId, + expectedRevision: run.revision, + generation: completion.generation, + disposition: completion.disposition, + completedAt: this.dependencies.clock.nowIso(), + }); + assertCompletedFenceRecord(run, completed); + return completed; + } catch (error) { + const recovered = await this.requireRun(run.backupRunId); + if ( + recovered.fenceCompletion?.status === 'completed' && + recovered.fenceCompletion.generation === completion.generation && + recovered.fenceCompletion.disposition === completion.disposition + ) { + return recovered; + } + throw error; + } + } + + private async requireRun(backupRunId: BackupRunId): Promise { + return requireBackupRun(this.dependencies.runs, backupRunId); + } + + private async transition( + current: BackupRunRecord, + request: Parameters[0] + ): Promise { + return transitionBackupRun(this.dependencies.runs, current, request); + } +} diff --git a/src/features/coordination-backup/core/application/coordinationBackupServiceSupport.ts b/src/features/coordination-backup/core/application/coordinationBackupServiceSupport.ts new file mode 100644 index 0000000000..21d9423ff3 --- /dev/null +++ b/src/features/coordination-backup/core/application/coordinationBackupServiceSupport.ts @@ -0,0 +1,437 @@ +import { + type BackupAcceptedCommandDrain, + type BackupCoordinationBarrier, + type BackupExclusion, + type BackupFenceCompletionDisposition, + type BackupManifestEntry, + type BackupParticipantDescriptor, + type BackupRunId, + type BackupRunRecord, + COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION, + COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION, + COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION, + type FlushedBackupParticipant, + type PendingBackupFenceCompletion, + type PreparedBackupParticipant, + type RequestCoordinationBackup, +} from '../../contracts'; +import { + assertBackupRunRecord, + BackupRunInvariantError, + transitionBackupRunState, +} from '../domain'; + +import { + BackupExecutionFault, + CoordinationBackupServiceError, +} from './coordinationBackupServiceTypes'; + +import type { + BackupRunRepository, + BackupWriterFenceLease, + CoordinationBackupParticipant, +} from './ports'; + +export function assertTransitionEvidenceMatches( + request: Parameters[0], + next: BackupRunRecord +): void { + if (request.to === 'quiescing') { + if ( + next.fenceLeaseId !== request.fenceLeaseId || + next.fence?.admittedRunId !== request.fence.admittedRunId || + next.fence.generation !== request.fence.generation + ) { + throw contractFault('transition_fence_evidence_mismatch'); + } + return; + } + if (request.to === 'committed') { + if ( + next.publication?.backupRunId !== request.publication.backupRunId || + next.publication.manifestHash !== request.publication.manifestHash || + next.publication.immutableGeneration !== request.publication.immutableGeneration || + next.fenceCompletion?.status !== 'pending' || + next.fenceCompletion.generation !== request.fenceCompletion.generation || + next.fenceCompletion.disposition !== request.fenceCompletion.disposition + ) { + throw contractFault('transition_commit_evidence_mismatch'); + } + return; + } + if (request.to === 'failed' || request.to === 'operator_required') { + const completionMatches = request.fenceCompletion + ? next.fenceCompletion?.status === 'pending' && + next.fenceCompletion.generation === request.fenceCompletion.generation && + next.fenceCompletion.disposition === request.fenceCompletion.disposition + : next.fenceCompletion === null; + if ( + next.failure?.code !== request.failure.code || + next.failure.phase !== request.failure.phase || + next.failure.safeMessage !== request.failure.safeMessage || + next.fenceLeaseId !== request.fenceLeaseId || + next.fence?.admittedRunId !== request.fence?.admittedRunId || + next.fence?.generation !== request.fence?.generation || + !completionMatches + ) { + throw contractFault('transition_failure_evidence_mismatch'); + } + } +} + +export function normalizeParticipants( + participants: readonly CoordinationBackupParticipant[] +): readonly CoordinationBackupParticipant[] { + const normalized = participants.map((participant) => + Object.freeze({ + descriptor: Object.freeze({ ...participant.descriptor }), + prepare: (request: Parameters[0]) => participant.prepare(request), + flush: (request: Parameters[0]) => participant.flush(request), + stage: (request: Parameters[0]) => participant.stage(request), + verify: (request: Parameters[0]) => participant.verify(request), + }) + ); + const sorted = normalized.toSorted((left, right) => + left.descriptor.participantId.localeCompare(right.descriptor.participantId) + ); + const ids = new Set(); + for (const participant of sorted) { + const descriptor = participant.descriptor; + if ( + !descriptor.participantId || + !descriptor.kind || + descriptor.contractVersion !== COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION || + descriptor.schemaVersion !== COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION || + ids.has(descriptor.participantId) + ) { + throw new BackupRunInvariantError( + 'invalid_record', + 'Coordination backup participant descriptor is invalid or duplicated', + { participantId: descriptor.participantId } + ); + } + ids.add(descriptor.participantId); + } + return Object.freeze(sorted); +} + +export function assertRequestedRun( + run: BackupRunRecord, + request: RequestCoordinationBackup, + participants: readonly CoordinationBackupParticipant[] +): void { + assertBackupRunRecord(run); + if ( + run.state !== 'requested' || + run.backupRunId !== request.backupRunId || + run.deploymentId !== request.deploymentId || + run.purpose !== request.purpose + ) { + throw new CoordinationBackupServiceError( + 'run_contract_invalid', + 'BackupRun repository returned a record that disagrees with the request', + run + ); + } + assertParticipantContract(run, participants); +} + +export function assertParticipantContract( + run: BackupRunRecord, + participants: readonly CoordinationBackupParticipant[] +): void { + const current = participants.map((participant) => participant.descriptor); + if (!sameDescriptorLists(run.participantDescriptors, current)) { + throw new CoordinationBackupServiceError( + 'participant_contract_mismatch', + 'Durable BackupRun participant contract does not match the registered participants', + run + ); + } +} + +export async function requireBackupRun( + runs: BackupRunRepository, + backupRunId: BackupRunId +): Promise { + const run = await runs.get(backupRunId); + if (!run) throw new CoordinationBackupServiceError('run_not_found', 'BackupRun was not found'); + assertBackupRunRecord(run); + return run; +} + +export async function transitionBackupRun( + runs: BackupRunRepository, + current: BackupRunRecord, + request: Parameters[0] +): Promise { + transitionBackupRunState(request.from, request.to); + if ( + request.backupRunId !== current.backupRunId || + request.expectedRevision !== current.revision || + request.from !== current.state + ) { + throw contractFault('transition_request_mismatch'); + } + const next = await runs.transition(request); + assertBackupRunRecord(next); + if ( + next.backupRunId !== current.backupRunId || + next.state !== request.to || + next.revision <= current.revision + ) { + throw contractFault('transition_result_mismatch'); + } + assertTransitionEvidenceMatches(request, next); + return next; +} + +export function assertRunState( + run: BackupRunRecord, + state: TState +): asserts run is BackupRunRecord & { readonly state: TState } { + if (run.state !== state) throw contractFault('backup_run_state_mismatch'); +} + +export function assertPreparedParticipant( + descriptor: BackupParticipantDescriptor, + prepared: PreparedBackupParticipant +): void { + if (!sameDescriptor(descriptor, prepared.descriptor) || !prepared.sourceGeneration) { + throw contractFault('prepared_participant_contract_mismatch'); + } +} + +export function assertFlushedParticipant( + descriptor: BackupParticipantDescriptor, + prepared: PreparedBackupParticipant, + flushed: FlushedBackupParticipant +): void { + if ( + !sameDescriptor(descriptor, flushed.descriptor) || + flushed.sourceGeneration !== prepared.sourceGeneration || + !flushed.durableBarrier + ) { + throw contractFault('flushed_participant_contract_mismatch'); + } +} + +export function assertAcceptedCommandDrain( + run: BackupRunRecord, + lease: BackupWriterFenceLease, + drain: BackupAcceptedCommandDrain +): void { + if ( + drain.admittedRunId !== run.backupRunId || + drain.fenceGeneration !== lease.evidence.generation || + !drain.throughCommandCursor || + !drain.durableBarrier + ) { + throw contractFault('accepted_command_drain_invalid'); + } +} + +export function assertCoordinationBarrierEvidence( + run: BackupRunRecord, + lease: BackupWriterFenceLease, + drain: BackupAcceptedCommandDrain, + participants: readonly FlushedBackupParticipant[], + barrier: BackupCoordinationBarrier +): void { + const observedPoints = barrier.participantRecoveryPoints + .map(recoveryPointKey) + .sort((left, right) => left.localeCompare(right)); + const expectedPoints = participants + .map((participant) => + recoveryPointKey({ + participantId: participant.descriptor.participantId, + sourceGeneration: participant.sourceGeneration, + durableBarrier: participant.durableBarrier, + }) + ) + .sort((left, right) => left.localeCompare(right)); + if ( + barrier.stateCompatibilityManifest.schemaVersion !== + COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION || + barrier.acceptedCommandDrain.admittedRunId !== run.backupRunId || + barrier.acceptedCommandDrain.fenceGeneration !== lease.evidence.generation || + barrier.acceptedCommandDrain.throughCommandCursor !== drain.throughCommandCursor || + barrier.acceptedCommandDrain.durableBarrier !== drain.durableBarrier || + new Set(observedPoints).size !== observedPoints.length || + !sameStrings(observedPoints, expectedPoints) + ) { + throw contractFault('coordination_barrier_invalid'); + } +} + +export function recoveryPointKey(point: { + readonly participantId: string; + readonly sourceGeneration: string; + readonly durableBarrier: string; +}): string { + return JSON.stringify([point.participantId, point.sourceGeneration, point.durableBarrier]); +} + +export function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function requireParticipantEvidence( + evidence: readonly TEvidence[], + participantId: string +): TEvidence { + const matches = evidence.filter( + (candidate) => candidate.descriptor.participantId === participantId + ); + if (matches.length !== 1) throw contractFault('participant_evidence_missing_or_duplicated'); + return matches[0]; +} + +export function sameDescriptorLists( + left: readonly BackupParticipantDescriptor[], + right: readonly BackupParticipantDescriptor[] +): boolean { + if (left.length !== right.length) return false; + const sortedLeft = [...left].sort((a, b) => a.participantId.localeCompare(b.participantId)); + const sortedRight = [...right].sort((a, b) => a.participantId.localeCompare(b.participantId)); + return sortedLeft.every((descriptor, index) => sameDescriptor(descriptor, sortedRight[index])); +} + +export function sameDescriptor( + left: BackupParticipantDescriptor, + right: BackupParticipantDescriptor | undefined +): boolean { + return ( + !!right && + left.participantId === right.participantId && + left.kind === right.kind && + left.contractVersion === right.contractVersion && + left.schemaVersion === right.schemaVersion && + left.required === right.required + ); +} + +export function sortEntries(entries: readonly BackupManifestEntry[]): BackupManifestEntry[] { + return [...entries].sort((left, right) => left.entryId.localeCompare(right.entryId)); +} + +export function sortExclusions(exclusions: readonly BackupExclusion[]): BackupExclusion[] { + return [...exclusions].sort((left, right) => { + const participantOrder = left.participantId.localeCompare(right.participantId); + return participantOrder || left.logicalType.localeCompare(right.logicalType); + }); +} + +export function sortFlushedParticipants( + participants: readonly FlushedBackupParticipant[] +): FlushedBackupParticipant[] { + return [...participants].sort((left, right) => + left.descriptor.participantId.localeCompare(right.descriptor.participantId) + ); +} + +export function assertVerificationPlanMatchesRun(run: BackupRunRecord): void { + const plan = run.verificationPlan; + if (!plan) throw contractFault('verification_plan_run_mismatch'); + if ( + plan.manifest.backupRunId !== run.backupRunId || + plan.manifest.deploymentId !== run.deploymentId || + plan.manifest.productKind !== run.productKind || + plan.manifest.purpose !== run.purpose || + plan.marker.backupRunId !== run.backupRunId || + plan.marker.manifestHash !== plan.manifest.manifestHash + ) { + throw contractFault('verification_plan_run_mismatch'); + } +} + +export function contractFault(code: string): BackupExecutionFault { + return new BackupExecutionFault( + code, + 'operator_required', + 'Durable coordination backup contract evidence is inconsistent' + ); +} + +export function classifyExecutionFault(error: unknown): BackupExecutionFault { + if (error instanceof BackupExecutionFault) return error; + if (error instanceof BackupRunInvariantError) { + return new BackupExecutionFault( + error.code, + 'operator_required', + 'BackupRun invariant validation failed', + { cause: error } + ); + } + return new BackupExecutionFault( + 'backup_port_failure', + 'failed', + 'A coordination backup boundary failed', + { cause: error } + ); +} + +export function asServiceError( + error: unknown, + run: BackupRunRecord +): CoordinationBackupServiceError { + if (error instanceof CoordinationBackupServiceError) return error; + if (error instanceof BackupExecutionFault && error.code === 'immutable_verification_failed') { + return new CoordinationBackupServiceError( + 'immutable_verification_failed', + 'Committed backup failed immutable verification', + run, + { cause: error } + ); + } + return new CoordinationBackupServiceError( + 'run_contract_invalid', + 'BackupRun recovery could not establish a safe durable result', + run, + { cause: error } + ); +} + +export function pendingFenceCompletion( + run: BackupRunRecord, + disposition: BackupFenceCompletionDisposition +): PendingBackupFenceCompletion { + if (!run.fence) throw contractFault('pending_fence_completion_missing_fence'); + return pendingFenceCompletionFor(run.fence.generation, disposition); +} + +export function pendingFenceCompletionFor( + generation: number, + disposition: BackupFenceCompletionDisposition +): PendingBackupFenceCompletion { + return Object.freeze({ + generation, + disposition, + status: 'pending' as const, + completedAt: null, + }); +} + +export function assertCompletedFenceRecord( + previous: BackupRunRecord, + completed: BackupRunRecord +): void { + assertBackupRunRecord(completed); + const previousCompletion = previous.fenceCompletion; + if ( + !previousCompletion || + completed.backupRunId !== previous.backupRunId || + completed.state !== previous.state || + completed.revision <= previous.revision || + completed.fenceCompletion?.status !== 'completed' || + completed.fenceCompletion.generation !== previousCompletion.generation || + completed.fenceCompletion.disposition !== previousCompletion.disposition + ) { + throw contractFault('fence_completion_result_mismatch'); + } +} + +export function replaceTerminalRecord(error: Error, run: BackupRunRecord): Error { + if (!(error instanceof CoordinationBackupServiceError) || !error.terminalRecord) return error; + return new CoordinationBackupServiceError(error.code, error.message, run, { cause: error.cause }); +} diff --git a/src/features/coordination-backup/core/application/coordinationBackupServiceTypes.ts b/src/features/coordination-backup/core/application/coordinationBackupServiceTypes.ts new file mode 100644 index 0000000000..03e180ff86 --- /dev/null +++ b/src/features/coordination-backup/core/application/coordinationBackupServiceTypes.ts @@ -0,0 +1,62 @@ +import type { BackupRunRecord } from '../../contracts'; +import type { + BackupCoordinationFlushPort, + BackupIdentityInventoryPort, + BackupManifestHashPort, + BackupPublicationPort, + BackupRunRepository, + BackupWriterFencePort, + CoordinationBackupClock, + CoordinationBackupParticipant, + ImmutableBackupVerifierPort, + SqliteOnlineBackupPort, + SqliteSnapshotIntegrityPort, +} from './ports'; + +export type CoordinationBackupServiceErrorCode = + | 'run_not_found' + | 'run_contract_invalid' + | 'participant_contract_mismatch' + | 'backup_fence_busy' + | 'immutable_verification_failed' + | 'backup_run_failed' + | 'backup_run_operator_required' + | 'fence_completion_failed'; + +export class CoordinationBackupServiceError extends Error { + constructor( + readonly code: CoordinationBackupServiceErrorCode, + message: string, + readonly terminalRecord: BackupRunRecord | null = null, + options?: ErrorOptions + ) { + super(message, options); + this.name = 'CoordinationBackupServiceError'; + } +} + +export class BackupExecutionFault extends Error { + constructor( + readonly code: string, + readonly disposition: 'failed' | 'operator_required', + readonly safeMessage: string, + options?: ErrorOptions + ) { + super(safeMessage, options); + this.name = 'BackupExecutionFault'; + } +} + +export interface CoordinationBackupServiceDependencies { + readonly runs: BackupRunRepository; + readonly writerFence: BackupWriterFencePort; + readonly coordinationFlush: BackupCoordinationFlushPort; + readonly identityInventory: BackupIdentityInventoryPort; + readonly onlineBackup: SqliteOnlineBackupPort; + readonly sqliteIntegrity: SqliteSnapshotIntegrityPort; + readonly manifestHash: BackupManifestHashPort; + readonly publication: BackupPublicationPort; + readonly immutableVerifier: ImmutableBackupVerifierPort; + readonly clock: CoordinationBackupClock; + readonly participants: readonly CoordinationBackupParticipant[]; +} diff --git a/src/features/coordination-backup/core/application/index.ts b/src/features/coordination-backup/core/application/index.ts new file mode 100644 index 0000000000..4f1afa813b --- /dev/null +++ b/src/features/coordination-backup/core/application/index.ts @@ -0,0 +1,2 @@ +export * from './CoordinationBackupService'; +export type * from './ports'; diff --git a/src/features/coordination-backup/core/application/ports.ts b/src/features/coordination-backup/core/application/ports.ts new file mode 100644 index 0000000000..5ebebbc5e9 --- /dev/null +++ b/src/features/coordination-backup/core/application/ports.ts @@ -0,0 +1,254 @@ +import type { + BackupAcceptedCommandDrain, + BackupCommitMarker, + BackupCoordinationBarrier, + BackupExclusion, + BackupFenceCompletionDisposition, + BackupFenceEvidence, + BackupIdentityInventory, + BackupManifest, + BackupManifestBody, + BackupManifestEntry, + BackupParticipantDescriptor, + BackupPublicationInspection, + BackupRunId, + BackupRunRecord, + BackupRunTransitionRequest, + BackupVerificationPlan, + CommittedBackupPublication, + FlushedBackupParticipant, + ImmutableBackupVerification, + OnlineBackupSnapshot, + PreparedBackupParticipant, + RequestCoordinationBackup, + Sha256Digest, + SqliteIntegrityEvidence, +} from '../../contracts'; + +export interface CreateBackupRunRequest extends RequestCoordinationBackup { + readonly requestedAt: string; + readonly participantDescriptors: readonly BackupParticipantDescriptor[]; +} + +export interface SaveBackupVerificationPlanRequest { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly plan: BackupVerificationPlan; + readonly at: string; +} + +export interface MarkBackupFenceCompletedRequest { + readonly backupRunId: BackupRunId; + readonly expectedRevision: number; + readonly generation: number; + readonly disposition: BackupFenceCompletionDisposition; + readonly completedAt: string; +} + +/** + * Implementations must persist create, transition, and verification-plan writes transactionally. + * Transition is compare-and-set on both state and revision; it never reports success before durable + * storage has committed. listRecoverable includes active runs and terminal runs whose durable fence + * completion is still pending. + */ +export interface BackupRunRepository { + create(request: CreateBackupRunRequest): Promise; + get(backupRunId: BackupRunId): Promise; + listRecoverable(): Promise; + transition(request: BackupRunTransitionRequest): Promise; + saveVerificationPlan(request: SaveBackupVerificationPlanRequest): Promise; + markFenceCompleted(request: MarkBackupFenceCompletedRequest): Promise; +} + +export interface BackupWriterFenceLease { + readonly leaseId: string; + readonly evidence: BackupFenceEvidence; +} + +export interface AcquireBackupWriterFenceRequest { + readonly backupRunId: BackupRunId; + readonly expectedGeneration: number | null; +} + +export interface CompleteBackupWriterFenceRequest { + readonly lease: BackupWriterFenceLease; + /** operator_required relinquishes ownership but must leave mutation admission durably closed. */ + readonly disposition: BackupFenceCompletionDisposition; +} + +export type AcquireBackupWriterFenceResult = + | { readonly status: 'acquired'; readonly lease: BackupWriterFenceLease } + | { readonly status: 'busy'; readonly activeRunId: BackupRunId }; + +export interface BackupWriterFencePort { + /** + * Exactly one exclusive lease may be acquired for a deployment fence at a time. Reacquiring for + * the same run and expected generation returns the same durable lease identity. + */ + acquire(request: AcquireBackupWriterFenceRequest): Promise; + /** Idempotent for the durable lease identity, including after a prior successful completion. */ + complete(request: CompleteBackupWriterFenceRequest): Promise; +} + +export interface PrepareBackupParticipantRequest { + readonly backupRunId: BackupRunId; + readonly fence: BackupFenceEvidence; +} + +export interface FlushBackupParticipantRequest< + TParticipantId extends string = string, + TKind extends string = string, +> extends PrepareBackupParticipantRequest { + readonly prepared: PreparedBackupParticipant; +} + +export interface StageBackupParticipantRequest< + TParticipantId extends string = string, + TKind extends string = string, +> extends PrepareBackupParticipantRequest { + readonly flushed: FlushedBackupParticipant; +} + +export interface StagedBackupParticipant { + readonly participantId: string; + readonly entries: readonly BackupManifestEntry[]; + readonly exclusions: readonly BackupExclusion[]; +} + +export interface VerifyBackupParticipantRequest< + TParticipantId extends string = string, + TKind extends string = string, +> extends StageBackupParticipantRequest { + readonly stagedEntries: readonly BackupManifestEntry[]; +} + +export type BackupParticipantVerification = + | { readonly status: 'verified' } + | { readonly status: 'invalid'; readonly reason: string }; + +/** + * Feature-owned participants expose typed lifecycle evidence, never roots or filesystem paths. + * Every method is idempotent for the tuple (BackupRunId, descriptor, sourceGeneration). + */ +export interface CoordinationBackupParticipant< + TParticipantId extends string = string, + TKind extends string = string, +> { + readonly descriptor: BackupParticipantDescriptor; + prepare( + request: PrepareBackupParticipantRequest + ): Promise>; + flush( + request: FlushBackupParticipantRequest + ): Promise>; + stage( + request: StageBackupParticipantRequest + ): Promise; + verify( + request: VerifyBackupParticipantRequest + ): Promise; +} + +export interface DrainAcceptedBackupCommandsRequest { + readonly backupRunId: BackupRunId; + readonly fence: BackupFenceEvidence; +} + +export interface CaptureCoordinationBarrierRequest extends DrainAcceptedBackupCommandsRequest { + readonly acceptedCommandDrain: BackupAcceptedCommandDrain; + readonly participants: readonly FlushedBackupParticipant[]; +} + +/** Fences/drains accepted commands before participants flush, then binds all durable barriers. */ +export interface BackupCoordinationFlushPort { + drainAcceptedCommands( + request: DrainAcceptedBackupCommandsRequest + ): Promise; + captureBarrier(request: CaptureCoordinationBarrierRequest): Promise; +} + +export interface CaptureBackupIdentityInventoryRequest { + readonly backupRunId: BackupRunId; + readonly fence: BackupFenceEvidence; + readonly barrier: BackupCoordinationBarrier; +} + +export interface BackupIdentityInventoryPort { + capture(request: CaptureBackupIdentityInventoryRequest): Promise; +} + +export type OnlineBackupResult = + | { readonly status: 'completed'; readonly snapshot: OnlineBackupSnapshot } + | { + readonly status: 'failed'; + readonly reason: 'busy_timeout' | 'deadline_exceeded' | 'source_corrupt'; + }; + +/** + * The only SQLite snapshot port. Adapters must invoke the driver's Online Backup API, bound BUSY and + * deadline handling, remove partial output on failure, and never substitute raw db/WAL/SHM copying. + */ +export interface SqliteOnlineBackupPort { + createOnlineSnapshot(request: { + readonly backupRunId: BackupRunId; + readonly fence: BackupFenceEvidence; + readonly coordinationBarrier: BackupCoordinationBarrier; + readonly participants: readonly FlushedBackupParticipant[]; + }): Promise; +} + +export type SqliteIntegrityResult = + | { readonly status: 'valid'; readonly evidence: SqliteIntegrityEvidence } + | { + readonly status: 'invalid'; + readonly reason: + | 'integrity_check_failed' + | 'application_id_mismatch' + | 'schema_mismatch' + | 'migration_incomplete' + | 'required_identity_missing'; + }; + +/** Reopens the staged snapshot through an independent connection before publication. */ +export interface SqliteSnapshotIntegrityPort { + reopenAndCheck(request: { + readonly backupRunId: BackupRunId; + readonly snapshot: OnlineBackupSnapshot; + }): Promise; +} + +export interface BackupManifestHashPort { + hashCanonicalManifest(body: BackupManifestBody): Promise; +} + +export interface BackupPublicationPort { + preparePrivateStage(backupRunId: BackupRunId): Promise; + inspect(backupRunId: BackupRunId): Promise; + writeRootManifest(request: { + readonly backupRunId: BackupRunId; + readonly manifest: BackupManifest; + }): Promise; + /** This is the final content write. It also fsyncs the stage and its parent before returning. */ + writeCommitMarkerLast(request: { + readonly backupRunId: BackupRunId; + readonly marker: BackupCommitMarker; + }): Promise; + /** Atomically renames the sealed private stage to its immutable committed name. */ + commitSealedStage(request: { + readonly backupRunId: BackupRunId; + readonly manifestHash: Sha256Digest; + }): Promise; + abortUncommittedStage(backupRunId: BackupRunId): Promise; +} + +export interface ImmutableBackupVerifierPort { + verify(request: { + readonly backupRunId: BackupRunId; + readonly location: 'staging' | 'committed'; + readonly expectedPlan: BackupVerificationPlan; + }): Promise; +} + +export interface CoordinationBackupClock { + nowIso(): string; +} diff --git a/src/features/coordination-backup/core/domain/backupRunInvariantError.ts b/src/features/coordination-backup/core/domain/backupRunInvariantError.ts new file mode 100644 index 0000000000..5626384bc2 --- /dev/null +++ b/src/features/coordination-backup/core/domain/backupRunInvariantError.ts @@ -0,0 +1,17 @@ +export type BackupRunInvariantErrorCode = + | 'invalid_state' + | 'invalid_transition' + | 'missing_transition_evidence' + | 'invalid_record' + | 'invalid_artifact_source'; + +export class BackupRunInvariantError extends Error { + constructor( + readonly code: BackupRunInvariantErrorCode, + message: string, + readonly details: Readonly> = {} + ) { + super(message); + this.name = 'BackupRunInvariantError'; + } +} diff --git a/src/features/coordination-backup/core/domain/backupRunState.ts b/src/features/coordination-backup/core/domain/backupRunState.ts new file mode 100644 index 0000000000..06da7a778e --- /dev/null +++ b/src/features/coordination-backup/core/domain/backupRunState.ts @@ -0,0 +1,370 @@ +import { parseTeamId } from '@shared/contracts/hosted/identifiers'; + +import { + type ActiveBackupRunState, + type BackupRunRecord, + type BackupRunState, + COORDINATION_BACKUP_COMMIT_MARKER_FORMAT, + COORDINATION_BACKUP_FORMAT, + COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION, + type CopiedSourceBackupRun, + type ImmutableBackupInspection, + type RestoreSetValidationRequest, + type RestoreSetValidationResult, + SQLITE_ONLINE_BACKUP_METHOD, +} from '../../contracts'; + +import { BackupRunInvariantError } from './backupRunInvariantError'; +import { + assertBackupRunState, + assertNonEmpty, + assertPositiveInteger, + assertSupportedParticipantDescriptor, + compareIdentityInventories, + findSqliteEntry, + invalidRecord, + requireEvidence, + sameManifestEntry, + stateAtOrAfter, + validateCompatibilityManifest, + validateCoordinationBarrier, + validateCopiedSourceRun, + validateFenceCompletion, + validateIdentityInventory, + validateManifestEntries, + validateParticipantSet, + validatePersistedParticipantEvidence, + validateRecoveryPointEvidence, +} from './backupRunValidation'; + +export { + BackupRunInvariantError, + type BackupRunInvariantErrorCode, +} from './backupRunInvariantError'; + +const LIVE_FORWARD_TRANSITIONS = new Map([ + ['requested', 'fencing'], + ['fencing', 'quiescing'], + ['quiescing', 'sqlite_snapshot'], + ['sqlite_snapshot', 'file_stage'], + ['file_stage', 'verifying'], + ['verifying', 'committed'], +]); + +const ACTIVE_STATES = new Set([ + 'requested', + 'fencing', + 'quiescing', + 'sqlite_snapshot', + 'file_stage', + 'verifying', +]); + +const TERMINAL_STATES = new Set([ + 'committed', + 'failed', + 'operator_required', + 'artifact_source', +]); + +export function transitionBackupRunState( + current: BackupRunState, + next: BackupRunState +): BackupRunState { + assertBackupRunState(current); + assertBackupRunState(next); + + const isForward = LIVE_FORWARD_TRANSITIONS.get(current) === next; + const isFailure = + ACTIVE_STATES.has(current) && (next === 'failed' || next === 'operator_required'); + if (!isForward && !isFailure) { + throw new BackupRunInvariantError( + 'invalid_transition', + 'BackupRun state transition is not allowed', + { current, next } + ); + } + return next; +} + +export function isActiveBackupRunState(state: BackupRunState): state is ActiveBackupRunState { + assertBackupRunState(state); + return ACTIVE_STATES.has(state); +} + +export function isTerminalBackupRunState(state: BackupRunState): boolean { + assertBackupRunState(state); + return TERMINAL_STATES.has(state); +} + +export function assertBackupRunRecord(record: BackupRunRecord): void { + assertBackupRunState(record.state); + assertNonEmpty(record.backupRunId, 'backupRunId'); + assertNonEmpty(record.deploymentId, 'deploymentId'); + assertPositiveInteger(record.revision, 'revision'); + + const descriptorIds = new Set(); + for (const descriptor of record.participantDescriptors) { + assertSupportedParticipantDescriptor(descriptor); + if (descriptorIds.has(descriptor.participantId)) { + throw new BackupRunInvariantError( + 'invalid_record', + 'BackupRun participant descriptors must be unique', + { participantId: descriptor.participantId } + ); + } + descriptorIds.add(descriptor.participantId); + } + + if ((record.fence === null) !== (record.fenceLeaseId === null)) { + throw invalidRecord('BackupRun writer fence lease identity is incomplete'); + } + if ( + record.fence && + (record.fence.admittedRunId !== record.backupRunId || + !Number.isSafeInteger(record.fence.generation) || + record.fence.generation < 1) + ) { + throw invalidRecord('BackupRun writer fence evidence is invalid'); + } + if (isActiveBackupRunState(record.state) && record.fenceCompletion !== null) { + throw invalidRecord('An active BackupRun cannot claim writer fence completion'); + } + + if (stateAtOrAfter(record.state, 'quiescing')) { + requireEvidence(record.fence, record.state, 'fence'); + requireEvidence(record.fenceLeaseId, record.state, 'fenceLeaseId'); + } + if (stateAtOrAfter(record.state, 'sqlite_snapshot')) { + requireEvidence(record.preparedParticipants, record.state, 'preparedParticipants'); + requireEvidence(record.flushedParticipants, record.state, 'flushedParticipants'); + requireEvidence(record.coordinationBarrier, record.state, 'coordinationBarrier'); + requireEvidence(record.identityInventory, record.state, 'identityInventory'); + validatePersistedParticipantEvidence( + record.participantDescriptors, + record.flushedParticipants, + true + ); + validateCoordinationBarrier( + record.backupRunId, + record.fence?.generation ?? 0, + record.coordinationBarrier, + record.flushedParticipants + ); + if ( + record.identityInventory?.schemaVersion !== + COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION || + record.identityInventory.deploymentId !== record.deploymentId + ) { + throw invalidRecord('BackupRun identity inventory deployment does not match the run'); + } + } + if (stateAtOrAfter(record.state, 'file_stage')) { + requireEvidence(record.sqliteSnapshot, record.state, 'sqliteSnapshot'); + if (record.sqliteSnapshot?.method !== SQLITE_ONLINE_BACKUP_METHOD) { + throw new BackupRunInvariantError( + 'invalid_record', + 'BackupRun snapshot was not created by the SQLite Online Backup API', + { method: record.sqliteSnapshot?.method } + ); + } + if ( + record.sqliteSnapshot.sourceRunId !== record.backupRunId || + record.sqliteSnapshot.entry.kind !== 'sqlite_snapshot' + ) { + throw invalidRecord('BackupRun SQLite snapshot evidence does not match the run'); + } + } + if (stateAtOrAfter(record.state, 'verifying')) { + requireEvidence(record.stagedEntries, record.state, 'stagedEntries'); + requireEvidence(record.exclusions, record.state, 'exclusions'); + } + if (record.state === 'committed') { + requireEvidence(record.verificationPlan, record.state, 'verificationPlan'); + requireEvidence(record.publication, record.state, 'publication'); + if ( + record.verificationPlan?.manifest.backupRunId !== record.backupRunId || + record.verificationPlan.manifest.sourceBackupRunId !== record.backupRunId || + record.verificationPlan.manifest.deploymentId !== record.deploymentId || + record.verificationPlan.manifest.productKind !== record.productKind || + record.verificationPlan.manifest.purpose !== record.purpose || + record.verificationPlan.manifest.fenceGeneration !== record.fence?.generation || + record.verificationPlan.manifest.format !== COORDINATION_BACKUP_FORMAT || + record.verificationPlan.marker.format !== COORDINATION_BACKUP_COMMIT_MARKER_FORMAT || + record.verificationPlan.marker.backupRunId !== record.backupRunId || + record.verificationPlan.marker.deploymentId !== record.deploymentId || + record.verificationPlan.marker.sealedAt !== record.verificationPlan.manifest.sealedAt || + record.verificationPlan.marker.manifestHash !== + record.verificationPlan.manifest.manifestHash || + record.publication?.backupRunId !== record.backupRunId || + record.publication.manifestHash !== record.verificationPlan.manifest.manifestHash + ) { + throw invalidRecord('Committed BackupRun publication evidence is inconsistent'); + } + validateFenceCompletion(record, 'committed'); + } + if (record.state === 'failed' || record.state === 'operator_required') { + requireEvidence(record.failure, record.state, 'failure'); + validateFenceCompletion(record, record.state === 'failed' ? 'aborted' : 'operator_required'); + } + if (record.state === 'artifact_source' && record.fenceCompletion !== null) { + throw invalidRecord('A copied artifact source cannot complete the source deployment fence'); + } +} + +export function finalizeCopiedSourceRun( + source: CopiedSourceBackupRun, + expectedRunId: CopiedSourceBackupRun['backupRunId'] +): CopiedSourceBackupRun & { readonly state: 'artifact_source' } { + if (source.backupRunId !== expectedRunId || source.state !== 'sqlite_snapshot') { + throw new BackupRunInvariantError( + 'invalid_artifact_source', + 'Only the matching sqlite_snapshot BackupRun copied by its own artifact may be finalized', + { + actualRunId: source.backupRunId, + expectedRunId, + sourceState: source.state, + } + ); + } + return Object.freeze({ ...source, state: 'artifact_source' as const }); +} + +export type ImmutableInspectionValidation = + | { readonly status: 'valid' } + | { readonly status: 'invalid'; readonly reasons: readonly string[] }; + +export function validateImmutableBackupInspection( + inspection: ImmutableBackupInspection +): ImmutableInspectionValidation { + const reasons: string[] = []; + const { manifest, marker } = inspection; + + if (manifest.format !== COORDINATION_BACKUP_FORMAT) reasons.push('unsupported_manifest_format'); + if (marker.format !== COORDINATION_BACKUP_COMMIT_MARKER_FORMAT) { + reasons.push('unsupported_commit_marker_format'); + } + if (manifest.manifestHash !== inspection.computedManifestHash) { + reasons.push('manifest_hash_mismatch'); + } + if (marker.manifestHash !== manifest.manifestHash) reasons.push('marker_hash_mismatch'); + if (marker.backupRunId !== manifest.backupRunId) reasons.push('marker_run_mismatch'); + if (marker.deploymentId !== manifest.deploymentId) reasons.push('marker_deployment_mismatch'); + if (marker.sealedAt !== manifest.sealedAt) reasons.push('marker_sealed_at_mismatch'); + if (manifest.sourceBackupRunId !== manifest.backupRunId) { + reasons.push('source_run_manifest_mismatch'); + } + if (manifest.productKind !== 'coordination_backup') reasons.push('unsupported_product_kind'); + if (manifest.sqliteSnapshot.method !== SQLITE_ONLINE_BACKUP_METHOD) { + reasons.push('sqlite_snapshot_method_invalid'); + } + if (manifest.sqliteSnapshot.sourceRunId !== manifest.sourceBackupRunId) { + reasons.push('sqlite_source_run_mismatch'); + } + if (!sameManifestEntry(manifest.sqliteSnapshot.entry, findSqliteEntry(manifest.entries))) { + reasons.push('sqlite_manifest_entry_mismatch'); + } + if (manifest.sqliteIntegrity.integrityCheck !== 'ok') reasons.push('sqlite_integrity_not_ok'); + if (manifest.sqliteIntegrity.applicationId !== manifest.sqliteSnapshot.applicationId) { + reasons.push('sqlite_application_id_mismatch'); + } + if (manifest.sqliteIntegrity.userVersion !== manifest.sqliteSnapshot.userVersion) { + reasons.push('sqlite_user_version_mismatch'); + } + if (manifest.identityInventory.deploymentId !== manifest.deploymentId) { + reasons.push('identity_deployment_mismatch'); + } + validateCompatibilityManifest(manifest.coordinationBarrier, reasons); + validateRecoveryPointEvidence( + manifest.backupRunId, + manifest.fenceGeneration, + manifest.coordinationBarrier, + manifest.participants, + reasons + ); + + validateManifestEntries(manifest.entries, inspection.measuredEntries, reasons); + validateParticipantSet(manifest, reasons); + validateIdentityInventory(manifest.identityInventory, manifest.entries, reasons); + compareIdentityInventories( + manifest.identityInventory, + inspection.observedIdentityInventory, + reasons + ); + validateCopiedSourceRun(inspection, reasons); + + return reasons.length === 0 + ? { status: 'valid' } + : { status: 'invalid', reasons: Object.freeze(reasons) }; +} + +export function validateCoordinationBackupRestoreSet( + request: RestoreSetValidationRequest +): RestoreSetValidationResult { + const reasons: string[] = []; + if (request.classification !== 'committed_v2') { + reasons.push( + request.classification === 'legacy_unverified' + ? 'legacy_unverified_not_restorable' + : 'partial_backup_not_restorable' + ); + } + if (request.purpose === 'replace_deployment') { + reasons.push('coordination_backup_cannot_replace_deployment'); + } + if (!request.inspection) { + reasons.push('immutable_inspection_missing'); + return { status: 'invalid', reasons: Object.freeze(reasons) }; + } + + const validation = validateImmutableBackupInspection(request.inspection); + if (validation.status === 'invalid') reasons.push(...validation.reasons); + const { manifest, copiedSourceRun } = request.inspection; + if (manifest.purpose !== request.purpose) { + reasons.push('restore_purpose_mismatch'); + } + if (manifest.deploymentId !== request.expectedDeploymentId) { + reasons.push('restore_deployment_mismatch'); + } + + const activeTeamIds: ReturnType[] = []; + const tombstonedIdentityIds: string[] = []; + for (const identity of manifest.identityInventory.identities) { + if (identity.state === 'tombstoned') tombstonedIdentityIds.push(identity.identityId); + if (identity.kind === 'team' && identity.state === 'active') { + try { + activeTeamIds.push(parseTeamId(identity.identityId)); + } catch { + reasons.push('team_identity_id_invalid'); + } + } + } + + if (reasons.length > 0) { + return { status: 'invalid', reasons: Object.freeze([...new Set(reasons)]) }; + } + + const finalized = finalizeCopiedSourceRun(copiedSourceRun, manifest.sourceBackupRunId); + const workspaceRegistrations = Object.freeze( + Object.fromEntries( + manifest.identityInventory.workspaceRegistrations + .filter((workspace) => workspace.state === 'registered') + .map((workspace) => [workspace.registrationKey, workspace.workspaceId]) + ) + ); + + return { + status: 'valid', + mapping: Object.freeze({ + deploymentId: manifest.deploymentId, + activeTeamIds: Object.freeze(activeTeamIds), + tombstonedIdentityIds: Object.freeze(tombstonedIdentityIds), + workspaceRegistrations, + sourceRunFinalization: Object.freeze({ + backupRunId: finalized.backupRunId, + from: 'sqlite_snapshot' as const, + to: finalized.state, + }), + }), + }; +} diff --git a/src/features/coordination-backup/core/domain/backupRunValidation.ts b/src/features/coordination-backup/core/domain/backupRunValidation.ts new file mode 100644 index 0000000000..e848e94bd0 --- /dev/null +++ b/src/features/coordination-backup/core/domain/backupRunValidation.ts @@ -0,0 +1,543 @@ +import { + type ActiveBackupRunState, + BACKUP_RUN_STATES, + type BackupCoordinationBarrier, + type BackupFenceCompletionDisposition, + type BackupIdentityInventory, + type BackupManifestEntry, + type BackupParticipantDescriptor, + type BackupRunRecord, + type BackupRunState, + COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION, + COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION, + COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION, + COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION, + type FlushedBackupParticipant, + type ImmutableBackupInspection, + type MeasuredBackupEntry, +} from '../../contracts'; + +import { BackupRunInvariantError } from './backupRunInvariantError'; + +export function assertBackupRunState(state: BackupRunState): void { + if (!(BACKUP_RUN_STATES as readonly unknown[]).includes(state)) { + throw new BackupRunInvariantError('invalid_state', 'Unknown BackupRun state', { state }); + } +} + +export function stateAtOrAfter(state: BackupRunState, threshold: ActiveBackupRunState): boolean { + const order: readonly BackupRunState[] = [ + 'requested', + 'fencing', + 'quiescing', + 'sqlite_snapshot', + 'file_stage', + 'verifying', + 'committed', + ]; + const stateIndex = order.indexOf(state); + const thresholdIndex = order.indexOf(threshold); + return stateIndex >= thresholdIndex; +} + +export function requireEvidence( + value: unknown, + state: BackupRunState, + evidenceName: string +): asserts value { + if (value === null || value === undefined) { + throw new BackupRunInvariantError( + 'missing_transition_evidence', + 'BackupRun is missing evidence required by its durable state', + { state, evidenceName } + ); + } +} + +export function assertNonEmpty(value: string, field: string): void { + if (value.length === 0) { + throw new BackupRunInvariantError('invalid_record', 'BackupRun field must not be empty', { + field, + }); + } +} + +export function assertPositiveInteger(value: number, field: string): void { + if (!Number.isInteger(value) || value < 1) { + throw new BackupRunInvariantError( + 'invalid_record', + 'BackupRun numeric field must be a positive integer', + { field, value } + ); + } +} + +export function assertSupportedParticipantDescriptor( + descriptor: BackupParticipantDescriptor +): void { + assertNonEmpty(descriptor.participantId, 'participantId'); + assertNonEmpty(descriptor.kind, 'participant kind'); + if ( + descriptor.contractVersion !== COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION || + descriptor.schemaVersion !== COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION + ) { + throw invalidRecord('BackupRun participant contract or schema version is unsupported'); + } +} + +export function validateFenceCompletion( + record: BackupRunRecord, + expectedDisposition: BackupFenceCompletionDisposition +): void { + if (!record.fence) { + if (record.fenceLeaseId !== null || record.fenceCompletion !== null) { + throw invalidRecord('BackupRun fence completion exists without a durable fence'); + } + if (expectedDisposition === 'committed') { + throw invalidRecord('A committed BackupRun must complete its durable writer fence'); + } + return; + } + requireEvidence(record.fenceLeaseId, record.state, 'fenceLeaseId'); + requireEvidence(record.fenceCompletion, record.state, 'fenceCompletion'); + if ( + record.fenceCompletion.generation !== record.fence.generation || + record.fenceCompletion.disposition !== expectedDisposition || + (record.fenceCompletion.status === 'pending' && record.fenceCompletion.completedAt !== null) || + (record.fenceCompletion.status === 'completed' && !record.fenceCompletion.completedAt) + ) { + throw invalidRecord('BackupRun fence completion evidence is inconsistent'); + } +} + +export function validateCoordinationBarrier( + backupRunId: BackupRunRecord['backupRunId'], + fenceGeneration: number, + barrier: BackupCoordinationBarrier, + participants: readonly FlushedBackupParticipant[] +): void { + const reasons: string[] = []; + validateCompatibilityManifest(barrier, reasons); + validateRecoveryPointEvidence(backupRunId, fenceGeneration, barrier, participants, reasons); + if (reasons.length > 0) { + throw invalidRecord('BackupRun coordination recovery-point evidence is inconsistent'); + } +} + +export function validateCompatibilityManifest( + barrier: BackupCoordinationBarrier, + reasons: string[] +): void { + const compatibility = barrier.stateCompatibilityManifest; + if (compatibility.schemaVersion !== COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION) { + reasons.push('unsupported_compatibility_schema_version'); + } + if (!compatibility.manifestId || !/^[0-9a-f]{64}$/.test(compatibility.sha256)) { + reasons.push('compatibility_manifest_invalid'); + } +} + +export function validateRecoveryPointEvidence( + backupRunId: BackupRunRecord['backupRunId'], + fenceGeneration: number, + barrier: BackupCoordinationBarrier, + participants: readonly FlushedBackupParticipant[], + reasons: string[] +): void { + const drain = barrier.acceptedCommandDrain; + if ( + drain.admittedRunId !== backupRunId || + drain.fenceGeneration !== fenceGeneration || + !drain.throughCommandCursor || + !drain.durableBarrier + ) { + reasons.push('accepted_command_drain_mismatch'); + } + if (!barrier.eventCursor || !barrier.eventEpoch) { + reasons.push('coordination_cursor_invalid'); + } + if (Object.values(barrier.journalCursors).some((cursor) => !cursor)) { + reasons.push('journal_cursor_invalid'); + } + + const participantPoints = barrier.participantRecoveryPoints + .map(participantRecoveryPointKey) + .sort((left, right) => left.localeCompare(right)); + const flushedPoints = participants + .map((participant) => + participantRecoveryPointKey({ + participantId: participant.descriptor.participantId, + sourceGeneration: participant.sourceGeneration, + durableBarrier: participant.durableBarrier, + }) + ) + .sort((left, right) => left.localeCompare(right)); + if ( + new Set(participantPoints).size !== participantPoints.length || + !sameStrings(participantPoints, flushedPoints) + ) { + reasons.push('participant_recovery_point_mismatch'); + } +} + +function participantRecoveryPointKey(point: { + readonly participantId: string; + readonly sourceGeneration: string; + readonly durableBarrier: string; +}): string { + return JSON.stringify([point.participantId, point.sourceGeneration, point.durableBarrier]); +} + +export function invalidRecord(message: string): BackupRunInvariantError { + return new BackupRunInvariantError('invalid_record', message); +} + +export function validatePersistedParticipantEvidence( + descriptors: readonly BackupRunRecord['participantDescriptors'][number][], + evidence: readonly NonNullable[number][], + requireFlush: boolean +): void { + if (descriptors.length !== evidence.length) { + throw invalidRecord('BackupRun participant evidence set is incomplete'); + } + const evidenceById = new Map( + evidence.map((item) => [item.descriptor.participantId, item] as const) + ); + if (evidenceById.size !== evidence.length) { + throw invalidRecord('BackupRun participant evidence set contains duplicates'); + } + for (const descriptor of descriptors) { + const item = evidenceById.get(descriptor.participantId); + if (!item) { + throw invalidRecord('BackupRun participant evidence disagrees with its durable contract'); + } + if ( + item.descriptor.kind !== descriptor.kind || + item.descriptor.contractVersion !== descriptor.contractVersion || + item.descriptor.schemaVersion !== descriptor.schemaVersion || + item.descriptor.required !== descriptor.required || + !item.sourceGeneration || + (requireFlush && + (!('durableBarrier' in item) || + typeof item.durableBarrier !== 'string' || + !item.durableBarrier)) + ) { + throw invalidRecord('BackupRun participant evidence disagrees with its durable contract'); + } + } +} + +export function validateManifestEntries( + manifestEntries: readonly BackupManifestEntry[], + measuredEntries: readonly MeasuredBackupEntry[], + reasons: string[] +): void { + const manifestById = new Map(); + let sqliteEntries = 0; + for (const entry of manifestEntries) { + if (manifestById.has(entry.entryId)) reasons.push('duplicate_manifest_entry'); + manifestById.set(entry.entryId, entry); + if (entry.kind === 'sqlite_snapshot') sqliteEntries += 1; + if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength < 0) { + reasons.push('manifest_entry_length_invalid'); + } + if (!Number.isSafeInteger(entry.mode) || entry.mode < 0) + reasons.push('manifest_entry_mode_invalid'); + } + if (sqliteEntries !== 1) reasons.push('sqlite_manifest_entry_count_invalid'); + + const measuredById = new Map(); + for (const measured of measuredEntries) { + if (measuredById.has(measured.entryId)) reasons.push('duplicate_measured_entry'); + measuredById.set(measured.entryId, measured); + } + if (manifestById.size !== measuredById.size) reasons.push('entry_set_incomplete'); + for (const [entryId, entry] of manifestById) { + const measured = measuredById.get(entryId); + if (!measured) { + reasons.push('entry_missing'); + continue; + } + if ( + entry.byteLength !== measured.byteLength || + entry.mode !== measured.mode || + entry.sha256 !== measured.sha256 + ) { + reasons.push('entry_measurement_mismatch'); + } + } +} + +export function findSqliteEntry( + entries: readonly BackupManifestEntry[] +): BackupManifestEntry | undefined { + return entries.find((entry) => entry.kind === 'sqlite_snapshot'); +} + +export function sameManifestEntry( + left: BackupManifestEntry, + right: BackupManifestEntry | undefined +): boolean { + return ( + !!right && + left.entryId === right.entryId && + left.participantId === right.participantId && + left.kind === right.kind && + left.logicalOwner === right.logicalOwner && + left.logicalType === right.logicalType && + left.schemaVersion === right.schemaVersion && + left.byteLength === right.byteLength && + left.mode === right.mode && + left.sha256 === right.sha256 && + left.sourceGeneration === right.sourceGeneration + ); +} + +export function validateParticipantSet( + manifest: ImmutableBackupInspection['manifest'], + reasons: string[] +): void { + const participantIds = new Set(); + for (const participant of manifest.participants) { + const { descriptor } = participant; + if (participantIds.has(descriptor.participantId)) reasons.push('duplicate_participant'); + participantIds.add(descriptor.participantId); + if (descriptor.contractVersion !== COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION) { + reasons.push('unsupported_participant_contract_version'); + } + if (descriptor.schemaVersion !== COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION) { + reasons.push('unsupported_participant_schema_version'); + } + if (!descriptor.participantId || !participant.sourceGeneration || !participant.durableBarrier) { + reasons.push('participant_evidence_incomplete'); + } + } + for (const entry of manifest.entries) { + if (entry.kind !== 'sqlite_snapshot') { + const participant = manifest.participants.find( + (candidate) => candidate.descriptor.participantId === entry.participantId + ); + if (!participant) reasons.push('entry_participant_missing'); + else if (entry.sourceGeneration !== participant.sourceGeneration) { + reasons.push('entry_participant_generation_mismatch'); + } + } + } +} + +export function validateIdentityInventory( + inventory: BackupIdentityInventory, + entries: readonly BackupManifestEntry[], + reasons: string[] +): void { + if (inventory.schemaVersion !== COORDINATION_BACKUP_IDENTITY_INVENTORY_SCHEMA_VERSION) { + reasons.push('unsupported_identity_inventory_schema_version'); + } + const entryById = new Map(entries.map((entry) => [entry.entryId, entry])); + const identities = new Set(); + const teamIds = new Set(); + let deploymentIdentities = 0; + for (const identity of inventory.identities) { + if (identities.has(identity.identityId)) reasons.push('duplicate_identity'); + identities.add(identity.identityId); + if (identity.kind === 'team') teamIds.add(identity.identityId); + if (identity.kind === 'deployment') { + deploymentIdentities += 1; + if ( + identity.identityId !== inventory.deploymentId || + identity.parentIdentityId !== null || + identity.state !== 'active' + ) { + reasons.push('deployment_identity_disagreement'); + } + } + if (identity.fileEntryId === null) { + if (identity.state !== 'tombstoned') reasons.push('identity_anchor_missing'); + } else { + const fileEntry = entryById.get(identity.fileEntryId); + if (!fileEntry) reasons.push('identity_anchor_missing'); + else if (fileEntry.kind !== 'identity_anchor' || fileEntry.sha256 !== identity.checksum) { + reasons.push('identity_anchor_disagreement'); + } + } + } + if (deploymentIdentities !== 1) reasons.push('deployment_identity_count_invalid'); + for (const identity of inventory.identities) { + if (identity.kind === 'team' && identity.parentIdentityId !== inventory.deploymentId) { + reasons.push('team_identity_parent_mismatch'); + } + if ( + identity.kind === 'member' && + (!identity.parentIdentityId || !teamIds.has(identity.parentIdentityId)) + ) { + reasons.push('member_identity_parent_missing'); + } + } + + const workspaceIds = new Set(); + const registrationKeys = new Set(); + for (const workspace of inventory.workspaceRegistrations) { + if (workspaceIds.has(workspace.workspaceId)) reasons.push('duplicate_workspace_id'); + if (registrationKeys.has(workspace.registrationKey)) reasons.push('duplicate_registration_key'); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/.test(workspace.registrationKey)) { + reasons.push('workspace_registration_key_invalid'); + } + workspaceIds.add(workspace.workspaceId); + registrationKeys.add(workspace.registrationKey); + } +} + +export function compareIdentityInventories( + expected: BackupIdentityInventory, + observed: BackupIdentityInventory, + reasons: string[] +): void { + if ( + expected.schemaVersion !== observed.schemaVersion || + expected.deploymentId !== observed.deploymentId + ) { + reasons.push('observed_identity_inventory_disagreement'); + } + const expectedIdentities = expected.identities + .map(identityComparisonKey) + .sort((left, right) => left.localeCompare(right)); + const observedIdentities = observed.identities + .map(identityComparisonKey) + .sort((left, right) => left.localeCompare(right)); + if (!sameStrings(expectedIdentities, observedIdentities)) { + reasons.push('observed_identity_inventory_disagreement'); + } + const expectedWorkspaces = expected.workspaceRegistrations + .map(workspaceComparisonKey) + .sort((left, right) => left.localeCompare(right)); + const observedWorkspaces = observed.workspaceRegistrations + .map(workspaceComparisonKey) + .sort((left, right) => left.localeCompare(right)); + if (!sameStrings(expectedWorkspaces, observedWorkspaces)) { + reasons.push('observed_workspace_inventory_disagreement'); + } +} + +function identityComparisonKey(identity: BackupIdentityInventory['identities'][number]): string { + return JSON.stringify([ + identity.kind, + identity.identityId, + identity.parentIdentityId ?? '', + identity.state, + identity.checksum, + identity.fileEntryId ?? '', + ]); +} + +function workspaceComparisonKey( + workspace: BackupIdentityInventory['workspaceRegistrations'][number] +): string { + return JSON.stringify([workspace.workspaceId, workspace.registrationKey, workspace.state]); +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function validateCopiedSourceRun( + inspection: ImmutableBackupInspection, + reasons: string[] +): void { + const { copiedSourceRun, manifest } = inspection; + if (copiedSourceRun.backupRunId !== manifest.sourceBackupRunId) { + reasons.push('copied_source_run_missing_or_mismatched'); + } + if (copiedSourceRun.deploymentId !== manifest.deploymentId) { + reasons.push('copied_source_deployment_mismatch'); + } + if (copiedSourceRun.productKind !== manifest.productKind) { + reasons.push('copied_source_product_mismatch'); + } + if (copiedSourceRun.purpose !== manifest.purpose) { + reasons.push('copied_source_purpose_mismatch'); + } + if (copiedSourceRun.state !== 'sqlite_snapshot') { + reasons.push('copied_source_state_invalid'); + } + if (copiedSourceRun.fenceGeneration !== manifest.fenceGeneration) { + reasons.push('copied_source_fence_mismatch'); + } + const copiedBarrierReasons: string[] = []; + validateCompatibilityManifest(copiedSourceRun.coordinationBarrier, copiedBarrierReasons); + validateRecoveryPointEvidence( + copiedSourceRun.backupRunId, + copiedSourceRun.fenceGeneration, + copiedSourceRun.coordinationBarrier, + copiedSourceRun.participants, + copiedBarrierReasons + ); + if ( + copiedBarrierReasons.length > 0 || + coordinationBarrierComparisonKey(copiedSourceRun.coordinationBarrier) !== + coordinationBarrierComparisonKey(manifest.coordinationBarrier) + ) { + reasons.push('copied_source_coordination_barrier_mismatch'); + } + const copiedParticipants = copiedSourceRun.participants + .map(flushedParticipantComparisonKey) + .sort((left, right) => left.localeCompare(right)); + const manifestParticipants = manifest.participants + .map(flushedParticipantComparisonKey) + .sort((left, right) => left.localeCompare(right)); + if (!sameStrings(copiedParticipants, manifestParticipants)) { + reasons.push('copied_source_participant_mismatch'); + } + const copiedIdentityReasons: string[] = []; + validateIdentityInventory( + copiedSourceRun.identityInventory, + manifest.entries, + copiedIdentityReasons + ); + compareIdentityInventories( + manifest.identityInventory, + copiedSourceRun.identityInventory, + copiedIdentityReasons + ); + if (copiedIdentityReasons.length > 0) { + reasons.push('copied_source_identity_inventory_mismatch'); + } +} + +function coordinationBarrierComparisonKey(barrier: BackupCoordinationBarrier): string { + const compatibility = barrier.stateCompatibilityManifest; + const drain = barrier.acceptedCommandDrain; + const participantPoints = barrier.participantRecoveryPoints + .map(participantRecoveryPointKey) + .sort((left, right) => left.localeCompare(right)); + const journalCursors = Object.entries(barrier.journalCursors) + .map(([journal, cursor]) => [journal, cursor] as const) + .sort( + ([leftJournal, leftCursor], [rightJournal, rightCursor]) => + leftJournal.localeCompare(rightJournal) || leftCursor.localeCompare(rightCursor) + ); + return JSON.stringify([ + compatibility.manifestId, + compatibility.schemaVersion, + compatibility.sha256, + drain.admittedRunId, + drain.fenceGeneration, + drain.throughCommandCursor, + drain.durableBarrier, + participantPoints, + barrier.eventCursor, + barrier.eventEpoch, + journalCursors, + ]); +} + +function flushedParticipantComparisonKey(participant: FlushedBackupParticipant): string { + return JSON.stringify([ + participant.descriptor.participantId, + participant.descriptor.kind, + participant.descriptor.contractVersion, + participant.descriptor.schemaVersion, + participant.descriptor.required ? 'required' : 'optional', + participant.sourceGeneration, + participant.durableBarrier, + ]); +} diff --git a/src/features/coordination-backup/core/domain/index.ts b/src/features/coordination-backup/core/domain/index.ts new file mode 100644 index 0000000000..84679e6124 --- /dev/null +++ b/src/features/coordination-backup/core/domain/index.ts @@ -0,0 +1 @@ +export * from './backupRunState'; diff --git a/src/features/coordination-backup/index.ts b/src/features/coordination-backup/index.ts new file mode 100644 index 0000000000..a6c74fad00 --- /dev/null +++ b/src/features/coordination-backup/index.ts @@ -0,0 +1,3 @@ +export * from './contracts'; +export * from './core/application'; +export * from './core/domain'; diff --git a/src/features/coordination-backup/main/adapters/output/DurableBackupIdentityInventory.ts b/src/features/coordination-backup/main/adapters/output/DurableBackupIdentityInventory.ts new file mode 100644 index 0000000000..04f8062a9c --- /dev/null +++ b/src/features/coordination-backup/main/adapters/output/DurableBackupIdentityInventory.ts @@ -0,0 +1,162 @@ +import { parseWorkspaceId } from '@shared/contracts/hosted'; + +import { parseSha256Digest } from '../../../contracts/coordinationBackupContracts'; + +import type { + BackupIdentityInventory, + BackupIdentityInventoryEntry, + BackupWorkspaceRegistrationEntry, +} from '../../../contracts/coordinationBackupContracts'; +import type { + BackupIdentityInventoryPort, + CaptureBackupIdentityInventoryRequest, +} from '../../../core/application'; +import type { DurableIdentityInventorySource } from '../../application/coordinationBackupMainPorts'; + +/** + * Injected durable authority only. Implementations are expected to compose the + * deployment identity, team/member identity registry, and workspace + * registration store; directory scans and current-run projections cannot + * satisfy this surface. + */ +export type { DurableIdentityInventorySource } from '../../application/coordinationBackupMainPorts'; + +export class DurableBackupIdentityInventory implements BackupIdentityInventoryPort { + constructor( + private readonly options: { + readonly deploymentId: string; + readonly source: DurableIdentityInventorySource; + } + ) { + if (!options.deploymentId || !options.source) { + throw new TypeError('durable-backup-identity-inventory-options-invalid'); + } + } + + async capture(request: CaptureBackupIdentityInventoryRequest): Promise { + if ( + request.fence.admittedRunId !== request.backupRunId || + request.barrier.acceptedCommandDrain.admittedRunId !== request.backupRunId || + request.barrier.acceptedCommandDrain.fenceGeneration !== request.fence.generation + ) { + throw new Error('durable-backup-identity-inventory-fence-mismatch'); + } + const inventory = await this.options.source.captureDurableIdentityInventory({ + backupRunId: request.backupRunId, + deploymentId: this.options.deploymentId, + fenceGeneration: request.fence.generation, + coordinationBarrier: request.barrier, + }); + validateInventory(inventory, this.options.deploymentId); + return Object.freeze({ + schemaVersion: 1 as const, + deploymentId: inventory.deploymentId, + identities: Object.freeze( + inventory.identities + .map((identity) => Object.freeze({ ...identity })) + .sort((left, right) => identityKey(left).localeCompare(identityKey(right))) + ), + workspaceRegistrations: Object.freeze( + inventory.workspaceRegistrations + .map((registration) => Object.freeze({ ...registration })) + .sort((left, right) => left.registrationKey.localeCompare(right.registrationKey)) + ), + }); + } +} + +function validateInventory(inventory: BackupIdentityInventory, deploymentId: string): void { + requireExactKeys(inventory, [ + 'schemaVersion', + 'deploymentId', + 'identities', + 'workspaceRegistrations', + ]); + if ( + inventory.schemaVersion !== 1 || + inventory.deploymentId !== deploymentId || + !Array.isArray(inventory.identities) || + !Array.isArray(inventory.workspaceRegistrations) + ) { + throw new Error('durable-backup-identity-inventory-invalid'); + } + const identityIds = new Set(); + const teamIds = new Set(); + let deploymentCount = 0; + for (const identity of inventory.identities as readonly BackupIdentityInventoryEntry[]) { + requireExactKeys(identity, [ + 'kind', + 'identityId', + 'parentIdentityId', + 'state', + 'checksum', + 'fileEntryId', + ]); + if ( + (identity.kind !== 'deployment' && identity.kind !== 'team' && identity.kind !== 'member') || + (identity.state !== 'active' && identity.state !== 'tombstoned') || + typeof identity.identityId !== 'string' || + identity.identityId.length === 0 || + (identity.parentIdentityId !== null && typeof identity.parentIdentityId !== 'string') || + (identity.fileEntryId !== null && typeof identity.fileEntryId !== 'string') || + (identity.state === 'active' && identity.fileEntryId === null) || + identityIds.has(identity.identityId) + ) { + throw new Error('durable-backup-identity-entry-invalid'); + } + parseSha256Digest(identity.checksum); + identityIds.add(identity.identityId); + if (identity.kind === 'team') teamIds.add(identity.identityId); + if (identity.kind === 'deployment') { + deploymentCount += 1; + if ( + identity.identityId !== deploymentId || + identity.parentIdentityId !== null || + identity.state !== 'active' + ) { + throw new Error('durable-backup-deployment-identity-invalid'); + } + } + } + if (deploymentCount !== 1) throw new Error('durable-backup-deployment-identity-count-invalid'); + for (const identity of inventory.identities as readonly BackupIdentityInventoryEntry[]) { + if ( + (identity.kind === 'team' && identity.parentIdentityId !== deploymentId) || + (identity.kind === 'member' && + (identity.parentIdentityId === null || !teamIds.has(identity.parentIdentityId))) + ) { + throw new Error('durable-backup-identity-parent-invalid'); + } + } + const workspaceIds = new Set(); + const registrationKeys = new Set(); + for (const registration of inventory.workspaceRegistrations as readonly BackupWorkspaceRegistrationEntry[]) { + requireExactKeys(registration, ['workspaceId', 'registrationKey', 'state']); + if ( + (registration.state !== 'registered' && registration.state !== 'disabled') || + typeof registration.workspaceId !== 'string' || + workspaceIds.has(registration.workspaceId) || + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/.test(registration.registrationKey) || + registrationKeys.has(registration.registrationKey) + ) { + throw new Error('durable-backup-workspace-registration-invalid'); + } + parseWorkspaceId(registration.workspaceId); + workspaceIds.add(registration.workspaceId); + registrationKeys.add(registration.registrationKey); + } +} + +function requireExactKeys(value: object, keys: readonly string[]): void { + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key)) + ) { + throw new Error('durable-backup-identity-surface-invalid'); + } +} + +function identityKey(identity: BackupIdentityInventory['identities'][number]): string { + return `${identity.kind}\0${identity.identityId}`; +} diff --git a/src/features/coordination-backup/main/adapters/output/SqliteBackupCoordinationFlush.ts b/src/features/coordination-backup/main/adapters/output/SqliteBackupCoordinationFlush.ts new file mode 100644 index 0000000000..234515c508 --- /dev/null +++ b/src/features/coordination-backup/main/adapters/output/SqliteBackupCoordinationFlush.ts @@ -0,0 +1,182 @@ +import { encodeReplayCursor } from '@features/coordination-events'; + +import { parseBackupRunId, parseSha256Digest } from '../../../contracts'; + +import type { + BackupAcceptedCommandDrain, + BackupCoordinationBarrier, + StateCompatibilityManifestRef, +} from '../../../contracts'; +import type { + BackupCoordinationFlushPort, + CaptureCoordinationBarrierRequest, + DrainAcceptedBackupCommandsRequest, +} from '../../../core/application'; +import type { DurableStateCompatibilityManifestSource } from '../../application/coordinationBackupMainPorts'; +import type { + CoordinationDrainStorageEvidence, + CoordinationDurabilityStorageGateway, +} from '@features/internal-storage/main'; + +export type { DurableStateCompatibilityManifestSource } from '../../application/coordinationBackupMainPorts'; + +export class SqliteBackupCoordinationFlush implements BackupCoordinationFlushPort { + constructor( + private readonly options: { + readonly storage: CoordinationDurabilityStorageGateway; + readonly deploymentId: string; + readonly compatibilityManifest: DurableStateCompatibilityManifestSource; + } + ) { + if (!options.storage || !options.deploymentId || !options.compatibilityManifest) { + throw new TypeError('sqlite-backup-coordination-flush-options-invalid'); + } + } + + async drainAcceptedCommands( + request: DrainAcceptedBackupCommandsRequest + ): Promise { + requireFenceRequest(request.backupRunId, request.fence); + const evidence = await this.options.storage.coordinationBackupDrain({ + deploymentId: this.options.deploymentId, + backupRunId: request.backupRunId, + fenceGeneration: request.fence.generation, + }); + return toAcceptedCommandDrain(evidence); + } + + async captureBarrier( + request: CaptureCoordinationBarrierRequest + ): Promise { + requireFenceRequest(request.backupRunId, request.fence); + const evidence = decodeDrainEvidence(request.acceptedCommandDrain.durableBarrier); + if ( + evidence.backupRunId !== request.backupRunId || + evidence.fenceGeneration !== request.fence.generation || + request.acceptedCommandDrain.admittedRunId !== request.backupRunId || + request.acceptedCommandDrain.fenceGeneration !== request.fence.generation || + request.acceptedCommandDrain.throughCommandCursor !== + commandCursor(evidence.throughCommandSequence) + ) { + throw new Error('coordination-backup-drain-evidence-mismatch'); + } + + const compatibility = await this.options.compatibilityManifest.captureCurrent({ + backupRunId: request.backupRunId, + fenceGeneration: request.fence.generation, + }); + requireCompatibilityManifest(compatibility); + const captured = await this.options.storage.coordinationBackupCapture({ + deploymentId: this.options.deploymentId, + evidence, + }); + if (captured.durableBarrier !== evidence.durableBarrier) { + throw new Error('coordination-backup-capture-evidence-mismatch'); + } + const eventCursor = encodeReplayCursor({ + deploymentId: this.options.deploymentId, + eventEpoch: captured.eventEpoch, + eventSequence: captured.throughEventSequence, + }); + return Object.freeze({ + stateCompatibilityManifest: Object.freeze({ ...compatibility }), + acceptedCommandDrain: Object.freeze({ ...request.acceptedCommandDrain }), + participantRecoveryPoints: Object.freeze( + request.participants + .map((participant) => + Object.freeze({ + participantId: participant.descriptor.participantId, + sourceGeneration: participant.sourceGeneration, + durableBarrier: participant.durableBarrier, + }) + ) + .sort((left, right) => left.participantId.localeCompare(right.participantId)) + ), + eventCursor, + eventEpoch: captured.eventEpoch, + journalCursors: Object.freeze({ + applicationCommandOutbox: commandCursor(captured.throughCommandSequence), + coordinationEvents: eventCursor, + }), + }); + } +} + +function toAcceptedCommandDrain( + evidence: CoordinationDrainStorageEvidence +): BackupAcceptedCommandDrain { + return Object.freeze({ + admittedRunId: parseBackupRunId(evidence.backupRunId), + fenceGeneration: evidence.fenceGeneration, + throughCommandCursor: commandCursor(evidence.throughCommandSequence), + durableBarrier: evidence.durableBarrier, + }); +} + +function commandCursor(sequence: number): string { + if (!Number.isSafeInteger(sequence) || sequence < 0) { + throw new Error('coordination-backup-command-cursor-invalid'); + } + return `application-command-outbox-v1:${sequence}`; +} + +function decodeDrainEvidence(durableBarrier: string): CoordinationDrainStorageEvidence { + const prefix = 'coordination-drain-v1.'; + if (typeof durableBarrier !== 'string' || !durableBarrier.startsWith(prefix)) { + throw new Error('coordination-backup-drain-evidence-invalid'); + } + let value: unknown; + try { + value = JSON.parse( + Buffer.from(durableBarrier.slice(prefix.length), 'base64url').toString('utf8') + ) as unknown; + } catch { + throw new Error('coordination-backup-drain-evidence-invalid'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('coordination-backup-drain-evidence-invalid'); + } + const record = value as Record; + if ( + typeof record.backupRunId !== 'string' || + !Number.isSafeInteger(record.fenceGeneration) || + !Number.isSafeInteger(record.throughCommandSequence) || + !Number.isSafeInteger(record.throughEventSequence) || + typeof record.eventEpoch !== 'string' + ) { + throw new Error('coordination-backup-drain-evidence-invalid'); + } + return Object.freeze({ + backupRunId: record.backupRunId, + fenceGeneration: record.fenceGeneration as number, + throughCommandSequence: record.throughCommandSequence as number, + throughEventSequence: record.throughEventSequence as number, + eventEpoch: record.eventEpoch, + durableBarrier, + }); +} + +function requireCompatibilityManifest(manifest: StateCompatibilityManifestRef): void { + if ( + !manifest || + typeof manifest.manifestId !== 'string' || + manifest.manifestId.length === 0 || + manifest.schemaVersion !== 3 + ) { + throw new Error('coordination-backup-compatibility-manifest-invalid'); + } + parseSha256Digest(manifest.sha256); +} + +function requireFenceRequest( + backupRunId: string, + fence: { readonly generation: number; readonly admittedRunId: string } +): void { + if ( + fence.admittedRunId !== backupRunId || + !Number.isSafeInteger(fence.generation) || + fence.generation <= 0 + ) { + throw new Error('coordination-backup-flush-fence-invalid'); + } +} diff --git a/src/features/coordination-backup/main/adapters/output/SqliteBackupRunRepository.ts b/src/features/coordination-backup/main/adapters/output/SqliteBackupRunRepository.ts new file mode 100644 index 0000000000..03373c4381 --- /dev/null +++ b/src/features/coordination-backup/main/adapters/output/SqliteBackupRunRepository.ts @@ -0,0 +1,223 @@ +import { assertBackupRunRecord } from '../../../core/domain'; + +import type { BackupRunId, BackupRunRecord, BackupRunTransitionRequest } from '../../../contracts'; +import type { + BackupRunRepository, + CreateBackupRunRequest, + MarkBackupFenceCompletedRequest, + SaveBackupVerificationPlanRequest, +} from '../../../core/application'; +import type { CoordinationDurabilityStorageGateway } from '@features/internal-storage/main'; + +export class SqliteBackupRunRepository implements BackupRunRepository { + constructor(private readonly storage: CoordinationDurabilityStorageGateway) { + if (!storage) throw new TypeError('sqlite-backup-run-repository-storage-required'); + } + + async create(request: CreateBackupRunRequest): Promise { + const record: BackupRunRecord = Object.freeze({ + backupRunId: request.backupRunId, + deploymentId: request.deploymentId, + productKind: 'coordination_backup', + purpose: request.purpose, + state: 'requested', + revision: 1, + requestedAt: request.requestedAt, + updatedAt: request.requestedAt, + participantDescriptors: Object.freeze( + request.participantDescriptors.map((descriptor) => Object.freeze({ ...descriptor })) + ), + fence: null, + fenceLeaseId: null, + fenceCompletion: null, + preparedParticipants: null, + flushedParticipants: null, + coordinationBarrier: null, + identityInventory: null, + sqliteSnapshot: null, + stagedEntries: null, + exclusions: null, + verificationPlan: null, + publication: null, + failure: null, + }); + assertBackupRunRecord(record); + return this.requireValid(await this.storage.coordinationBackupRunCreate(record)); + } + + async get(backupRunId: BackupRunId): Promise { + const record = await this.storage.coordinationBackupRunGet(backupRunId); + return record ? this.requireValid(record) : null; + } + + async listRecoverable(): Promise { + const records = await this.storage.coordinationBackupRunListRecoverable(); + return Object.freeze(records.map((record) => this.requireValid(record))); + } + + async transition(request: BackupRunTransitionRequest): Promise { + const current = await this.requireCurrent( + request.backupRunId, + request.expectedRevision, + request.from + ); + const record = applyTransition(current, request); + return this.compareAndSet(current, record); + } + + async saveVerificationPlan(request: SaveBackupVerificationPlanRequest): Promise { + const current = await this.requireCurrent( + request.backupRunId, + request.expectedRevision, + 'verifying' + ); + if (current.verificationPlan !== null) { + throw new Error('backup-run-verification-plan-already-saved'); + } + const record = Object.freeze({ + ...current, + revision: current.revision + 1, + updatedAt: request.at, + verificationPlan: Object.freeze({ ...request.plan }), + }); + return this.compareAndSet(current, record); + } + + async markFenceCompleted(request: MarkBackupFenceCompletedRequest): Promise { + const current = await this.requireCurrent( + request.backupRunId, + request.expectedRevision, + undefined + ); + const pending = current.fenceCompletion; + if ( + pending?.status !== 'pending' || + pending.generation !== request.generation || + pending.disposition !== request.disposition + ) { + throw new Error('backup-run-fence-completion-mismatch'); + } + const record = Object.freeze({ + ...current, + revision: current.revision + 1, + updatedAt: request.completedAt, + fenceCompletion: Object.freeze({ + ...pending, + status: 'completed' as const, + completedAt: request.completedAt, + }), + }); + return this.compareAndSet(current, record); + } + + private async requireCurrent( + backupRunId: BackupRunId, + expectedRevision: number, + expectedState: BackupRunRecord['state'] | undefined + ): Promise { + const current = await this.get(backupRunId); + if (!current) throw new Error('backup-run-not-found'); + if ( + current.revision !== expectedRevision || + (expectedState !== undefined && current.state !== expectedState) + ) { + throw new Error('backup-run-compare-and-set-failed'); + } + return current; + } + + private async compareAndSet( + current: BackupRunRecord, + record: BackupRunRecord + ): Promise { + assertBackupRunRecord(record); + const stored = await this.storage.coordinationBackupRunCompareAndSet({ + backupRunId: current.backupRunId, + expectedRevision: current.revision, + expectedState: current.state, + record, + }); + const valid = this.requireValid(stored); + if ( + valid.backupRunId !== record.backupRunId || + valid.revision !== record.revision || + valid.state !== record.state + ) { + throw new Error('backup-run-compare-and-set-result-mismatch'); + } + return valid; + } + + private requireValid(record: BackupRunRecord): BackupRunRecord { + assertBackupRunRecord(record); + return record; + } +} + +function applyTransition( + current: BackupRunRecord, + request: BackupRunTransitionRequest +): BackupRunRecord { + const base = { + ...current, + state: request.to, + revision: current.revision + 1, + updatedAt: request.at, + }; + let record: BackupRunRecord; + switch (request.to) { + case 'fencing': + record = base; + break; + case 'quiescing': + record = Object.freeze({ + ...base, + fence: Object.freeze({ ...request.fence }), + fenceLeaseId: request.fenceLeaseId, + }); + break; + case 'sqlite_snapshot': + record = Object.freeze({ + ...base, + preparedParticipants: Object.freeze([...request.preparedParticipants]), + flushedParticipants: Object.freeze([...request.flushedParticipants]), + coordinationBarrier: Object.freeze({ ...request.coordinationBarrier }), + identityInventory: Object.freeze({ ...request.identityInventory }), + }); + break; + case 'file_stage': + record = Object.freeze({ + ...base, + sqliteSnapshot: Object.freeze({ ...request.sqliteSnapshot }), + }); + break; + case 'verifying': + record = Object.freeze({ + ...base, + stagedEntries: Object.freeze([...request.stagedEntries]), + exclusions: Object.freeze([...request.exclusions]), + }); + break; + case 'committed': + record = Object.freeze({ + ...base, + publication: Object.freeze({ ...request.publication }), + fenceCompletion: Object.freeze({ ...request.fenceCompletion }), + }); + break; + case 'failed': + case 'operator_required': + record = Object.freeze({ + ...base, + failure: Object.freeze({ ...request.failure }), + fence: request.fence ? Object.freeze({ ...request.fence }) : null, + fenceLeaseId: request.fenceLeaseId, + fenceCompletion: request.fenceCompletion + ? Object.freeze({ ...request.fenceCompletion }) + : null, + }); + break; + } + assertBackupRunRecord(record); + return record; +} diff --git a/src/features/coordination-backup/main/adapters/output/SqliteBackupWriterFence.ts b/src/features/coordination-backup/main/adapters/output/SqliteBackupWriterFence.ts new file mode 100644 index 0000000000..9581ebc333 --- /dev/null +++ b/src/features/coordination-backup/main/adapters/output/SqliteBackupWriterFence.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'node:crypto'; + +import { parseBackupRunId } from '../../../contracts'; + +import type { + AcquireBackupWriterFenceRequest, + AcquireBackupWriterFenceResult, + BackupWriterFencePort, + CompleteBackupWriterFenceRequest, +} from '../../../core/application'; +import type { CoordinationDurabilityStorageGateway } from '@features/internal-storage/main'; + +export class SqliteBackupWriterFence implements BackupWriterFencePort { + private readonly nowIso: () => string; + private readonly createLeaseId: () => string; + + constructor( + private readonly options: { + readonly storage: CoordinationDurabilityStorageGateway; + readonly deploymentId: string; + readonly nowIso?: () => string; + readonly createLeaseId?: () => string; + } + ) { + if (!options.storage || !options.deploymentId) { + throw new TypeError('sqlite-backup-writer-fence-options-invalid'); + } + this.nowIso = options.nowIso ?? (() => new Date().toISOString()); + this.createLeaseId = options.createLeaseId ?? randomUUID; + } + + async acquire(request: AcquireBackupWriterFenceRequest): Promise { + const result = await this.options.storage.coordinationBackupFenceAcquire({ + deploymentId: this.options.deploymentId, + backupRunId: request.backupRunId, + expectedGeneration: request.expectedGeneration, + leaseId: this.createLeaseId(), + acquiredAt: this.nowIso(), + }); + if (result.status === 'busy') { + return Object.freeze({ + status: 'busy' as const, + activeRunId: parseBackupRunId(result.activeRunId), + }); + } + return Object.freeze({ + status: 'acquired' as const, + lease: Object.freeze({ + leaseId: result.leaseId, + evidence: Object.freeze({ + generation: result.generation, + admittedRunId: request.backupRunId, + }), + }), + }); + } + + async complete(request: CompleteBackupWriterFenceRequest): Promise { + await this.options.storage.coordinationBackupFenceComplete({ + deploymentId: this.options.deploymentId, + backupRunId: request.lease.evidence.admittedRunId, + generation: request.lease.evidence.generation, + leaseId: request.lease.leaseId, + disposition: request.disposition, + completedAt: this.nowIso(), + }); + } +} diff --git a/src/features/coordination-backup/main/adapters/output/SqliteOnlineBackupAdapter.ts b/src/features/coordination-backup/main/adapters/output/SqliteOnlineBackupAdapter.ts new file mode 100644 index 0000000000..da3b600cb4 --- /dev/null +++ b/src/features/coordination-backup/main/adapters/output/SqliteOnlineBackupAdapter.ts @@ -0,0 +1,212 @@ +import { + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES, + INTERNAL_STORAGE_SCHEMA_VERSION, +} from '@features/internal-storage/main'; + +import { parseSha256Digest, SQLITE_ONLINE_BACKUP_METHOD } from '../../../contracts'; + +import type { + BackupManifestEntry, + BackupRunId, + OnlineBackupSnapshot, + SqliteIntegrityEvidence, +} from '../../../contracts'; +import type { + OnlineBackupResult, + SqliteIntegrityResult, + SqliteOnlineBackupPort, + SqliteSnapshotIntegrityPort, +} from '../../../core/application'; +import type { + BackupPublicationArtifactWriter, + SqliteBackupArtifactPublisher, +} from '../../infrastructure'; +import type { CoordinationDurabilityStorageGateway } from '@features/internal-storage/main'; + +export const INTERNAL_STORAGE_SQLITE_BACKUP_ENTRY_ID = 'sqlite/internal-storage.sqlite' as const; + +export class SqliteOnlineBackupAdapter + implements SqliteOnlineBackupPort, SqliteSnapshotIntegrityPort +{ + private readonly nowMs: () => number; + private readonly deadlineMs: number; + private readonly busyRetryMs: number; + private readonly pagesPerStep: number; + + constructor( + private readonly options: { + readonly storage: CoordinationDurabilityStorageGateway; + readonly snapshotPublisher: SqliteBackupArtifactPublisher; + readonly artifactWriter: BackupPublicationArtifactWriter; + readonly nowMs?: () => number; + readonly deadlineMs?: number; + readonly busyRetryMs?: number; + readonly pagesPerStep?: number; + } + ) { + if (!options.storage || !options.snapshotPublisher || !options.artifactWriter) { + throw new TypeError('sqlite-online-backup-adapter-options-invalid'); + } + this.nowMs = options.nowMs ?? Date.now; + this.deadlineMs = options.deadlineMs ?? 30_000; + this.busyRetryMs = options.busyRetryMs ?? 25; + this.pagesPerStep = options.pagesPerStep ?? 128; + for (const [name, value] of [ + ['deadlineMs', this.deadlineMs], + ['busyRetryMs', this.busyRetryMs], + ['pagesPerStep', this.pagesPerStep], + ] as const) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`sqlite-online-backup-adapter-${name}-invalid`); + } + } + } + + async createOnlineSnapshot( + request: Parameters[0] + ): Promise { + requireSnapshotRequest(request.backupRunId, request.fence, request.coordinationBarrier); + const result = await this.options.storage.coordinationBackupSqliteOnline({ + backupRunId: request.backupRunId, + deadlineAtMs: this.nowMs() + this.deadlineMs, + busyRetryMs: this.busyRetryMs, + pagesPerStep: this.pagesPerStep, + }); + if (result.status !== 'completed') { + return Object.freeze({ status: 'failed' as const, reason: result.status }); + } + if ( + result.applicationId !== INTERNAL_STORAGE_APPLICATION_ID || + result.userVersion !== INTERNAL_STORAGE_SCHEMA_VERSION || + typeof result.byteLength !== 'number' || + !Number.isSafeInteger(result.byteLength) || + result.byteLength <= 0 || + typeof result.mode !== 'number' || + !Number.isInteger(result.mode) || + result.mode < 0 || + result.mode > 0o777 || + typeof result.sha256 !== 'string' + ) { + throw new Error('sqlite-online-backup-worker-evidence-invalid'); + } + const snapshotDigest = parseSha256Digest(result.sha256); + const measured = await this.options.snapshotPublisher.publishSqliteSnapshot({ + backupRunId: request.backupRunId, + entryId: INTERNAL_STORAGE_SQLITE_BACKUP_ENTRY_ID, + byteLength: result.byteLength, + sha256: snapshotDigest, + readChunk: (offset) => + this.options.storage.coordinationBackupSqliteReadChunk({ + backupRunId: request.backupRunId, + offset, + maximumBytes: 1024 * 1024, + }), + }); + const entry: BackupManifestEntry & { readonly kind: 'sqlite_snapshot' } = Object.freeze({ + entryId: INTERNAL_STORAGE_SQLITE_BACKUP_ENTRY_ID, + participantId: 'internal-storage', + kind: 'sqlite_snapshot' as const, + logicalOwner: 'internal-storage', + logicalType: 'coordination-database', + schemaVersion: INTERNAL_STORAGE_SCHEMA_VERSION, + byteLength: measured.byteLength, + mode: measured.mode, + sha256: measured.sha256, + sourceGeneration: sqliteSourceGeneration(request), + }); + if ( + measured.byteLength !== result.byteLength || + measured.mode !== result.mode || + measured.sha256 !== snapshotDigest + ) { + throw new Error('sqlite-online-backup-staged-artifact-mismatch'); + } + const snapshot: OnlineBackupSnapshot = Object.freeze({ + method: SQLITE_ONLINE_BACKUP_METHOD, + entry, + applicationId: result.applicationId, + userVersion: result.userVersion, + sourceRunId: request.backupRunId, + }); + return Object.freeze({ status: 'completed' as const, snapshot }); + } + + async reopenAndCheck( + request: Parameters[0] + ): Promise { + if ( + request.snapshot.sourceRunId !== request.backupRunId || + request.snapshot.method !== SQLITE_ONLINE_BACKUP_METHOD || + request.snapshot.applicationId !== INTERNAL_STORAGE_APPLICATION_ID || + request.snapshot.userVersion !== INTERNAL_STORAGE_SCHEMA_VERSION || + request.snapshot.entry.entryId !== INTERNAL_STORAGE_SQLITE_BACKUP_ENTRY_ID + ) { + return { status: 'invalid', reason: 'schema_mismatch' }; + } + const result = await this.options.storage.coordinationBackupSqliteVerify({ + backupRunId: request.backupRunId, + }); + if (result.status === 'invalid') return result; + if ( + result.applicationId !== request.snapshot.applicationId || + result.userVersion !== request.snapshot.userVersion || + !sameStrings(result.requiredTables, INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES) + ) { + return { status: 'invalid', reason: 'schema_mismatch' }; + } + const measured = await this.options.artifactWriter.measureStagedArtifact({ + backupRunId: request.backupRunId, + entryId: request.snapshot.entry.entryId, + }); + if ( + measured.byteLength !== request.snapshot.entry.byteLength || + measured.mode !== request.snapshot.entry.mode || + measured.sha256 !== request.snapshot.entry.sha256 + ) { + return { status: 'invalid', reason: 'integrity_check_failed' }; + } + await this.options.storage.coordinationBackupSqliteDiscard(request.backupRunId); + const requiredInvariants = Object.freeze( + Object.fromEntries([ + ...INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES.map( + (table) => [`table:${table}`, true] as const + ), + ['sourceBackupRun', true] as const, + ]) + ) as SqliteIntegrityEvidence['requiredInvariants']; + return Object.freeze({ + status: 'valid' as const, + evidence: Object.freeze({ + integrityCheck: 'ok' as const, + applicationId: result.applicationId, + userVersion: result.userVersion, + requiredInvariants, + }), + }); + } +} + +function requireSnapshotRequest( + backupRunId: BackupRunId, + fence: Parameters[0]['fence'], + barrier: Parameters[0]['coordinationBarrier'] +): void { + if ( + fence.admittedRunId !== backupRunId || + barrier.acceptedCommandDrain.admittedRunId !== backupRunId || + barrier.acceptedCommandDrain.fenceGeneration !== fence.generation + ) { + throw new Error('sqlite-online-backup-barrier-fence-mismatch'); + } +} + +function sqliteSourceGeneration( + request: Parameters[0] +): string { + return `internal-storage-v${INTERNAL_STORAGE_SCHEMA_VERSION}:fence-${request.fence.generation}:${request.coordinationBarrier.acceptedCommandDrain.durableBarrier}`; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/src/features/coordination-backup/main/adapters/output/index.ts b/src/features/coordination-backup/main/adapters/output/index.ts new file mode 100644 index 0000000000..d779c8f799 --- /dev/null +++ b/src/features/coordination-backup/main/adapters/output/index.ts @@ -0,0 +1,5 @@ +export * from './DurableBackupIdentityInventory'; +export * from './SqliteBackupCoordinationFlush'; +export * from './SqliteBackupRunRepository'; +export * from './SqliteBackupWriterFence'; +export * from './SqliteOnlineBackupAdapter'; diff --git a/src/features/coordination-backup/main/application/coordinationBackupMainPorts.ts b/src/features/coordination-backup/main/application/coordinationBackupMainPorts.ts new file mode 100644 index 0000000000..7722394d64 --- /dev/null +++ b/src/features/coordination-backup/main/application/coordinationBackupMainPorts.ts @@ -0,0 +1,23 @@ +import type { + BackupIdentityInventory, + StateCompatibilityManifestRef, +} from '../../contracts/coordinationBackupContracts'; +import type { CaptureBackupIdentityInventoryRequest } from '../../core/application'; + +/** Durable identity authority injected by the owning deployment and workspace features. */ +export interface DurableIdentityInventorySource { + captureDurableIdentityInventory(input: { + readonly backupRunId: string; + readonly deploymentId: string; + readonly fenceGeneration: number; + readonly coordinationBarrier: CaptureBackupIdentityInventoryRequest['barrier']; + }): Promise; +} + +/** Current state-compatibility authority captured at the coordination barrier. */ +export interface DurableStateCompatibilityManifestSource { + captureCurrent(input: { + readonly backupRunId: string; + readonly fenceGeneration: number; + }): Promise; +} diff --git a/src/features/coordination-backup/main/composition/createCoordinationBackupFeature.ts b/src/features/coordination-backup/main/composition/createCoordinationBackupFeature.ts new file mode 100644 index 0000000000..3ddc76bbd0 --- /dev/null +++ b/src/features/coordination-backup/main/composition/createCoordinationBackupFeature.ts @@ -0,0 +1,118 @@ +import { CoordinationBackupService } from '../../core/application'; +import { DurableBackupIdentityInventory } from '../adapters/output/DurableBackupIdentityInventory'; +import { SqliteBackupCoordinationFlush } from '../adapters/output/SqliteBackupCoordinationFlush'; +import { SqliteBackupRunRepository } from '../adapters/output/SqliteBackupRunRepository'; +import { SqliteBackupWriterFence } from '../adapters/output/SqliteBackupWriterFence'; +import { SqliteOnlineBackupAdapter } from '../adapters/output/SqliteOnlineBackupAdapter'; +import { + NodeBackupManifestHasher, + NodeBackupPublication, + NodeImmutableBackupVerifier, +} from '../infrastructure'; +import { CoordinationEventBackupParticipant } from '../participants'; + +import type { CoordinationBackupParticipant } from '../../core/application'; +import type { + DurableIdentityInventorySource, + DurableStateCompatibilityManifestSource, +} from '../application/coordinationBackupMainPorts'; +import type { CoordinationEventJournal } from '@features/coordination-events'; +import type { CoordinationDurabilityStorageGateway } from '@features/internal-storage/main'; + +export interface CreateCoordinationBackupFeatureOptions { + readonly storage: CoordinationDurabilityStorageGateway; + readonly deploymentId: string; + readonly backupRoot: string; + readonly eventJournal: CoordinationEventJournal; + readonly identityInventorySource: DurableIdentityInventorySource; + readonly compatibilityManifestSource: DurableStateCompatibilityManifestSource; + /** Identity/workspace participants are injected by their durable owning features. */ + readonly participants?: readonly CoordinationBackupParticipant[]; + readonly now?: () => Date; + readonly createFenceLeaseId?: () => string; + readonly onlineBackup?: { + readonly deadlineMs?: number; + readonly busyRetryMs?: number; + readonly pagesPerStep?: number; + }; +} + +export interface CoordinationBackupFeature { + /** The only public mutation/recovery facade for this feature composition. */ + readonly service: CoordinationBackupService; +} + +export function createCoordinationBackupFeature( + options: CreateCoordinationBackupFeatureOptions +): CoordinationBackupFeature { + if ( + !options.storage || + !options.deploymentId || + !options.backupRoot || + !options.eventJournal || + !options.identityInventorySource || + !options.compatibilityManifestSource + ) { + throw new TypeError('coordination-backup-feature-options-invalid'); + } + const now = options.now ?? (() => new Date()); + const publication = new NodeBackupPublication({ backupRoot: options.backupRoot }); + const onlineBackup = new SqliteOnlineBackupAdapter({ + storage: options.storage, + snapshotPublisher: publication, + artifactWriter: publication, + nowMs: () => now().getTime(), + ...(options.onlineBackup?.deadlineMs === undefined + ? {} + : { deadlineMs: options.onlineBackup.deadlineMs }), + ...(options.onlineBackup?.busyRetryMs === undefined + ? {} + : { busyRetryMs: options.onlineBackup.busyRetryMs }), + ...(options.onlineBackup?.pagesPerStep === undefined + ? {} + : { pagesPerStep: options.onlineBackup.pagesPerStep }), + }); + const participants = Object.freeze([ + new CoordinationEventBackupParticipant({ + deploymentId: options.deploymentId, + journal: options.eventJournal, + artifactWriter: publication, + }), + ...(options.participants ?? []), + ]); + const runs = new SqliteBackupRunRepository(options.storage); + const writerFence = new SqliteBackupWriterFence({ + storage: options.storage, + deploymentId: options.deploymentId, + nowIso: () => now().toISOString(), + ...(options.createFenceLeaseId === undefined + ? {} + : { createLeaseId: options.createFenceLeaseId }), + }); + const coordinationFlush = new SqliteBackupCoordinationFlush({ + storage: options.storage, + deploymentId: options.deploymentId, + compatibilityManifest: options.compatibilityManifestSource, + }); + const identityInventory = new DurableBackupIdentityInventory({ + deploymentId: options.deploymentId, + source: options.identityInventorySource, + }); + const immutableVerifier = new NodeImmutableBackupVerifier({ + backupRoot: options.backupRoot, + }); + const service = new CoordinationBackupService({ + runs, + writerFence, + coordinationFlush, + identityInventory, + onlineBackup, + sqliteIntegrity: onlineBackup, + manifestHash: new NodeBackupManifestHasher(), + publication, + immutableVerifier, + clock: { nowIso: () => now().toISOString() }, + participants, + }); + return Object.freeze({ service }); +} diff --git a/src/features/coordination-backup/main/index.ts b/src/features/coordination-backup/main/index.ts new file mode 100644 index 0000000000..1aeb8d096c --- /dev/null +++ b/src/features/coordination-backup/main/index.ts @@ -0,0 +1,5 @@ +export type { + DurableIdentityInventorySource, + DurableStateCompatibilityManifestSource, +} from './application/coordinationBackupMainPorts'; +export * from './composition/createCoordinationBackupFeature'; diff --git a/src/features/coordination-backup/main/infrastructure/NodeBackupManifestHasher.ts b/src/features/coordination-backup/main/infrastructure/NodeBackupManifestHasher.ts new file mode 100644 index 0000000000..970da49e25 --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/NodeBackupManifestHasher.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto'; + +import { parseSha256Digest } from '../../contracts'; + +import { canonicalBackupJson } from './canonicalBackupJson'; + +import type { BackupManifestBody, Sha256Digest } from '../../contracts'; +import type { BackupManifestHashPort } from '../../core/application'; + +export function sha256Bytes(bytes: Uint8Array): Sha256Digest { + return parseSha256Digest(createHash('sha256').update(bytes).digest('hex')); +} + +export class NodeBackupManifestHasher implements BackupManifestHashPort { + async hashCanonicalManifest(body: BackupManifestBody): Promise { + return sha256Bytes(Buffer.from(canonicalBackupJson(body), 'utf8')); + } +} diff --git a/src/features/coordination-backup/main/infrastructure/NodeBackupPublication.ts b/src/features/coordination-backup/main/infrastructure/NodeBackupPublication.ts new file mode 100644 index 0000000000..3a27a21611 --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/NodeBackupPublication.ts @@ -0,0 +1,514 @@ +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + type BackupCommitMarker, + type BackupManifest, + type BackupManifestEntry, + type BackupPublicationInspection, + type BackupRunId, + type CommittedBackupPublication, + type MeasuredBackupEntry, + parseSha256Digest, + type Sha256Digest, +} from '../../contracts'; + +import { + BACKUP_COMMIT_MARKER_FILE, + BACKUP_DIRECTORY_MODE, + BACKUP_METADATA_FILE_MODE, + type BackupPathLayout, + createBackupPathLayout, + generationPaths, + manifestHashFromGenerationName, + resolveArtifactPath, + stagePaths, + validateArtifactEntryId, +} from './backupPathLayout'; +import { canonicalBackupJson } from './canonicalBackupJson'; +import { NodeBackupManifestHasher } from './NodeBackupManifestHasher'; +import { + committedPublication, + createArtifactParents, + createOrRequirePrivateDirectory, + fsyncArtifactParents, + fsyncDirectory, + generationCandidates, + isAlreadyExists, + lstatOrNull, + markerMatchesManifest, + measureRegularFile, + ownerFor, + pathsForDirectory, + publicationError, + readSealedMetadata, + readTypedMetadata, + reapOwnedPreparationDirectories, + removeOwnedMarkerTemporaryFiles, + removeOwnedPreparationDirectory, + removePrivateTemporaryFile, + requireDirectory, + requireMetadataMode, + requireOwnedStage, + requireUnsealedAndManifestFree, + sameIdentity, + unlinkIfSameIdentity, + validateArtifactTree, + validateArtifactWriteRequest, + validateManifestHash, + walkTreeNoLinks, + writeExclusiveFile, + writeIdempotentFile, + writeIdempotentMetadata, +} from './nodeBackupPublicationFs'; +import { + type BackupArtifactMeasureRequest, + type BackupArtifactWriteRequest, + type BackupPublicationArtifactWriter, + type NodeBackupPublicationOptions, + type SqliteBackupArtifactChunk, + type SqliteBackupArtifactPublisher, +} from './nodeBackupPublicationTypes'; + +import type { BackupPublicationPort } from '../../core/application'; + +const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0; +const MARKER_TEMPORARY_PREFIX = `.${BACKUP_COMMIT_MARKER_FILE}.prepare-`; +export { measureRegularFile } from './nodeBackupPublicationFs'; +export { + type BackupArtifactMeasureRequest, + type BackupArtifactWriteRequest, + type BackupPublicationArtifactWriter, + BackupPublicationError, + type NodeBackupPublicationOptions, + type SqliteBackupArtifactChunk, + type SqliteBackupArtifactPublisher, +} from './nodeBackupPublicationTypes'; + +export class NodeBackupPublication + implements BackupPublicationPort, BackupPublicationArtifactWriter, SqliteBackupArtifactPublisher +{ + private readonly configuredLayout: BackupPathLayout; + private readonly manifestHasher = new NodeBackupManifestHasher(); + private readonly runLocks = new Map>(); + private boundLayout: BackupPathLayout | null = null; + + constructor(options: NodeBackupPublicationOptions | string) { + this.configuredLayout = createBackupPathLayout( + typeof options === 'string' ? options : options.backupRoot + ); + } + + async preparePrivateStage(backupRunId: BackupRunId): Promise { + return this.withRunLock(backupRunId, async () => { + const layout = await this.ensureLayout(); + await reapOwnedPreparationDirectories(layout, backupRunId); + const inspection = await this.inspectUnlocked(layout, backupRunId); + if ( + inspection.status === 'committed' || + inspection.status === 'staging_unsealed' || + inspection.status === 'staging_sealed' + ) { + return; + } + if (inspection.status !== 'absent') throw publicationError('prepare-state-ambiguous'); + + const temporaryDirectory = await fs.promises.mkdtemp( + path.join(layout.stagingRoot, `.prepare-${backupRunId}-`) + ); + const temporaryPaths = pathsForDirectory(temporaryDirectory); + try { + await requireDirectory(temporaryDirectory, BACKUP_DIRECTORY_MODE, layout.stagingRoot); + await writeExclusiveFile( + temporaryPaths.owner, + Buffer.from(canonicalBackupJson(ownerFor(backupRunId)), 'utf8'), + BACKUP_METADATA_FILE_MODE + ); + await fsyncDirectory(temporaryDirectory); + const destination = stagePaths(layout, backupRunId).directory; + try { + await fs.promises.rename(temporaryDirectory, destination); + await fsyncDirectory(layout.stagingRoot); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const raced = await this.inspectUnlocked(layout, backupRunId); + if (raced.status !== 'staging_unsealed') throw publicationError('prepare-race-ambiguous'); + } + } finally { + await removeOwnedPreparationDirectory(temporaryPaths, backupRunId, layout.stagingRoot); + } + }); + } + + async inspect(backupRunId: BackupRunId): Promise { + return this.withRunLock(backupRunId, async () => { + try { + const layout = await this.findExistingLayout(); + return layout ? this.inspectUnlocked(layout, backupRunId) : { status: 'absent' }; + } catch { + return { status: 'ambiguous' }; + } + }); + } + + async writeArtifact(request: BackupArtifactWriteRequest): Promise { + return this.withRunLock(request.backupRunId, async () => { + validateArtifactWriteRequest(request); + const layout = await this.ensureLayout(); + const paths = stagePaths(layout, request.backupRunId); + await requireOwnedStage(paths, request.backupRunId, layout.stagingRoot); + await requireUnsealedAndManifestFree(paths); + + const artifactPath = resolveArtifactPath(paths.directory, request.entryId); + await createArtifactParents(paths.directory, request.entryId); + const bytes = Buffer.from(request.bytes); + const expectedHash = parseSha256Digest(createHash('sha256').update(bytes).digest('hex')); + await writeIdempotentFile(artifactPath, bytes, request.mode, expectedHash); + await fsyncArtifactParents(paths.directory, request.entryId); + + return Object.freeze({ + entryId: request.entryId, + participantId: request.participantId, + kind: request.kind, + logicalOwner: request.logicalOwner, + logicalType: request.logicalType, + schemaVersion: request.schemaVersion, + byteLength: bytes.byteLength, + mode: request.mode, + sha256: expectedHash, + sourceGeneration: request.sourceGeneration, + }); + }); + } + + async measureStagedArtifact(request: BackupArtifactMeasureRequest): Promise { + return this.withRunLock(request.backupRunId, async () => { + const layout = await this.ensureLayout(); + const paths = stagePaths(layout, request.backupRunId); + await requireOwnedStage(paths, request.backupRunId, layout.stagingRoot); + const measured = await measureRegularFile( + resolveArtifactPath(paths.directory, request.entryId), + request.entryId + ); + return Object.freeze(measured); + }); + } + + async publishSqliteSnapshot(request: { + readonly backupRunId: BackupRunId; + readonly entryId: string; + readonly byteLength: number; + readonly sha256: Sha256Digest; + readonly readChunk: (offset: number) => Promise; + }): Promise { + return this.withRunLock(request.backupRunId, async () => { + validateArtifactEntryId(request.entryId); + if (!Number.isSafeInteger(request.byteLength) || request.byteLength <= 0) { + throw publicationError('sqlite-source-length-invalid'); + } + parseSha256Digest(request.sha256); + const layout = await this.ensureLayout(); + const paths = stagePaths(layout, request.backupRunId); + await requireOwnedStage(paths, request.backupRunId, layout.stagingRoot); + await requireUnsealedAndManifestFree(paths); + await createArtifactParents(paths.directory, request.entryId); + const artifactPath = resolveArtifactPath(paths.directory, request.entryId); + const existing = await lstatOrNull(artifactPath); + if (existing) { + const measured = await measureRegularFile(artifactPath, request.entryId); + if ( + measured.byteLength !== request.byteLength || + measured.sha256 !== request.sha256 || + measured.mode !== BACKUP_METADATA_FILE_MODE + ) { + throw publicationError('sqlite-artifact-existing-mismatch'); + } + return Object.freeze(measured); + } + + let handle: fs.promises.FileHandle | null = null; + let createdIdentity: fs.Stats | null = null; + try { + handle = await fs.promises.open( + artifactPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NO_FOLLOW, + BACKUP_METADATA_FILE_MODE + ); + await handle.chmod(BACKUP_METADATA_FILE_MODE); + createdIdentity = await handle.stat(); + if (!createdIdentity.isFile()) throw publicationError('sqlite-artifact-not-regular'); + const hash = createHash('sha256'); + let offset = 0; + while (offset < request.byteLength) { + const chunk = await request.readChunk(offset); + if ( + chunk.offset !== offset || + chunk.totalByteLength !== request.byteLength || + !(chunk.bytes instanceof Uint8Array) || + chunk.bytes.byteLength === 0 || + offset + chunk.bytes.byteLength > request.byteLength || + chunk.eof !== (offset + chunk.bytes.byteLength === request.byteLength) + ) { + throw publicationError('sqlite-source-chunk-invalid'); + } + const bytes = Buffer.from(chunk.bytes); + let written = 0; + while (written < bytes.byteLength) { + const result = await handle.write( + bytes, + written, + bytes.byteLength - written, + offset + written + ); + if (result.bytesWritten === 0) throw publicationError('sqlite-artifact-short-write'); + written += result.bytesWritten; + } + hash.update(bytes); + offset += bytes.byteLength; + } + if (hash.digest('hex') !== request.sha256) { + throw publicationError('sqlite-source-digest-mismatch'); + } + await handle.sync(); + const finalized = await handle.stat(); + if ( + !sameIdentity(createdIdentity, finalized) || + finalized.size !== request.byteLength || + (finalized.mode & 0o777) !== BACKUP_METADATA_FILE_MODE + ) { + throw publicationError('sqlite-artifact-finalize-mismatch'); + } + await handle.close(); + handle = null; + await requireOwnedStage(paths, request.backupRunId, layout.stagingRoot); + await requireUnsealedAndManifestFree(paths); + await createArtifactParents(paths.directory, request.entryId); + const measured = await measureRegularFile(artifactPath, request.entryId); + if ( + measured.byteLength !== request.byteLength || + measured.sha256 !== request.sha256 || + measured.mode !== BACKUP_METADATA_FILE_MODE + ) { + throw publicationError('sqlite-artifact-measurement-mismatch'); + } + await fsyncArtifactParents(paths.directory, request.entryId); + return Object.freeze(measured); + } catch (error) { + await handle?.close().catch(() => undefined); + if (createdIdentity) await unlinkIfSameIdentity(artifactPath, createdIdentity); + throw error; + } + }); + } + + async writeRootManifest(request: { + readonly backupRunId: BackupRunId; + readonly manifest: BackupManifest; + }): Promise { + return this.withRunLock(request.backupRunId, async () => { + const layout = await this.ensureLayout(); + const paths = stagePaths(layout, request.backupRunId); + await requireOwnedStage(paths, request.backupRunId, layout.stagingRoot); + if (await lstatOrNull(paths.marker)) throw publicationError('manifest-after-marker'); + await validateManifestHash(this.manifestHasher, request.manifest); + await validateArtifactTree(paths, request.manifest, false); + await writeIdempotentMetadata(paths.manifest, request.manifest); + await fsyncDirectory(paths.directory); + }); + } + + async writeCommitMarkerLast(request: { + readonly backupRunId: BackupRunId; + readonly marker: BackupCommitMarker; + }): Promise { + return this.withRunLock(request.backupRunId, async () => { + const layout = await this.ensureLayout(); + const paths = stagePaths(layout, request.backupRunId); + await requireOwnedStage(paths, request.backupRunId, layout.stagingRoot); + await removeOwnedMarkerTemporaryFiles(paths, request.backupRunId, layout.stagingRoot); + const manifest = await readTypedMetadata(paths.manifest); + await validateManifestHash(this.manifestHasher, manifest); + if (!markerMatchesManifest(request.marker, manifest, request.backupRunId)) { + throw publicationError('commit-marker-manifest-mismatch'); + } + const markerStat = await lstatOrNull(paths.marker); + await validateArtifactTree(paths, manifest, markerStat !== null); + if (markerStat) { + const existing = await readTypedMetadata(paths.marker); + if (canonicalBackupJson(existing) !== canonicalBackupJson(request.marker)) { + throw publicationError('commit-marker-mismatch'); + } + await requireMetadataMode(paths.marker, markerStat); + return; + } + + const temporaryMarker = path.join( + paths.directory, + `${MARKER_TEMPORARY_PREFIX}${randomUUID()}` + ); + try { + await writeExclusiveFile( + temporaryMarker, + Buffer.from(canonicalBackupJson(request.marker), 'utf8'), + BACKUP_METADATA_FILE_MODE + ); + await fs.promises.rename(temporaryMarker, paths.marker); + await fsyncDirectory(paths.directory); + await fsyncDirectory(layout.stagingRoot); + } finally { + await removePrivateTemporaryFile(temporaryMarker, paths.directory); + } + }); + } + + async commitSealedStage(request: { + readonly backupRunId: BackupRunId; + readonly manifestHash: Sha256Digest; + }): Promise { + return this.withRunLock(request.backupRunId, async () => { + const layout = await this.ensureLayout(); + const inspection = await this.inspectUnlocked(layout, request.backupRunId); + if (inspection.status === 'committed') { + if (inspection.publication.manifestHash !== request.manifestHash) { + throw publicationError('committed-generation-mismatch'); + } + return inspection.publication; + } + if (inspection.status !== 'staging_sealed') { + throw publicationError('stage-not-sealed'); + } + + const source = stagePaths(layout, request.backupRunId); + const manifest = await readTypedMetadata(source.manifest); + if (manifest.manifestHash !== request.manifestHash) { + throw publicationError('sealed-manifest-hash-mismatch'); + } + await validateManifestHash(this.manifestHasher, manifest); + await validateArtifactTree(source, manifest, true); + + const target = generationPaths(layout, request.backupRunId, request.manifestHash); + if (await lstatOrNull(target.directory)) + throw publicationError('immutable-generation-exists'); + await fs.promises.rename(source.directory, target.directory); + // Make the new recoverable name durable before making removal of the old name durable. + await fsyncDirectory(layout.generationsRoot); + await fsyncDirectory(layout.stagingRoot); + + return committedPublication(request.backupRunId, request.manifestHash); + }); + } + + async abortUncommittedStage(backupRunId: BackupRunId): Promise { + return this.withRunLock(backupRunId, async () => { + const layout = await this.findExistingLayout(); + if (!layout) return; + const inspection = await this.inspectUnlocked(layout, backupRunId); + if (inspection.status === 'absent') return; + if (inspection.status !== 'staging_unsealed') { + throw publicationError('abort-refused'); + } + const paths = stagePaths(layout, backupRunId); + await requireOwnedStage(paths, backupRunId, layout.stagingRoot); + await walkTreeNoLinks(paths.directory); + if (await lstatOrNull(paths.marker)) throw publicationError('abort-sealed-stage'); + await fs.promises.rm(paths.directory, { recursive: true }); + await fsyncDirectory(layout.stagingRoot); + }); + } + + private async ensureLayout(): Promise { + await fs.promises.mkdir(this.configuredLayout.root, { + recursive: true, + mode: BACKUP_DIRECTORY_MODE, + }); + const rootStat = await fs.promises.lstat(this.configuredLayout.root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) + throw publicationError('root-invalid'); + const canonicalRoot = await fs.promises.realpath(this.configuredLayout.root); + const candidate = createBackupPathLayout(canonicalRoot); + if (this.boundLayout && this.boundLayout.root !== candidate.root) { + throw publicationError('root-binding-changed'); + } + this.boundLayout = candidate; + + await createOrRequirePrivateDirectory(candidate.stagingRoot, candidate.root); + await createOrRequirePrivateDirectory(candidate.generationsRoot, candidate.root); + return candidate; + } + + private async findExistingLayout(): Promise { + const rootStat = await lstatOrNull(this.configuredLayout.root); + if (!rootStat) return null; + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) + throw publicationError('root-invalid'); + const canonicalRoot = await fs.promises.realpath(this.configuredLayout.root); + const candidate = createBackupPathLayout(canonicalRoot); + if (this.boundLayout && this.boundLayout.root !== candidate.root) { + throw publicationError('root-binding-changed'); + } + const [stagingStat, generationsStat] = await Promise.all([ + lstatOrNull(candidate.stagingRoot), + lstatOrNull(candidate.generationsRoot), + ]); + if (!stagingStat && !generationsStat) return null; + if (!stagingStat || !generationsStat) throw publicationError('layout-partial'); + await requireDirectory(candidate.stagingRoot, BACKUP_DIRECTORY_MODE, candidate.root); + await requireDirectory(candidate.generationsRoot, BACKUP_DIRECTORY_MODE, candidate.root); + this.boundLayout = candidate; + return candidate; + } + + private async inspectUnlocked( + layout: BackupPathLayout, + backupRunId: BackupRunId + ): Promise { + try { + const stage = stagePaths(layout, backupRunId); + const stageStat = await lstatOrNull(stage.directory); + const generations = await generationCandidates(layout, backupRunId); + if (generations.length > 1 || (stageStat && generations.length > 0)) { + return { status: 'ambiguous' }; + } + if (generations.length === 1) { + const candidate = generations[0]; + const hashText = manifestHashFromGenerationName(candidate.name, backupRunId); + if (!hashText) return { status: 'ambiguous' }; + const hash = parseSha256Digest(hashText); + const paths = pathsForDirectory(candidate.path); + await requireOwnedStage(paths, backupRunId, layout.generationsRoot); + const { manifest, marker } = await readSealedMetadata(paths, backupRunId); + if (manifest.manifestHash !== hash || marker.manifestHash !== hash) { + return { status: 'ambiguous' }; + } + return { status: 'committed', publication: committedPublication(backupRunId, hash) }; + } + if (!stageStat) return { status: 'absent' }; + if (!stageStat.isDirectory() || stageStat.isSymbolicLink()) return { status: 'ambiguous' }; + await requireOwnedStage(stage, backupRunId, layout.stagingRoot); + const marker = await lstatOrNull(stage.marker); + if (!marker) return { status: 'staging_unsealed' }; + await readSealedMetadata(stage, backupRunId); + return { status: 'staging_sealed' }; + } catch { + return { status: 'ambiguous' }; + } + } + + private async withRunLock(backupRunId: BackupRunId, action: () => Promise): Promise { + const key = backupRunId as string; + const previous = this.runLocks.get(key) ?? Promise.resolve(); + let release = (): void => undefined; + const current = new Promise((resolve) => { + release = resolve; + }); + this.runLocks.set(key, current); + await previous; + try { + return await action(); + } finally { + release(); + if (this.runLocks.get(key) === current) this.runLocks.delete(key); + } + } +} diff --git a/src/features/coordination-backup/main/infrastructure/NodeImmutableBackupVerifier.ts b/src/features/coordination-backup/main/infrastructure/NodeImmutableBackupVerifier.ts new file mode 100644 index 0000000000..311b9994a6 --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/NodeImmutableBackupVerifier.ts @@ -0,0 +1,395 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + type BackupCommitMarker, + type BackupIdentityInventory, + type BackupManifest, + type BackupRunId, + type CopiedSourceBackupRun, + type ImmutableBackupInspection, + type ImmutableBackupVerification, + type MeasuredBackupEntry, + parseSha256Digest, +} from '../../contracts'; +import { validateImmutableBackupInspection } from '../../core/domain'; + +import { + artifactAncestorEntryIds, + BACKUP_COMMIT_MARKER_FILE, + BACKUP_DIRECTORY_MODE, + BACKUP_GENERATIONS_DIRECTORY, + BACKUP_METADATA_FILE_MODE, + BACKUP_ROOT_MANIFEST_FILE, + BACKUP_STAGE_OWNER_FILE, + BACKUP_STAGING_DIRECTORY, + type BackupPathLayout, + type BackupStagePaths, + createBackupPathLayout, + generationName, + generationPaths, + isPathInside, + resolveArtifactPath, + stagePaths, + validateArtifactEntryId, +} from './backupPathLayout'; +import { canonicalBackupJson } from './canonicalBackupJson'; +import { NodeBackupManifestHasher } from './NodeBackupManifestHasher'; +import { measureRegularFile } from './NodeBackupPublication'; + +import type { ImmutableBackupVerifierPort } from '../../core/application'; + +const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0; +const OWNER_FORMAT = 'coordination-backup-private-stage/v1' as const; +const MAX_METADATA_BYTES = 16 * 1024 * 1024; + +export interface NodeImmutableBackupVerifierOptions { + readonly backupRoot: string; +} + +export class NodeImmutableBackupVerifier implements ImmutableBackupVerifierPort { + private readonly layout: BackupPathLayout; + private readonly manifestHasher = new NodeBackupManifestHasher(); + + constructor(options: NodeImmutableBackupVerifierOptions | string) { + this.layout = createBackupPathLayout( + typeof options === 'string' ? options : options.backupRoot + ); + } + + async verify( + request: Parameters[0] + ): Promise { + try { + return await this.verifyOrThrow(request); + } catch (error) { + return { + status: 'invalid', + reasons: Object.freeze([verificationReason(error)]), + }; + } + } + + private async verifyOrThrow( + request: Parameters[0] + ): Promise { + const layout = await bindReadOnlyLayout(this.layout); + const paths = await resolveUnambiguousLocation( + layout, + request.backupRunId, + request.location, + request.expectedPlan.manifest.manifestHash + ); + await requireDirectory(paths.directory, layout.root); + + const expectedOwner = canonicalBackupJson({ + format: OWNER_FORMAT, + backupRunId: request.backupRunId, + }); + const observedOwner = await readCanonicalMetadata(paths.owner); + if (observedOwner.serialized !== expectedOwner) throw verifierError('stage-owner-mismatch'); + + const observedManifest = await readCanonicalMetadata(paths.manifest); + const observedMarker = await readCanonicalMetadata(paths.marker); + const expectedManifestJson = canonicalBackupJson(request.expectedPlan.manifest); + const expectedMarkerJson = canonicalBackupJson(request.expectedPlan.marker); + if (observedManifest.serialized !== expectedManifestJson) { + throw verifierError('manifest-mismatch'); + } + if (observedMarker.serialized !== expectedMarkerJson) { + throw verifierError('commit-marker-mismatch'); + } + + const manifest = observedManifest.value as BackupManifest; + const marker = observedMarker.value as BackupCommitMarker; + const { manifestHash, ...manifestBody } = manifest; + const computedManifestHash = await this.manifestHasher.hashCanonicalManifest(manifestBody); + if ( + parseSha256Digest(manifestHash) !== computedManifestHash || + marker.manifestHash !== computedManifestHash + ) { + throw verifierError('manifest-digest-mismatch'); + } + if ( + marker.backupRunId !== request.backupRunId || + manifest.backupRunId !== request.backupRunId || + marker.deploymentId !== manifest.deploymentId || + marker.sealedAt !== manifest.sealedAt + ) { + throw verifierError('marker-manifest-binding-mismatch'); + } + + const measuredEntries = await verifyExactTree(paths, manifest); + const inspection = buildInspection(manifest, marker, computedManifestHash, measuredEntries); + const domainValidation = validateImmutableBackupInspection(inspection); + if (domainValidation.status === 'invalid') { + return { status: 'invalid', reasons: domainValidation.reasons }; + } + return { status: 'verified', inspection }; + } +} + +async function bindReadOnlyLayout(configured: BackupPathLayout): Promise { + const rootStat = await fs.promises.lstat(configured.root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw verifierError('root-invalid'); + const realRoot = await fs.promises.realpath(configured.root); + const layout = createBackupPathLayout(realRoot); + await requirePrivateDirectory(layout.stagingRoot, layout.root, BACKUP_STAGING_DIRECTORY); + await requirePrivateDirectory(layout.generationsRoot, layout.root, BACKUP_GENERATIONS_DIRECTORY); + return layout; +} + +async function resolveUnambiguousLocation( + layout: BackupPathLayout, + backupRunId: BackupRunId, + location: 'staging' | 'committed', + manifestHash: BackupManifest['manifestHash'] +): Promise { + const stage = stagePaths(layout, backupRunId); + const stageExists = (await lstatOrNull(stage.directory)) !== null; + const prefix = `${backupRunId}.`; + const candidates = (await fs.promises.readdir(layout.generationsRoot)).filter((name) => + name.startsWith(prefix) + ); + if (location === 'staging') { + if (!stageExists || candidates.length !== 0) throw verifierError('publication-ambiguous'); + return stage; + } + const expectedName = generationName(backupRunId, manifestHash); + if (stageExists || candidates.length !== 1 || candidates[0] !== expectedName) { + throw verifierError('publication-ambiguous'); + } + return generationPaths(layout, backupRunId, manifestHash); +} + +async function verifyExactTree( + paths: BackupStagePaths, + manifest: BackupManifest +): Promise { + const expectedFiles = new Set([ + BACKUP_STAGE_OWNER_FILE, + BACKUP_ROOT_MANIFEST_FILE, + BACKUP_COMMIT_MARKER_FILE, + ]); + const expectedDirectories = new Set(); + const entryIds = new Set(); + for (const entry of manifest.entries) { + validateArtifactEntryId(entry.entryId); + if (entryIds.has(entry.entryId)) throw verifierError('manifest-entry-duplicate'); + entryIds.add(entry.entryId); + expectedFiles.add(entry.entryId); + for (const ancestor of artifactAncestorEntryIds(entry.entryId)) { + expectedDirectories.add(ancestor); + } + } + + const observed = await walkExactTree(paths.directory); + requireSameSet(observed.files, expectedFiles, 'file-set-mismatch'); + requireSameSet(observed.directories, expectedDirectories, 'directory-set-mismatch'); + + const measuredEntries: MeasuredBackupEntry[] = []; + for (const entry of manifest.entries) { + const measured = await measureRegularFile( + resolveArtifactPath(paths.directory, entry.entryId), + entry.entryId + ); + if ( + measured.byteLength !== entry.byteLength || + measured.mode !== entry.mode || + measured.sha256 !== entry.sha256 + ) { + throw verifierError('artifact-digest-size-or-mode-mismatch'); + } + measuredEntries.push(Object.freeze(measured)); + } + return Object.freeze(measuredEntries); +} + +function buildInspection( + manifest: BackupManifest, + marker: BackupCommitMarker, + computedManifestHash: BackupManifest['manifestHash'], + measuredEntries: readonly MeasuredBackupEntry[] +): ImmutableBackupInspection { + /* + * The independently checked SQLite digest binds the previously recorded integrity/inventory + * evidence. Re-projecting these typed values from that hash-bound manifest does not reopen or copy + * SQLite/WAL/SHM and cannot invent a different source generation. + */ + const observedIdentityInventory: BackupIdentityInventory = manifest.identityInventory; + const copiedSourceRun: CopiedSourceBackupRun = Object.freeze({ + backupRunId: manifest.sourceBackupRunId, + deploymentId: manifest.deploymentId, + productKind: manifest.productKind, + purpose: manifest.purpose, + state: 'sqlite_snapshot', + fenceGeneration: manifest.fenceGeneration, + coordinationBarrier: manifest.coordinationBarrier, + participants: manifest.participants, + identityInventory: manifest.identityInventory, + }); + return Object.freeze({ + manifest, + marker, + computedManifestHash, + measuredEntries, + observedIdentityInventory, + copiedSourceRun, + }); +} + +async function readCanonicalMetadata( + filePath: string +): Promise<{ readonly serialized: string; readonly value: unknown }> { + const stat = await fs.promises.lstat(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size > MAX_METADATA_BYTES || + (stat.mode & 0o777) !== BACKUP_METADATA_FILE_MODE + ) { + throw verifierError('metadata-file-invalid'); + } + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | NO_FOLLOW); + const descriptor = await handle.stat(); + if ( + !descriptor.isFile() || + !sameIdentity(stat, descriptor) || + descriptor.size > MAX_METADATA_BYTES || + descriptor.mode !== stat.mode + ) { + throw verifierError('metadata-identity-race'); + } + const bytes = await handle.readFile(); + const after = await fs.promises.lstat(filePath); + if ( + bytes.byteLength !== descriptor.size || + !sameIdentity(descriptor, after) || + after.isSymbolicLink() || + after.mode !== descriptor.mode + ) { + throw verifierError('metadata-changed-during-read'); + } + const serialized = bytes.toString('utf8'); + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw verifierError('metadata-json-invalid'); + } + if (canonicalBackupJson(value) !== serialized) throw verifierError('metadata-not-canonical'); + return { serialized, value }; + } finally { + await handle?.close(); + } +} + +async function walkExactTree( + root: string +): Promise<{ readonly files: ReadonlySet; readonly directories: ReadonlySet }> { + const files = new Set(); + const directories = new Set(); + + async function visit(directory: string): Promise { + for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + const stat = await fs.promises.lstat(candidate); + if (stat.isSymbolicLink()) throw verifierError('symlink-refused'); + const relative = path.relative(root, candidate).split(path.sep).join('/'); + if (stat.isDirectory()) { + if ((stat.mode & 0o777) !== BACKUP_DIRECTORY_MODE) { + throw verifierError('directory-mode-mismatch'); + } + directories.add(relative); + await visit(candidate); + } else if (stat.isFile()) { + files.add(relative); + } else { + throw verifierError('non-file-entry-refused'); + } + } + } + + await visit(root); + return { files, directories }; +} + +async function requirePrivateDirectory( + directory: string, + root: string, + expectedName: string +): Promise { + if (path.basename(directory) !== expectedName) throw verifierError('layout-invalid'); + await requireDirectory(directory, root); +} + +async function requireDirectory(directory: string, parent: string): Promise { + const stat = await fs.promises.lstat(directory); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (stat.mode & 0o777) !== BACKUP_DIRECTORY_MODE + ) { + throw verifierError('directory-invalid'); + } + const [realDirectory, realParent] = await Promise.all([ + fs.promises.realpath(directory), + fs.promises.realpath(parent), + ]); + if (!isPathInside(realParent, realDirectory)) throw verifierError('directory-path-escape'); + const after = await fs.promises.lstat(directory); + if ( + !sameIdentity(stat, after) || + !after.isDirectory() || + after.isSymbolicLink() || + after.mode !== stat.mode + ) { + throw verifierError('directory-identity-race'); + } +} + +function requireSameSet( + observed: ReadonlySet, + expected: ReadonlySet, + reason: string +): void { + if ( + observed.size !== expected.size || + [...observed].some((entry) => !expected.has(entry)) || + [...expected].some((entry) => !observed.has(entry)) + ) { + throw verifierError(reason); + } +} + +async function lstatOrNull(filePath: string): Promise { + try { + return await fs.promises.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return null; + throw error; + } +} + +function sameIdentity(left: fs.Stats, right: fs.Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function verifierError(reason: string): Error { + return new Error(`coordination-backup-verifier-${reason}`); +} + +function verificationReason(error: unknown): string { + if (error instanceof Error && error.message.startsWith('coordination-backup-verifier-')) { + return error.message.slice('coordination-backup-verifier-'.length); + } + if (error instanceof Error && error.message.startsWith('coordination-backup-publication-')) { + return error.message.slice('coordination-backup-publication-'.length); + } + if (error instanceof TypeError && error.message.startsWith('coordination-backup-')) { + return error.message.slice('coordination-backup-'.length); + } + return 'verification-boundary-failed'; +} diff --git a/src/features/coordination-backup/main/infrastructure/backupPathLayout.ts b/src/features/coordination-backup/main/infrastructure/backupPathLayout.ts new file mode 100644 index 0000000000..76587f8044 --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/backupPathLayout.ts @@ -0,0 +1,136 @@ +import path from 'node:path'; + +import type { BackupRunId, Sha256Digest } from '../../contracts'; + +export const BACKUP_STAGING_DIRECTORY = '.coordination-backup-staging'; +export const BACKUP_GENERATIONS_DIRECTORY = 'coordination-backup-generations'; +export const BACKUP_STAGE_OWNER_FILE = '.coordination-backup-stage.json'; +export const BACKUP_ROOT_MANIFEST_FILE = 'manifest.json'; +export const BACKUP_COMMIT_MARKER_FILE = 'commit-marker.json'; + +export const BACKUP_DIRECTORY_MODE = 0o700; +export const BACKUP_METADATA_FILE_MODE = 0o600; + +const SHA256_TEXT_PATTERN = /^[0-9a-f]{64}$/; + +export interface BackupPathLayout { + readonly root: string; + readonly stagingRoot: string; + readonly generationsRoot: string; +} + +export interface BackupStagePaths { + readonly directory: string; + readonly owner: string; + readonly manifest: string; + readonly marker: string; +} + +export function createBackupPathLayout(root: string): BackupPathLayout { + if (typeof root !== 'string' || root.length === 0 || !path.isAbsolute(root)) { + throw new TypeError('coordination-backup-root-must-be-absolute'); + } + const normalized = path.resolve(root); + if (normalized === path.parse(normalized).root) { + throw new TypeError('coordination-backup-root-too-broad'); + } + return Object.freeze({ + root: normalized, + stagingRoot: path.join(normalized, BACKUP_STAGING_DIRECTORY), + generationsRoot: path.join(normalized, BACKUP_GENERATIONS_DIRECTORY), + }); +} + +export function stagePaths(layout: BackupPathLayout, backupRunId: BackupRunId): BackupStagePaths { + return pathsForDirectory(path.join(layout.stagingRoot, backupRunId)); +} + +export function generationName(backupRunId: BackupRunId, manifestHash: Sha256Digest): string { + return `${backupRunId}.${manifestHash}`; +} + +export function generationPaths( + layout: BackupPathLayout, + backupRunId: BackupRunId, + manifestHash: Sha256Digest +): BackupStagePaths { + return pathsForDirectory( + path.join(layout.generationsRoot, generationName(backupRunId, manifestHash)) + ); +} + +export function isGenerationNameForRun(name: string, backupRunId: BackupRunId): boolean { + const prefix = `${backupRunId}.`; + return name.startsWith(prefix) && SHA256_TEXT_PATTERN.test(name.slice(prefix.length)); +} + +export function manifestHashFromGenerationName( + name: string, + backupRunId: BackupRunId +): string | null { + if (!isGenerationNameForRun(name, backupRunId)) return null; + return name.slice(backupRunId.length + 1); +} + +export function resolveArtifactPath(directory: string, entryId: string): string { + const segments = validateArtifactEntryId(entryId); + const candidate = path.resolve(directory, ...segments); + if (!isPathInside(directory, candidate)) { + throw new TypeError('coordination-backup-artifact-path-escape'); + } + return candidate; +} + +export function validateArtifactEntryId(entryId: string): readonly string[] { + if ( + typeof entryId !== 'string' || + entryId.length === 0 || + entryId.length > 512 || + entryId.includes('\\') || + entryId.includes('\0') || + path.posix.isAbsolute(entryId) + ) { + throw new TypeError('coordination-backup-artifact-entry-id-invalid'); + } + const segments = entryId.split('/'); + if ( + segments.some( + (segment) => + segment.length === 0 || segment === '.' || segment === '..' || segment.length > 255 + ) + ) { + throw new TypeError('coordination-backup-artifact-entry-id-invalid'); + } + if ( + segments.length === 1 && + [BACKUP_STAGE_OWNER_FILE, BACKUP_ROOT_MANIFEST_FILE, BACKUP_COMMIT_MARKER_FILE].includes( + segments[0] + ) + ) { + throw new TypeError('coordination-backup-artifact-entry-id-reserved'); + } + return Object.freeze(segments); +} + +export function artifactAncestorEntryIds(entryId: string): readonly string[] { + const segments = validateArtifactEntryId(entryId); + const result: string[] = []; + for (let index = 1; index < segments.length; index += 1) { + result.push(segments.slice(0, index).join('/')); + } + return Object.freeze(result); +} + +export function isPathInside(parent: string, candidate: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative.length > 0 && !relative.startsWith(`..${path.sep}`) && relative !== '..'; +} + +function pathsForDirectory(directory: string): BackupStagePaths { + return Object.freeze({ + directory, + owner: path.join(directory, BACKUP_STAGE_OWNER_FILE), + manifest: path.join(directory, BACKUP_ROOT_MANIFEST_FILE), + marker: path.join(directory, BACKUP_COMMIT_MARKER_FILE), + }); +} diff --git a/src/features/coordination-backup/main/infrastructure/canonicalBackupJson.ts b/src/features/coordination-backup/main/infrastructure/canonicalBackupJson.ts new file mode 100644 index 0000000000..56936a5a0c --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/canonicalBackupJson.ts @@ -0,0 +1,103 @@ +const OBJECT_PROTOTYPE = Object.prototype; + +export class CanonicalBackupJsonError extends TypeError { + constructor(readonly reason: string) { + super(`coordination-backup-canonical-json-${reason}`); + this.name = 'CanonicalBackupJsonError'; + } +} + +/** + * Serializes the JSON data model with object keys recursively ordered by UTF-16 code unit. + * Values which JSON.stringify would silently discard or coerce are rejected instead. + */ +export function canonicalBackupJson(value: unknown): string { + const ancestors = new Set(); + return serialize(value, ancestors); +} + +export function canonicalBackupJsonBytes(value: unknown): Uint8Array { + return Buffer.from(canonicalBackupJson(value), 'utf8'); +} + +function serialize(value: unknown, ancestors: Set): string { + if (value === null) return 'null'; + + switch (typeof value) { + case 'string': + case 'boolean': + return JSON.stringify(value); + case 'number': + if (!Number.isFinite(value)) throw new CanonicalBackupJsonError('non-finite-number'); + return JSON.stringify(value); + case 'object': + return serializeObject(value, ancestors); + case 'bigint': + throw new CanonicalBackupJsonError('bigint'); + case 'undefined': + throw new CanonicalBackupJsonError('undefined'); + case 'function': + throw new CanonicalBackupJsonError('function'); + case 'symbol': + throw new CanonicalBackupJsonError('symbol'); + } + throw new CanonicalBackupJsonError('unsupported-value'); +} + +function serializeObject(value: object, ancestors: Set): string { + if (ancestors.has(value)) throw new CanonicalBackupJsonError('cycle'); + ancestors.add(value); + try { + if (Array.isArray(value)) { + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.some( + (key) => + typeof key !== 'string' || + (key !== 'length' && (!/^(?:0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length)) + ) + ) { + throw new CanonicalBackupJsonError('array-property'); + } + const items: string[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!OBJECT_PROTOTYPE.hasOwnProperty.call(value, index)) { + throw new CanonicalBackupJsonError('sparse-array'); + } + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new CanonicalBackupJsonError('array-accessor'); + } + items.push(serialize(value[index], ancestors)); + } + return `[${items.join(',')}]`; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== OBJECT_PROTOTYPE && prototype !== null) { + throw new CanonicalBackupJsonError('non-plain-object'); + } + + const record = value as Record; + const ownKeys = Reflect.ownKeys(record); + if (ownKeys.some((key) => typeof key !== 'string')) { + throw new CanonicalBackupJsonError('symbol-key'); + } + const keys = Object.keys(record).sort(); + if (ownKeys.length !== keys.length) { + throw new CanonicalBackupJsonError('non-enumerable-property'); + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new CanonicalBackupJsonError('object-accessor'); + } + } + const properties = keys.map( + (key) => `${JSON.stringify(key)}:${serialize(record[key], ancestors)}` + ); + return `{${properties.join(',')}}`; + } finally { + ancestors.delete(value); + } +} diff --git a/src/features/coordination-backup/main/infrastructure/index.ts b/src/features/coordination-backup/main/infrastructure/index.ts new file mode 100644 index 0000000000..b95ef8baa9 --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/index.ts @@ -0,0 +1,5 @@ +export * from './backupPathLayout'; +export * from './canonicalBackupJson'; +export * from './NodeBackupManifestHasher'; +export * from './NodeBackupPublication'; +export * from './NodeImmutableBackupVerifier'; diff --git a/src/features/coordination-backup/main/infrastructure/nodeBackupPublicationFs.ts b/src/features/coordination-backup/main/infrastructure/nodeBackupPublicationFs.ts new file mode 100644 index 0000000000..6233712ac3 --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/nodeBackupPublicationFs.ts @@ -0,0 +1,636 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + type BackupCommitMarker, + type BackupManifest, + type BackupManifestEntry, + type BackupRunId, + type CommittedBackupPublication, + COORDINATION_BACKUP_COMMIT_MARKER_FORMAT, + COORDINATION_BACKUP_FORMAT, + type MeasuredBackupEntry, + parseSha256Digest, + type Sha256Digest, + SQLITE_ONLINE_BACKUP_METHOD, +} from '../../contracts'; + +import { + artifactAncestorEntryIds, + BACKUP_COMMIT_MARKER_FILE, + BACKUP_DIRECTORY_MODE, + BACKUP_METADATA_FILE_MODE, + BACKUP_ROOT_MANIFEST_FILE, + BACKUP_STAGE_OWNER_FILE, + type BackupPathLayout, + type BackupStagePaths, + generationName, + isPathInside, + resolveArtifactPath, + validateArtifactEntryId, +} from './backupPathLayout'; +import { canonicalBackupJson } from './canonicalBackupJson'; +import { NodeBackupManifestHasher } from './NodeBackupManifestHasher'; +import { + type BackupArtifactWriteRequest, + BackupPublicationError, +} from './nodeBackupPublicationTypes'; + +const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0; +const DIRECTORY_ONLY = fs.constants.O_DIRECTORY ?? 0; +const OWNER_FORMAT = 'coordination-backup-private-stage/v1' as const; +const MAX_METADATA_BYTES = 16 * 1024 * 1024; +const MARKER_TEMPORARY_PREFIX = `.${BACKUP_COMMIT_MARKER_FILE}.prepare-`; +const MARKER_TEMPORARY_SUFFIX_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const PREPARATION_DIRECTORY_SUFFIX_PATTERN = /^[A-Za-z0-9]{6}$/; + +interface StageOwner { + readonly format: typeof OWNER_FORMAT; + readonly backupRunId: BackupRunId; +} + +export async function generationCandidates( + layout: BackupPathLayout, + backupRunId: BackupRunId +): Promise { + const names = await fs.promises.readdir(layout.generationsRoot); + const prefix = `${backupRunId}.`; + return names + .filter((name) => name.startsWith(prefix)) + .map((name) => ({ name, path: path.join(layout.generationsRoot, name) })); +} + +export async function readSealedMetadata( + paths: BackupStagePaths, + backupRunId: BackupRunId +): Promise<{ readonly manifest: BackupManifest; readonly marker: BackupCommitMarker }> { + const manifest = await readTypedMetadata(paths.manifest); + const marker = await readTypedMetadata(paths.marker); + if (!markerMatchesManifest(marker, manifest, backupRunId)) { + throw publicationError('sealed-metadata-mismatch'); + } + const hasher = new NodeBackupManifestHasher(); + await validateManifestHash(hasher, manifest); + return { manifest, marker }; +} + +export async function validateManifestHash( + hasher: NodeBackupManifestHasher, + manifest: BackupManifest +): Promise { + if (!manifest || typeof manifest !== 'object' || typeof manifest.manifestHash !== 'string') { + throw publicationError('manifest-invalid'); + } + const { manifestHash, ...body } = manifest; + const computed = await hasher.hashCanonicalManifest(body); + if (parseSha256Digest(manifestHash) !== computed) + throw publicationError('manifest-hash-mismatch'); + const sqliteEntry = manifest.entries.find( + (entry) => entry.entryId === manifest.sqliteSnapshot?.entry?.entryId + ); + if ( + manifest.format !== COORDINATION_BACKUP_FORMAT || + manifest.sourceBackupRunId !== manifest.backupRunId || + manifest.sqliteSnapshot?.method !== SQLITE_ONLINE_BACKUP_METHOD || + manifest.sqliteSnapshot.sourceRunId !== manifest.sourceBackupRunId || + manifest.sqliteSnapshot.entry.kind !== 'sqlite_snapshot' || + !sqliteEntry || + canonicalBackupJson(sqliteEntry) !== canonicalBackupJson(manifest.sqliteSnapshot.entry) || + manifest.identityInventory?.deploymentId !== manifest.deploymentId + ) { + throw publicationError('manifest-contract-mismatch'); + } +} + +export async function validateArtifactTree( + paths: BackupStagePaths, + manifest: BackupManifest, + requireMarker: boolean +): Promise { + const expectedFiles = new Set([ + BACKUP_STAGE_OWNER_FILE, + ...((await lstatOrNull(paths.manifest)) ? [BACKUP_ROOT_MANIFEST_FILE] : []), + ...(requireMarker ? [BACKUP_COMMIT_MARKER_FILE] : []), + ]); + const expectedDirectories = new Set(); + const entryIds = new Set(); + for (const entry of manifest.entries) { + validateManifestEntry(entry); + if (entryIds.has(entry.entryId)) throw publicationError('manifest-entry-duplicate'); + entryIds.add(entry.entryId); + expectedFiles.add(entry.entryId); + for (const ancestor of artifactAncestorEntryIds(entry.entryId)) + expectedDirectories.add(ancestor); + } + + const observed = await walkTreeNoLinks(paths.directory); + for (const file of observed.files) { + if (!expectedFiles.has(file)) throw publicationError('artifact-extra-entry'); + } + for (const expected of expectedFiles) { + if (!observed.files.has(expected)) throw publicationError('artifact-missing-entry'); + } + for (const directory of observed.directories) { + if (!expectedDirectories.has(directory)) throw publicationError('artifact-extra-directory'); + } + for (const expected of expectedDirectories) { + if (!observed.directories.has(expected)) throw publicationError('artifact-missing-directory'); + } + + for (const entry of manifest.entries) { + const measured = await measureRegularFile( + resolveArtifactPath(paths.directory, entry.entryId), + entry.entryId + ); + if ( + measured.byteLength !== entry.byteLength || + measured.mode !== entry.mode || + measured.sha256 !== entry.sha256 + ) { + throw publicationError('artifact-measurement-mismatch'); + } + } +} + +export function validateManifestEntry(entry: BackupManifestEntry): void { + validateArtifactEntryId(entry.entryId); + if ( + !Number.isSafeInteger(entry.byteLength) || + entry.byteLength < 0 || + !isFileMode(entry.mode) || + !Number.isSafeInteger(entry.schemaVersion) || + entry.schemaVersion < 0 + ) { + throw publicationError('manifest-entry-invalid'); + } + parseSha256Digest(entry.sha256); + requireNonEmpty(entry.participantId, 'participant-id'); + requireNonEmpty(entry.logicalOwner, 'logical-owner'); + requireNonEmpty(entry.logicalType, 'logical-type'); + requireNonEmpty(entry.sourceGeneration, 'source-generation'); +} + +export function validateArtifactWriteRequest(request: BackupArtifactWriteRequest): void { + validateArtifactEntryId(request.entryId); + requireNonEmpty(request.participantId, 'participant-id'); + requireNonEmpty(request.logicalOwner, 'logical-owner'); + requireNonEmpty(request.logicalType, 'logical-type'); + requireNonEmpty(request.sourceGeneration, 'source-generation'); + if (!(request.bytes instanceof Uint8Array)) throw publicationError('artifact-bytes-invalid'); + if (!Number.isSafeInteger(request.schemaVersion) || request.schemaVersion < 0) { + throw publicationError('artifact-schema-version-invalid'); + } + if (!isFileMode(request.mode)) throw publicationError('artifact-mode-invalid'); +} + +export function requireNonEmpty(value: string, field: string): void { + if (typeof value !== 'string' || value.length === 0) throw publicationError(`${field}-invalid`); +} + +export function isFileMode(mode: number): boolean { + return Number.isInteger(mode) && mode >= 0 && mode <= 0o777 && (mode & 0o400) !== 0; +} + +export async function createArtifactParents(directory: string, entryId: string): Promise { + const segments = validateArtifactEntryId(entryId); + let current = directory; + for (const segment of segments.slice(0, -1)) { + current = path.join(current, segment); + try { + await fs.promises.mkdir(current, { mode: BACKUP_DIRECTORY_MODE }); + await fsyncDirectory(path.dirname(current)); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + await requireDirectory(current, BACKUP_DIRECTORY_MODE, directory); + } +} + +export async function fsyncArtifactParents(directory: string, entryId: string): Promise { + const ancestors = [...artifactAncestorEntryIds(entryId)].reverse(); + for (const relative of ancestors) await fsyncDirectory(resolveArtifactPath(directory, relative)); + await fsyncDirectory(directory); +} + +export async function createOrRequirePrivateDirectory( + directory: string, + parent: string +): Promise { + try { + await fs.promises.mkdir(directory, { mode: BACKUP_DIRECTORY_MODE }); + await fsyncDirectory(parent); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + await requireDirectory(directory, BACKUP_DIRECTORY_MODE, parent); +} + +export async function requireDirectory( + directory: string, + expectedMode: number, + parent: string +): Promise { + const stat = await fs.promises.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== expectedMode) { + throw publicationError('directory-invalid'); + } + const realDirectory = await fs.promises.realpath(directory); + const realParent = await fs.promises.realpath(parent); + if (!isPathInside(realParent, realDirectory)) throw publicationError('directory-path-escape'); + const after = await fs.promises.lstat(directory); + if ( + !sameIdentity(stat, after) || + !after.isDirectory() || + after.isSymbolicLink() || + after.mode !== stat.mode + ) { + throw publicationError('directory-identity-race'); + } +} + +export async function requireOwnedStage( + paths: BackupStagePaths, + backupRunId: BackupRunId, + parent: string +): Promise { + await requireDirectory(paths.directory, BACKUP_DIRECTORY_MODE, parent); + const owner = await readTypedMetadata(paths.owner); + if (canonicalBackupJson(owner) !== canonicalBackupJson(ownerFor(backupRunId))) { + throw publicationError('stage-owner-mismatch'); + } +} + +export async function requireUnsealedAndManifestFree(paths: BackupStagePaths): Promise { + if (await lstatOrNull(paths.marker)) throw publicationError('stage-sealed'); + if (await lstatOrNull(paths.manifest)) throw publicationError('stage-manifest-written'); +} + +export async function writeIdempotentMetadata(filePath: string, value: unknown): Promise { + const bytes = Buffer.from(canonicalBackupJson(value), 'utf8'); + const existing = await lstatOrNull(filePath); + if (!existing) { + await writeExclusiveFile(filePath, bytes, BACKUP_METADATA_FILE_MODE); + return; + } + await requireMetadataMode(filePath, existing); + const observed = await readRegularFileNoFollow( + filePath, + MAX_METADATA_BYTES, + BACKUP_METADATA_FILE_MODE + ); + if (!observed.equals(bytes)) throw publicationError('metadata-mismatch'); +} + +export async function readTypedMetadata(filePath: string): Promise { + const stat = await fs.promises.lstat(filePath); + await requireMetadataMode(filePath, stat); + const bytes = await readRegularFileNoFollow( + filePath, + MAX_METADATA_BYTES, + BACKUP_METADATA_FILE_MODE + ); + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString('utf8')); + } catch { + throw publicationError('metadata-json-invalid'); + } + if (canonicalBackupJson(parsed) !== bytes.toString('utf8')) { + throw publicationError('metadata-not-canonical'); + } + return parsed as T; +} + +export async function requireMetadataMode(filePath: string, stat: fs.Stats): Promise { + if ( + stat.isSymbolicLink() || + !stat.isFile() || + (stat.mode & 0o777) !== BACKUP_METADATA_FILE_MODE + ) { + throw publicationError(`metadata-file-invalid:${path.basename(filePath)}`); + } +} + +export async function writeIdempotentFile( + filePath: string, + bytes: Buffer, + mode: number, + expectedHash: Sha256Digest +): Promise { + const existing = await lstatOrNull(filePath); + if (!existing) { + await writeExclusiveFile(filePath, bytes, mode); + return; + } + const measured = await measureRegularFile(filePath, path.basename(filePath)); + if ( + measured.byteLength !== bytes.byteLength || + measured.mode !== mode || + measured.sha256 !== expectedHash + ) { + throw publicationError('artifact-existing-mismatch'); + } +} + +export async function writeExclusiveFile( + filePath: string, + bytes: Buffer, + mode: number +): Promise { + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open( + filePath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NO_FOLLOW, + mode + ); + await handle.chmod(mode); + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle?.close(); + } +} + +export async function measureRegularFile( + filePath: string, + entryId: string +): Promise { + const stat = await fs.promises.lstat(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) throw publicationError('artifact-not-regular-file'); + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | NO_FOLLOW); + const descriptorStat = await handle.stat(); + if (!descriptorStat.isFile() || !sameIdentity(stat, descriptorStat)) { + throw publicationError('artifact-identity-race'); + } + const hash = createHash('sha256'); + const chunk = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < descriptorStat.size) { + const { bytesRead } = await handle.read( + chunk, + 0, + Math.min(chunk.byteLength, descriptorStat.size - offset), + offset + ); + if (bytesRead === 0) throw publicationError('artifact-short-read'); + hash.update(chunk.subarray(0, bytesRead)); + offset += bytesRead; + } + const afterDescriptor = await handle.stat(); + const afterPath = await fs.promises.lstat(filePath); + if ( + !sameIdentity(descriptorStat, afterDescriptor) || + descriptorStat.size !== afterDescriptor.size || + descriptorStat.mode !== afterDescriptor.mode || + !sameIdentity(afterDescriptor, afterPath) || + afterPath.isSymbolicLink() + ) { + throw publicationError('artifact-changed-during-read'); + } + return { + entryId, + byteLength: descriptorStat.size, + mode: descriptorStat.mode & 0o777, + sha256: parseSha256Digest(hash.digest('hex')), + }; + } finally { + await handle?.close(); + } +} + +export async function readRegularFileNoFollow( + filePath: string, + maximumBytes: number, + expectedMode: number +): Promise { + const before = await fs.promises.lstat(filePath); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.size > maximumBytes || + (before.mode & 0o777) !== expectedMode + ) { + throw publicationError('metadata-read-invalid'); + } + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | NO_FOLLOW); + const descriptor = await handle.stat(); + if ( + !sameIdentity(before, descriptor) || + descriptor.size > maximumBytes || + descriptor.mode !== before.mode + ) { + throw publicationError('metadata-identity-race'); + } + const bytes = await handle.readFile(); + const after = await fs.promises.lstat(filePath); + if ( + bytes.byteLength !== descriptor.size || + !sameIdentity(descriptor, after) || + after.isSymbolicLink() || + after.mode !== descriptor.mode + ) { + throw publicationError('metadata-changed-during-read'); + } + return bytes; + } finally { + await handle?.close(); + } +} + +export async function walkTreeNoLinks( + root: string +): Promise<{ readonly files: ReadonlySet; readonly directories: ReadonlySet }> { + const files = new Set(); + const directories = new Set(); + + async function visit(directory: string): Promise { + const entries = await fs.promises.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const candidate = path.join(directory, entry.name); + const stat = await fs.promises.lstat(candidate); + if (stat.isSymbolicLink()) throw publicationError('symlink-refused'); + const relative = path.relative(root, candidate).split(path.sep).join('/'); + if (stat.isDirectory()) { + if ((stat.mode & 0o777) !== BACKUP_DIRECTORY_MODE) { + throw publicationError('directory-mode-mismatch'); + } + directories.add(relative); + await visit(candidate); + } else if (stat.isFile()) { + files.add(relative); + } else { + throw publicationError('non-file-entry-refused'); + } + } + } + + await visit(root); + return { files, directories }; +} + +export async function fsyncDirectory(directory: string): Promise { + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(directory, fs.constants.O_RDONLY | DIRECTORY_ONLY | NO_FOLLOW); + const stat = await handle.stat(); + if (!stat.isDirectory()) throw publicationError('fsync-target-not-directory'); + await handle.sync(); + } finally { + await handle?.close(); + } +} + +export async function reapOwnedPreparationDirectories( + layout: BackupPathLayout, + backupRunId: BackupRunId +): Promise { + const prefix = `.prepare-${backupRunId}-`; + const names = await fs.promises.readdir(layout.stagingRoot); + for (const name of names) { + const suffix = name.slice(prefix.length); + if (!name.startsWith(prefix) || !PREPARATION_DIRECTORY_SUFFIX_PATTERN.test(suffix)) continue; + await removeOwnedPreparationDirectory( + pathsForDirectory(path.join(layout.stagingRoot, name)), + backupRunId, + layout.stagingRoot + ); + } +} + +export async function removeOwnedPreparationDirectory( + paths: BackupStagePaths, + backupRunId: BackupRunId, + parent: string +): Promise { + const stat = await lstatOrNull(paths.directory); + if (!stat) return; + try { + await requireOwnedStage(paths, backupRunId, parent); + const observed = await walkTreeNoLinks(paths.directory); + if ( + observed.directories.size === 0 && + observed.files.size === 1 && + observed.files.has(BACKUP_STAGE_OWNER_FILE) + ) { + await fs.promises.rm(paths.directory, { recursive: true }); + await fsyncDirectory(parent); + } + } catch { + // A preparation path that no longer proves ownership is deliberately left untouched. + } +} + +export async function removeOwnedMarkerTemporaryFiles( + paths: BackupStagePaths, + backupRunId: BackupRunId, + stagingRoot: string +): Promise { + await requireOwnedStage(paths, backupRunId, stagingRoot); + const names = await fs.promises.readdir(paths.directory); + for (const name of names) { + const suffix = name.slice(MARKER_TEMPORARY_PREFIX.length); + if ( + !name.startsWith(MARKER_TEMPORARY_PREFIX) || + !MARKER_TEMPORARY_SUFFIX_PATTERN.test(suffix) + ) { + continue; + } + await removePrivateTemporaryFile(path.join(paths.directory, name), paths.directory); + } +} + +export async function removePrivateTemporaryFile(filePath: string, parent: string): Promise { + const stat = await lstatOrNull(filePath); + if ( + !stat || + !stat.isFile() || + stat.isSymbolicLink() || + (stat.mode & 0o777) !== BACKUP_METADATA_FILE_MODE + ) { + return; + } + await fs.promises.unlink(filePath); + await fsyncDirectory(parent); +} + +export async function lstatOrNull(filePath: string): Promise { + try { + return await fs.promises.lstat(filePath); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +export async function unlinkIfSameIdentity(filePath: string, expected: fs.Stats): Promise { + const observed = await lstatOrNull(filePath); + if (!observed) return; + if (!sameIdentity(expected, observed) || observed.isSymbolicLink() || !observed.isFile()) { + throw publicationError('sqlite-artifact-cleanup-identity-mismatch'); + } + await fs.promises.unlink(filePath); +} + +export function sameIdentity(left: fs.Stats, right: fs.Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +export function markerMatchesManifest( + marker: BackupCommitMarker, + manifest: BackupManifest, + backupRunId: BackupRunId +): boolean { + return ( + marker.backupRunId === backupRunId && + manifest.backupRunId === backupRunId && + marker.format === COORDINATION_BACKUP_COMMIT_MARKER_FORMAT && + manifest.format === COORDINATION_BACKUP_FORMAT && + marker.manifestHash === manifest.manifestHash && + marker.deploymentId === manifest.deploymentId && + marker.sealedAt === manifest.sealedAt + ); +} + +export function ownerFor(backupRunId: BackupRunId): StageOwner { + return Object.freeze({ format: OWNER_FORMAT, backupRunId }); +} + +export function committedPublication( + backupRunId: BackupRunId, + manifestHash: Sha256Digest +): CommittedBackupPublication { + return Object.freeze({ + backupRunId, + manifestHash, + immutableGeneration: generationName(backupRunId, manifestHash), + }); +} + +export function pathsForDirectory(directory: string): BackupStagePaths { + return { + directory, + owner: path.join(directory, BACKUP_STAGE_OWNER_FILE), + manifest: path.join(directory, BACKUP_ROOT_MANIFEST_FILE), + marker: path.join(directory, BACKUP_COMMIT_MARKER_FILE), + }; +} + +export function publicationError(code: string): BackupPublicationError { + return new BackupPublicationError(code); +} + +export function isNotFound(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +} + +export function isAlreadyExists(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === 'EEXIST' || code === 'ENOTEMPTY'; +} diff --git a/src/features/coordination-backup/main/infrastructure/nodeBackupPublicationTypes.ts b/src/features/coordination-backup/main/infrastructure/nodeBackupPublicationTypes.ts new file mode 100644 index 0000000000..42fcb533fa --- /dev/null +++ b/src/features/coordination-backup/main/infrastructure/nodeBackupPublicationTypes.ts @@ -0,0 +1,63 @@ +import type { + BackupManifestEntry, + BackupRunId, + MeasuredBackupEntry, + Sha256Digest, +} from '../../contracts'; + +export interface NodeBackupPublicationOptions { + readonly backupRoot: string; +} + +export interface BackupArtifactWriteRequest { + readonly backupRunId: BackupRunId; + readonly entryId: string; + readonly participantId: string; + readonly kind: BackupManifestEntry['kind']; + readonly logicalOwner: string; + readonly logicalType: string; + readonly schemaVersion: number; + readonly sourceGeneration: string; + readonly bytes: Uint8Array; + readonly mode: number; +} + +export interface BackupArtifactMeasureRequest { + readonly backupRunId: BackupRunId; + readonly entryId: string; +} + +/** A capability writer for one declared artifact; it never exposes its filesystem location. */ +export interface BackupPublicationArtifactWriter { + writeArtifact(request: BackupArtifactWriteRequest): Promise; + measureStagedArtifact(request: BackupArtifactMeasureRequest): Promise; +} + +export interface SqliteBackupArtifactChunk { + readonly offset: number; + readonly totalByteLength: number; + readonly bytes: Uint8Array; + readonly eof: boolean; +} + +/** + * A run/entry-bound publication sink. The SQLite worker supplies immutable + * chunks from its private Online Backup scratch file; no reusable filesystem + * path capability crosses the main/worker boundary. + */ +export interface SqliteBackupArtifactPublisher { + publishSqliteSnapshot(request: { + readonly backupRunId: BackupRunId; + readonly entryId: string; + readonly byteLength: number; + readonly sha256: Sha256Digest; + readonly readChunk: (offset: number) => Promise; + }): Promise; +} + +export class BackupPublicationError extends Error { + constructor(readonly code: string) { + super(`coordination-backup-publication-${code}`); + this.name = 'BackupPublicationError'; + } +} diff --git a/src/features/coordination-backup/main/participants/CoordinationEventBackupParticipant.ts b/src/features/coordination-backup/main/participants/CoordinationEventBackupParticipant.ts new file mode 100644 index 0000000000..afcd9610dd --- /dev/null +++ b/src/features/coordination-backup/main/participants/CoordinationEventBackupParticipant.ts @@ -0,0 +1,248 @@ +import { + assertCoordinationEventRecoveryPoint, + createCoordinationEventRecoveryPoint, +} from '@features/coordination-events'; + +import { + COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION, + COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION, +} from '../../contracts'; +import { canonicalBackupJson } from '../infrastructure'; + +import type { + BackupParticipantDescriptor, + FlushedBackupParticipant, + PreparedBackupParticipant, +} from '../../contracts'; +import type { + BackupParticipantVerification, + CoordinationBackupParticipant, + FlushBackupParticipantRequest, + PrepareBackupParticipantRequest, + StageBackupParticipantRequest, + StagedBackupParticipant, + VerifyBackupParticipantRequest, +} from '../../core/application'; +import type { BackupPublicationArtifactWriter } from '../infrastructure'; +import type { + CoordinationEventJournal, + CoordinationEventRecoveryPoint, +} from '@features/coordination-events'; + +const PARTICIPANT_ID = 'coordination-events' as const; +const PARTICIPANT_KIND = 'event-journal-recovery-point' as const; +const ENTRY_ID = 'events/recovery-point.json' as const; +const BARRIER_PREFIX = 'coordination-event-recovery-point-v1.'; + +export class CoordinationEventBackupParticipant implements CoordinationBackupParticipant< + typeof PARTICIPANT_ID, + typeof PARTICIPANT_KIND +> { + readonly descriptor: BackupParticipantDescriptor = + Object.freeze({ + participantId: PARTICIPANT_ID, + kind: PARTICIPANT_KIND, + contractVersion: COORDINATION_BACKUP_PARTICIPANT_CONTRACT_VERSION, + schemaVersion: COORDINATION_BACKUP_PARTICIPANT_SCHEMA_VERSION, + required: true, + }); + + constructor( + private readonly options: { + readonly deploymentId: string; + readonly journal: CoordinationEventJournal; + readonly artifactWriter: BackupPublicationArtifactWriter; + } + ) { + if (!options.deploymentId || !options.journal || !options.artifactWriter) { + throw new TypeError('coordination-event-backup-participant-options-invalid'); + } + } + + async prepare( + request: PrepareBackupParticipantRequest + ): Promise> { + requireFence(request.backupRunId, request.fence); + const watermark = await this.options.journal.getWatermark(); + requireDeployment(watermark.deploymentId, this.options.deploymentId); + return Object.freeze({ + descriptor: this.descriptor, + sourceGeneration: sourceGeneration(watermark.deploymentId, watermark.eventEpoch), + }); + } + + async flush( + request: FlushBackupParticipantRequest + ): Promise> { + requireFence(request.backupRunId, request.fence); + requirePrepared(request.prepared, this.descriptor); + const recoveryPoint = createCoordinationEventRecoveryPoint({ + participantId: PARTICIPANT_ID, + watermark: await this.options.journal.getWatermark(), + }); + if ( + recoveryPoint.deploymentId !== this.options.deploymentId || + sourceGeneration(recoveryPoint.deploymentId, recoveryPoint.eventEpoch) !== + request.prepared.sourceGeneration + ) { + throw new Error('coordination-event-backup-source-generation-changed'); + } + return Object.freeze({ + ...request.prepared, + durableBarrier: encodeRecoveryPoint(recoveryPoint), + }); + } + + async stage( + request: StageBackupParticipantRequest + ): Promise { + requireFence(request.backupRunId, request.fence); + requireFlushed(request.flushed, this.descriptor); + const recoveryPoint = decodeRecoveryPoint(request.flushed.durableBarrier); + requireRecoveryPointMatchesFlush(recoveryPoint, request.flushed, this.options.deploymentId); + const entry = await this.options.artifactWriter.writeArtifact({ + backupRunId: request.backupRunId, + entryId: ENTRY_ID, + participantId: PARTICIPANT_ID, + kind: 'participant_file', + logicalOwner: 'coordination-events', + logicalType: 'event-journal-recovery-point', + schemaVersion: recoveryPoint.schemaVersion, + sourceGeneration: request.flushed.sourceGeneration, + bytes: Buffer.from(canonicalBackupJson(recoveryPoint), 'utf8'), + mode: 0o600, + }); + return Object.freeze({ + participantId: PARTICIPANT_ID, + entries: Object.freeze([entry]), + exclusions: Object.freeze([]), + }); + } + + async verify( + request: VerifyBackupParticipantRequest + ): Promise { + try { + requireFence(request.backupRunId, request.fence); + requireFlushed(request.flushed, this.descriptor); + if (request.stagedEntries.length !== 1) return invalid('entry-count-mismatch'); + const recoveryPoint = decodeRecoveryPoint(request.flushed.durableBarrier); + requireRecoveryPointMatchesFlush(recoveryPoint, request.flushed, this.options.deploymentId); + const current = createCoordinationEventRecoveryPoint({ + participantId: PARTICIPANT_ID, + watermark: await this.options.journal.getWatermark(), + }); + if (canonicalBackupJson(current) !== canonicalBackupJson(recoveryPoint)) { + return invalid('journal-advanced-after-barrier'); + } + const entry = request.stagedEntries[0]; + if ( + entry.entryId !== ENTRY_ID || + entry.participantId !== PARTICIPANT_ID || + entry.kind !== 'participant_file' || + entry.logicalOwner !== 'coordination-events' || + entry.logicalType !== 'event-journal-recovery-point' || + entry.schemaVersion !== recoveryPoint.schemaVersion || + entry.sourceGeneration !== request.flushed.sourceGeneration || + entry.mode !== 0o600 + ) { + return invalid('entry-contract-mismatch'); + } + const measured = await this.options.artifactWriter.measureStagedArtifact({ + backupRunId: request.backupRunId, + entryId: entry.entryId, + }); + if ( + measured.byteLength !== entry.byteLength || + measured.mode !== entry.mode || + measured.sha256 !== entry.sha256 + ) { + return invalid('staged-artifact-mismatch'); + } + return { status: 'verified' }; + } catch { + return invalid('verification-boundary-failed'); + } + } +} + +function encodeRecoveryPoint(recoveryPoint: CoordinationEventRecoveryPoint): string { + return `${BARRIER_PREFIX}${Buffer.from(canonicalBackupJson(recoveryPoint), 'utf8').toString( + 'base64url' + )}`; +} + +function decodeRecoveryPoint(value: string): CoordinationEventRecoveryPoint { + if (typeof value !== 'string' || !value.startsWith(BARRIER_PREFIX)) { + throw new Error('coordination-event-backup-barrier-invalid'); + } + let recoveryPoint: unknown; + try { + recoveryPoint = JSON.parse( + Buffer.from(value.slice(BARRIER_PREFIX.length), 'base64url').toString('utf8') + ) as unknown; + } catch { + throw new Error('coordination-event-backup-barrier-invalid'); + } + assertCoordinationEventRecoveryPoint(recoveryPoint as CoordinationEventRecoveryPoint); + return recoveryPoint as CoordinationEventRecoveryPoint; +} + +function requirePrepared( + prepared: PreparedBackupParticipant, + descriptor: BackupParticipantDescriptor +): void { + if ( + prepared.descriptor.participantId !== descriptor.participantId || + prepared.descriptor.kind !== descriptor.kind || + prepared.descriptor.contractVersion !== descriptor.contractVersion || + prepared.descriptor.schemaVersion !== descriptor.schemaVersion || + prepared.sourceGeneration.length === 0 + ) { + throw new Error('coordination-event-backup-prepared-invalid'); + } +} + +function requireFlushed( + flushed: FlushedBackupParticipant, + descriptor: BackupParticipantDescriptor +): void { + requirePrepared(flushed, descriptor); + if (!flushed.durableBarrier) throw new Error('coordination-event-backup-flush-invalid'); +} + +function requireRecoveryPointMatchesFlush( + recoveryPoint: CoordinationEventRecoveryPoint, + flushed: FlushedBackupParticipant, + deploymentId: string +): void { + if ( + recoveryPoint.participantId !== PARTICIPANT_ID || + recoveryPoint.deploymentId !== deploymentId || + sourceGeneration(recoveryPoint.deploymentId, recoveryPoint.eventEpoch) !== + flushed.sourceGeneration + ) { + throw new Error('coordination-event-backup-recovery-point-mismatch'); + } +} + +function requireFence( + backupRunId: string, + fence: { readonly admittedRunId: string; readonly generation: number } +): void { + if (fence.admittedRunId !== backupRunId || fence.generation <= 0) { + throw new Error('coordination-event-backup-fence-invalid'); + } +} + +function requireDeployment(actual: string, expected: string): void { + if (actual !== expected) throw new Error('coordination-event-backup-deployment-mismatch'); +} + +function sourceGeneration(deploymentId: string, eventEpoch: string): string { + return `${deploymentId}:${eventEpoch}`; +} + +function invalid(reason: string): BackupParticipantVerification { + return { status: 'invalid', reason }; +} diff --git a/src/features/coordination-backup/main/participants/TypedFileBackupParticipant.ts b/src/features/coordination-backup/main/participants/TypedFileBackupParticipant.ts new file mode 100644 index 0000000000..bbce098c1e --- /dev/null +++ b/src/features/coordination-backup/main/participants/TypedFileBackupParticipant.ts @@ -0,0 +1,361 @@ +import { + type BackupExclusion, + type BackupManifestEntry, + type BackupParticipantDescriptor, + type BackupRunId, + type FlushedBackupParticipant, + type PreparedBackupParticipant, +} from '../../contracts'; +import { + type BackupParticipantVerification, + type CoordinationBackupParticipant, + type FlushBackupParticipantRequest, + type PrepareBackupParticipantRequest, + type StageBackupParticipantRequest, + type StagedBackupParticipant, + type VerifyBackupParticipantRequest, +} from '../../core/application'; +import { + type BackupPublicationArtifactWriter, + sha256Bytes, + validateArtifactEntryId, +} from '../infrastructure'; + +const EXCLUSION_REASONS = new Set([ + 'credential', + 'session_or_ticket', + 'ephemeral_runtime', + 'rebuildable_cache', + 'secret_diagnostic', + 'outside_coordination_scope', +]); + +export interface TypedFileSourceExclusion { + readonly logicalType: string; + readonly reason: BackupExclusion['reason']; +} + +export interface TypedFileBackupSourceSnapshot { + readonly bytes: Uint8Array; + readonly generation: TGeneration; + readonly durableBarrier: string; + readonly exclusions: readonly TypedFileSourceExclusion[]; +} + +/** + * A feature-owned capability. The source returns typed bytes and evidence only; storage-location + * capabilities are intentionally absent from both the request and response contracts. + */ +export interface TypedFileBackupSource { + readSnapshot(request: { + readonly backupRunId: BackupRunId; + readonly fenceGeneration: number; + }): Promise>; +} + +export interface TypedFileBackupParticipantOptions< + TParticipantId extends string, + TKind extends string, + TGeneration extends string, +> { + readonly descriptor: BackupParticipantDescriptor; + readonly entry: { + readonly entryId: string; + readonly kind: Extract; + readonly logicalOwner: string; + readonly logicalType: string; + readonly schemaVersion: number; + readonly mode: number; + }; + readonly source: TypedFileBackupSource; + readonly artifactWriter: BackupPublicationArtifactWriter; + readonly maximumBytes?: number; +} + +export class TypedFileBackupParticipantError extends Error { + constructor(readonly code: string) { + super(`coordination-backup-typed-file-participant-${code}`); + this.name = 'TypedFileBackupParticipantError'; + } +} + +export class TypedFileBackupParticipant< + TParticipantId extends string, + TKind extends string, + TGeneration extends string, +> implements CoordinationBackupParticipant { + readonly descriptor: BackupParticipantDescriptor; + private readonly maximumBytes: number; + + constructor( + private readonly options: TypedFileBackupParticipantOptions + ) { + this.descriptor = Object.freeze({ ...options.descriptor }); + this.maximumBytes = options.maximumBytes ?? 16 * 1024 * 1024; + validateOptions(options, this.maximumBytes); + } + + async prepare( + request: PrepareBackupParticipantRequest + ): Promise> { + const snapshot = await this.readSnapshot(request.backupRunId, request.fence.generation); + return Object.freeze({ + descriptor: this.descriptor, + sourceGeneration: snapshot.generation, + }); + } + + async flush( + request: FlushBackupParticipantRequest + ): Promise> { + this.requirePrepared(request.prepared); + const snapshot = await this.readSnapshot(request.backupRunId, request.fence.generation); + if (snapshot.generation !== request.prepared.sourceGeneration) { + throw participantError('source-generation-changed-before-flush'); + } + return Object.freeze({ + descriptor: this.descriptor, + sourceGeneration: snapshot.generation, + durableBarrier: snapshot.durableBarrier, + }); + } + + async stage( + request: StageBackupParticipantRequest + ): Promise { + this.requireFlushed(request.flushed); + const snapshot = await this.readSnapshot(request.backupRunId, request.fence.generation); + this.requireSnapshotMatchesFlush(snapshot, request.flushed); + const entry = await this.options.artifactWriter.writeArtifact({ + backupRunId: request.backupRunId, + entryId: this.options.entry.entryId, + participantId: this.descriptor.participantId, + kind: this.options.entry.kind, + logicalOwner: this.options.entry.logicalOwner, + logicalType: this.options.entry.logicalType, + schemaVersion: this.options.entry.schemaVersion, + sourceGeneration: request.flushed.sourceGeneration, + bytes: snapshot.bytes, + mode: this.options.entry.mode, + }); + return Object.freeze({ + participantId: this.descriptor.participantId, + entries: Object.freeze([entry]), + exclusions: Object.freeze( + snapshot.exclusions.map((exclusion) => + Object.freeze({ + participantId: this.descriptor.participantId, + logicalType: exclusion.logicalType, + reason: exclusion.reason, + }) + ) + ), + }); + } + + async verify( + request: VerifyBackupParticipantRequest + ): Promise { + try { + this.requireFlushed(request.flushed); + if (request.stagedEntries.length !== 1) return invalid('entry-count-mismatch'); + const entry = request.stagedEntries[0]; + if (!this.entryMatchesConfiguration(entry, request.flushed.sourceGeneration)) { + return invalid('entry-contract-mismatch'); + } + const snapshot = await this.readSnapshot(request.backupRunId, request.fence.generation); + this.requireSnapshotMatchesFlush(snapshot, request.flushed); + if ( + snapshot.bytes.byteLength !== entry.byteLength || + sha256Bytes(snapshot.bytes) !== entry.sha256 + ) { + return invalid('source-bytes-changed'); + } + const measured = await this.options.artifactWriter.measureStagedArtifact({ + backupRunId: request.backupRunId, + entryId: entry.entryId, + }); + if ( + measured.entryId !== entry.entryId || + measured.byteLength !== entry.byteLength || + measured.mode !== entry.mode || + measured.sha256 !== entry.sha256 + ) { + return invalid('staged-artifact-mismatch'); + } + return { status: 'verified' }; + } catch (error) { + return invalid( + error instanceof TypedFileBackupParticipantError + ? error.code + : 'verification-boundary-failed' + ); + } + } + + private async readSnapshot( + backupRunId: BackupRunId, + fenceGeneration: number + ): Promise> { + const snapshot = await this.options.source.readSnapshot({ backupRunId, fenceGeneration }); + validateSnapshot(snapshot, this.maximumBytes); + return Object.freeze({ + bytes: Uint8Array.from(snapshot.bytes), + generation: snapshot.generation, + durableBarrier: snapshot.durableBarrier, + exclusions: Object.freeze( + snapshot.exclusions.map((exclusion) => Object.freeze({ ...exclusion })) + ), + }); + } + + private requirePrepared(prepared: PreparedBackupParticipant): void { + if ( + prepared.descriptor.participantId !== this.descriptor.participantId || + prepared.descriptor.kind !== this.descriptor.kind || + prepared.descriptor.contractVersion !== this.descriptor.contractVersion || + prepared.descriptor.schemaVersion !== this.descriptor.schemaVersion || + prepared.sourceGeneration.length === 0 + ) { + throw participantError('prepared-evidence-mismatch'); + } + } + + private requireFlushed(flushed: FlushedBackupParticipant): void { + this.requirePrepared(flushed); + if (flushed.durableBarrier.length === 0) throw participantError('flush-barrier-invalid'); + } + + private requireSnapshotMatchesFlush( + snapshot: TypedFileBackupSourceSnapshot, + flushed: FlushedBackupParticipant + ): void { + if ( + snapshot.generation !== flushed.sourceGeneration || + snapshot.durableBarrier !== flushed.durableBarrier + ) { + throw participantError('source-evidence-changed-after-flush'); + } + } + + private entryMatchesConfiguration(entry: BackupManifestEntry, sourceGeneration: string): boolean { + return ( + entry.entryId === this.options.entry.entryId && + entry.participantId === this.descriptor.participantId && + entry.kind === this.options.entry.kind && + entry.logicalOwner === this.options.entry.logicalOwner && + entry.logicalType === this.options.entry.logicalType && + entry.schemaVersion === this.options.entry.schemaVersion && + entry.mode === this.options.entry.mode && + entry.sourceGeneration === sourceGeneration + ); + } +} + +function validateOptions< + TParticipantId extends string, + TKind extends string, + TGeneration extends string, +>( + options: TypedFileBackupParticipantOptions, + maximumBytes: number +): void { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { + throw participantError('maximum-bytes-invalid'); + } + validateArtifactEntryId(options.entry.entryId); + if ( + options.descriptor.participantId.length === 0 || + options.descriptor.kind.length === 0 || + options.descriptor.contractVersion !== 1 || + options.descriptor.schemaVersion !== 1 || + typeof options.descriptor.required !== 'boolean' || + options.entry.logicalOwner.length === 0 || + options.entry.logicalType.length === 0 || + !Number.isSafeInteger(options.entry.schemaVersion) || + options.entry.schemaVersion < 0 || + !Number.isInteger(options.entry.mode) || + options.entry.mode < 0 || + options.entry.mode > 0o777 || + (options.entry.mode & 0o400) === 0 || + typeof options.source.readSnapshot !== 'function' || + typeof options.artifactWriter.writeArtifact !== 'function' || + typeof options.artifactWriter.measureStagedArtifact !== 'function' + ) { + throw participantError('entry-configuration-invalid'); + } +} + +function validateSnapshot( + snapshot: TypedFileBackupSourceSnapshot, + maximumBytes: number +): void { + if (!snapshot || typeof snapshot !== 'object') throw participantError('source-snapshot-invalid'); + const snapshotValues = readExactOwnDataProperties(snapshot, [ + 'bytes', + 'durableBarrier', + 'exclusions', + 'generation', + ]); + if (!snapshotValues) { + throw participantError('source-snapshot-surface-invalid'); + } + if ( + !(snapshotValues.bytes instanceof Uint8Array) || + snapshotValues.bytes.byteLength > maximumBytes + ) { + throw participantError('source-bytes-invalid'); + } + if (typeof snapshotValues.generation !== 'string' || snapshotValues.generation.length === 0) { + throw participantError('source-generation-invalid'); + } + if ( + typeof snapshotValues.durableBarrier !== 'string' || + snapshotValues.durableBarrier.length === 0 + ) { + throw participantError('source-barrier-invalid'); + } + if (!Array.isArray(snapshotValues.exclusions)) { + throw participantError('source-exclusions-invalid'); + } + for (const exclusion of snapshotValues.exclusions) { + const exclusionValues = readExactOwnDataProperties(exclusion, ['logicalType', 'reason']); + if ( + !exclusionValues || + typeof exclusionValues.logicalType !== 'string' || + exclusionValues.logicalType.length === 0 || + !EXCLUSION_REASONS.has(exclusionValues.reason as BackupExclusion['reason']) + ) { + throw participantError('source-exclusion-invalid'); + } + } +} + +function readExactOwnDataProperties( + value: unknown, + expectedKeys: readonly string[] +): Readonly> | null { + if (!value || typeof value !== 'object') return null; + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== expectedKeys.length || + ownKeys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + return null; + } + const values: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return null; + values[key] = descriptor.value; + } + return values; +} + +function invalid(reason: string): BackupParticipantVerification { + return { status: 'invalid', reason }; +} + +function participantError(code: string): TypedFileBackupParticipantError { + return new TypedFileBackupParticipantError(code); +} diff --git a/src/features/coordination-backup/main/participants/index.ts b/src/features/coordination-backup/main/participants/index.ts new file mode 100644 index 0000000000..5db06b6a03 --- /dev/null +++ b/src/features/coordination-backup/main/participants/index.ts @@ -0,0 +1,2 @@ +export * from './CoordinationEventBackupParticipant'; +export * from './TypedFileBackupParticipant'; diff --git a/src/features/coordination-events/contracts/coordinationEventContracts.ts b/src/features/coordination-events/contracts/coordinationEventContracts.ts new file mode 100644 index 0000000000..257a225267 --- /dev/null +++ b/src/features/coordination-events/contracts/coordinationEventContracts.ts @@ -0,0 +1,188 @@ +export const COORDINATION_EVENT_SCHEMA_VERSION = 1 as const; +export const COORDINATION_SNAPSHOT_SCHEMA_VERSION = 1 as const; +export const REPLAY_CURSOR_SCHEMA_VERSION = 1 as const; +export const EVENT_JOURNAL_WATERMARK_SCHEMA_VERSION = 1 as const; +export const COORDINATION_EVENT_RECOVERY_POINT_SCHEMA_VERSION = 1 as const; + +export const COORDINATION_EVENT_SCOPE_KINDS = Object.freeze([ + 'instance', + 'catalog', + 'workspace', + 'team', + 'run', + 'session', +] as const); + +export type CoordinationEventScopeKind = (typeof COORDINATION_EVENT_SCOPE_KINDS)[number]; + +export const COORDINATION_EVENT_ACTOR_KINDS = Object.freeze([ + 'operator', + 'verified_runtime', + 'external_file', + 'recovery', +] as const); + +export type CoordinationEventActorKind = (typeof COORDINATION_EVENT_ACTOR_KINDS)[number]; + +export const SNAPSHOT_EVENT_HANDOFF_MODES = Object.freeze([ + 'same_transaction', + 'lower_barrier', +] as const); + +export type SnapshotEventHandoffMode = (typeof SNAPSHOT_EVENT_HANDOFF_MODES)[number]; + +/** + * Cursors are intentionally opaque outside the coordination-events feature. + * Callers may persist or echo the value, but must not construct or increment it. + */ +declare const replayCursorBrand: unique symbol; +export type ReplayCursor = string & { readonly [replayCursorBrand]: true }; + +export type CoordinationJsonValue = + | null + | boolean + | number + | string + | readonly CoordinationJsonValue[] + | { readonly [key: string]: CoordinationJsonValue }; + +export interface ReplayCursorPosition { + readonly cursorVersion: typeof REPLAY_CURSOR_SCHEMA_VERSION; + readonly deploymentId: string; + readonly eventEpoch: string; + /** The last event already represented or processed by the cursor holder. */ + readonly eventSequence: number; +} + +/** + * `retentionFloorSequence` is the oldest cursor position the journal can still + * replay after. Rows with a sequence greater than that position remain + * available. It is therefore a cursor floor, not the first retained row. + */ +export interface EventJournalWatermark { + readonly schemaVersion: typeof EVENT_JOURNAL_WATERMARK_SCHEMA_VERSION; + readonly deploymentId: string; + readonly eventEpoch: string; + readonly retentionFloorSequence: number; + readonly highWatermarkSequence: number; +} + +export interface CoordinationEventScope { + readonly kind: CoordinationEventScopeKind; + readonly scopeId: string; +} + +export type CoordinationEventActor = + | { + readonly kind: 'operator'; + readonly actorRef: string; + } + | { + readonly kind: 'verified_runtime'; + readonly actorRef: string; + readonly runId: string; + readonly memberId?: string; + } + | { + readonly kind: 'external_file'; + readonly actorRef?: string; + readonly fileWriterEpoch: number; + readonly observationSequence: number; + } + | { + readonly kind: 'recovery'; + readonly actorRef: string; + }; + +/** + * Revisions are resource-scoped. Journal event sequence is deliberately not a + * data revision because runtime observations may produce events without + * mutating a durable projection. + */ +export interface CoordinationResourceRevision { + readonly resourceKey: string; + readonly generation: number; + readonly revision: number; +} + +export interface CoordinationEventDraft< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly schemaVersion: typeof COORDINATION_EVENT_SCHEMA_VERSION; + readonly eventId: string; + readonly scope: CoordinationEventScope; + readonly workspaceId?: string; + readonly teamId?: string; + readonly runId?: string; + readonly actor: CoordinationEventActor; + readonly eventType: string; + readonly resourceRevision?: CoordinationResourceRevision; + readonly emittedAt: string; + readonly payload: TPayload; +} + +/** + * Untrusted event input deliberately excludes server-owned attribution. The + * application layer binds `actor` and `runId` from a trusted request context + * immediately before the durable append. + */ +export type CoordinationEventPublishDraft< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> = Omit, 'actor' | 'runId'>; + +export interface CoordinationEventEnvelope< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> extends CoordinationEventDraft { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly eventSequence: number; + /** The SSE id and durable replay position assigned by the journal. */ + readonly eventCursor: ReplayCursor; +} + +export interface CoordinationSnapshotMetadata { + readonly schemaVersion: typeof COORDINATION_SNAPSHOT_SCHEMA_VERSION; + readonly deploymentId: string; + readonly eventEpoch: string; + readonly handoffMode: SnapshotEventHandoffMode; + /** + * A same-transaction cursor or a retained lower barrier captured before an + * external projection read. This must never be described as the latest + * cursor represented by the payload. + */ + readonly replayCursor: ReplayCursor; + readonly revisionVector: readonly CoordinationResourceRevision[]; +} + +export interface CoordinationSnapshotEnvelope { + readonly metadata: CoordinationSnapshotMetadata; + readonly snapshot: TSnapshot; +} + +export interface CoordinationReplayBatch< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly schemaVersion: typeof COORDINATION_EVENT_SCHEMA_VERSION; + readonly deploymentId: string; + readonly eventEpoch: string; + readonly fromCursor: ReplayCursor; + readonly nextCursor: ReplayCursor; + readonly events: readonly CoordinationEventEnvelope[]; + readonly watermark: EventJournalWatermark; + readonly hasMore: boolean; +} + +/** + * A portable barrier contributed by the event journal to a coordinated + * recovery point. The owning backup feature decides how it is staged and + * published; this feature owns only the journal consistency contract. + */ +export interface CoordinationEventRecoveryPoint { + readonly schemaVersion: typeof COORDINATION_EVENT_RECOVERY_POINT_SCHEMA_VERSION; + readonly participantId: string; + readonly deploymentId: string; + readonly eventEpoch: string; + readonly retentionFloorSequence: number; + readonly highWatermarkSequence: number; + readonly replayCursor: ReplayCursor; +} diff --git a/src/features/coordination-events/contracts/hostedEventStreamContracts.ts b/src/features/coordination-events/contracts/hostedEventStreamContracts.ts new file mode 100644 index 0000000000..961184fe05 --- /dev/null +++ b/src/features/coordination-events/contracts/hostedEventStreamContracts.ts @@ -0,0 +1,90 @@ +import type { + CoordinationEventScope, + CoordinationJsonValue, + CoordinationResourceRevision, + ReplayCursor, +} from './coordinationEventContracts'; +import type { TeamId } from '@shared/contracts/hosted'; + +export const HOSTED_COORDINATION_EVENT_STREAM_ROUTE = '/api/hosted/v1/events' as const; +export const HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION = 1 as const; +export const HOSTED_COORDINATION_EVENT_BOOTSTRAP_ROUTE = '/api/hosted/v1/events/bootstrap' as const; +export const HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION = 1 as const; + +export interface HostedCoordinationEventBootstrapRequest { + readonly schemaVersion: typeof HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION; + readonly teamId: TeamId; +} + +/** + * Closed snapshot carried by the shared lower-barrier handoff. Task and inbox + * state are deliberately fetched through their own bounded page contracts. + */ +export interface HostedCoordinationEventBootstrapSnapshot { + readonly schemaVersion: typeof HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION; + readonly kind: 'team_event_bootstrap'; + readonly teamId: TeamId; +} + +export const HOSTED_COORDINATION_EVENT_SSE_EVENT = 'coordination_event' as const; +export const HOSTED_COORDINATION_RESYNC_SSE_EVENT = 'resync_required' as const; + +export const HOSTED_COORDINATION_RESYNC_REASONS = Object.freeze([ + 'malformed_cursor', + 'foreign_deployment', + 'foreign_epoch', + 'cursor_expired', + 'cursor_ahead', + 'event_gap', + 'projection_invalid', +] as const); + +export type HostedCoordinationResyncReason = (typeof HOSTED_COORDINATION_RESYNC_REASONS)[number]; + +/** + * Scope-authorized projection returned by the hosted access boundary. Journal + * identity and cursor fields are deliberately absent so the HTTP adapter, not + * a projector, remains their authority. + */ +export interface HostedCoordinationEventProjection< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly scope: CoordinationEventScope; + readonly eventType: string; + readonly resourceRevision?: CoordinationResourceRevision; + /** Explicit allowlisted browser payload produced by the authorization boundary. */ + readonly publicPayload: TPayload; +} + +/** + * Browser-safe event envelope. Actor attribution, raw workspace paths, command + * bodies, and provider payloads are never part of this transport contract. + * `previousEventCursor` permits gap detection without decoding opaque cursors. + */ +export interface HostedCoordinationEventEnvelope< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly schemaVersion: typeof HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION; + readonly kind: typeof HOSTED_COORDINATION_EVENT_SSE_EVENT; + readonly deploymentId: string; + readonly eventEpoch: string; + readonly eventSequence: number; + readonly eventId: string; + readonly previousEventCursor: ReplayCursor; + readonly eventCursor: ReplayCursor; + readonly scope: CoordinationEventScope; + readonly eventType: string; + readonly resourceRevision?: CoordinationResourceRevision; + readonly emittedAt: string; + readonly payload: TPayload; +} + +export interface HostedCoordinationResyncRequired { + readonly schemaVersion: typeof HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION; + readonly kind: typeof HOSTED_COORDINATION_RESYNC_SSE_EVENT; + readonly reason: HostedCoordinationResyncReason; +} + +export type HostedCoordinationEventStreamMessage = + | HostedCoordinationEventEnvelope + | HostedCoordinationResyncRequired; diff --git a/src/features/coordination-events/contracts/index.ts b/src/features/coordination-events/contracts/index.ts new file mode 100644 index 0000000000..349a4a804c --- /dev/null +++ b/src/features/coordination-events/contracts/index.ts @@ -0,0 +1,2 @@ +export * from './coordinationEventContracts'; +export * from './hostedEventStreamContracts'; diff --git a/src/features/coordination-events/core/application/CoordinationEventHandoff.ts b/src/features/coordination-events/core/application/CoordinationEventHandoff.ts new file mode 100644 index 0000000000..20672cffe4 --- /dev/null +++ b/src/features/coordination-events/core/application/CoordinationEventHandoff.ts @@ -0,0 +1,442 @@ +import { + type CoordinationEventEnvelope, + type CoordinationJsonValue, + type CoordinationReplayBatch, + type CoordinationSnapshotEnvelope, + type EventJournalWatermark, +} from '../../contracts'; +import { + assertCommittedEventMatchesDraft, + assertCoordinationEventDraft, + assertCoordinationSnapshotMetadata, + assertJournalWatermark, + assertJournalWatermarkProgression, + createCoordinationReplayBatch, + createCoordinationSnapshotMetadata, + decodeReplayCursor, + materializeCoordinationSnapshotData, + materializeEventJournalWatermark, + validateReplayCursor, +} from '../domain'; + +import { CoordinationEventHandoffError } from './coordinationEventHandoffError'; +import { + assertBoundedPositiveInteger, + assertIdentifier, + assertSameJournalIdentity, + assertSnapshotRequest, + bindTrustedEventAttribution, + encodePosition, + invalidOptions, + journalProtocolError, + materializeCommittedEventAppend, + materializeJournalReplayRead, +} from './coordinationEventHandoffSupport'; + +import type { + CoordinationEventDeadlineScheduler, + CoordinationEventJournal, + CoordinationEventWakeup, + CoordinationSnapshotRequest, + ExternalCoordinationSnapshotSource, + PublishCoordinationEventCommand, + SameTransactionCoordinationSnapshotSource, +} from './ports'; + +const DEFAULT_MAX_REPLAY_EVENTS = 500; +const DEFAULT_REPLAY_BATCH_SIZE = 100; +const DEFAULT_EXTERNAL_SNAPSHOT_TIMEOUT_MS = 15_000; +const MAX_REPLAY_EVENTS = 10_000; +const MAX_EXTERNAL_SNAPSHOT_TIMEOUT_MS = 60_000; + +export { + CoordinationEventHandoffError, + type CoordinationEventHandoffErrorCode, +} from './coordinationEventHandoffError'; + +export interface CoordinationEventHandoffOptions { + readonly journal: CoordinationEventJournal; + readonly deadlineScheduler: CoordinationEventDeadlineScheduler; + readonly wakeup?: CoordinationEventWakeup; + readonly defaultMaxReplayEvents?: number; + readonly replayBatchSize?: number; + readonly externalSnapshotTimeoutMs?: number; +} + +export interface ReplayCoordinationEventsInput { + readonly cursor: string; + readonly maxEvents?: number; +} + +export interface PublishCommittedCoordinationEventResult< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly event: CoordinationEventEnvelope; + readonly liveWakeup: 'not_configured' | 'delivered' | 'failed'; +} + +type SnapshotDeadlinePhase = 'barrier' | 'read'; + +function settleSnapshotPhaseBeforeDeadline(input: { + readonly operation: Promise; + readonly deadlineAtMs: number; + readonly abortController: AbortController; + readonly deadlineScheduler: CoordinationEventDeadlineScheduler; + readonly phase: SnapshotDeadlinePhase | (() => SnapshotDeadlinePhase); +}): Promise { + const remainingMs = input.deadlineAtMs - Date.now(); + if (remainingMs <= 0) { + input.abortController.abort(); + return Promise.reject(snapshotDeadlineError(input)); + } + + return new Promise((resolve, reject) => { + let settled = false; + const cancelDeadline = input.deadlineScheduler.scheduleDeadline(remainingMs, () => { + if (settled) { + return; + } + settled = true; + input.abortController.abort(); + reject(snapshotDeadlineError(input)); + }); + + input.operation.then( + (value) => { + if (settled) { + return; + } + if (input.abortController.signal.aborted || Date.now() >= input.deadlineAtMs) { + settled = true; + cancelDeadline(); + input.abortController.abort(); + reject(snapshotDeadlineError(input)); + return; + } + settled = true; + cancelDeadline(); + resolve(value); + }, + (error: unknown) => { + if (settled) { + return; + } + if (input.abortController.signal.aborted || Date.now() >= input.deadlineAtMs) { + settled = true; + cancelDeadline(); + input.abortController.abort(); + reject(snapshotDeadlineError(input)); + return; + } + settled = true; + cancelDeadline(); + reject( + error instanceof Error + ? error + : new Error('External snapshot operation rejected', { cause: error }) + ); + } + ); + }); +} + +function snapshotDeadlineError(input: { + readonly phase: SnapshotDeadlinePhase | (() => SnapshotDeadlinePhase); + readonly deadlineAtMs: number; +}): CoordinationEventHandoffError { + const phaseDescription: Record = { + barrier: 'journal barrier observation', + read: 'source observation', + }; + const phase = typeof input.phase === 'function' ? input.phase() : input.phase; + return new CoordinationEventHandoffError( + 'snapshot_retry', + `External snapshot ${phaseDescription[phase]} exceeded its deadline`, + { + phase, + deadlineAtMs: input.deadlineAtMs, + } + ); +} + +function assertSnapshotDeadlineNotExceeded(input: { + readonly deadlineAtMs: number; + readonly abortController: AbortController; + readonly phase: SnapshotDeadlinePhase; +}): void { + if (input.abortController.signal.aborted || Date.now() >= input.deadlineAtMs) { + input.abortController.abort(); + throw snapshotDeadlineError(input); + } +} + +export class CoordinationEventHandoff { + private readonly journal: CoordinationEventJournal; + private readonly deadlineScheduler: CoordinationEventDeadlineScheduler; + private readonly wakeup: CoordinationEventWakeup | undefined; + private readonly defaultMaxReplayEvents: number; + private readonly replayBatchSize: number; + private readonly externalSnapshotTimeoutMs: number; + private lastObservedWatermark: EventJournalWatermark | undefined; + + constructor(options: CoordinationEventHandoffOptions) { + if (!options?.journal) { + throw invalidOptions('Coordination event journal is required'); + } + if (!options.deadlineScheduler) { + throw invalidOptions('Coordination event deadline scheduler is required'); + } + this.journal = options.journal; + this.deadlineScheduler = options.deadlineScheduler; + this.wakeup = options.wakeup; + this.defaultMaxReplayEvents = options.defaultMaxReplayEvents ?? DEFAULT_MAX_REPLAY_EVENTS; + this.replayBatchSize = options.replayBatchSize ?? DEFAULT_REPLAY_BATCH_SIZE; + this.externalSnapshotTimeoutMs = + options.externalSnapshotTimeoutMs ?? DEFAULT_EXTERNAL_SNAPSHOT_TIMEOUT_MS; + + assertBoundedPositiveInteger( + this.defaultMaxReplayEvents, + 'defaultMaxReplayEvents', + MAX_REPLAY_EVENTS + ); + assertBoundedPositiveInteger(this.replayBatchSize, 'replayBatchSize', MAX_REPLAY_EVENTS); + assertBoundedPositiveInteger( + this.externalSnapshotTimeoutMs, + 'externalSnapshotTimeoutMs', + MAX_EXTERNAL_SNAPSHOT_TIMEOUT_MS + ); + } + + async captureSameTransactionSnapshot(input: { + readonly request: CoordinationSnapshotRequest; + readonly source: SameTransactionCoordinationSnapshotSource; + }): Promise> { + assertSnapshotRequest(input.request); + const read = await input.source.readSnapshotWithEventBarrier(input.request); + const watermark = materializeEventJournalWatermark(read.watermark); + this.observeJournalWatermark(watermark); + const metadata = createCoordinationSnapshotMetadata({ + watermark, + handoffMode: 'same_transaction', + revisionVector: read.revisionVector, + }); + assertCoordinationSnapshotMetadata(metadata, watermark); + const snapshot = materializeCoordinationSnapshotData(read.snapshot); + return Object.freeze({ metadata, snapshot }); + } + + async captureExternalSnapshot(input: { + readonly request: CoordinationSnapshotRequest; + readonly source: ExternalCoordinationSnapshotSource; + }): Promise> { + assertSnapshotRequest(input.request); + const deadlineController = new AbortController(); + const deadlineAtMs = Date.now() + this.externalSnapshotTimeoutMs; + try { + const lowerBarrier = await this.observeBarrierWatermark(deadlineAtMs, deadlineController); + + const read = await settleSnapshotPhaseBeforeDeadline({ + operation: Promise.resolve().then(() => + input.source.readStableSnapshot(input.request, { + signal: deadlineController.signal, + deadlineAtMs, + }) + ), + deadlineAtMs, + abortController: deadlineController, + deadlineScheduler: this.deadlineScheduler, + phase: 'read', + }); + + assertIdentifier(read.sourceGenerationBefore, 'sourceGenerationBefore'); + assertIdentifier(read.sourceGenerationAfter, 'sourceGenerationAfter'); + if (read.sourceGenerationBefore !== read.sourceGenerationAfter) { + throw new CoordinationEventHandoffError( + 'snapshot_retry', + 'External snapshot source generation changed during the stable read', + { + sourceGenerationBefore: read.sourceGenerationBefore, + sourceGenerationAfter: read.sourceGenerationAfter, + } + ); + } + + const finalWatermark = await this.observeBarrierWatermark(deadlineAtMs, deadlineController); + assertSameJournalIdentity(lowerBarrier, finalWatermark); + try { + validateReplayCursor( + encodePosition(lowerBarrier, lowerBarrier.highWatermarkSequence), + finalWatermark + ); + } catch (error) { + throw new CoordinationEventHandoffError( + 'snapshot_retry', + 'External snapshot replay barrier is no longer retained', + { + replayBarrierSequence: lowerBarrier.highWatermarkSequence, + retentionFloorSequence: finalWatermark.retentionFloorSequence, + }, + error + ); + } + + const metadata = createCoordinationSnapshotMetadata({ + watermark: lowerBarrier, + handoffMode: 'lower_barrier', + revisionVector: read.revisionVector, + }); + const snapshot = materializeCoordinationSnapshotData(read.snapshot); + const envelope = Object.freeze({ metadata, snapshot }); + assertSnapshotDeadlineNotExceeded({ + deadlineAtMs, + abortController: deadlineController, + phase: 'read', + }); + return envelope; + } finally { + deadlineController.abort(); + } + } + + async replay( + input: ReplayCoordinationEventsInput + ): Promise> { + const maxEvents = input.maxEvents ?? this.defaultMaxReplayEvents; + assertBoundedPositiveInteger(maxEvents, 'maxEvents', MAX_REPLAY_EVENTS); + + const initialWatermark = materializeEventJournalWatermark(await this.journal.getWatermark()); + this.observeJournalWatermark(initialWatermark); + const from = validateReplayCursor(input.cursor, initialWatermark); + const targetSequence = initialWatermark.highWatermarkSequence; + let currentSequence = from.eventSequence; + const events: CoordinationEventEnvelope[] = []; + + while (currentSequence < targetSequence && events.length < maxEvents) { + const limit = Math.min( + this.replayBatchSize, + maxEvents - events.length, + targetSequence - currentSequence + ); + const read = materializeJournalReplayRead( + await this.journal.readCommittedEvents({ + afterSequence: currentSequence, + throughSequence: targetSequence, + limit, + }), + limit + ); + this.observeJournalWatermark(read.watermark); + assertSameJournalIdentity(initialWatermark, read.watermark); + if (read.watermark.highWatermarkSequence < targetSequence) { + throw journalProtocolError('Event journal high watermark regressed during replay', { + targetSequence, + observedHighWatermarkSequence: read.watermark.highWatermarkSequence, + }); + } + + const page = createCoordinationReplayBatch({ + fromCursor: encodePosition(initialWatermark, currentSequence), + events: read.events, + watermark: read.watermark, + maxEvents: limit, + throughSequence: targetSequence, + }); + events.push(...page.events); + currentSequence = decodeReplayCursor(page.nextCursor).eventSequence; + } + + const finalWatermark = materializeEventJournalWatermark(await this.journal.getWatermark()); + this.observeJournalWatermark(finalWatermark); + assertSameJournalIdentity(initialWatermark, finalWatermark); + if (finalWatermark.highWatermarkSequence < targetSequence) { + throw journalProtocolError('Event journal high watermark regressed after replay', { + targetSequence, + observedHighWatermarkSequence: finalWatermark.highWatermarkSequence, + }); + } + validateReplayCursor(encodePosition(initialWatermark, currentSequence), finalWatermark); + + const boundedTargetSequence = Math.min(targetSequence, from.eventSequence + maxEvents); + const validated = createCoordinationReplayBatch({ + fromCursor: input.cursor, + events, + watermark: initialWatermark, + maxEvents, + throughSequence: boundedTargetSequence, + }); + return Object.freeze({ + ...validated, + watermark: Object.freeze({ ...finalWatermark }), + hasMore: currentSequence < finalWatermark.highWatermarkSequence, + }); + } + + /** + * Durable append always precedes the lossy live wake-up. A wake-up failure is + * reported but never turns a committed mutation/event into a retryable write. + */ + async publishCommittedEvent( + command: PublishCoordinationEventCommand + ): Promise> { + const draft = bindTrustedEventAttribution(command); + assertCoordinationEventDraft(draft); + const committed = materializeCommittedEventAppend( + await this.journal.appendCommittedEvent(draft) + ); + this.observeJournalWatermark(committed.watermark); + assertCommittedEventMatchesDraft(committed.event, draft); + if (committed.event.eventSequence <= committed.watermark.retentionFloorSequence) { + throw journalProtocolError( + 'Newly committed event is not replayable above the retention floor', + { + eventId: committed.event.eventId, + eventSequence: committed.event.eventSequence, + retentionFloorSequence: committed.watermark.retentionFloorSequence, + } + ); + } + + if (!this.wakeup) { + return Object.freeze({ event: committed.event, liveWakeup: 'not_configured' }); + } + try { + await this.wakeup.notifyCommittedEvent(committed.event); + return Object.freeze({ event: committed.event, liveWakeup: 'delivered' }); + } catch { + return Object.freeze({ event: committed.event, liveWakeup: 'failed' }); + } + } + + private async observeBarrierWatermark( + deadlineAtMs: number, + abortController: AbortController + ): Promise { + const watermark = materializeEventJournalWatermark( + await settleSnapshotPhaseBeforeDeadline({ + operation: Promise.resolve().then(() => this.journal.getWatermark()), + deadlineAtMs, + abortController, + deadlineScheduler: this.deadlineScheduler, + phase: 'barrier', + }) + ); + this.observeJournalWatermark(watermark); + return watermark; + } + + private observeJournalWatermark(watermark: EventJournalWatermark): void { + assertJournalWatermark(watermark); + if (this.lastObservedWatermark) { + try { + assertJournalWatermarkProgression(this.lastObservedWatermark, watermark); + } catch (error) { + throw journalProtocolError('Event journal watermark regressed or changed identity', { + previousWatermark: this.lastObservedWatermark, + currentWatermark: watermark, + cause: error instanceof Error ? error.message : String(error), + }); + } + } + this.lastObservedWatermark = Object.freeze({ ...watermark }); + } +} diff --git a/src/features/coordination-events/core/application/coordinationEventHandoffError.ts b/src/features/coordination-events/core/application/coordinationEventHandoffError.ts new file mode 100644 index 0000000000..977df93d12 --- /dev/null +++ b/src/features/coordination-events/core/application/coordinationEventHandoffError.ts @@ -0,0 +1,16 @@ +export type CoordinationEventHandoffErrorCode = + | 'invalid_handoff_options' + | 'snapshot_retry' + | 'journal_protocol_error'; + +export class CoordinationEventHandoffError extends Error { + constructor( + readonly code: CoordinationEventHandoffErrorCode, + message: string, + readonly details: Readonly> = {}, + readonly cause?: unknown + ) { + super(message); + this.name = 'CoordinationEventHandoffError'; + } +} diff --git a/src/features/coordination-events/core/application/coordinationEventHandoffSupport.ts b/src/features/coordination-events/core/application/coordinationEventHandoffSupport.ts new file mode 100644 index 0000000000..dce1eb7745 --- /dev/null +++ b/src/features/coordination-events/core/application/coordinationEventHandoffSupport.ts @@ -0,0 +1,191 @@ +import { + COORDINATION_EVENT_SCOPE_KINDS, + type CoordinationEventActor, + type CoordinationEventDraft, + type CoordinationEventEnvelope, + type CoordinationJsonValue, + type EventJournalWatermark, +} from '../../contracts'; +import { + encodeReplayCursor, + materializeCoordinationEventDraft, + materializeCoordinationEventEnvelope, + materializeCoordinationEventEnvelopes, + materializeCoordinationJsonPayload, + materializeEventJournalWatermark, +} from '../domain'; + +import { CoordinationEventHandoffError } from './coordinationEventHandoffError'; + +import type { + CoordinationSnapshotRequest, + PublishCoordinationEventCommand, + TrustedCoordinationEventContext, +} from './ports'; + +export function bindTrustedEventAttribution( + command: PublishCoordinationEventCommand +): CoordinationEventDraft { + if (!command?.trustedContext || !command.draft) { + throw invalidOptions('Trusted event context and publish draft are required'); + } + const context: TrustedCoordinationEventContext = command.trustedContext; + const draft = command.draft; + const payload = materializeCoordinationJsonPayload(draft.payload) as TPayload; + return materializeCoordinationEventDraft({ + schemaVersion: draft.schemaVersion, + eventId: draft.eventId, + scope: draft.scope, + workspaceId: draft.workspaceId, + teamId: draft.teamId, + ...(context.runId === undefined ? {} : { runId: context.runId }), + actor: bindTrustedActor(context.actor), + eventType: draft.eventType, + resourceRevision: draft.resourceRevision, + emittedAt: draft.emittedAt, + payload, + }); +} + +function bindTrustedActor(actor: CoordinationEventActor): CoordinationEventActor { + switch (actor?.kind) { + case 'operator': + case 'recovery': + return Object.freeze({ kind: actor.kind, actorRef: actor.actorRef }); + case 'verified_runtime': + return Object.freeze({ + kind: actor.kind, + actorRef: actor.actorRef, + runId: actor.runId, + ...(actor.memberId === undefined ? {} : { memberId: actor.memberId }), + }); + case 'external_file': + return Object.freeze({ + kind: actor.kind, + ...(actor.actorRef === undefined ? {} : { actorRef: actor.actorRef }), + fileWriterEpoch: actor.fileWriterEpoch, + observationSequence: actor.observationSequence, + }); + default: + throw invalidOptions('Trusted event actor kind is invalid'); + } +} + +export function encodePosition(watermark: EventJournalWatermark, eventSequence: number): string { + return encodeReplayCursor({ + deploymentId: watermark.deploymentId, + eventEpoch: watermark.eventEpoch, + eventSequence, + }); +} + +export function assertSnapshotRequest(request: CoordinationSnapshotRequest): void { + if (!request || !COORDINATION_EVENT_SCOPE_KINDS.includes(request.scopeKind)) { + throw invalidOptions('Coordination snapshot scope kind is invalid'); + } + assertIdentifier(request.scopeId, 'scopeId'); +} + +export function assertSameJournalIdentity( + expected: EventJournalWatermark, + actual: EventJournalWatermark +): void { + if (expected.deploymentId !== actual.deploymentId || expected.eventEpoch !== actual.eventEpoch) { + throw journalProtocolError('Event journal identity changed during one handoff operation', { + expectedDeploymentId: expected.deploymentId, + actualDeploymentId: actual.deploymentId, + expectedEventEpoch: expected.eventEpoch, + actualEventEpoch: actual.eventEpoch, + }); + } +} + +export function assertBoundedPositiveInteger(value: number, field: string, maximum: number): void { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw invalidOptions(`${field} must be a bounded positive safe integer`, { + field, + value, + maximum, + }); + } +} + +export function assertIdentifier(value: string, field: string): void { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 256 || + value.trim() !== value + ) { + throw invalidOptions(`${field} must be a bounded non-empty string`, { field }); + } +} + +export function materializeJournalReplayRead< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +>( + value: unknown, + maximumEvents: number +): { + readonly events: readonly CoordinationEventEnvelope[]; + readonly watermark: EventJournalWatermark; +} { + const record = requireJournalDataObject(value, 'replay read'); + const watermark = materializeEventJournalWatermark(readJournalDataProperty(record, 'watermark')); + return Object.freeze({ + events: materializeCoordinationEventEnvelopes( + readJournalDataProperty(record, 'events'), + watermark, + maximumEvents + ), + watermark, + }); +} + +export function materializeCommittedEventAppend( + value: unknown +): { + readonly event: CoordinationEventEnvelope; + readonly watermark: EventJournalWatermark; +} { + const record = requireJournalDataObject(value, 'committed append'); + const watermark = materializeEventJournalWatermark(readJournalDataProperty(record, 'watermark')); + return Object.freeze({ + event: materializeCoordinationEventEnvelope( + readJournalDataProperty(record, 'event'), + watermark + ), + watermark, + }); +} + +function requireJournalDataObject(value: unknown, boundary: string): object { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw journalProtocolError(`Event journal ${boundary} must be a data object`); + } + return value; +} + +function readJournalDataProperty(record: object, field: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, field); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw journalProtocolError( + `Event journal ${field} must be returned as an enumerable data property` + ); + } + return descriptor.value; +} + +export function invalidOptions( + message: string, + details: Readonly> = {} +): CoordinationEventHandoffError { + return new CoordinationEventHandoffError('invalid_handoff_options', message, details); +} + +export function journalProtocolError( + message: string, + details: Readonly> = {} +): CoordinationEventHandoffError { + return new CoordinationEventHandoffError('journal_protocol_error', message, details); +} diff --git a/src/features/coordination-events/core/application/index.ts b/src/features/coordination-events/core/application/index.ts new file mode 100644 index 0000000000..4ca33767c7 --- /dev/null +++ b/src/features/coordination-events/core/application/index.ts @@ -0,0 +1,2 @@ +export * from './CoordinationEventHandoff'; +export type * from './ports'; diff --git a/src/features/coordination-events/core/application/ports.ts b/src/features/coordination-events/core/application/ports.ts new file mode 100644 index 0000000000..aab9eb6cc3 --- /dev/null +++ b/src/features/coordination-events/core/application/ports.ts @@ -0,0 +1,125 @@ +import type { + CoordinationEventActor, + CoordinationEventDraft, + CoordinationEventEnvelope, + CoordinationEventPublishDraft, + CoordinationEventScopeKind, + CoordinationJsonValue, + CoordinationResourceRevision, + EventJournalWatermark, +} from '../../contracts'; + +/** + * This value must be created from authenticated server/runtime state, never + * deserialized from the event submission body. Keeping it separate from the + * publish draft prevents a caller from supplying actor, run, or member + * attribution that is later persisted as trusted fact. + */ +export interface TrustedCoordinationEventContext { + readonly actor: CoordinationEventActor; + readonly runId?: string; +} + +export interface PublishCoordinationEventCommand< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly trustedContext: TrustedCoordinationEventContext; + readonly draft: CoordinationEventPublishDraft; +} + +export interface CoordinationSnapshotRequest { + readonly scopeKind: CoordinationEventScopeKind; + readonly scopeId: string; +} + +export interface SameTransactionCoordinationSnapshotRead { + readonly snapshot: TSnapshot; + readonly revisionVector: readonly CoordinationResourceRevision[]; + /** + * Projection, revision vector, and watermark must be read from the same + * storage transaction. The application layer deliberately cannot synthesize + * this guarantee from separate reads. + */ + readonly watermark: EventJournalWatermark; +} + +export interface SameTransactionCoordinationSnapshotSource { + readSnapshotWithEventBarrier( + request: CoordinationSnapshotRequest + ): Promise>; +} + +export interface ExternalCoordinationSnapshotRead { + readonly snapshot: TSnapshot; + readonly revisionVector: readonly CoordinationResourceRevision[]; + /** Stable, opaque feature-owned generation evidence from before and after the scan. */ + readonly sourceGenerationBefore: string; + readonly sourceGenerationAfter: string; +} + +export interface ExternalCoordinationSnapshotReadContext { + /** + * The source must stop an in-flight scan promptly when this signal aborts. + * Core independently enforces the absolute deadline, discards any late + * result, and falls back to a fresh snapshot when the returned cursor is no + * longer retained. Adapters remain responsible for stopping underlying scan + * work when aborted rather than relying on promise settlement for safety. + */ + readonly signal: AbortSignal; + /** Unix epoch milliseconds for the bounded external observation deadline. */ + readonly deadlineAtMs: number; +} + +export interface ExternalCoordinationSnapshotSource { + readStableSnapshot( + request: CoordinationSnapshotRequest, + context: ExternalCoordinationSnapshotReadContext + ): Promise>; +} + +export interface CoordinationEventDeadlineScheduler { + /** Schedules one deadline and returns an idempotent cancellation callback. */ + scheduleDeadline(delayMs: number, onDeadline: () => void): () => void; +} + +export interface CoordinationJournalReplayRead< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly events: readonly CoordinationEventEnvelope[]; + /** + * Watermark observed by the durable query. Returning it closes retention + * races and lets core reject an overtaken cursor. + */ + readonly watermark: EventJournalWatermark; +} + +export interface CommittedCoordinationEventAppend< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly event: CoordinationEventEnvelope; + readonly watermark: EventJournalWatermark; +} + +export interface CoordinationEventJournal { + getWatermark(): Promise; + readCommittedEvents(input: { + readonly afterSequence: number; + readonly throughSequence: number; + readonly limit: number; + }): Promise>; + /** + * Implementations assign epoch/sequence/cursor and durably commit the one + * outbox-journal row before resolving. Prepared rows must never be returned. + */ + appendCommittedEvent( + draft: CoordinationEventDraft + ): Promise>; +} + +/** + * A wake-up is only a coalescing latency hint. Durable journal replay remains + * authoritative when this port fails or the process crashes before it runs. + */ +export interface CoordinationEventWakeup { + notifyCommittedEvent(event: CoordinationEventEnvelope): Promise; +} diff --git a/src/features/coordination-events/core/domain/coordinationJson.ts b/src/features/coordination-events/core/domain/coordinationJson.ts new file mode 100644 index 0000000000..8c95ec6323 --- /dev/null +++ b/src/features/coordination-events/core/domain/coordinationJson.ts @@ -0,0 +1,390 @@ +import { + MAX_COORDINATION_EVENT_PAYLOAD_DEPTH, + MAX_COORDINATION_EVENT_PAYLOAD_NODES, + MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES, + MAX_COORDINATION_SNAPSHOT_DEPTH, + MAX_COORDINATION_SNAPSHOT_NODES, + SnapshotEventHandoffError, +} from './snapshotEventLimits'; + +import type { CoordinationJsonValue } from '../../contracts'; + +function invalidEvent( + message: string, + details: Readonly> = {} +): SnapshotEventHandoffError { + return new SnapshotEventHandoffError('invalid_coordination_event', message, details); +} + +/** + * Materializes adapter-owned snapshot data as a fresh accessor-free immutable + * tree. Only data that can actually be made deeply immutable is admitted: + * primitives, dense arrays, and plain records. Mutable built-in objects, + * prototypes, symbols, hidden properties, accessors, and cycles fail closed. + */ +export function materializeCoordinationSnapshotData(value: TSnapshot): TSnapshot { + const ancestors = new Set(); + let materializedNodeCount = 0; + + const invalidSnapshot = ( + message: string, + details: Readonly> = {} + ): SnapshotEventHandoffError => + new SnapshotEventHandoffError('invalid_snapshot_data', message, details); + + const materialize = (current: unknown, depth: number): unknown => { + materializedNodeCount += 1; + if (materializedNodeCount > MAX_COORDINATION_SNAPSHOT_NODES) { + throw invalidSnapshot('Coordination snapshot exceeds its total-node budget', { + maximumNodes: MAX_COORDINATION_SNAPSHOT_NODES, + }); + } + if (depth > MAX_COORDINATION_SNAPSHOT_DEPTH) { + throw invalidSnapshot('Coordination snapshot exceeds its nesting-depth budget', { + maximumDepth: MAX_COORDINATION_SNAPSHOT_DEPTH, + }); + } + if ( + current === null || + current === undefined || + typeof current === 'string' || + typeof current === 'boolean' || + typeof current === 'number' || + typeof current === 'bigint' + ) { + return current; + } + if (typeof current !== 'object') { + throw invalidSnapshot('Coordination snapshot must contain only detached data'); + } + if (ancestors.has(current)) { + throw invalidSnapshot('Coordination snapshot must be acyclic'); + } + + const prototype = Object.getPrototypeOf(current) as unknown; + if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) { + throw invalidSnapshot( + 'Coordination snapshot must contain only arrays and plain data objects' + ); + } + + ancestors.add(current); + try { + if (Array.isArray(current)) { + const ownKeys = Reflect.ownKeys(current); + if ( + ownKeys.length !== current.length + 1 || + ownKeys.some( + (key) => typeof key !== 'string' || (key !== 'length' && !/^(?:0|[1-9]\d*)$/.test(key)) + ) + ) { + throw invalidSnapshot( + 'Coordination snapshot arrays must contain only dense data indices' + ); + } + const descriptors = Object.getOwnPropertyDescriptors(current); + const result: unknown[] = []; + for (let index = 0; index < current.length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw invalidSnapshot( + 'Coordination snapshot arrays cannot contain sparse indices or accessors' + ); + } + result.push(materialize(descriptor.value, depth + 1)); + } + return Object.freeze(result); + } + + const ownKeys = Reflect.ownKeys(current); + const descriptors = Object.getOwnPropertyDescriptors(current); + const result = Object.create(null) as Record; + for (const key of ownKeys) { + if (typeof key !== 'string') { + throw invalidSnapshot( + 'Coordination snapshot objects cannot contain symbols or hidden properties' + ); + } + const descriptor = descriptors[key]; + if (!descriptor?.enumerable) { + throw invalidSnapshot( + 'Coordination snapshot objects cannot contain symbols or hidden properties' + ); + } + if (!('value' in descriptor)) { + throw invalidSnapshot('Coordination snapshot objects cannot contain accessors'); + } + Object.defineProperty(result, key, { + value: materialize(descriptor.value, depth + 1), + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(result); + } finally { + ancestors.delete(current); + } + }; + + return materialize(value, 0) as TSnapshot; +} + +/** + * Copies untrusted payload data into an accessor-free immutable JSON tree + * before the canonical budget validator observes it. Property descriptors are + * inspected without invoking getters, so a value cannot change between + * validation and the durable append. + */ +export function materializeCoordinationJsonPayload(value: unknown): CoordinationJsonValue { + const ancestors = new Set(); + let materializedNodeCount = 0; + + const materialize = (current: unknown, depth: number): CoordinationJsonValue => { + materializedNodeCount += 1; + if (materializedNodeCount > MAX_COORDINATION_EVENT_PAYLOAD_NODES) { + throw invalidEvent('Coordination event payload exceeds its total-node budget', { + maximumNodes: MAX_COORDINATION_EVENT_PAYLOAD_NODES, + }); + } + if (depth > MAX_COORDINATION_EVENT_PAYLOAD_DEPTH) { + throw invalidEvent('Coordination event payload exceeds its nesting-depth budget', { + maximumDepth: MAX_COORDINATION_EVENT_PAYLOAD_DEPTH, + }); + } + if (current === null || typeof current === 'string' || typeof current === 'boolean') { + return current; + } + if (typeof current === 'number') { + if (!Number.isFinite(current)) { + throw invalidEvent('Coordination event payload must be strict JSON'); + } + return current; + } + if (typeof current !== 'object' || ancestors.has(current)) { + throw invalidEvent('Coordination event payload must be strict acyclic JSON'); + } + + const prototype = Object.getPrototypeOf(current) as unknown; + if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) { + throw invalidEvent('Coordination event payload must contain only plain JSON objects'); + } + ancestors.add(current); + try { + if (Array.isArray(current)) { + if (materializedNodeCount + current.length > MAX_COORDINATION_EVENT_PAYLOAD_NODES) { + throw invalidEvent('Coordination event payload exceeds its total-node budget', { + maximumNodes: MAX_COORDINATION_EVENT_PAYLOAD_NODES, + }); + } + const ownPropertySymbols = Object.getOwnPropertySymbols(current); + const ownPropertyNames = Object.getOwnPropertyNames(current); + if (ownPropertySymbols.length > 0 || ownPropertyNames.length !== current.length + 1) { + throw invalidEvent('Coordination event payload arrays must contain only JSON indices'); + } + const descriptors = Object.getOwnPropertyDescriptors(current); + const result: CoordinationJsonValue[] = []; + for (let index = 0; index < current.length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw invalidEvent( + 'Coordination event payload cannot contain sparse arrays or accessors' + ); + } + result.push(materialize(descriptor.value, depth + 1)); + } + return Object.freeze(result); + } + + const ownKeys = Reflect.ownKeys(current); + if (materializedNodeCount + ownKeys.length > MAX_COORDINATION_EVENT_PAYLOAD_NODES) { + throw invalidEvent('Coordination event payload exceeds its total-node budget', { + maximumNodes: MAX_COORDINATION_EVENT_PAYLOAD_NODES, + }); + } + const descriptors = Object.getOwnPropertyDescriptors(current); + const result = Object.create(null) as Record; + for (const key of ownKeys) { + if (typeof key !== 'string') { + throw invalidEvent( + 'Coordination event payload objects cannot contain symbols or hidden properties' + ); + } + const descriptor = descriptors[key]; + if (!descriptor.enumerable) { + throw invalidEvent( + 'Coordination event payload objects cannot contain symbols or hidden properties' + ); + } + if (!('value' in descriptor)) { + throw invalidEvent('Coordination event payload objects cannot contain accessors'); + } + Object.defineProperty(result, key, { + value: materialize(descriptor.value, depth + 1), + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(result); + } finally { + ancestors.delete(current); + } + }; + + const payload = materialize(value, 0); + assertCoordinationJsonPayload(payload); + return payload; +} + +export function assertCoordinationJsonPayload( + value: unknown +): asserts value is CoordinationJsonValue { + type WorkItem = + | { readonly kind: 'value'; readonly value: unknown; readonly depth: number } + | { readonly kind: 'leave'; readonly value: object }; + + const work: WorkItem[] = [{ kind: 'value', value, depth: 0 }]; + const ancestors = new Set(); + const encoder = new TextEncoder(); + let nodeCount = 0; + let scheduledNodeCount = 1; + let byteCount = 0; + + const addBytes = (count: number): void => { + byteCount += count; + if (byteCount > MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES) { + throw invalidEvent('Coordination event payload exceeds its UTF-8 byte budget', { + maximumBytes: MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES, + }); + } + }; + const addJsonStringBytes = (input: string): void => { + if (input.length + 2 > MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES - byteCount) { + throw invalidEvent('Coordination event payload exceeds its UTF-8 byte budget', { + maximumBytes: MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES, + }); + } + addBytes(encoder.encode(JSON.stringify(input)).byteLength); + }; + const scheduleNodes = (count: number): void => { + scheduledNodeCount += count; + if (scheduledNodeCount > MAX_COORDINATION_EVENT_PAYLOAD_NODES) { + throw invalidEvent('Coordination event payload exceeds its total-node budget', { + maximumNodes: MAX_COORDINATION_EVENT_PAYLOAD_NODES, + }); + } + }; + + while (work.length > 0) { + const item = work.pop()!; + if (item.kind === 'leave') { + ancestors.delete(item.value); + continue; + } + + nodeCount += 1; + if (nodeCount > MAX_COORDINATION_EVENT_PAYLOAD_NODES) { + throw invalidEvent('Coordination event payload exceeds its total-node budget', { + maximumNodes: MAX_COORDINATION_EVENT_PAYLOAD_NODES, + }); + } + if (item.depth > MAX_COORDINATION_EVENT_PAYLOAD_DEPTH) { + throw invalidEvent('Coordination event payload exceeds its nesting-depth budget', { + maximumDepth: MAX_COORDINATION_EVENT_PAYLOAD_DEPTH, + }); + } + + const current = item.value; + if (current === null) { + addBytes(4); + continue; + } + if (typeof current === 'string') { + addJsonStringBytes(current); + continue; + } + if (typeof current === 'boolean') { + addBytes(current ? 4 : 5); + continue; + } + if (typeof current === 'number') { + if (!Number.isFinite(current)) { + throw invalidEvent('Coordination event payload must be strict JSON'); + } + addBytes(String(Object.is(current, -0) ? 0 : current).length); + continue; + } + if (typeof current !== 'object' || ancestors.has(current)) { + throw invalidEvent('Coordination event payload must be strict acyclic JSON'); + } + + const prototype = Object.getPrototypeOf(current) as unknown; + if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) { + throw invalidEvent('Coordination event payload must contain only plain JSON objects'); + } + ancestors.add(current); + work.push({ kind: 'leave', value: current }); + + if (Array.isArray(current)) { + addBytes(2 + Math.max(0, current.length - 1)); + scheduleNodes(current.length); + if ( + Object.getOwnPropertySymbols(current).length > 0 || + Object.getOwnPropertyNames(current).length !== current.length + 1 + ) { + throw invalidEvent('Coordination event payload arrays must contain only JSON indices'); + } + for (let index = current.length - 1; index >= 0; index -= 1) { + if (!(index in current)) { + throw invalidEvent('Coordination event payload cannot contain sparse arrays'); + } + work.push({ kind: 'value', value: current[index], depth: item.depth + 1 }); + } + continue; + } + + const record = current as Readonly>; + const keys = Object.keys(record); + addBytes(2 + Math.max(0, keys.length - 1) + keys.length); + scheduleNodes(keys.length); + if (Reflect.ownKeys(record).length !== keys.length) { + throw invalidEvent( + 'Coordination event payload objects cannot contain symbols or hidden properties' + ); + } + for (let index = keys.length - 1; index >= 0; index -= 1) { + const key = keys[index]; + addJsonStringBytes(key); + work.push({ kind: 'value', value: record[key], depth: item.depth + 1 }); + } + } +} + +export function sameStructuredValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + if (typeof left !== 'object' || left === null || typeof right !== 'object' || right === null) { + return false; + } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((item, index) => sameStructuredValue(item, right[index])) + ); + } + const leftRecord = left as Readonly>; + const rightRecord = right as Readonly>; + const leftKeys = Object.keys(leftRecord).sort(); + const rightKeys = Object.keys(rightRecord).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && sameStructuredValue(leftRecord[key], rightRecord[key]) + ) + ); +} diff --git a/src/features/coordination-events/core/domain/index.ts b/src/features/coordination-events/core/domain/index.ts new file mode 100644 index 0000000000..1e6b458303 --- /dev/null +++ b/src/features/coordination-events/core/domain/index.ts @@ -0,0 +1,2 @@ +export * from './replayCursor'; +export * from './snapshotEventHandoff'; diff --git a/src/features/coordination-events/core/domain/replayCursor.ts b/src/features/coordination-events/core/domain/replayCursor.ts new file mode 100644 index 0000000000..13be1741fa --- /dev/null +++ b/src/features/coordination-events/core/domain/replayCursor.ts @@ -0,0 +1,340 @@ +import { + EVENT_JOURNAL_WATERMARK_SCHEMA_VERSION, + type EventJournalWatermark, + REPLAY_CURSOR_SCHEMA_VERSION, + type ReplayCursor, + type ReplayCursorPosition, +} from '../../contracts'; + +const REPLAY_CURSOR_PREFIX = 'cev'; +const MAX_CURSOR_LENGTH = 2_048; +const MAX_CURSOR_IDENTITY_LENGTH = 256; +const BASE64_URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +export type ReplayCursorErrorCode = + | 'invalid_replay_cursor' + | 'unsupported_replay_cursor_version' + | 'invalid_journal_watermark' + | 'replay_cursor_deployment_mismatch' + | 'replay_cursor_epoch_mismatch' + | 'replay_cursor_stale' + | 'replay_cursor_ahead'; + +export class ReplayCursorError extends Error { + constructor( + readonly code: ReplayCursorErrorCode, + message: string, + readonly details: Readonly> = {} + ) { + super(message); + this.name = 'ReplayCursorError'; + } +} + +export function encodeReplayCursor( + input: Omit & { + readonly cursorVersion?: typeof REPLAY_CURSOR_SCHEMA_VERSION; + } +): ReplayCursor { + const position = { + cursorVersion: input.cursorVersion ?? REPLAY_CURSOR_SCHEMA_VERSION, + deploymentId: input.deploymentId, + eventEpoch: input.eventEpoch, + eventSequence: input.eventSequence, + } satisfies ReplayCursorPosition; + assertReplayCursorPosition(position); + + const payload = JSON.stringify([ + position.cursorVersion, + position.deploymentId, + position.eventEpoch, + position.eventSequence, + ]); + const cursor = `${REPLAY_CURSOR_PREFIX}${position.cursorVersion}.${encodeBase64Url(payload)}`; + if (cursor.length > MAX_CURSOR_LENGTH) { + throw invalidCursor('Replay cursor exceeds its encoded size bound'); + } + return cursor as ReplayCursor; +} + +export const createReplayCursor = encodeReplayCursor; + +export function decodeReplayCursor(cursor: string): ReplayCursorPosition { + if ( + typeof cursor !== 'string' || + cursor.length === 0 || + cursor.length > MAX_CURSOR_LENGTH || + cursor.trim() !== cursor + ) { + throw invalidCursor('Replay cursor must be a bounded non-empty canonical string'); + } + + const match = /^cev(\d+)\.([A-Za-z0-9_-]+)$/.exec(cursor); + if (!match) { + throw invalidCursor('Replay cursor has an invalid envelope'); + } + + const envelopeVersion = Number(match[1]); + if (!Number.isSafeInteger(envelopeVersion) || envelopeVersion !== REPLAY_CURSOR_SCHEMA_VERSION) { + throw new ReplayCursorError( + 'unsupported_replay_cursor_version', + 'Replay cursor version is not supported', + { cursorVersion: match[1] } + ); + } + + let decoded: unknown; + try { + decoded = JSON.parse(decodeBase64Url(match[2])) as unknown; + } catch (error) { + throw invalidCursor('Replay cursor payload is malformed', { + cause: error instanceof Error ? error.message : String(error), + }); + } + + if (!Array.isArray(decoded) || decoded.length !== 4) { + throw invalidCursor('Replay cursor payload must use the canonical tuple shape'); + } + + const [cursorVersion, deploymentId, eventEpoch, eventSequence] = decoded; + if ( + typeof cursorVersion !== 'number' || + typeof deploymentId !== 'string' || + typeof eventEpoch !== 'string' || + typeof eventSequence !== 'number' + ) { + throw invalidCursor('Replay cursor payload fields have invalid types'); + } + if (cursorVersion !== envelopeVersion) { + throw invalidCursor('Replay cursor envelope and payload versions disagree'); + } + + const position = { + cursorVersion, + deploymentId, + eventEpoch, + eventSequence, + } as ReplayCursorPosition; + assertReplayCursorPosition(position); + + if (encodeReplayCursor(position) !== cursor) { + throw invalidCursor('Replay cursor is not canonically encoded'); + } + + return Object.freeze(position); +} + +export function validateReplayCursor( + cursor: string, + watermark: EventJournalWatermark +): ReplayCursorPosition { + const immutableWatermark = materializeEventJournalWatermark(watermark); + const position = decodeReplayCursor(cursor); + + if (position.deploymentId !== immutableWatermark.deploymentId) { + throw new ReplayCursorError( + 'replay_cursor_deployment_mismatch', + 'Replay cursor belongs to another deployment', + { + cursorDeploymentId: position.deploymentId, + expectedDeploymentId: immutableWatermark.deploymentId, + } + ); + } + if (position.eventEpoch !== immutableWatermark.eventEpoch) { + throw new ReplayCursorError( + 'replay_cursor_epoch_mismatch', + 'Replay cursor belongs to another event epoch', + { + cursorEventEpoch: position.eventEpoch, + expectedEventEpoch: immutableWatermark.eventEpoch, + } + ); + } + if (position.eventSequence < immutableWatermark.retentionFloorSequence) { + throw new ReplayCursorError( + 'replay_cursor_stale', + 'Replay cursor is below the retained journal floor', + { + cursorSequence: position.eventSequence, + retentionFloorSequence: immutableWatermark.retentionFloorSequence, + } + ); + } + if (position.eventSequence > immutableWatermark.highWatermarkSequence) { + throw new ReplayCursorError( + 'replay_cursor_ahead', + 'Replay cursor is ahead of the durable journal', + { + cursorSequence: position.eventSequence, + highWatermarkSequence: immutableWatermark.highWatermarkSequence, + } + ); + } + + return position; +} + +/** + * Copies a journal-owned watermark without invoking accessors, then validates + * and freezes the copy. Journal adapters are a trust boundary: their mutable + * return objects must never become application state by reference. + */ +export function materializeEventJournalWatermark(value: unknown): EventJournalWatermark { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidWatermark('Event journal watermark must be a data object'); + } + const record = value; + const watermark = Object.freeze({ + schemaVersion: readWatermarkDataProperty(record, 'schemaVersion'), + deploymentId: readWatermarkDataProperty(record, 'deploymentId'), + eventEpoch: readWatermarkDataProperty(record, 'eventEpoch'), + retentionFloorSequence: readWatermarkDataProperty(record, 'retentionFloorSequence'), + highWatermarkSequence: readWatermarkDataProperty(record, 'highWatermarkSequence'), + }) as EventJournalWatermark; + assertJournalWatermark(watermark); + return watermark; +} + +export function isReplayCursor(value: unknown): value is ReplayCursor { + if (typeof value !== 'string') { + return false; + } + try { + decodeReplayCursor(value); + return true; + } catch { + return false; + } +} + +export function assertJournalWatermark( + watermark: EventJournalWatermark +): asserts watermark is EventJournalWatermark { + if (!watermark || watermark.schemaVersion !== EVENT_JOURNAL_WATERMARK_SCHEMA_VERSION) { + throw new ReplayCursorError( + 'invalid_journal_watermark', + 'Event journal watermark version is not supported', + { schemaVersion: watermark?.schemaVersion } + ); + } + assertIdentity(watermark.deploymentId, 'deploymentId', 'invalid_journal_watermark'); + assertIdentity(watermark.eventEpoch, 'eventEpoch', 'invalid_journal_watermark'); + if ( + !isSequence(watermark.retentionFloorSequence) || + !isSequence(watermark.highWatermarkSequence) || + watermark.retentionFloorSequence > watermark.highWatermarkSequence + ) { + throw new ReplayCursorError( + 'invalid_journal_watermark', + 'Event journal watermark sequence range is invalid', + { + retentionFloorSequence: watermark.retentionFloorSequence, + highWatermarkSequence: watermark.highWatermarkSequence, + } + ); + } +} + +function assertReplayCursorPosition(position: ReplayCursorPosition): void { + if (position.cursorVersion !== REPLAY_CURSOR_SCHEMA_VERSION) { + throw new ReplayCursorError( + 'unsupported_replay_cursor_version', + 'Replay cursor version is not supported', + { cursorVersion: position.cursorVersion } + ); + } + assertIdentity(position.deploymentId, 'deploymentId', 'invalid_replay_cursor'); + assertIdentity(position.eventEpoch, 'eventEpoch', 'invalid_replay_cursor'); + if (!isSequence(position.eventSequence)) { + throw invalidCursor('Replay cursor eventSequence must be a non-negative safe integer', { + eventSequence: position.eventSequence, + }); + } +} + +function assertIdentity( + value: string, + field: string, + code: 'invalid_replay_cursor' | 'invalid_journal_watermark' +): void { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_CURSOR_IDENTITY_LENGTH || + value.trim() !== value + ) { + throw new ReplayCursorError(code, `Replay cursor ${field} is invalid`, { field }); + } +} + +function isSequence(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +function invalidCursor( + message: string, + details: Readonly> = {} +): ReplayCursorError { + return new ReplayCursorError('invalid_replay_cursor', message, details); +} + +function invalidWatermark(message: string): ReplayCursorError { + return new ReplayCursorError('invalid_journal_watermark', message); +} + +function readWatermarkDataProperty(record: object, field: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, field); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw invalidWatermark(`Event journal watermark ${field} must be an enumerable data property`); + } + return descriptor.value; +} + +function encodeBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value); + let encoded = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index]; + const second = bytes[index + 1]; + const third = bytes[index + 2]; + const combined = (first << 16) | ((second ?? 0) << 8) | (third ?? 0); + encoded += BASE64_URL_ALPHABET[(combined >>> 18) & 63]; + encoded += BASE64_URL_ALPHABET[(combined >>> 12) & 63]; + if (second !== undefined) { + encoded += BASE64_URL_ALPHABET[(combined >>> 6) & 63]; + } + if (third !== undefined) { + encoded += BASE64_URL_ALPHABET[combined & 63]; + } + } + return encoded; +} + +function decodeBase64Url(value: string): string { + if (value.length === 0 || value.length % 4 === 1) { + throw new Error('Invalid base64url length'); + } + + const bytes: number[] = []; + let buffer = 0; + let bitCount = 0; + for (const character of value) { + const digit = BASE64_URL_ALPHABET.indexOf(character); + if (digit < 0) { + throw new Error('Invalid base64url character'); + } + buffer = (buffer << 6) | digit; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + bytes.push((buffer >>> bitCount) & 255); + buffer &= (1 << bitCount) - 1; + } + } + if (bitCount > 0 && buffer !== 0) { + throw new Error('Invalid base64url trailing bits'); + } + + return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes)); +} diff --git a/src/features/coordination-events/core/domain/snapshotEventHandoff.ts b/src/features/coordination-events/core/domain/snapshotEventHandoff.ts new file mode 100644 index 0000000000..e6c94d35bd --- /dev/null +++ b/src/features/coordination-events/core/domain/snapshotEventHandoff.ts @@ -0,0 +1,33 @@ +export { + materializeCoordinationJsonPayload, + materializeCoordinationSnapshotData, +} from './coordinationJson'; +export { + assertCoordinationEventDraft, + assertCoordinationEventEnvelope, + assertCoordinationSnapshotMetadata, + createCoordinationSnapshotMetadata, + type CreateSnapshotMetadataInput, + materializeCoordinationEventDraft, + materializeCoordinationEventEnvelope, + materializeCoordinationEventEnvelopes, +} from './snapshotEventIdentity'; +export { + MAX_COORDINATION_EVENT_PAYLOAD_DEPTH, + MAX_COORDINATION_EVENT_PAYLOAD_NODES, + MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES, + MAX_RECONCILIATION_PROCESSED_EVENT_IDS, + SnapshotEventHandoffError, + type SnapshotEventHandoffErrorCode, +} from './snapshotEventLimits'; +export { + assertCommittedEventMatchesDraft, + assertCoordinationEventRecoveryPoint, + assertJournalWatermarkProgression, + type CoordinationReplayReconciliationState, + createCoordinationEventRecoveryPoint, + createCoordinationReplayBatch, + type CreateReplayBatchInput, + type ReconcileCoordinationReplayResult, + reconcileCoordinationSnapshotReplay, +} from './snapshotReplay'; diff --git a/src/features/coordination-events/core/domain/snapshotEventIdentity.ts b/src/features/coordination-events/core/domain/snapshotEventIdentity.ts new file mode 100644 index 0000000000..fd586e5961 --- /dev/null +++ b/src/features/coordination-events/core/domain/snapshotEventIdentity.ts @@ -0,0 +1,724 @@ +import { + COORDINATION_EVENT_ACTOR_KINDS, + COORDINATION_EVENT_SCHEMA_VERSION, + COORDINATION_EVENT_SCOPE_KINDS, + COORDINATION_SNAPSHOT_SCHEMA_VERSION, + type CoordinationEventDraft, + type CoordinationEventEnvelope, + type CoordinationJsonValue, + type CoordinationResourceRevision, + type CoordinationSnapshotMetadata, + type EventJournalWatermark, + SNAPSHOT_EVENT_HANDOFF_MODES, + type SnapshotEventHandoffMode, +} from '../../contracts'; + +import { + assertCoordinationJsonPayload, + materializeCoordinationJsonPayload, +} from './coordinationJson'; +import { + assertJournalWatermark, + decodeReplayCursor, + encodeReplayCursor, + materializeEventJournalWatermark, + validateReplayCursor, +} from './replayCursor'; +import { + MAX_DOMAIN_REPLAY_BATCH_SIZE, + MAX_EVENT_TYPE_LENGTH, + MAX_IDENTIFIER_LENGTH, + MAX_RECONCILIATION_PROCESSED_EVENT_IDS, + MAX_REVISION_VECTOR_LENGTH, + SnapshotEventHandoffError, +} from './snapshotEventLimits'; + +import type { CoordinationReplayReconciliationState } from './snapshotReplay'; + +export interface CreateSnapshotMetadataInput { + readonly watermark: EventJournalWatermark; + readonly handoffMode: SnapshotEventHandoffMode; + readonly revisionVector: readonly CoordinationResourceRevision[]; +} + +export function createCoordinationSnapshotMetadata( + input: CreateSnapshotMetadataInput +): CoordinationSnapshotMetadata { + assertJournalWatermark(input.watermark); + if (!SNAPSHOT_EVENT_HANDOFF_MODES.includes(input.handoffMode)) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Snapshot event handoff mode is invalid', + { handoffMode: input.handoffMode } + ); + } + const revisionVector = materializeRevisionVector(input.revisionVector); + assertRevisionVector(revisionVector); + + return Object.freeze({ + schemaVersion: COORDINATION_SNAPSHOT_SCHEMA_VERSION, + deploymentId: input.watermark.deploymentId, + eventEpoch: input.watermark.eventEpoch, + handoffMode: input.handoffMode, + replayCursor: encodeReplayCursor({ + deploymentId: input.watermark.deploymentId, + eventEpoch: input.watermark.eventEpoch, + eventSequence: input.watermark.highWatermarkSequence, + }), + revisionVector, + }); +} + +export function assertCoordinationSnapshotMetadata( + metadata: CoordinationSnapshotMetadata, + expectedWatermark?: EventJournalWatermark +): void { + const materializedMetadata = materializeCoordinationSnapshotMetadata(metadata); + assertMaterializedCoordinationSnapshotMetadata(materializedMetadata, expectedWatermark); +} + +export function materializeCoordinationSnapshotMetadata( + metadata: CoordinationSnapshotMetadata +): CoordinationSnapshotMetadata { + if (!metadata || typeof metadata !== 'object') { + throw new SnapshotEventHandoffError( + 'unsupported_snapshot_version', + 'Coordination snapshot metadata version is not supported', + { schemaVersion: null } + ); + } + const schemaVersion = metadata.schemaVersion; + if (schemaVersion !== COORDINATION_SNAPSHOT_SCHEMA_VERSION) { + throw new SnapshotEventHandoffError( + 'unsupported_snapshot_version', + 'Coordination snapshot metadata version is not supported', + { schemaVersion } + ); + } + const revisionVector = materializeRevisionVector(metadata.revisionVector); + return Object.freeze({ + schemaVersion, + deploymentId: metadata.deploymentId, + eventEpoch: metadata.eventEpoch, + handoffMode: metadata.handoffMode, + replayCursor: metadata.replayCursor, + revisionVector, + }); +} + +export function assertMaterializedCoordinationSnapshotMetadata( + metadata: CoordinationSnapshotMetadata, + expectedWatermark?: EventJournalWatermark +): void { + assertIdentifier(metadata.deploymentId, 'deploymentId', 'invalid_snapshot_metadata'); + assertIdentifier(metadata.eventEpoch, 'eventEpoch', 'invalid_snapshot_metadata'); + if (!SNAPSHOT_EVENT_HANDOFF_MODES.includes(metadata.handoffMode)) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Coordination snapshot handoff mode is invalid', + { handoffMode: metadata.handoffMode } + ); + } + assertRevisionVector(metadata.revisionVector); + + const cursor = decodeReplayCursor(metadata.replayCursor); + if (cursor.deploymentId !== metadata.deploymentId || cursor.eventEpoch !== metadata.eventEpoch) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Coordination snapshot cursor identity does not match its metadata', + { + cursorDeploymentId: cursor.deploymentId, + metadataDeploymentId: metadata.deploymentId, + cursorEventEpoch: cursor.eventEpoch, + metadataEventEpoch: metadata.eventEpoch, + } + ); + } + + if (expectedWatermark) { + const position = validateReplayCursor(metadata.replayCursor, expectedWatermark); + if (position.eventSequence !== expectedWatermark.highWatermarkSequence) { + throw new SnapshotEventHandoffError( + 'journal_watermark_mismatch', + 'Coordination snapshot cursor is not the captured journal barrier', + { + cursorSequence: position.eventSequence, + highWatermarkSequence: expectedWatermark.highWatermarkSequence, + } + ); + } + } +} + +export function assertCoordinationEventDraft( + draft: CoordinationEventDraft +): asserts draft is CoordinationEventDraft { + if (draft?.schemaVersion !== COORDINATION_EVENT_SCHEMA_VERSION) { + throw new SnapshotEventHandoffError( + 'unsupported_event_version', + 'Coordination event version is not supported', + { schemaVersion: draft?.schemaVersion } + ); + } + assertIdentifier(draft.eventId, 'eventId', 'invalid_coordination_event'); + assertScope(draft); + assertOptionalIdentity(draft.workspaceId, 'workspaceId'); + assertOptionalIdentity(draft.teamId, 'teamId'); + assertOptionalIdentity(draft.runId, 'runId'); + assertScopeReferences(draft); + assertActor(draft); + assertIdentifier( + draft.eventType, + 'eventType', + 'invalid_coordination_event', + MAX_EVENT_TYPE_LENGTH + ); + if (draft.resourceRevision) { + assertResourceRevision(draft.resourceRevision); + } + if (!isRfc3339(draft.emittedAt)) { + throw invalidEvent('Coordination event emittedAt must be an RFC3339 timestamp', { + emittedAt: draft.emittedAt, + }); + } + assertCoordinationJsonPayload(draft.payload); +} + +export function assertCoordinationEventEnvelope( + event: CoordinationEventEnvelope, + expectedWatermark?: EventJournalWatermark +): asserts event is CoordinationEventEnvelope { + assertCoordinationEventDraft(event); + assertIdentifier(event.deploymentId, 'deploymentId', 'invalid_coordination_event'); + assertIdentifier(event.eventEpoch, 'eventEpoch', 'invalid_coordination_event'); + if (!Number.isSafeInteger(event.eventSequence) || event.eventSequence <= 0) { + throw invalidEvent('Coordination event sequence must be a positive safe integer', { + eventSequence: event.eventSequence, + }); + } + + const position = decodeReplayCursor(event.eventCursor); + if ( + position.deploymentId !== event.deploymentId || + position.eventEpoch !== event.eventEpoch || + position.eventSequence !== event.eventSequence + ) { + throw new SnapshotEventHandoffError( + 'event_cursor_mismatch', + 'Coordination event cursor does not identify its event sequence', + { + eventId: event.eventId, + cursorPosition: position, + eventDeploymentId: event.deploymentId, + eventEpoch: event.eventEpoch, + eventSequence: event.eventSequence, + } + ); + } + + if (expectedWatermark) { + assertJournalWatermark(expectedWatermark); + if ( + event.deploymentId !== expectedWatermark.deploymentId || + event.eventEpoch !== expectedWatermark.eventEpoch || + event.eventSequence > expectedWatermark.highWatermarkSequence + ) { + throw new SnapshotEventHandoffError( + 'journal_watermark_mismatch', + 'Coordination event is outside the supplied journal watermark', + { + eventId: event.eventId, + eventSequence: event.eventSequence, + highWatermarkSequence: expectedWatermark.highWatermarkSequence, + } + ); + } + } +} + +/** + * Copies a caller-owned draft through data-property descriptors and returns a + * fresh deeply frozen value before a durable adapter can observe it. Nested + * scope, actor, resource revision, and payload values retain no caller-owned + * references or accessors. + */ +export function materializeCoordinationEventDraft< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +>(value: unknown): CoordinationEventDraft { + const record = requireEventDataObject(value, 'draft'); + const scopeRecord = requireEventDataObject(readEventDataProperty(record, 'scope'), 'scope'); + const actorRecord = requireEventDataObject(readEventDataProperty(record, 'actor'), 'actor'); + const resourceRevisionValue = readOptionalEventDataProperty(record, 'resourceRevision'); + const draft = Object.freeze({ + schemaVersion: readEventDataProperty(record, 'schemaVersion'), + eventId: readEventDataProperty(record, 'eventId'), + scope: Object.freeze({ + kind: readEventDataProperty(scopeRecord, 'kind'), + scopeId: readEventDataProperty(scopeRecord, 'scopeId'), + }), + ...copyOptionalEventDataProperty(record, 'workspaceId'), + ...copyOptionalEventDataProperty(record, 'teamId'), + ...copyOptionalEventDataProperty(record, 'runId'), + actor: materializeEventActor(actorRecord), + eventType: readEventDataProperty(record, 'eventType'), + ...(resourceRevisionValue === undefined + ? {} + : { resourceRevision: materializeResourceRevision(resourceRevisionValue) }), + emittedAt: readEventDataProperty(record, 'emittedAt'), + payload: materializeCoordinationJsonPayload(readEventDataProperty(record, 'payload')), + }) as unknown as CoordinationEventDraft; + assertCoordinationEventDraft(draft); + return draft; +} + +/** + * Copies an adapter-owned event through data-property descriptors, validates + * the bounded copy, and returns a fresh deeply frozen envelope. No accessor on + * the source envelope or its contract-owned nested values is invoked. + */ +export function materializeCoordinationEventEnvelope< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +>(value: unknown, expectedWatermark?: EventJournalWatermark): CoordinationEventEnvelope { + const record = requireEventDataObject(value, 'envelope'); + const scopeRecord = requireEventDataObject(readEventDataProperty(record, 'scope'), 'scope'); + const actorRecord = requireEventDataObject(readEventDataProperty(record, 'actor'), 'actor'); + const resourceRevisionValue = readOptionalEventDataProperty(record, 'resourceRevision'); + + const event = Object.freeze({ + schemaVersion: readEventDataProperty(record, 'schemaVersion'), + eventId: readEventDataProperty(record, 'eventId'), + scope: Object.freeze({ + kind: readEventDataProperty(scopeRecord, 'kind'), + scopeId: readEventDataProperty(scopeRecord, 'scopeId'), + }), + ...copyOptionalEventDataProperty(record, 'workspaceId'), + ...copyOptionalEventDataProperty(record, 'teamId'), + ...copyOptionalEventDataProperty(record, 'runId'), + actor: materializeEventActor(actorRecord), + eventType: readEventDataProperty(record, 'eventType'), + ...(resourceRevisionValue === undefined + ? {} + : { resourceRevision: materializeResourceRevision(resourceRevisionValue) }), + emittedAt: readEventDataProperty(record, 'emittedAt'), + payload: materializeCoordinationJsonPayload(readEventDataProperty(record, 'payload')), + deploymentId: readEventDataProperty(record, 'deploymentId'), + eventEpoch: readEventDataProperty(record, 'eventEpoch'), + eventSequence: readEventDataProperty(record, 'eventSequence'), + eventCursor: readEventDataProperty(record, 'eventCursor'), + }) as unknown as CoordinationEventEnvelope; + const immutableWatermark = + expectedWatermark === undefined + ? undefined + : materializeEventJournalWatermark(expectedWatermark); + assertCoordinationEventEnvelope(event, immutableWatermark); + return event; +} + +export function materializeCoordinationEventEnvelopes< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +>( + value: unknown, + expectedWatermark: EventJournalWatermark, + maximumEvents = MAX_DOMAIN_REPLAY_BATCH_SIZE +): readonly CoordinationEventEnvelope[] { + if (!Array.isArray(value) || value.length > maximumEvents) { + throw new SnapshotEventHandoffError( + 'invalid_replay_limit', + 'Replay event collection is invalid or exceeds its bound', + { eventCount: Array.isArray(value) ? value.length : null, maximumEvents } + ); + } + const ownPropertySymbols = Object.getOwnPropertySymbols(value); + const ownPropertyNames = Object.getOwnPropertyNames(value); + if (ownPropertySymbols.length > 0 || ownPropertyNames.length !== value.length + 1) { + throw invalidEvent('Replay event collection must contain only dense event indices'); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const events: CoordinationEventEnvelope[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw invalidEvent('Replay event collection cannot contain sparse indices or accessors'); + } + events.push( + materializeCoordinationEventEnvelope(descriptor.value, expectedWatermark) + ); + } + return Object.freeze(events); +} + +function requireEventDataObject(value: unknown, field: string): object { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidEvent(`Coordination event ${field} must be a data object`); + } + return value; +} + +function readEventDataProperty(record: object, field: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, field); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw invalidEvent(`Coordination event ${field} must be an enumerable data property`); + } + return descriptor.value; +} + +function readOptionalEventDataProperty(record: object, field: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, field); + if (!descriptor) { + return undefined; + } + if (!descriptor.enumerable || !('value' in descriptor)) { + throw invalidEvent(`Coordination event ${field} must be an enumerable data property`); + } + return descriptor.value; +} + +function copyOptionalEventDataProperty( + record: object, + field: string +): Readonly> { + const value = readOptionalEventDataProperty(record, field); + return value === undefined ? {} : { [field]: value }; +} + +function materializeEventActor(record: object): CoordinationEventDraft['actor'] { + const kind = readEventDataProperty(record, 'kind'); + switch (kind) { + case 'operator': + case 'recovery': + return Object.freeze({ + kind, + actorRef: readEventDataProperty(record, 'actorRef'), + }) as CoordinationEventDraft['actor']; + case 'verified_runtime': + return Object.freeze({ + kind, + actorRef: readEventDataProperty(record, 'actorRef'), + runId: readEventDataProperty(record, 'runId'), + ...copyOptionalEventDataProperty(record, 'memberId'), + }) as CoordinationEventDraft['actor']; + case 'external_file': + return Object.freeze({ + kind, + ...copyOptionalEventDataProperty(record, 'actorRef'), + fileWriterEpoch: readEventDataProperty(record, 'fileWriterEpoch'), + observationSequence: readEventDataProperty(record, 'observationSequence'), + }) as CoordinationEventDraft['actor']; + default: + return Object.freeze({ kind }) as CoordinationEventDraft['actor']; + } +} + +function materializeResourceRevision(value: unknown): CoordinationResourceRevision { + const record = requireEventDataObject(value, 'resourceRevision'); + return Object.freeze({ + resourceKey: readEventDataProperty(record, 'resourceKey'), + generation: readEventDataProperty(record, 'generation'), + revision: readEventDataProperty(record, 'revision'), + }) as unknown as CoordinationResourceRevision; +} + +function assertScope(draft: CoordinationEventDraft): void { + if (!draft.scope || !COORDINATION_EVENT_SCOPE_KINDS.includes(draft.scope.kind)) { + throw invalidEvent('Coordination event scope kind is invalid', { + scopeKind: draft.scope?.kind, + }); + } + assertIdentifier(draft.scope.scopeId, 'scopeId', 'invalid_coordination_event'); +} + +function assertScopeReferences(draft: CoordinationEventDraft): void { + const requiredReference = + draft.scope.kind === 'workspace' + ? draft.workspaceId + : draft.scope.kind === 'team' + ? draft.teamId + : draft.scope.kind === 'run' + ? draft.runId + : draft.scope.scopeId; + if ( + (draft.scope.kind === 'workspace' || + draft.scope.kind === 'team' || + draft.scope.kind === 'run') && + requiredReference !== draft.scope.scopeId + ) { + throw invalidEvent('Coordination event scope identity does not match its resource reference', { + scopeKind: draft.scope.kind, + scopeId: draft.scope.scopeId, + resourceReference: requiredReference, + }); + } +} + +function assertActor(draft: CoordinationEventDraft): void { + if (!draft.actor || !COORDINATION_EVENT_ACTOR_KINDS.includes(draft.actor.kind)) { + throw invalidEvent('Coordination event actor kind is invalid', { + actorKind: draft.actor?.kind, + }); + } + if (draft.actor.kind === 'external_file') { + assertOptionalIdentity(draft.actor.actorRef, 'actorRef'); + if ( + !Number.isSafeInteger(draft.actor.fileWriterEpoch) || + draft.actor.fileWriterEpoch < 0 || + !Number.isSafeInteger(draft.actor.observationSequence) || + draft.actor.observationSequence < 0 + ) { + throw invalidEvent( + 'External-file event attribution requires non-negative writer and observation sequences', + { + fileWriterEpoch: draft.actor.fileWriterEpoch, + observationSequence: draft.actor.observationSequence, + } + ); + } + if (draft.runId !== undefined) { + throw invalidEvent('External-file events cannot claim verified run attribution'); + } + return; + } + + assertIdentifier(draft.actor.actorRef, 'actorRef', 'invalid_coordination_event'); + if (draft.actor.kind === 'verified_runtime') { + assertIdentifier(draft.actor.runId, 'actor.runId', 'invalid_coordination_event'); + assertOptionalIdentity(draft.actor.memberId, 'actor.memberId'); + if (draft.runId !== draft.actor.runId) { + throw invalidEvent('Verified-runtime event run attribution does not match its runId', { + eventRunId: draft.runId, + actorRunId: draft.actor.runId, + }); + } + } +} + +export function assertRevisionVector(vector: readonly CoordinationResourceRevision[]): void { + if (!Array.isArray(vector) || vector.length > MAX_REVISION_VECTOR_LENGTH) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Snapshot revision vector is invalid or exceeds its bound', + { revisionCount: Array.isArray(vector) ? vector.length : null } + ); + } + const keys = new Set(); + for (const revision of vector) { + assertResourceRevision(revision, 'invalid_snapshot_metadata'); + if (keys.has(revision.resourceKey)) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Snapshot revision vector contains a duplicate resource key', + { resourceKey: revision.resourceKey } + ); + } + keys.add(revision.resourceKey); + } +} + +export function assertJournalIdentity( + metadata: CoordinationSnapshotMetadata, + watermark: EventJournalWatermark +): void { + if ( + metadata.deploymentId !== watermark.deploymentId || + metadata.eventEpoch !== watermark.eventEpoch + ) { + throw new SnapshotEventHandoffError( + 'journal_watermark_mismatch', + 'Reconciliation watermark does not match the snapshot journal identity', + { + snapshotDeploymentId: metadata.deploymentId, + watermarkDeploymentId: watermark.deploymentId, + snapshotEventEpoch: metadata.eventEpoch, + watermarkEventEpoch: watermark.eventEpoch, + } + ); + } +} + +export function assertReconciliationState( + state: CoordinationReplayReconciliationState, + metadata: CoordinationSnapshotMetadata, + revisionVector: readonly CoordinationResourceRevision[] +): void { + if ( + state?.snapshotCursor !== metadata.replayCursor || + state.deploymentId !== metadata.deploymentId || + state.eventEpoch !== metadata.eventEpoch + ) { + throw new SnapshotEventHandoffError( + 'journal_watermark_mismatch', + 'Replay reconciliation state does not belong to this snapshot' + ); + } + assertJournalWatermark(state.watermark); + const snapshotSequence = decodeReplayCursor(metadata.replayCursor).eventSequence; + if ( + !Number.isSafeInteger(state.processedThroughSequence) || + state.processedThroughSequence < snapshotSequence || + state.processedThroughSequence > state.watermark.highWatermarkSequence || + !Number.isSafeInteger(state.nextEventSequence) || + state.nextEventSequence !== state.processedThroughSequence + 1 + ) { + throw new SnapshotEventHandoffError( + 'event_sequence_discontinuity', + 'Replay reconciliation continuation floor is invalid', + { + snapshotSequence, + processedThroughSequence: state.processedThroughSequence, + nextEventSequence: state.nextEventSequence, + stateHighWatermarkSequence: state.watermark.highWatermarkSequence, + } + ); + } + assertRevisionVector(revisionVector); + if ( + !Array.isArray(state.processedEventIds) || + state.processedEventIds.length > MAX_RECONCILIATION_PROCESSED_EVENT_IDS + ) { + throw new SnapshotEventHandoffError( + 'duplicate_event', + 'Replay reconciliation event identity state is invalid or exceeds its window', + { maximumEventIds: MAX_RECONCILIATION_PROCESSED_EVENT_IDS } + ); + } + const eventIds = new Set(); + for (const eventId of state.processedEventIds) { + assertIdentifier(eventId, 'eventId', 'invalid_coordination_event'); + if (eventIds.has(eventId)) { + throw new SnapshotEventHandoffError( + 'duplicate_event', + 'Replay reconciliation state contains a duplicate eventId', + { eventId } + ); + } + eventIds.add(eventId); + } +} + +export function rememberProcessedEventId(seenEventIds: Set, eventId: string): void { + seenEventIds.add(eventId); + if (seenEventIds.size <= MAX_RECONCILIATION_PROCESSED_EVENT_IDS) { + return; + } + const oldestEventId = seenEventIds.values().next().value; + if (oldestEventId !== undefined) { + seenEventIds.delete(oldestEventId); + } +} + +function assertResourceRevision( + revision: CoordinationResourceRevision, + code: 'invalid_snapshot_metadata' | 'invalid_coordination_event' = 'invalid_coordination_event' +): void { + if (!revision) { + throw new SnapshotEventHandoffError(code, 'Coordination resource revision is required'); + } + assertIdentifier(revision.resourceKey, 'resourceKey', code); + if ( + !Number.isSafeInteger(revision.generation) || + revision.generation < 0 || + !Number.isSafeInteger(revision.revision) || + revision.revision < 0 + ) { + throw new SnapshotEventHandoffError( + code, + 'Coordination resource generation and revision must be non-negative safe integers', + { + resourceKey: revision.resourceKey, + generation: revision.generation, + revision: revision.revision, + } + ); + } +} + +export function assertReplayLimit(maxEvents: number): void { + if ( + !Number.isSafeInteger(maxEvents) || + maxEvents <= 0 || + maxEvents > MAX_DOMAIN_REPLAY_BATCH_SIZE + ) { + throw new SnapshotEventHandoffError( + 'invalid_replay_limit', + 'Replay batch limit must be a bounded positive safe integer', + { maxEvents, maximum: MAX_DOMAIN_REPLAY_BATCH_SIZE } + ); + } +} + +export function assertIdentifier( + value: string, + field: string, + code: 'invalid_snapshot_metadata' | 'invalid_coordination_event' | 'invalid_recovery_point', + maximumLength = MAX_IDENTIFIER_LENGTH +): void { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumLength || + value.trim() !== value + ) { + throw new SnapshotEventHandoffError(code, `Coordination event ${field} is invalid`, { field }); + } +} + +function assertOptionalIdentity(value: string | undefined, field: string): void { + if (value !== undefined) { + assertIdentifier(value, field, 'invalid_coordination_event'); + } +} + +function invalidEvent( + message: string, + details: Readonly> = {} +): SnapshotEventHandoffError { + return new SnapshotEventHandoffError('invalid_coordination_event', message, details); +} + +function isRfc3339(value: string): boolean { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +export function materializeRevisionVector( + vector: readonly CoordinationResourceRevision[] +): readonly CoordinationResourceRevision[] { + if (!Array.isArray(vector)) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Snapshot revision vector is invalid or exceeds its bound', + { revisionCount: null } + ); + } + const revisionCount = vector.length; + if ( + !Number.isSafeInteger(revisionCount) || + revisionCount < 0 || + revisionCount > MAX_REVISION_VECTOR_LENGTH + ) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Snapshot revision vector is invalid or exceeds its bound', + { revisionCount } + ); + } + + const materialized: CoordinationResourceRevision[] = []; + for (let index = 0; index < revisionCount; index += 1) { + const revision = vector[index]; + if (!revision || typeof revision !== 'object') { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Coordination resource revision is required', + { revisionIndex: index } + ); + } + const resourceKey = revision.resourceKey; + const generation = revision.generation; + const revisionNumber = revision.revision; + materialized.push(Object.freeze({ resourceKey, generation, revision: revisionNumber })); + } + return Object.freeze(materialized); +} diff --git a/src/features/coordination-events/core/domain/snapshotEventLimits.ts b/src/features/coordination-events/core/domain/snapshotEventLimits.ts new file mode 100644 index 0000000000..2113cc094e --- /dev/null +++ b/src/features/coordination-events/core/domain/snapshotEventLimits.ts @@ -0,0 +1,38 @@ +export const MAX_IDENTIFIER_LENGTH = 256; +export const MAX_EVENT_TYPE_LENGTH = 256; +export const MAX_REVISION_VECTOR_LENGTH = 10_000; +export const MAX_DOMAIN_REPLAY_BATCH_SIZE = 10_000; +export const MAX_COORDINATION_SNAPSHOT_DEPTH = 128; +export const MAX_COORDINATION_SNAPSHOT_NODES = 100_000; +export const MAX_RECONCILIATION_PROCESSED_EVENT_IDS = 10_000; +export const MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES = 256 * 1_024; +export const MAX_COORDINATION_EVENT_PAYLOAD_DEPTH = 64; +export const MAX_COORDINATION_EVENT_PAYLOAD_NODES = 10_000; + +export type SnapshotEventHandoffErrorCode = + | 'unsupported_snapshot_version' + | 'unsupported_event_version' + | 'unsupported_recovery_point_version' + | 'invalid_snapshot_metadata' + | 'invalid_snapshot_data' + | 'invalid_coordination_event' + | 'invalid_replay_limit' + | 'event_sequence_discontinuity' + | 'resource_revision_discontinuity' + | 'resource_revision_regression' + | 'duplicate_event' + | 'event_cursor_mismatch' + | 'journal_watermark_mismatch' + | 'journal_watermark_regression' + | 'invalid_recovery_point'; + +export class SnapshotEventHandoffError extends Error { + constructor( + readonly code: SnapshotEventHandoffErrorCode, + message: string, + readonly details: Readonly> = {} + ) { + super(message); + this.name = 'SnapshotEventHandoffError'; + } +} diff --git a/src/features/coordination-events/core/domain/snapshotReplay.ts b/src/features/coordination-events/core/domain/snapshotReplay.ts new file mode 100644 index 0000000000..3be8c318bc --- /dev/null +++ b/src/features/coordination-events/core/domain/snapshotReplay.ts @@ -0,0 +1,477 @@ +import { + COORDINATION_EVENT_RECOVERY_POINT_SCHEMA_VERSION, + COORDINATION_EVENT_SCHEMA_VERSION, + type CoordinationEventDraft, + type CoordinationEventEnvelope, + type CoordinationEventRecoveryPoint, + type CoordinationJsonValue, + type CoordinationReplayBatch, + type CoordinationResourceRevision, + type CoordinationSnapshotMetadata, + type EventJournalWatermark, +} from '../../contracts'; + +import { sameStructuredValue } from './coordinationJson'; +import { + assertJournalWatermark, + decodeReplayCursor, + encodeReplayCursor, + materializeEventJournalWatermark, + validateReplayCursor, +} from './replayCursor'; +import { + assertCoordinationEventDraft, + assertCoordinationEventEnvelope, + assertIdentifier, + assertJournalIdentity, + assertMaterializedCoordinationSnapshotMetadata, + assertReconciliationState, + assertReplayLimit, + assertRevisionVector, + materializeCoordinationEventEnvelopes, + materializeCoordinationSnapshotMetadata, + materializeRevisionVector, + rememberProcessedEventId, +} from './snapshotEventIdentity'; +import { MAX_REVISION_VECTOR_LENGTH, SnapshotEventHandoffError } from './snapshotEventLimits'; + +export interface CreateReplayBatchInput< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly fromCursor: string; + readonly events: readonly CoordinationEventEnvelope[]; + readonly watermark: EventJournalWatermark; + readonly maxEvents: number; + /** Allows a caller to freeze one bounded replay target while newer rows commit. */ + readonly throughSequence?: number; +} + +export function createCoordinationReplayBatch< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +>(input: CreateReplayBatchInput): CoordinationReplayBatch { + assertReplayLimit(input.maxEvents); + const watermark = materializeEventJournalWatermark(input.watermark); + const events = materializeCoordinationEventEnvelopes( + input.events, + watermark, + input.maxEvents + ); + const from = validateReplayCursor(input.fromCursor, watermark); + const throughSequence = input.throughSequence ?? watermark.highWatermarkSequence; + if ( + !Number.isSafeInteger(throughSequence) || + throughSequence < from.eventSequence || + throughSequence > watermark.highWatermarkSequence + ) { + throw new SnapshotEventHandoffError( + 'journal_watermark_mismatch', + 'Replay target is outside the journal watermark', + { + fromSequence: from.eventSequence, + throughSequence, + highWatermarkSequence: watermark.highWatermarkSequence, + } + ); + } + + const expectedCount = Math.min(input.maxEvents, throughSequence - from.eventSequence); + if (events.length !== expectedCount) { + throw new SnapshotEventHandoffError( + 'event_sequence_discontinuity', + 'Replay journal did not return the complete requested sequence range', + { + fromSequence: from.eventSequence, + throughSequence, + maxEvents: input.maxEvents, + expectedCount, + actualCount: events.length, + } + ); + } + + const seenEventIds = new Set(); + let expectedSequence = from.eventSequence + 1; + for (const event of events) { + if (seenEventIds.has(event.eventId)) { + throw new SnapshotEventHandoffError( + 'duplicate_event', + 'Replay journal returned a duplicate eventId', + { eventId: event.eventId } + ); + } + seenEventIds.add(event.eventId); + + if ( + event.deploymentId !== from.deploymentId || + event.eventEpoch !== from.eventEpoch || + event.eventSequence !== expectedSequence + ) { + throw new SnapshotEventHandoffError( + 'event_sequence_discontinuity', + 'Replay journal event sequence is not contiguous', + { + eventId: event.eventId, + expectedSequence, + actualSequence: event.eventSequence, + expectedDeploymentId: from.deploymentId, + actualDeploymentId: event.deploymentId, + expectedEventEpoch: from.eventEpoch, + actualEventEpoch: event.eventEpoch, + } + ); + } + expectedSequence += 1; + } + + const nextSequence = expectedSequence - 1; + const nextCursor = encodeReplayCursor({ + deploymentId: from.deploymentId, + eventEpoch: from.eventEpoch, + eventSequence: nextSequence, + }); + return Object.freeze({ + schemaVersion: COORDINATION_EVENT_SCHEMA_VERSION, + deploymentId: from.deploymentId, + eventEpoch: from.eventEpoch, + fromCursor: input.fromCursor as CoordinationReplayBatch['fromCursor'], + nextCursor, + events, + watermark, + hasMore: nextSequence < watermark.highWatermarkSequence, + }); +} + +export interface ReconcileCoordinationReplayResult< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly applicableEvents: readonly CoordinationEventEnvelope[]; + readonly duplicateEventIds: readonly string[]; + readonly revisionVector: readonly CoordinationResourceRevision[]; + readonly state: CoordinationReplayReconciliationState; +} + +/** + * Serializable continuation state makes dedupe, revision continuity, and + * watermark monotonicity explicit across bounded reconciliation calls. + */ +export interface CoordinationReplayReconciliationState { + readonly snapshotCursor: string; + readonly deploymentId: string; + readonly eventEpoch: string; + /** Highest journal sequence already reconciled, including duplicates. */ + readonly processedThroughSequence: number; + readonly nextEventSequence: number; + readonly revisionVector: readonly CoordinationResourceRevision[]; + /** Oldest-to-newest first-seen IDs in the bounded deterministic dedupe window. */ + readonly processedEventIds: readonly string[]; + readonly watermark: EventJournalWatermark; +} + +/** + * Reconciles the deliberate snapshot/replay overlap by resource generation and + * revision. Same/older revisions are duplicates; a newer non-contiguous + * revision fails closed so the caller can replace the projection with a fresh + * snapshot instead of applying a partial aggregate history. + */ +export function reconcileCoordinationSnapshotReplay< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +>(input: { + readonly metadata: CoordinationSnapshotMetadata; + readonly events: readonly CoordinationEventEnvelope[]; + readonly watermark: EventJournalWatermark; + readonly previousState?: CoordinationReplayReconciliationState; +}): ReconcileCoordinationReplayResult { + const metadata = materializeCoordinationSnapshotMetadata(input.metadata); + assertMaterializedCoordinationSnapshotMetadata(metadata); + const previousState = input.previousState; + const previousStateRevisionVector = previousState + ? materializeRevisionVector(previousState.revisionVector) + : undefined; + const snapshotPosition = decodeReplayCursor(metadata.replayCursor); + const watermark = materializeEventJournalWatermark(input.watermark); + const events = materializeCoordinationEventEnvelopes(input.events, watermark); + assertJournalIdentity(metadata, watermark); + + let expectedSequence = snapshotPosition.eventSequence + 1; + let processedThroughSequence = snapshotPosition.eventSequence; + let revisionVector = metadata.revisionVector; + let processedEventIds: readonly string[] = []; + if (previousState && previousStateRevisionVector) { + assertReconciliationState(previousState, metadata, previousStateRevisionVector); + assertJournalWatermarkProgression(previousState.watermark, watermark); + processedThroughSequence = previousState.processedThroughSequence; + expectedSequence = previousState.nextEventSequence; + revisionVector = previousStateRevisionVector; + processedEventIds = previousState.processedEventIds; + } + validateReplayCursor( + encodeReplayCursor({ + deploymentId: metadata.deploymentId, + eventEpoch: metadata.eventEpoch, + eventSequence: expectedSequence - 1, + }), + watermark + ); + + const revisions = new Map( + revisionVector.map((revision) => [revision.resourceKey, Object.freeze({ ...revision })]) + ); + const seenEventIds = new Set(processedEventIds); + const applicableEvents: CoordinationEventEnvelope[] = []; + const duplicateEventIds: string[] = []; + + for (const event of events) { + if (event.deploymentId !== metadata.deploymentId || event.eventEpoch !== metadata.eventEpoch) { + throw new SnapshotEventHandoffError( + 'event_sequence_discontinuity', + 'Snapshot replay event belongs to a different journal identity', + { + eventId: event.eventId, + expectedDeploymentId: metadata.deploymentId, + actualDeploymentId: event.deploymentId, + expectedEventEpoch: metadata.eventEpoch, + actualEventEpoch: event.eventEpoch, + } + ); + } + + const alreadyProcessed = seenEventIds.has(event.eventId); + if (event.eventSequence <= processedThroughSequence) { + duplicateEventIds.push(event.eventId); + continue; + } + if (event.eventSequence !== expectedSequence) { + throw new SnapshotEventHandoffError( + 'event_sequence_discontinuity', + 'Snapshot replay does not begin contiguously after its lower barrier', + { + eventId: event.eventId, + expectedDeploymentId: metadata.deploymentId, + actualDeploymentId: event.deploymentId, + expectedEventEpoch: metadata.eventEpoch, + actualEventEpoch: event.eventEpoch, + expectedSequence, + eventSequence: event.eventSequence, + } + ); + } + processedThroughSequence = event.eventSequence; + expectedSequence += 1; + if (alreadyProcessed) { + duplicateEventIds.push(event.eventId); + continue; + } + rememberProcessedEventId(seenEventIds, event.eventId); + + const nextRevision = event.resourceRevision; + if (!nextRevision) { + applicableEvents.push(event); + continue; + } + + const currentRevision = revisions.get(nextRevision.resourceKey); + const isOlderRevision = + currentRevision && + (nextRevision.generation < currentRevision.generation || + (nextRevision.generation === currentRevision.generation && + nextRevision.revision < currentRevision.revision)); + if (isOlderRevision) { + if (metadata.handoffMode === 'lower_barrier') { + duplicateEventIds.push(event.eventId); + continue; + } + throw new SnapshotEventHandoffError( + 'resource_revision_regression', + 'Snapshot replay resource revision regressed', + { + eventId: event.eventId, + resourceKey: nextRevision.resourceKey, + currentGeneration: currentRevision.generation, + currentRevision: currentRevision.revision, + eventGeneration: nextRevision.generation, + eventRevision: nextRevision.revision, + } + ); + } + if ( + nextRevision.generation === currentRevision?.generation && + nextRevision.revision === currentRevision.revision + ) { + duplicateEventIds.push(event.eventId); + continue; + } + if ( + currentRevision && + ((nextRevision.generation === currentRevision.generation && + nextRevision.revision !== currentRevision.revision + 1) || + nextRevision.generation > currentRevision.generation + 1) + ) { + throw new SnapshotEventHandoffError( + 'resource_revision_discontinuity', + 'Snapshot replay resource revision is not contiguous', + { + eventId: event.eventId, + resourceKey: nextRevision.resourceKey, + currentGeneration: currentRevision.generation, + currentRevision: currentRevision.revision, + eventGeneration: nextRevision.generation, + eventRevision: nextRevision.revision, + } + ); + } + + if (!currentRevision && revisions.size >= MAX_REVISION_VECTOR_LENGTH) { + throw new SnapshotEventHandoffError( + 'invalid_snapshot_metadata', + 'Snapshot replay revision vector would exceed its bound', + { + eventId: event.eventId, + resourceKey: nextRevision.resourceKey, + revisionCount: revisions.size + 1, + maximumRevisionCount: MAX_REVISION_VECTOR_LENGTH, + } + ); + } + revisions.set(nextRevision.resourceKey, Object.freeze({ ...nextRevision })); + applicableEvents.push(event); + } + + const nextRevisionVector = Object.freeze([...revisions.values()]); + assertRevisionVector(nextRevisionVector); + const state = Object.freeze({ + snapshotCursor: metadata.replayCursor, + deploymentId: metadata.deploymentId, + eventEpoch: metadata.eventEpoch, + processedThroughSequence, + nextEventSequence: expectedSequence, + revisionVector: nextRevisionVector, + processedEventIds: Object.freeze([...seenEventIds]), + watermark, + }); + return Object.freeze({ + applicableEvents: Object.freeze(applicableEvents), + duplicateEventIds: Object.freeze(duplicateEventIds), + revisionVector: nextRevisionVector, + state, + }); +} + +export function assertJournalWatermarkProgression( + previous: EventJournalWatermark, + current: EventJournalWatermark +): void { + assertJournalWatermark(previous); + assertJournalWatermark(current); + if ( + previous.deploymentId !== current.deploymentId || + previous.eventEpoch !== current.eventEpoch + ) { + throw new SnapshotEventHandoffError( + 'journal_watermark_mismatch', + 'Event journal identity changed across observations', + { + previousDeploymentId: previous.deploymentId, + currentDeploymentId: current.deploymentId, + previousEventEpoch: previous.eventEpoch, + currentEventEpoch: current.eventEpoch, + } + ); + } + if ( + current.retentionFloorSequence < previous.retentionFloorSequence || + current.highWatermarkSequence < previous.highWatermarkSequence + ) { + throw new SnapshotEventHandoffError( + 'journal_watermark_regression', + 'Event journal watermark regressed across observations', + { + previousRetentionFloorSequence: previous.retentionFloorSequence, + currentRetentionFloorSequence: current.retentionFloorSequence, + previousHighWatermarkSequence: previous.highWatermarkSequence, + currentHighWatermarkSequence: current.highWatermarkSequence, + } + ); + } +} + +export function assertCommittedEventMatchesDraft( + event: CoordinationEventEnvelope, + draft: CoordinationEventDraft +): void { + assertCoordinationEventEnvelope(event); + assertCoordinationEventDraft(draft); + const fields = [ + 'schemaVersion', + 'eventId', + 'scope', + 'workspaceId', + 'teamId', + 'runId', + 'actor', + 'eventType', + 'resourceRevision', + 'emittedAt', + 'payload', + ] as const; + for (const field of fields) { + if (!sameStructuredValue(event[field], draft[field])) { + throw new SnapshotEventHandoffError( + 'invalid_coordination_event', + 'Committed coordination event does not match the supplied draft', + { eventId: draft.eventId, field } + ); + } + } +} + +export function createCoordinationEventRecoveryPoint(input: { + readonly participantId: string; + readonly watermark: EventJournalWatermark; +}): CoordinationEventRecoveryPoint { + assertIdentifier(input.participantId, 'participantId', 'invalid_recovery_point'); + assertJournalWatermark(input.watermark); + return Object.freeze({ + schemaVersion: COORDINATION_EVENT_RECOVERY_POINT_SCHEMA_VERSION, + participantId: input.participantId, + deploymentId: input.watermark.deploymentId, + eventEpoch: input.watermark.eventEpoch, + retentionFloorSequence: input.watermark.retentionFloorSequence, + highWatermarkSequence: input.watermark.highWatermarkSequence, + replayCursor: encodeReplayCursor({ + deploymentId: input.watermark.deploymentId, + eventEpoch: input.watermark.eventEpoch, + eventSequence: input.watermark.highWatermarkSequence, + }), + }); +} + +export function assertCoordinationEventRecoveryPoint( + recoveryPoint: CoordinationEventRecoveryPoint +): void { + if (recoveryPoint?.schemaVersion !== COORDINATION_EVENT_RECOVERY_POINT_SCHEMA_VERSION) { + throw new SnapshotEventHandoffError( + 'unsupported_recovery_point_version', + 'Coordination event recovery-point version is not supported', + { schemaVersion: recoveryPoint?.schemaVersion } + ); + } + assertIdentifier(recoveryPoint.participantId, 'participantId', 'invalid_recovery_point'); + const watermark: EventJournalWatermark = { + schemaVersion: 1, + deploymentId: recoveryPoint.deploymentId, + eventEpoch: recoveryPoint.eventEpoch, + retentionFloorSequence: recoveryPoint.retentionFloorSequence, + highWatermarkSequence: recoveryPoint.highWatermarkSequence, + }; + const position = validateReplayCursor(recoveryPoint.replayCursor, watermark); + if (position.eventSequence !== recoveryPoint.highWatermarkSequence) { + throw new SnapshotEventHandoffError( + 'invalid_recovery_point', + 'Coordination event recovery-point cursor does not match its durable barrier', + { + cursorSequence: position.eventSequence, + highWatermarkSequence: recoveryPoint.highWatermarkSequence, + } + ); + } +} diff --git a/src/features/coordination-events/index.ts b/src/features/coordination-events/index.ts new file mode 100644 index 0000000000..f3b52437e9 --- /dev/null +++ b/src/features/coordination-events/index.ts @@ -0,0 +1,37 @@ +export * from './contracts'; +export * from './core/application'; +export { bindTrustedEventAttribution } from './core/application/coordinationEventHandoffSupport'; +export type { + CoordinationReplayReconciliationState, + CreateReplayBatchInput, + CreateSnapshotMetadataInput, + ReconcileCoordinationReplayResult, + ReplayCursorErrorCode, + SnapshotEventHandoffErrorCode, +} from './core/domain'; +export { + assertCommittedEventMatchesDraft, + assertCoordinationEventDraft, + assertCoordinationEventEnvelope, + assertCoordinationEventRecoveryPoint, + assertCoordinationSnapshotMetadata, + assertJournalWatermark, + assertJournalWatermarkProgression, + createCoordinationEventRecoveryPoint, + createCoordinationReplayBatch, + createCoordinationSnapshotMetadata, + createReplayCursor, + decodeReplayCursor, + encodeReplayCursor, + isReplayCursor, + materializeCoordinationEventEnvelope, + materializeEventJournalWatermark, + MAX_COORDINATION_EVENT_PAYLOAD_DEPTH, + MAX_COORDINATION_EVENT_PAYLOAD_NODES, + MAX_COORDINATION_EVENT_PAYLOAD_UTF8_BYTES, + MAX_RECONCILIATION_PROCESSED_EVENT_IDS, + reconcileCoordinationSnapshotReplay, + ReplayCursorError, + SnapshotEventHandoffError, + validateReplayCursor, +} from './core/domain'; diff --git a/src/features/coordination-events/main/adapters/input/http/HostedCoordinationEventBootstrapController.ts b/src/features/coordination-events/main/adapters/input/http/HostedCoordinationEventBootstrapController.ts new file mode 100644 index 0000000000..131fb9163f --- /dev/null +++ b/src/features/coordination-events/main/adapters/input/http/HostedCoordinationEventBootstrapController.ts @@ -0,0 +1,268 @@ +import { + HOSTED_COORDINATION_EVENT_BOOTSTRAP_ROUTE, + HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION, + type HostedCoordinationEventBootstrapSnapshot, +} from '../../../../contracts'; + +import type { CoordinationEventHandoff } from '../../../../core/application'; +import type { + HostedCoordinationEventBootstrapAuthorizer, + HostedCoordinationEventBootstrapFence, +} from '../../../application/HostedCoordinationEventStreamPorts'; +import type { TeamId } from '@shared/contracts/hosted'; + +interface HostedCoordinationBootstrapSocket { + readonly destroyed: boolean; + once(event: 'close', listener: () => void): unknown; + removeListener(event: 'close', listener: () => void): unknown; +} + +interface HostedCoordinationBootstrapRawRequest { + readonly aborted: boolean; + readonly socket: HostedCoordinationBootstrapSocket; + once(event: 'aborted', listener: () => void): unknown; + removeListener(event: 'aborted', listener: () => void): unknown; +} + +interface HostedCoordinationBootstrapRawReply { + readonly destroyed: boolean; + once(event: 'close', listener: () => void): unknown; + removeListener(event: 'close', listener: () => void): unknown; +} + +interface HostedCoordinationBootstrapRequest { + readonly body: unknown; + readonly raw: HostedCoordinationBootstrapRawRequest; +} + +interface HostedCoordinationBootstrapReply { + readonly raw: HostedCoordinationBootstrapRawReply; + code(statusCode: number): HostedCoordinationBootstrapReply; + header(name: string, value: string): HostedCoordinationBootstrapReply; + send(payload: unknown): unknown; +} + +interface HostedCoordinationBootstrapApplication { + post( + route: string, + handler: ( + request: HostedCoordinationBootstrapRequest, + reply: HostedCoordinationBootstrapReply + ) => Promise + ): void; +} + +interface HostedCoordinationEventBootstrapControllerOptions { + readonly handoff: CoordinationEventHandoff; + readonly authorizer: HostedCoordinationEventBootstrapAuthorizer; +} + +class BootstrapAuthorizationError extends Error {} +class BootstrapOperationAbortedError extends Error {} + +function record(value: unknown): Readonly> | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null + ? (value as Readonly>) + : null; +} + +function canonicalTeamId(value: unknown): value is TeamId { + return typeof value === 'string' && /^team_[0-9a-f]{32}$/u.test(value); +} + +function parseRequest(value: unknown): TeamId | null { + const input = record(value); + if (input === null) return null; + const keys = Object.keys(input); + return keys.length === 2 && + keys.includes('schemaVersion') && + keys.includes('teamId') && + input.schemaVersion === HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION && + canonicalTeamId(input.teamId) + ? input.teamId + : null; +} + +function validSourceGeneration(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value + ); +} + +function waitForCurrent( + fence: HostedCoordinationEventBootstrapFence, + signals: readonly AbortSignal[] +): Promise { + if (signals.some((signal) => signal.aborted)) return Promise.resolve(false); + return new Promise((resolve) => { + let settled = false; + const finish = (current: boolean): void => { + if (settled) return; + settled = true; + for (const signal of signals) signal.removeEventListener('abort', onAbort); + resolve(current); + }; + const onAbort = (): void => finish(false); + for (const signal of signals) signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve() + .then(() => fence.isCurrent()) + .then( + (current) => finish(current === true), + () => finish(false) + ); + if (signals.some((signal) => signal.aborted)) onAbort(); + }); +} + +function waitForOperationUnlessAborted( + operation: () => Promise, + signal: AbortSignal +): Promise { + if (signal.aborted) return Promise.reject(new BootstrapOperationAbortedError()); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (next: () => void): void => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + next(); + }; + const onAbort = (): void => finish(() => reject(new BootstrapOperationAbortedError())); + signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve() + .then(operation) + .then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)) + ); + if (signal.aborted) onAbort(); + }); +} + +function sendError( + reply: HostedCoordinationBootstrapReply, + statusCode: 400 | 403 | 503, + error: string +): unknown { + return reply.code(statusCode).send(Object.freeze({ error })); +} + +export class HostedCoordinationEventBootstrapController { + private readonly options: HostedCoordinationEventBootstrapControllerOptions; + private readonly activeOperations = new Set(); + private closed = false; + + constructor(options: HostedCoordinationEventBootstrapControllerOptions) { + if (!options?.handoff || !options.authorizer) { + throw new Error('invalid_hosted_coordination_event_bootstrap_options'); + } + this.options = options; + } + + register(app: unknown): void { + const httpApp = app as HostedCoordinationBootstrapApplication; + httpApp.post(HOSTED_COORDINATION_EVENT_BOOTSTRAP_ROUTE, (request, reply) => + this.handle(request, reply) + ); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const operation of [...this.activeOperations]) operation.abort(); + this.activeOperations.clear(); + } + + private async handle( + request: HostedCoordinationBootstrapRequest, + reply: HostedCoordinationBootstrapReply + ): Promise { + reply.header('Cache-Control', 'no-store'); + if (this.closed) { + return sendError(reply, 503, 'coordination_event_bootstrap_unavailable'); + } + const teamId = parseRequest(request.body); + if (teamId === null) { + return sendError(reply, 400, 'coordination_event_bootstrap_request_invalid'); + } + + const ownerController = new AbortController(); + const abort = (): void => ownerController.abort(); + request.raw.once('aborted', abort); + request.raw.socket.once('close', abort); + reply.raw.once('close', abort); + if (request.raw.aborted || request.raw.socket.destroyed || reply.raw.destroyed) { + abort(); + } + this.activeOperations.add(ownerController); + if (ownerController.signal.aborted) { + this.activeOperations.delete(ownerController); + request.raw.removeListener('aborted', abort); + request.raw.socket.removeListener('close', abort); + reply.raw.removeListener('close', abort); + return undefined; + } + + let fence: HostedCoordinationEventBootstrapFence | null; + try { + fence = await waitForOperationUnlessAborted( + () => this.options.authorizer.captureTeamBootstrapFence(request, teamId), + ownerController.signal + ); + if (fence === null || !validSourceGeneration(fence.sourceGeneration)) { + throw new BootstrapAuthorizationError(); + } + const capturedFence = fence; + const snapshot = await waitForOperationUnlessAborted( + () => + this.options.handoff.captureExternalSnapshot({ + request: { scopeKind: 'team', scopeId: teamId }, + source: { + readStableSnapshot: async (_scope, context) => { + if (ownerController.signal.aborted || context.signal.aborted) { + throw new BootstrapOperationAbortedError(); + } + if ( + !(await waitForCurrent(capturedFence, [ownerController.signal, context.signal])) + ) { + throw new BootstrapAuthorizationError(); + } + const bootstrap: HostedCoordinationEventBootstrapSnapshot = Object.freeze({ + schemaVersion: HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION, + kind: 'team_event_bootstrap', + teamId, + }); + return Object.freeze({ + snapshot: bootstrap, + revisionVector: Object.freeze([]), + sourceGenerationBefore: capturedFence.sourceGeneration, + sourceGenerationAfter: capturedFence.sourceGeneration, + }); + }, + }, + }), + ownerController.signal + ); + if ( + ownerController.signal.aborted || + !(await waitForCurrent(capturedFence, [ownerController.signal])) + ) { + throw new BootstrapAuthorizationError(); + } + return reply.code(200).send(snapshot); + } catch (error) { + if (ownerController.signal.aborted) return undefined; + return error instanceof BootstrapAuthorizationError + ? sendError(reply, 403, 'coordination_event_bootstrap_forbidden') + : sendError(reply, 503, 'coordination_event_bootstrap_unavailable'); + } finally { + this.activeOperations.delete(ownerController); + request.raw.removeListener('aborted', abort); + request.raw.socket.removeListener('close', abort); + reply.raw.removeListener('close', abort); + ownerController.abort(); + } + } +} diff --git a/src/features/coordination-events/main/adapters/input/http/HostedCoordinationEventStreamController.ts b/src/features/coordination-events/main/adapters/input/http/HostedCoordinationEventStreamController.ts new file mode 100644 index 0000000000..d760d950de --- /dev/null +++ b/src/features/coordination-events/main/adapters/input/http/HostedCoordinationEventStreamController.ts @@ -0,0 +1,800 @@ +import { + type CoordinationEventEnvelope, + type CoordinationReplayBatch, + HOSTED_COORDINATION_EVENT_SSE_EVENT, + HOSTED_COORDINATION_EVENT_STREAM_ROUTE, + HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION, + HOSTED_COORDINATION_RESYNC_SSE_EVENT, + type HostedCoordinationEventEnvelope, + type HostedCoordinationEventProjection, + type HostedCoordinationResyncReason, + type HostedCoordinationResyncRequired, + type ReplayCursor, +} from '../../../../contracts'; + +import { + hostedCoordinationEventStreamAuthorizationIsCurrent as authorizationIsCurrent, + type HostedCoordinationEventStreamCurrentAuthorization, +} from './hostedCoordinationEventStreamAuthorization'; + +import type { ReplayCoordinationEventsInput } from '../../../../core/application'; +import type { CoordinationEventWakeupListener } from '../../../infrastructure/InProcessCoordinationEventWakeupHub'; + +interface HostedCoordinationHttpSocket { + readonly destroyed: boolean; + once(event: 'close', listener: () => void): unknown; + removeListener(event: 'close', listener: () => void): unknown; +} + +interface HostedCoordinationHttpRawRequest { + readonly aborted: boolean; + readonly destroyed: boolean; + readonly socket: HostedCoordinationHttpSocket; + once(event: 'aborted', listener: () => void): unknown; + removeListener(event: 'aborted', listener: () => void): unknown; +} + +interface HostedCoordinationHttpRequest { + readonly headers: Readonly>; + readonly query: unknown; + readonly raw: HostedCoordinationHttpRawRequest; +} + +interface HostedCoordinationHttpRawReply { + readonly destroyed: boolean; + readonly writableEnded: boolean; + end(): unknown; + flushHeaders(): unknown; + once(event: 'close' | 'drain' | 'error', listener: () => void): unknown; + removeListener(event: 'close' | 'drain' | 'error', listener: () => void): unknown; + write(frame: string): boolean; + writeHead(statusCode: number, headers: Readonly>): unknown; +} + +interface HostedCoordinationHttpReply { + readonly raw: HostedCoordinationHttpRawReply; + code(statusCode: number): HostedCoordinationHttpReply; + hijack(): void; + send(payload: unknown): unknown; +} + +interface HostedCoordinationHttpApplication { + get( + route: string, + handler: ( + request: HostedCoordinationHttpRequest, + reply: HostedCoordinationHttpReply + ) => Promise + ): void; +} + +const DEFAULT_REPLAY_BATCH_SIZE = 100; +const MAX_REPLAY_BATCH_SIZE = 500; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; +const DEFAULT_SLOW_CONSUMER_TIMEOUT_MS = 5_000; +const DEFAULT_MAX_FRAME_BYTES = 256 * 1_024; +const MAX_CURSOR_LENGTH = 2_048; +const MAX_IDENTIFIER_LENGTH = 256; +const ABORTED_OPERATION = Symbol('aborted_operation'); +const UTF8_ENCODER = new TextEncoder(); + +function utf8ByteLength(value: string): number { + return UTF8_ENCODER.encode(value).byteLength; +} + +interface HostedCoordinationEventReplay { + replay(input: ReplayCoordinationEventsInput): Promise; +} + +/** Live admission whose projector remains bound to the authorized grant context. */ +interface HostedCoordinationEventStreamAuthorization extends HostedCoordinationEventStreamCurrentAuthorization { + projectEvent( + event: CoordinationEventEnvelope + ): HostedCoordinationEventProjection | null | Promise; +} + +interface HostedCoordinationEventStreamAuthorizer { + readonly allowedOrigin: string; + authorize( + request: HostedCoordinationHttpRequest + ): Promise; +} + +interface HostedCoordinationEventWakeupSource { + subscribe(listener: CoordinationEventWakeupListener): () => void; +} + +export interface HostedCoordinationEventStreamScheduler { + schedule(delayMs: number, callback: () => void): () => void; +} + +interface HostedCoordinationEventStreamControllerOptions { + readonly replay: HostedCoordinationEventReplay; + readonly authorizer: HostedCoordinationEventStreamAuthorizer; + readonly wakeups: HostedCoordinationEventWakeupSource; + readonly scheduler: HostedCoordinationEventStreamScheduler; + readonly replayBatchSize?: number; + readonly heartbeatIntervalMs?: number; + readonly slowConsumerTimeoutMs?: number; + readonly maxFrameBytes?: number; +} + +type WakeResult = 'wakeup' | 'heartbeat' | 'closed'; + +interface PreparedStream { + readonly authorization: HostedCoordinationEventStreamAuthorization; + readonly requestedCursor: ReplayCursor; + readonly firstBatch: CoordinationReplayBatch; + readonly firstReplayWakeVersion: number; + readonly wakeSignal: WakeSignal; + readonly signal: AbortSignal; + readonly closeStream: () => void; +} + +class WakeSignal { + private versionValue = 0; + private readonly listeners = new Set<() => void>(); + + get version(): number { + return this.versionValue; + } + + notify = (): void => { + this.versionValue += 1; + for (const listener of [...this.listeners]) listener(); + }; + + wait(input: { + readonly afterVersion: number; + readonly delayMs: number; + readonly signal: AbortSignal; + readonly scheduler: HostedCoordinationEventStreamScheduler; + }): Promise { + if (input.signal.aborted) return Promise.resolve('closed'); + if (this.versionValue !== input.afterVersion) return Promise.resolve('wakeup'); + return new Promise((resolve) => { + let settled = false; + let cancelSchedule = (): void => undefined; + const finish = (result: WakeResult): void => { + if (settled) return; + settled = true; + cancelSchedule(); + this.listeners.delete(onWakeup); + input.signal.removeEventListener('abort', onAbort); + resolve(result); + }; + const onWakeup = (): void => finish('wakeup'); + const onAbort = (): void => finish('closed'); + cancelSchedule = input.scheduler.schedule(input.delayMs, () => finish('heartbeat')); + this.listeners.add(onWakeup); + input.signal.addEventListener('abort', onAbort, { once: true }); + if (this.versionValue !== input.afterVersion) finish('wakeup'); + }); + } +} + +function positiveBounded(value: number, field: string, maximum: number): number { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw new Error(`invalid_hosted_event_stream_option:${field}`); + } + return value; +} + +function exactHeader(value: string | readonly string[] | undefined): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function headerContainsMediaType(value: string | null, mediaType: string): boolean { + return ( + value + ?.split(',') + .some((candidate) => candidate.split(';', 1)[0]?.trim().toLowerCase() === mediaType) ?? false + ); +} + +function exactRefererOrigin(value: string | null, allowedOrigin: string): boolean { + if (value === null) return false; + try { + return new URL(value).origin === allowedOrigin; + } catch { + return false; + } +} + +function admitsSameOriginEventSource( + headers: Readonly>, + allowedOrigin: string +): boolean { + const origin = exactHeader(headers.origin); + if (origin !== null) return origin === allowedOrigin; + + // Native same-origin EventSource omits Origin and, under `no-referrer`, also + // Referer. Fetch Metadata headers are browser-controlled, so require their + // exact SSE shape instead of weakening the route to any cookie-bearing GET. + // If a Referer is present despite policy, it must still be same-origin. + const referer = exactHeader(headers.referer); + return ( + exactHeader(headers['sec-fetch-site']) === 'same-origin' && + exactHeader(headers['sec-fetch-mode']) === 'cors' && + exactHeader(headers['sec-fetch-dest']) === 'empty' && + headerContainsMediaType(exactHeader(headers.accept), 'text/event-stream') && + (referer === null || exactRefererOrigin(referer, allowedOrigin)) + ); +} + +function initialCursor(request: HostedCoordinationHttpRequest): string | null { + const reconnectCursor = exactHeader(request.headers['last-event-id']); + if (reconnectCursor !== null) return reconnectCursor; + const query = request.query as { readonly after?: unknown } | null; + return typeof query?.after === 'string' ? query.after : null; +} + +function boundedCursor(value: string | null): value is ReplayCursor { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_CURSOR_LENGTH && + value.trim() === value && + !value.includes('\r') && + !value.includes('\n') + ); +} + +function resyncReason(error: unknown): HostedCoordinationResyncReason | null { + const code = + typeof error === 'object' && error !== null && 'code' in error + ? (error as { readonly code?: unknown }).code + : undefined; + switch (code) { + case 'invalid_replay_cursor': + case 'unsupported_replay_cursor_version': + return 'malformed_cursor'; + case 'replay_cursor_deployment_mismatch': + return 'foreign_deployment'; + case 'replay_cursor_epoch_mismatch': + return 'foreign_epoch'; + case 'replay_cursor_stale': + return 'cursor_expired'; + case 'replay_cursor_ahead': + return 'cursor_ahead'; + case 'event_sequence_discontinuity': + case 'event_cursor_mismatch': + case 'journal_watermark_mismatch': + case 'journal_watermark_regression': + case 'resource_revision_discontinuity': + case 'journal_protocol_error': + return 'event_gap'; + default: + return null; + } +} + +function validIdentifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_IDENTIFIER_LENGTH && + value.trim() === value && + !value.includes('\r') && + !value.includes('\n') + ); +} + +function awaitUnlessAborted( + operation: Promise, + signal: AbortSignal +): Promise { + if (signal.aborted) return Promise.resolve(ABORTED_OPERATION); + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => signal.removeEventListener('abort', onAbort); + const onAbort = (): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(ABORTED_OPERATION); + }; + signal.addEventListener('abort', onAbort, { once: true }); + operation.then( + (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + } + ); + if (signal.aborted) onAbort(); + }); +} + +async function invokeUnlessAborted( + operation: () => Promise, + signal: AbortSignal +): Promise { + if (signal.aborted) return ABORTED_OPERATION; + return await awaitUnlessAborted(operation(), signal); +} + +function rawConnectionClosed( + request: HostedCoordinationHttpRequest, + reply: HostedCoordinationHttpReply +): boolean { + return ( + request.raw.aborted || + request.raw.destroyed || + request.raw.socket.destroyed || + reply.raw.destroyed || + reply.raw.writableEnded + ); +} + +function materializeProjectedEnvelope(input: { + readonly event: CoordinationEventEnvelope; + readonly projection: HostedCoordinationEventProjection; + readonly previousEventCursor: ReplayCursor; + readonly maxFrameBytes: number; +}): { readonly envelope: HostedCoordinationEventEnvelope; readonly data: string } | null { + const { event, projection } = input; + if ( + !validIdentifier(event.deploymentId) || + !validIdentifier(event.eventEpoch) || + !validIdentifier(event.eventId) || + !boundedCursor(event.eventCursor) || + !Number.isSafeInteger(event.eventSequence) || + event.eventSequence < 0 || + !event.scope || + !validIdentifier(event.scope.scopeId) || + !validIdentifier(projection.eventType) || + !projection.scope || + !validIdentifier(projection.scope.scopeId) || + projection.publicPayload === undefined || + projection.scope.kind !== event.scope.kind || + projection.scope.scopeId !== event.scope.scopeId + ) { + return null; + } + const envelope: HostedCoordinationEventEnvelope = Object.freeze({ + schemaVersion: HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION, + kind: HOSTED_COORDINATION_EVENT_SSE_EVENT, + deploymentId: event.deploymentId, + eventEpoch: event.eventEpoch, + eventSequence: event.eventSequence, + eventId: event.eventId, + previousEventCursor: input.previousEventCursor, + eventCursor: event.eventCursor, + scope: Object.freeze({ ...projection.scope }), + eventType: projection.eventType, + ...(projection.resourceRevision === undefined + ? {} + : { resourceRevision: Object.freeze({ ...projection.resourceRevision }) }), + emittedAt: event.emittedAt, + payload: projection.publicPayload, + }); + let data: string; + try { + data = JSON.stringify(envelope); + } catch { + return null; + } + if (utf8ByteLength(data) > input.maxFrameBytes) return null; + return Object.freeze({ envelope, data }); +} + +function eventFrame(cursor: ReplayCursor, data: string): string { + return `id: ${cursor}\nevent: ${HOSTED_COORDINATION_EVENT_SSE_EVENT}\ndata: ${data}\n\n`; +} + +function resyncFrame(reason: HostedCoordinationResyncReason): string { + const message: HostedCoordinationResyncRequired = Object.freeze({ + schemaVersion: HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION, + kind: HOSTED_COORDINATION_RESYNC_SSE_EVENT, + reason, + }); + return `event: ${HOSTED_COORDINATION_RESYNC_SSE_EVENT}\ndata: ${JSON.stringify(message)}\n\n`; +} + +export class HostedCoordinationEventStreamController { + private readonly options: HostedCoordinationEventStreamControllerOptions; + private readonly replayBatchSize: number; + private readonly heartbeatIntervalMs: number; + private readonly slowConsumerTimeoutMs: number; + private readonly maxFrameBytes: number; + private readonly activeStreams = new Set<() => void>(); + private closed = false; + + constructor(options: unknown) { + const controllerOptions = options as HostedCoordinationEventStreamControllerOptions; + if ( + !controllerOptions?.replay || + !controllerOptions.authorizer || + !controllerOptions.wakeups || + !controllerOptions.scheduler + ) { + throw new Error('invalid_hosted_event_stream_options'); + } + this.options = controllerOptions; + this.replayBatchSize = positiveBounded( + controllerOptions.replayBatchSize ?? DEFAULT_REPLAY_BATCH_SIZE, + 'replayBatchSize', + MAX_REPLAY_BATCH_SIZE + ); + this.heartbeatIntervalMs = positiveBounded( + controllerOptions.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS, + 'heartbeatIntervalMs', + 60 * 60 * 1_000 + ); + this.slowConsumerTimeoutMs = positiveBounded( + controllerOptions.slowConsumerTimeoutMs ?? DEFAULT_SLOW_CONSUMER_TIMEOUT_MS, + 'slowConsumerTimeoutMs', + 60_000 + ); + this.maxFrameBytes = positiveBounded( + controllerOptions.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES, + 'maxFrameBytes', + 1024 * 1024 + ); + } + + register(app: unknown): void { + const httpApp = app as HostedCoordinationHttpApplication; + httpApp.get(HOSTED_COORDINATION_EVENT_STREAM_ROUTE, async (request, reply) => { + await this.handle(request, reply); + }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const closeStream of [...this.activeStreams]) closeStream(); + } + + private async handle( + request: HostedCoordinationHttpRequest, + reply: HostedCoordinationHttpReply + ): Promise { + if (this.closed) { + await reply.code(503).send({ error: 'event_stream_closed' }); + return; + } + if (!admitsSameOriginEventSource(request.headers, this.options.authorizer.allowedOrigin)) { + await reply.code(403).send({ error: 'origin_invalid' }); + return; + } + + const wakeSignal = new WakeSignal(); + const streamController = new AbortController(); + let unsubscribeWakeup = (): void => undefined; + let streamDisposed = false; + let streamClosed = false; + let authorizationComplete = false; + const disposeStream = (): void => { + if (streamDisposed) return; + streamDisposed = true; + unsubscribeWakeup(); + this.activeStreams.delete(closeStream); + request.raw.removeListener('aborted', onAborted); + request.raw.socket.removeListener('close', onAborted); + reply.raw.removeListener('close', onAborted); + reply.raw.removeListener('error', onAborted); + }; + const closeStream = (): void => { + if (streamClosed) return; + streamClosed = true; + streamController.abort(); + disposeStream(); + if (authorizationComplete && !reply.raw.destroyed && !reply.raw.writableEnded) { + reply.raw.end(); + } + }; + const onAborted = (): void => closeStream(); + request.raw.once('aborted', onAborted); + request.raw.socket.once('close', onAborted); + reply.raw.once('close', onAborted); + reply.raw.once('error', onAborted); + this.activeStreams.add(closeStream); + if (this.closed || rawConnectionClosed(request, reply)) { + closeStream(); + return; + } + + let authorization: HostedCoordinationEventStreamAuthorization | null; + try { + const result = await invokeUnlessAborted( + () => this.options.authorizer.authorize(request), + streamController.signal + ); + if (result === ABORTED_OPERATION) return; + authorization = result; + } catch { + if (streamController.signal.aborted) return; + authorization = null; + } + if (streamController.signal.aborted || rawConnectionClosed(request, reply)) { + closeStream(); + return; + } + if (authorization === null) { + streamController.abort(); + disposeStream(); + await reply.code(401).send({ error: 'authentication_required' }); + return; + } + authorizationComplete = true; + + const cursor = initialCursor(request); + if (!boundedCursor(cursor)) { + try { + await this.sendTerminalResync( + reply, + 'malformed_cursor', + authorization, + streamController.signal + ); + } finally { + closeStream(); + } + return; + } + + try { + const subscribedUnsubscribe = this.options.wakeups.subscribe(wakeSignal.notify); + unsubscribeWakeup = subscribedUnsubscribe; + if (streamDisposed) subscribedUnsubscribe(); + } catch { + if (streamController.signal.aborted || rawConnectionClosed(request, reply)) { + closeStream(); + return; + } + streamController.abort(); + disposeStream(); + await reply.code(503).send({ error: 'event_stream_unavailable' }); + return; + } + if (streamController.signal.aborted || rawConnectionClosed(request, reply)) { + closeStream(); + return; + } + const firstReplayWakeVersion = wakeSignal.version; + let firstBatch: CoordinationReplayBatch; + try { + if (!(await authorizationIsCurrent(authorization, streamController.signal))) { + closeStream(); + return; + } + const result = await invokeUnlessAborted( + () => + this.options.replay.replay({ + cursor, + maxEvents: this.replayBatchSize, + }), + streamController.signal + ); + if (result === ABORTED_OPERATION) return; + firstBatch = result; + if (!(await authorizationIsCurrent(authorization, streamController.signal))) { + closeStream(); + return; + } + } catch (error) { + if (streamController.signal.aborted) return; + const reason = resyncReason(error); + try { + if (reason !== null) { + await this.sendTerminalResync(reply, reason, authorization, streamController.signal); + } else { + streamController.abort(); + disposeStream(); + await reply.code(503).send({ error: 'event_stream_unavailable' }); + } + } finally { + closeStream(); + } + return; + } + + if (streamController.signal.aborted) return; + + const prepared: PreparedStream = { + authorization, + requestedCursor: cursor, + firstBatch, + firstReplayWakeVersion, + wakeSignal, + signal: streamController.signal, + closeStream, + }; + await this.runStream(reply, prepared); + } + + private async sendTerminalResync( + reply: HostedCoordinationHttpReply, + reason: HostedCoordinationResyncReason, + authorization: HostedCoordinationEventStreamAuthorization, + signal: AbortSignal = new AbortController().signal + ): Promise { + reply.hijack(); + reply.raw.writeHead(200, this.sseHeaders()); + reply.raw.flushHeaders(); + await this.writeAuthorized(reply, resyncFrame(reason), authorization, signal); + if (!reply.raw.destroyed && !reply.raw.writableEnded) reply.raw.end(); + } + + private async runStream( + reply: HostedCoordinationHttpReply, + prepared: PreparedStream + ): Promise { + if (!(await authorizationIsCurrent(prepared.authorization, prepared.signal))) { + prepared.closeStream(); + return; + } + reply.hijack(); + reply.raw.writeHead(200, this.sseHeaders()); + reply.raw.flushHeaders(); + let replayCursor = prepared.requestedCursor; + let deliveredCursor = prepared.requestedCursor; + let nextBatch: CoordinationReplayBatch | null = prepared.firstBatch; + const replayWakeVersion = prepared.firstReplayWakeVersion; + + try { + while (!prepared.signal.aborted) { + const wakeVersionBeforeReplay = + nextBatch === null ? prepared.wakeSignal.version : replayWakeVersion; + do { + let batch: CoordinationReplayBatch; + if (nextBatch !== null) { + batch = nextBatch; + nextBatch = null; + } else { + if (!(await authorizationIsCurrent(prepared.authorization, prepared.signal))) { + return; + } + const result = await invokeUnlessAborted( + () => + this.options.replay.replay({ + cursor: replayCursor, + maxEvents: this.replayBatchSize, + }), + prepared.signal + ); + if (result === ABORTED_OPERATION) return; + batch = result; + if (!(await authorizationIsCurrent(prepared.authorization, prepared.signal))) { + return; + } + } + for (const event of batch.events) { + const projected = await prepared.authorization.projectEvent(event); + if (projected === null) { + if (!(await authorizationIsCurrent(prepared.authorization, prepared.signal))) { + return; + } + continue; + } + const materialized = materializeProjectedEnvelope({ + event, + projection: projected, + previousEventCursor: deliveredCursor, + maxFrameBytes: this.maxFrameBytes, + }); + if (materialized === null) { + await this.writeAuthorized( + reply, + resyncFrame('projection_invalid'), + prepared.authorization, + prepared.signal + ); + return; + } + const wrote = await this.writeAuthorized( + reply, + eventFrame(event.eventCursor, materialized.data), + prepared.authorization, + prepared.signal + ); + if (!wrote) return; + deliveredCursor = event.eventCursor; + } + replayCursor = batch.nextCursor; + if (!batch.hasMore) break; + } while (!prepared.signal.aborted); + + if (prepared.signal.aborted) break; + if (prepared.wakeSignal.version !== wakeVersionBeforeReplay) continue; + const wakeResult = await prepared.wakeSignal.wait({ + afterVersion: wakeVersionBeforeReplay, + delayMs: this.heartbeatIntervalMs, + signal: prepared.signal, + scheduler: this.options.scheduler, + }); + if (wakeResult === 'closed') break; + if (wakeResult === 'heartbeat') { + const wrote = await this.writeAuthorized( + reply, + ': heartbeat\n\n', + prepared.authorization, + prepared.signal + ); + if (!wrote) break; + } + // Wake-ups are hints. Both wake and heartbeat re-query durable state. + } + } catch (error) { + const reason = resyncReason(error); + if (reason !== null && !prepared.signal.aborted) { + await this.writeAuthorized( + reply, + resyncFrame(reason), + prepared.authorization, + prepared.signal + ); + } + } finally { + prepared.closeStream(); + } + } + + private async writeAuthorized( + reply: HostedCoordinationHttpReply, + frame: string, + authorization: HostedCoordinationEventStreamAuthorization, + signal: AbortSignal + ): Promise { + if (!(await authorizationIsCurrent(authorization, signal))) return false; + return this.writeBounded(reply, frame, signal); + } + + private writeBounded( + reply: HostedCoordinationHttpReply, + frame: string, + signal: AbortSignal + ): Promise { + if ( + signal.aborted || + reply.raw.destroyed || + reply.raw.writableEnded || + utf8ByteLength(frame) > this.maxFrameBytes + 512 + ) { + return Promise.resolve(false); + } + try { + if (reply.raw.write(frame)) return Promise.resolve(true); + } catch { + return Promise.resolve(false); + } + return new Promise((resolve) => { + let settled = false; + let cancelDeadline = (): void => undefined; + const finish = (writable: boolean): void => { + if (settled) return; + settled = true; + cancelDeadline(); + reply.raw.removeListener('drain', onDrain); + reply.raw.removeListener('close', onClose); + signal.removeEventListener('abort', onClose); + resolve(writable); + }; + const onDrain = (): void => finish(true); + const onClose = (): void => finish(false); + cancelDeadline = this.options.scheduler.schedule(this.slowConsumerTimeoutMs, onClose); + reply.raw.once('drain', onDrain); + reply.raw.once('close', onClose); + signal.addEventListener('abort', onClose, { once: true }); + if (signal.aborted || reply.raw.destroyed || reply.raw.writableEnded) onClose(); + }); + } + + private sseHeaders(): Readonly> { + return Object.freeze({ + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-store, private', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + } +} diff --git a/src/features/coordination-events/main/adapters/input/http/hostedCoordinationEventStreamAuthorization.ts b/src/features/coordination-events/main/adapters/input/http/hostedCoordinationEventStreamAuthorization.ts new file mode 100644 index 0000000000..04ee061523 --- /dev/null +++ b/src/features/coordination-events/main/adapters/input/http/hostedCoordinationEventStreamAuthorization.ts @@ -0,0 +1,27 @@ +const ABORTED_AUTHORIZATION = Symbol('aborted_authorization'); + +export interface HostedCoordinationEventStreamCurrentAuthorization { + isCurrent(): boolean | Promise; +} + +export async function hostedCoordinationEventStreamAuthorizationIsCurrent( + authorization: HostedCoordinationEventStreamCurrentAuthorization, + signal: AbortSignal +): Promise { + if (signal.aborted) return false; + try { + const current = await new Promise((resolve, reject) => { + const onAbort = (): void => resolve(ABORTED_AUTHORIZATION); + signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve(authorization.isCurrent()) + .then(resolve, reject) + .finally(() => { + signal.removeEventListener('abort', onAbort); + }); + if (signal.aborted) onAbort(); + }); + return current !== ABORTED_AUTHORIZATION && current; + } catch { + return false; + } +} diff --git a/src/features/coordination-events/main/adapters/output/SqliteCoordinationEventJournal.ts b/src/features/coordination-events/main/adapters/output/SqliteCoordinationEventJournal.ts new file mode 100644 index 0000000000..e83e146f01 --- /dev/null +++ b/src/features/coordination-events/main/adapters/output/SqliteCoordinationEventJournal.ts @@ -0,0 +1,179 @@ +import { + type CoordinationEventDraft, + type CoordinationEventEnvelope, + type CoordinationJsonValue, + EVENT_JOURNAL_WATERMARK_SCHEMA_VERSION, + type EventJournalWatermark, +} from '../../../contracts'; +import { + encodeReplayCursor, + materializeCoordinationEventEnvelope, + materializeEventJournalWatermark, +} from '../../../core/domain'; + +import type { + CommittedCoordinationEventAppend, + CoordinationEventJournal, + CoordinationJournalReplayRead, +} from '../../../core/application'; +import type { + CoordinationDurabilityStorageGateway, + StoredCoordinationEventRow, + StoredEventJournalMetadata, +} from '@features/internal-storage/main'; + +export interface SqliteCoordinationEventJournalOptions { + readonly storage: CoordinationDurabilityStorageGateway; + readonly deploymentId: string; + readonly eventEpoch?: string; + readonly now?: () => Date; +} + +/** The only durable event authority; command outbox rows are journaled into these same rows. */ +export class SqliteCoordinationEventJournal implements CoordinationEventJournal { + private readonly now: () => Date; + private initialization: Promise | null = null; + + constructor(private readonly options: SqliteCoordinationEventJournalOptions) { + if (!options.storage || !options.deploymentId) { + throw new TypeError('sqlite-coordination-event-journal-options-invalid'); + } + this.now = options.now ?? (() => new Date()); + } + + async initialize(): Promise { + return mapWatermark(await this.requireInitialization()); + } + + async getWatermark(): Promise { + await this.requireInitialization(); + return mapWatermark( + await this.options.storage.coordinationEventGetWatermark(this.options.deploymentId) + ); + } + + async readCommittedEvents(input: { + readonly afterSequence: number; + readonly throughSequence: number; + readonly limit: number; + }): Promise> { + await this.requireInitialization(); + const result = await this.options.storage.coordinationEventRead({ + deploymentId: this.options.deploymentId, + ...input, + }); + const watermark = mapWatermark(result.watermark); + return Object.freeze({ + events: Object.freeze( + result.rows.map((row) => materializeStoredEvent(row, watermark)) + ), + watermark, + }); + } + + async appendCommittedEvent( + draft: CoordinationEventDraft + ): Promise> { + const initialized = await this.requireInitialization(); + const result = await this.options.storage.coordinationEventAppend({ + deploymentId: this.options.deploymentId, + eventEpoch: initialized.eventEpoch, + draft: draft as CoordinationEventDraft, + bodyJson: canonicalJson(draft), + nowIso: this.now().toISOString(), + }); + const watermark = mapWatermark(result.watermark); + return Object.freeze({ + event: materializeStoredEvent(result.row, watermark), + watermark, + }); + } + + async pruneThrough(throughSequence: number): Promise { + const initialized = await this.requireInitialization(); + const now = this.now(); + return mapWatermark( + await this.options.storage.coordinationEventPrune({ + deploymentId: this.options.deploymentId, + eventEpoch: initialized.eventEpoch, + throughSequence, + nowIso: now.toISOString(), + }) + ); + } + + private requireInitialization(): Promise { + this.initialization ??= this.options.storage.coordinationEventInitialize({ + deploymentId: this.options.deploymentId, + ...(this.options.eventEpoch === undefined ? {} : { eventEpoch: this.options.eventEpoch }), + nowIso: this.now().toISOString(), + }); + return this.initialization; + } +} + +function materializeStoredEvent( + row: StoredCoordinationEventRow, + watermark: EventJournalWatermark +): CoordinationEventEnvelope { + let body: unknown; + try { + body = JSON.parse(row.bodyJson) as unknown; + } catch (error) { + throw new Error('sqlite-coordination-event-body-corrupt', { cause: error }); + } + if (canonicalJson(body) !== row.bodyJson) { + throw new Error('sqlite-coordination-event-body-not-canonical'); + } + if (row.deploymentId !== watermark.deploymentId || row.eventEpoch !== watermark.eventEpoch) { + throw new Error('sqlite-coordination-event-identity-mismatch'); + } + return materializeCoordinationEventEnvelope( + Object.freeze({ + ...(body as CoordinationEventDraft), + deploymentId: row.deploymentId, + eventEpoch: row.eventEpoch, + eventSequence: row.eventSequence, + eventCursor: encodeReplayCursor({ + deploymentId: row.deploymentId, + eventEpoch: row.eventEpoch, + eventSequence: row.eventSequence, + }), + }), + watermark + ); +} + +function mapWatermark(metadata: StoredEventJournalMetadata): EventJournalWatermark { + return materializeEventJournalWatermark({ + schemaVersion: EVENT_JOURNAL_WATERMARK_SCHEMA_VERSION, + deploymentId: metadata.deploymentId, + eventEpoch: metadata.eventEpoch, + retentionFloorSequence: metadata.retentionFloorSequence, + highWatermarkSequence: metadata.highWatermarkSequence, + }); +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(normalize(value)); +} + +function normalize(value: unknown): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('coordination-event-json-number-invalid'); + return value; + } + if (Array.isArray(value)) return value.map(normalize); + if (!value || typeof value !== 'object') throw new TypeError('coordination-event-json-invalid'); + const result: Record = {}; + // Code-unit ordering: body_json bytes are persisted and byte-verified on read, + // so key order must not depend on the process locale or ICU version. + for (const key of Object.keys(value).sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + )) { + const child = (value as Record)[key]; + if (child !== undefined) result[key] = normalize(child); + } + return result; +} diff --git a/src/features/coordination-events/main/application/HostedCoordinationEventRetentionOwner.ts b/src/features/coordination-events/main/application/HostedCoordinationEventRetentionOwner.ts new file mode 100644 index 0000000000..67f2117f0d --- /dev/null +++ b/src/features/coordination-events/main/application/HostedCoordinationEventRetentionOwner.ts @@ -0,0 +1,81 @@ +import type { EventJournalWatermark } from '../../contracts'; +import type { CoordinationEventJournal } from '../../core/application'; + +export interface HostedCoordinationEventRetentionJournal extends CoordinationEventJournal { + pruneThrough(throughSequence: number): Promise; +} + +export interface HostedCoordinationEventRetentionScheduler { + schedule(delayMs: number, callback: () => void): () => void; +} + +export interface HostedCoordinationEventRetentionPolicy { + readonly intervalMs: number; + readonly maxRetainedEvents: number; +} + +/** + * Sole production owner for event-journal retention. It reuses the live journal and its hosted + * storage worker, so pruning stays ordered with append/replay operations on the single writer. + */ +export class HostedCoordinationEventRetentionOwner { + private cancelDeadline: (() => void) | null = null; + private closed = false; + private running = false; + + constructor( + private readonly journal: HostedCoordinationEventRetentionJournal, + private readonly scheduler: HostedCoordinationEventRetentionScheduler, + private readonly policy: HostedCoordinationEventRetentionPolicy + ) { + if ( + !Number.isSafeInteger(policy.intervalMs) || + policy.intervalMs < 50 || + policy.intervalMs > 86_400_000 || + !Number.isSafeInteger(policy.maxRetainedEvents) || + policy.maxRetainedEvents < 1 || + policy.maxRetainedEvents > 1_000_000 + ) { + throw new TypeError('hosted-coordination-event-retention-policy-invalid'); + } + } + + start(): void { + if (this.closed || this.cancelDeadline !== null || this.running) return; + this.schedule(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.cancelDeadline?.(); + this.cancelDeadline = null; + } + + private schedule(): void { + if (this.closed) return; + this.cancelDeadline = this.scheduler.schedule(this.policy.intervalMs, () => { + this.cancelDeadline = null; + void this.run(); + }); + } + + private async run(): Promise { + if (this.closed || this.running) return; + this.running = true; + try { + const watermark = await this.journal.getWatermark(); + const retained = watermark.highWatermarkSequence - watermark.retentionFloorSequence; + if (retained > this.policy.maxRetainedEvents) { + await this.journal.pruneThrough( + watermark.highWatermarkSequence - this.policy.maxRetainedEvents + ); + } + } catch { + // Retention is retried by the bounded scheduler. Append and replay authority stay available. + } finally { + this.running = false; + this.schedule(); + } + } +} diff --git a/src/features/coordination-events/main/application/HostedCoordinationEventStreamPorts.ts b/src/features/coordination-events/main/application/HostedCoordinationEventStreamPorts.ts new file mode 100644 index 0000000000..0cc7d7e664 --- /dev/null +++ b/src/features/coordination-events/main/application/HostedCoordinationEventStreamPorts.ts @@ -0,0 +1,26 @@ +import type { CoordinationEventEnvelope, HostedCoordinationEventProjection } from '../../contracts'; +import type { TeamId } from '@shared/contracts/hosted'; + +export interface HostedCoordinationEventBootstrapFence { + readonly sourceGeneration: string; + isCurrent(): boolean | Promise; +} + +export interface HostedCoordinationEventBootstrapAuthorizer { + captureTeamBootstrapFence( + request: unknown, + teamId: TeamId + ): Promise; +} + +export interface HostedCoordinationEventStreamAuthorization { + isCurrent(): boolean | Promise; + projectEvent( + event: CoordinationEventEnvelope + ): HostedCoordinationEventProjection | null | Promise; +} + +export interface HostedCoordinationEventStreamAuthorizer extends HostedCoordinationEventBootstrapAuthorizer { + readonly allowedOrigin: string; + authorize(request: unknown): Promise; +} diff --git a/src/features/coordination-events/main/composition/createCoordinationEventsFeature.ts b/src/features/coordination-events/main/composition/createCoordinationEventsFeature.ts new file mode 100644 index 0000000000..0e3531e944 --- /dev/null +++ b/src/features/coordination-events/main/composition/createCoordinationEventsFeature.ts @@ -0,0 +1,66 @@ +import { CoordinationEventHandoff } from '../../core/application'; +import { SqliteCoordinationEventJournal } from '../adapters/output/SqliteCoordinationEventJournal'; +import { + HostedCoordinationEventRetentionOwner, + type HostedCoordinationEventRetentionPolicy, + type HostedCoordinationEventRetentionScheduler, +} from '../application/HostedCoordinationEventRetentionOwner'; + +import type { + CoordinationEventDeadlineScheduler, + CoordinationEventWakeup, +} from '../../core/application'; +import type { CoordinationDurabilityStorageGateway } from '@features/internal-storage/main'; + +const NODE_DEADLINE_SCHEDULER: CoordinationEventDeadlineScheduler = Object.freeze({ + scheduleDeadline(delayMs: number, onDeadline: () => void) { + const handle = setTimeout(onDeadline, delayMs); + return () => clearTimeout(handle); + }, +}); + +export interface CreateCoordinationEventsFeatureOptions { + readonly storage: CoordinationDurabilityStorageGateway; + readonly deploymentId: string; + readonly eventEpoch?: string; + readonly wakeup?: CoordinationEventWakeup; + readonly now?: () => Date; + readonly retention?: { + readonly policy: HostedCoordinationEventRetentionPolicy; + readonly scheduler: HostedCoordinationEventRetentionScheduler; + }; +} + +export interface CoordinationEventsFeature { + /** Public publish/replay/snapshot facade; mutable storage primitives stay private. */ + readonly handoff: CoordinationEventHandoff; + close(): void; +} + +export function createCoordinationEventsFeature( + input: CreateCoordinationEventsFeatureOptions +): CoordinationEventsFeature { + const journal = new SqliteCoordinationEventJournal({ + storage: input.storage, + deploymentId: input.deploymentId, + ...(input.eventEpoch === undefined ? {} : { eventEpoch: input.eventEpoch }), + ...(input.now === undefined ? {} : { now: input.now }), + }); + const retentionOwner = + input.retention === undefined + ? null + : new HostedCoordinationEventRetentionOwner( + journal, + input.retention.scheduler, + input.retention.policy + ); + retentionOwner?.start(); + return Object.freeze({ + handoff: new CoordinationEventHandoff({ + journal, + deadlineScheduler: NODE_DEADLINE_SCHEDULER, + ...(input.wakeup === undefined ? {} : { wakeup: input.wakeup }), + }), + close: () => retentionOwner?.close(), + }); +} diff --git a/src/features/coordination-events/main/composition/createHostedCoordinationEventStream.ts b/src/features/coordination-events/main/composition/createHostedCoordinationEventStream.ts new file mode 100644 index 0000000000..fb1fa71955 --- /dev/null +++ b/src/features/coordination-events/main/composition/createHostedCoordinationEventStream.ts @@ -0,0 +1,174 @@ +import { HostedCoordinationEventBootstrapController } from '../adapters/input/http/HostedCoordinationEventBootstrapController'; +import { HostedCoordinationEventStreamController } from '../adapters/input/http/HostedCoordinationEventStreamController'; +import { InProcessCoordinationEventWakeupHub } from '../infrastructure/InProcessCoordinationEventWakeupHub'; + +import { createCoordinationEventsFeature } from './createCoordinationEventsFeature'; + +import type { CoordinationEventEnvelope, CoordinationReplayBatch } from '../../contracts'; +import type { + CoordinationEventHandoff, + ReplayCoordinationEventsInput, +} from '../../core/application'; +import type { HostedCoordinationEventStreamAuthorizer } from '../application/HostedCoordinationEventStreamPorts'; +import type { CoordinationDurabilityStorageGateway } from '@features/internal-storage/main'; +import type { TeamId } from '@shared/contracts/hosted'; + +const NODE_STREAM_SCHEDULER: HostedCoordinationEventStreamScheduler = Object.freeze({ + schedule(delayMs: number, callback: () => void): () => void { + const handle = setTimeout(callback, delayMs); + handle.unref(); + return () => clearTimeout(handle); + }, +}); + +const DEFAULT_RETENTION_POLICY = Object.freeze({ + intervalMs: 60_000, + maxRetainedEvents: 10_000, +}); + +export interface HostedCoordinationEventStreamScheduler { + schedule(delayMs: number, callback: () => void): () => void; +} + +export type HostedCoordinationEventStorage = Pick< + CoordinationDurabilityStorageGateway, + | 'coordinationEventInitialize' + | 'coordinationEventGetWatermark' + | 'coordinationEventRead' + | 'coordinationEventAppend' + | 'coordinationEventPrune' +>; + +export interface CreateHostedCoordinationEventStreamOptions { + readonly storage: HostedCoordinationEventStorage; + readonly deploymentId: string; + readonly authorizer: HostedCoordinationEventStreamAuthorizer; + readonly scheduler?: HostedCoordinationEventStreamScheduler; + readonly replayBatchSize?: number; + readonly heartbeatIntervalMs?: number; + readonly slowConsumerTimeoutMs?: number; + readonly maxFrameBytes?: number; + readonly retentionScheduler?: HostedCoordinationEventStreamScheduler; + readonly retentionPolicy?: { + readonly intervalMs: number; + readonly maxRetainedEvents: number; + }; +} + +export interface HostedCoordinationEventStream { + readonly handoff: CoordinationEventHandoff; + /** Lossy latency hint after an atomic commit through the shared storage worker. */ + notifyDurableCommit(): Promise; + register(app: unknown): void; + close(): void; +} + +type PresentedCoordinationEvent = CoordinationEventEnvelope & { + scope: { kind: CoordinationEventEnvelope['scope']['kind']; scopeId: string }; +}; + +function presentationReplay(input: { + readonly handoff: CoordinationEventHandoff; + readonly sourceEvents: WeakMap; +}) { + return Object.freeze({ + replay: async (request: ReplayCoordinationEventsInput): Promise => { + const batch = await input.handoff.replay(request); + return Object.freeze({ + ...batch, + events: Object.freeze( + batch.events.map((event) => { + const presented: PresentedCoordinationEvent = { + ...event, + scope: { ...event.scope }, + }; + input.sourceEvents.set(presented, event); + return presented; + }) + ), + }); + }, + }); +} + +function presentationAuthorizer(input: { + readonly authorizer: HostedCoordinationEventStreamAuthorizer; + readonly sourceEvents: WeakMap; +}): HostedCoordinationEventStreamAuthorizer { + return Object.freeze({ + allowedOrigin: input.authorizer.allowedOrigin, + captureTeamBootstrapFence: (request: unknown, teamId: TeamId) => + input.authorizer.captureTeamBootstrapFence(request, teamId), + authorize: async (request: unknown) => { + const authorization = await input.authorizer.authorize(request); + if (authorization === null) return null; + return Object.freeze({ + isCurrent: () => authorization.isCurrent(), + projectEvent: async (presented: CoordinationEventEnvelope) => { + const source = input.sourceEvents.get(presented); + if (source === undefined) return null; + const projection = await authorization.projectEvent(source); + if (projection === null) return null; + const mutable = presented as PresentedCoordinationEvent; + mutable.scope.kind = projection.scope.kind; + mutable.scope.scopeId = projection.scope.scopeId; + return projection; + }, + }); + }, + }); +} + +export function createHostedCoordinationEventStream( + options: CreateHostedCoordinationEventStreamOptions +): HostedCoordinationEventStream { + const wakeupHub = new InProcessCoordinationEventWakeupHub(); + // The reusable feature currently names the broader coordination durability + // port, but its event journal consumes exactly this five-operation gateway. + const feature = createCoordinationEventsFeature({ + storage: options.storage as CoordinationDurabilityStorageGateway, + deploymentId: options.deploymentId, + wakeup: wakeupHub, + retention: { + policy: options.retentionPolicy ?? DEFAULT_RETENTION_POLICY, + scheduler: options.retentionScheduler ?? NODE_STREAM_SCHEDULER, + }, + }); + const { handoff } = feature; + const sourceEvents = new WeakMap(); + const controller = new HostedCoordinationEventStreamController({ + replay: presentationReplay({ handoff, sourceEvents }), + authorizer: presentationAuthorizer({ authorizer: options.authorizer, sourceEvents }), + wakeups: wakeupHub, + scheduler: options.scheduler ?? NODE_STREAM_SCHEDULER, + ...(options.replayBatchSize === undefined ? {} : { replayBatchSize: options.replayBatchSize }), + ...(options.heartbeatIntervalMs === undefined + ? {} + : { heartbeatIntervalMs: options.heartbeatIntervalMs }), + ...(options.slowConsumerTimeoutMs === undefined + ? {} + : { slowConsumerTimeoutMs: options.slowConsumerTimeoutMs }), + ...(options.maxFrameBytes === undefined ? {} : { maxFrameBytes: options.maxFrameBytes }), + }); + const bootstrapController = new HostedCoordinationEventBootstrapController({ + handoff, + authorizer: options.authorizer, + }); + let closed = false; + return Object.freeze({ + handoff, + notifyDurableCommit: () => wakeupHub.notifyCommittedEvent({} as CoordinationEventEnvelope), + register: (app: unknown) => { + controller.register(app); + bootstrapController.register(app); + }, + close: () => { + if (closed) return; + closed = true; + controller.close(); + bootstrapController.close(); + feature.close(); + wakeupHub.close(); + }, + }); +} diff --git a/src/features/coordination-events/main/hosted.ts b/src/features/coordination-events/main/hosted.ts new file mode 100644 index 0000000000..ab56522e86 --- /dev/null +++ b/src/features/coordination-events/main/hosted.ts @@ -0,0 +1,11 @@ +export type { + HostedCoordinationEventBootstrapAuthorizer, + HostedCoordinationEventBootstrapFence, + HostedCoordinationEventStreamAuthorization, + HostedCoordinationEventStreamAuthorizer, +} from './application/HostedCoordinationEventStreamPorts'; +export { + createHostedCoordinationEventStream, + type CreateHostedCoordinationEventStreamOptions, + type HostedCoordinationEventStream, +} from './composition/createHostedCoordinationEventStream'; diff --git a/src/features/coordination-events/main/index.ts b/src/features/coordination-events/main/index.ts new file mode 100644 index 0000000000..76e9969949 --- /dev/null +++ b/src/features/coordination-events/main/index.ts @@ -0,0 +1,13 @@ +export type { + HostedCoordinationEventBootstrapAuthorizer, + HostedCoordinationEventBootstrapFence, + HostedCoordinationEventStreamAuthorization, + HostedCoordinationEventStreamAuthorizer, +} from './application/HostedCoordinationEventStreamPorts'; +export * from './composition/createCoordinationEventsFeature'; +export { + createHostedCoordinationEventStream, + type CreateHostedCoordinationEventStreamOptions, + type HostedCoordinationEventStorage, + type HostedCoordinationEventStream, +} from './composition/createHostedCoordinationEventStream'; diff --git a/src/features/coordination-events/main/infrastructure/InProcessCoordinationEventWakeupHub.ts b/src/features/coordination-events/main/infrastructure/InProcessCoordinationEventWakeupHub.ts new file mode 100644 index 0000000000..7a5829418d --- /dev/null +++ b/src/features/coordination-events/main/infrastructure/InProcessCoordinationEventWakeupHub.ts @@ -0,0 +1,54 @@ +import type { CoordinationEventEnvelope } from '../../contracts'; +import type { CoordinationEventWakeup } from '../../core/application'; + +export type CoordinationEventWakeupListener = () => void; + +/** + * Process-local wake-ups are latency hints only. Notifications in the same + * microtask turn coalesce into one listener pass; durable replay remains the + * source of truth and listener failures cannot invalidate a committed event. + */ +export class InProcessCoordinationEventWakeupHub implements CoordinationEventWakeup { + private readonly listeners = new Set(); + private scheduledFlush: Promise | null = null; + private closed = false; + + subscribe(listener: CoordinationEventWakeupListener): () => void { + if (this.closed) { + return () => undefined; + } + this.listeners.add(listener); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + this.listeners.delete(listener); + }; + } + + notifyCommittedEvent(_event: CoordinationEventEnvelope): Promise { + if (this.closed) return Promise.resolve(); + if (this.scheduledFlush === null) { + this.scheduledFlush = Promise.resolve() + .then(() => { + for (const listener of [...this.listeners]) { + try { + listener(); + } catch { + // A lossy hint listener must never change durable publish outcome. + } + } + }) + .finally(() => { + this.scheduledFlush = null; + }); + } + return this.scheduledFlush; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.listeners.clear(); + } +} diff --git a/src/features/coordination-events/renderer/hooks/useHostedCoordinationEvents.ts b/src/features/coordination-events/renderer/hooks/useHostedCoordinationEvents.ts new file mode 100644 index 0000000000..92d07a0f50 --- /dev/null +++ b/src/features/coordination-events/renderer/hooks/useHostedCoordinationEvents.ts @@ -0,0 +1,318 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { HostedCoordinationEventReconciler } from '../reconciliation/HostedCoordinationEventReconciler'; + +import type { + CoordinationEventScope, + CoordinationJsonValue, + HostedCoordinationEventEnvelope, + ReplayCursor, +} from '../../contracts'; +import type { + HostedCoordinationEventConnection, + HostedCoordinationEventTransport, + HostedCoordinationSnapshotResyncCause, + HostedCoordinationSnapshotResyncPort, +} from '../ports/HostedCoordinationEventRendererPorts'; +import type { HostedCoordinationEventReconciliationState } from '../reconciliation/HostedCoordinationEventReconciler'; + +export type HostedCoordinationEventsStatus = + | 'idle' + | 'resyncing' + | 'connecting' + | 'live' + | 'reconnecting' + | 'error'; + +export interface UseHostedCoordinationEventsInput< + TSnapshot, + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly authenticated: boolean; + readonly scope: CoordinationEventScope | null; + readonly transport: HostedCoordinationEventTransport; + readonly snapshotResync: HostedCoordinationSnapshotResyncPort; + readonly applyEvent?: ( + snapshot: TSnapshot, + event: HostedCoordinationEventEnvelope + ) => TSnapshot; + /** Defaults to exact scope identity while still advancing over other authorized events. */ + readonly shouldApplyEvent?: ( + event: HostedCoordinationEventEnvelope, + scope: CoordinationEventScope + ) => boolean; + readonly reconciler?: HostedCoordinationEventReconciler; +} + +export interface UseHostedCoordinationEventsResult< + TSnapshot, + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly status: HostedCoordinationEventsStatus; + readonly snapshot: TSnapshot | null; + readonly lastEvent: HostedCoordinationEventEnvelope | null; + readonly cursor: ReplayCursor | null; + readonly generation: number; + readonly error: Error | null; +} + +interface HookState { + readonly selectionKey: string | null; + readonly status: HostedCoordinationEventsStatus; + readonly snapshot: TSnapshot | null; + readonly lastEvent: HostedCoordinationEventEnvelope | null; + readonly cursor: ReplayCursor | null; + readonly generation: number; + readonly error: Error | null; +} + +const DEFAULT_RECONCILER = new HostedCoordinationEventReconciler(); + +function exactScopeMatch( + event: HostedCoordinationEventEnvelope, + scope: CoordinationEventScope +): boolean { + return event.scope.kind === scope.kind && event.scope.scopeId === scope.scopeId; +} + +function asError(error: unknown, fallback: string): Error { + return error instanceof Error ? error : new Error(fallback); +} + +function selectionKey(scope: CoordinationEventScope): string { + return JSON.stringify([scope.kind, scope.scopeId]); +} + +function emptyState( + key: string | null, + generation: number, + status: HostedCoordinationEventsStatus = 'idle' +): HookState { + return { + selectionKey: key, + status, + snapshot: null, + lastEvent: null, + cursor: null, + generation, + error: null, + }; +} + +export function useHostedCoordinationEvents< + TSnapshot, + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +>( + input: UseHostedCoordinationEventsInput +): UseHostedCoordinationEventsResult { + const reconciler = input.reconciler ?? DEFAULT_RECONCILER; + const applyEventRef = useRef(input.applyEvent); + const shouldApplyEventRef = useRef(input.shouldApplyEvent); + applyEventRef.current = input.applyEvent; + shouldApplyEventRef.current = input.shouldApplyEvent; + + const scopeKind = input.scope?.kind ?? null; + const scopeId = input.scope?.scopeId ?? null; + const requestedSelectionKey = useMemo( + () => + input.authenticated && scopeKind !== null && scopeId !== null + ? selectionKey({ kind: scopeKind, scopeId }) + : null, + [input.authenticated, scopeId, scopeKind] + ); + const generationRef = useRef(0); + const [state, setState] = useState>(() => emptyState(null, 0)); + + useEffect(() => { + const generation = generationRef.current + 1; + generationRef.current = generation; + if ( + !input.authenticated || + scopeKind === null || + scopeId === null || + requestedSelectionKey === null + ) { + setState(emptyState(null, generation)); + return () => { + if (generationRef.current === generation) generationRef.current += 1; + }; + } + + const scope: CoordinationEventScope = Object.freeze({ kind: scopeKind, scopeId }); + const ownerController = new AbortController(); + let snapshotController: AbortController | null = null; + let snapshotOwnerAbortListener: (() => void) | null = null; + let connection: HostedCoordinationEventConnection | null = null; + let reconciliation: HostedCoordinationEventReconciliationState | null = null; + let currentSnapshot: TSnapshot | null = null; + let hasSnapshot = false; + let snapshotRequest = 0; + + const isCurrent = (): boolean => + generationRef.current === generation && !ownerController.signal.aborted; + const update = ( + updater: (current: HookState) => HookState + ): void => { + if (!isCurrent()) return; + setState((current) => + current.generation === generation && current.selectionKey === requestedSelectionKey + ? updater(current) + : current + ); + }; + + setState(emptyState(requestedSelectionKey, generation, 'resyncing')); + + const startSnapshotResync = async ( + cause: HostedCoordinationSnapshotResyncCause + ): Promise => { + if (!isCurrent()) return; + const request = ++snapshotRequest; + connection?.close(); + connection = null; + if (snapshotOwnerAbortListener) { + ownerController.signal.removeEventListener('abort', snapshotOwnerAbortListener); + snapshotOwnerAbortListener = null; + } + snapshotController?.abort(); + snapshotController = new AbortController(); + const requestController = snapshotController; + const abortRequest = (): void => requestController.abort(); + snapshotOwnerAbortListener = abortRequest; + ownerController.signal.addEventListener('abort', abortRequest, { once: true }); + update((current) => ({ ...current, status: 'resyncing', error: null })); + + try { + const snapshot = await input.snapshotResync.loadSnapshot({ + scope, + cause, + signal: requestController.signal, + }); + if (!isCurrent() || requestController.signal.aborted || request !== snapshotRequest) { + return; + } + const nextReconciliation = reconciler.fromSnapshot({ snapshot, generation }); + reconciliation = nextReconciliation; + currentSnapshot = snapshot.snapshot; + hasSnapshot = true; + update((current) => ({ + ...current, + status: 'connecting', + snapshot: snapshot.snapshot, + lastEvent: null, + cursor: nextReconciliation.cursor, + error: null, + })); + + const openedConnection = input.transport.connect({ + resumeCursor: nextReconciliation.cursor, + signal: ownerController.signal, + handlers: { + onOpen: () => { + update((current) => ({ ...current, status: 'live', error: null })); + }, + onReconnectScheduled: () => { + update((current) => ({ ...current, status: 'reconnecting' })); + }, + onError: (error) => { + update((current) => ({ ...current, status: 'error', error })); + }, + onResyncRequired: (reason) => { + if (isCurrent()) void startSnapshotResync(reason); + }, + onEvent: (event) => { + if (!isCurrent() || reconciliation === null || !hasSnapshot) { + return Object.freeze({ kind: 'stop' }); + } + const result = reconciler.reconcile({ + state: reconciliation, + event, + generation, + }); + if (result.kind === 'stale_generation') return Object.freeze({ kind: 'stop' }); + if (result.kind === 'resync_required') { + void startSnapshotResync(result.reason); + return Object.freeze({ kind: 'stop' }); + } + reconciliation = result.state; + if (result.kind === 'duplicate') { + update((current) => ({ ...current, cursor: result.state.cursor })); + return Object.freeze({ kind: 'advance', resumeCursor: result.state.cursor }); + } + + const shouldApply = (shouldApplyEventRef.current ?? exactScopeMatch)(event, scope); + if (!shouldApply) { + update((current) => ({ ...current, cursor: result.state.cursor })); + return Object.freeze({ kind: 'advance', resumeCursor: result.state.cursor }); + } + try { + currentSnapshot = applyEventRef.current + ? applyEventRef.current(currentSnapshot as TSnapshot, event) + : currentSnapshot; + } catch { + void startSnapshotResync('projection_invalid'); + return Object.freeze({ kind: 'stop' }); + } + const nextSnapshot = currentSnapshot; + update((current) => ({ + ...current, + snapshot: nextSnapshot, + lastEvent: event, + cursor: result.state.cursor, + error: null, + })); + return Object.freeze({ kind: 'advance', resumeCursor: result.state.cursor }); + }, + }, + }); + if (!isCurrent() || request !== snapshotRequest) openedConnection.close(); + else connection = openedConnection; + } catch (error) { + if (!isCurrent() || requestController.signal.aborted || request !== snapshotRequest) { + return; + } + update((current) => ({ + ...current, + status: 'error', + error: asError(error, 'Hosted coordination snapshot resync failed'), + })); + } finally { + ownerController.signal.removeEventListener('abort', abortRequest); + if (snapshotOwnerAbortListener === abortRequest) snapshotOwnerAbortListener = null; + } + }; + + void startSnapshotResync('initial'); + return () => { + if (generationRef.current === generation) generationRef.current += 1; + ownerController.abort(); + if (snapshotOwnerAbortListener) { + ownerController.signal.removeEventListener('abort', snapshotOwnerAbortListener); + snapshotOwnerAbortListener = null; + } + snapshotController?.abort(); + connection?.close(); + connection = null; + reconciliation = null; + currentSnapshot = null; + hasSnapshot = false; + }; + }, [ + input.authenticated, + input.snapshotResync, + input.transport, + reconciler, + requestedSelectionKey, + scopeId, + scopeKind, + ]); + + if (state.selectionKey !== requestedSelectionKey) { + return emptyState( + requestedSelectionKey, + state.generation, + requestedSelectionKey === null ? 'idle' : 'resyncing' + ); + } + return state; +} diff --git a/src/features/coordination-events/renderer/index.ts b/src/features/coordination-events/renderer/index.ts new file mode 100644 index 0000000000..a445633543 --- /dev/null +++ b/src/features/coordination-events/renderer/index.ts @@ -0,0 +1,38 @@ +export type { + HostedCoordinationEventsStatus, + UseHostedCoordinationEventsInput, + UseHostedCoordinationEventsResult, +} from './hooks/useHostedCoordinationEvents'; +export { useHostedCoordinationEvents } from './hooks/useHostedCoordinationEvents'; +export type { + HostedCoordinationEventBackoffPort, + HostedCoordinationEventConnection, + HostedCoordinationEventDisposition, + HostedCoordinationEventSourceConstructor, + HostedCoordinationEventSourceEvent, + HostedCoordinationEventSourceInit, + HostedCoordinationEventSourceLike, + HostedCoordinationEventSourceListener, + HostedCoordinationEventTimingPort, + HostedCoordinationEventTransport, + HostedCoordinationEventTransportConnectInput, + HostedCoordinationEventTransportHandlers, + HostedCoordinationSnapshotResyncCause, + HostedCoordinationSnapshotResyncInput, + HostedCoordinationSnapshotResyncPort, +} from './ports/HostedCoordinationEventRendererPorts'; +export type { + HostedCoordinationEventReconcileResult, + HostedCoordinationEventReconcilerOptions, + HostedCoordinationEventReconciliationState, +} from './reconciliation/HostedCoordinationEventReconciler'; +export { HostedCoordinationEventReconciler } from './reconciliation/HostedCoordinationEventReconciler'; +export type { + HostedCoordinationEventBootstrapFetchPort, + HostedCoordinationEventBootstrapHttpRequestInit, + HostedCoordinationEventBootstrapHttpResponse, + HostedCoordinationEventBootstrapTransportDependencies, +} from './transport/createHostedCoordinationEventBootstrapTransport'; +export { createHostedCoordinationEventBootstrapTransport } from './transport/createHostedCoordinationEventBootstrapTransport'; +export type { CreateHostedCoordinationEventTransportOptions } from './transport/createHostedCoordinationEventTransport'; +export { createHostedCoordinationEventTransport } from './transport/createHostedCoordinationEventTransport'; diff --git a/src/features/coordination-events/renderer/ports/HostedCoordinationEventRendererPorts.ts b/src/features/coordination-events/renderer/ports/HostedCoordinationEventRendererPorts.ts new file mode 100644 index 0000000000..63d11d0a26 --- /dev/null +++ b/src/features/coordination-events/renderer/ports/HostedCoordinationEventRendererPorts.ts @@ -0,0 +1,105 @@ +import type { + CoordinationEventScope, + CoordinationJsonValue, + CoordinationSnapshotEnvelope, + HostedCoordinationEventEnvelope, + HostedCoordinationResyncReason, + ReplayCursor, +} from '../../contracts'; + +export interface HostedCoordinationEventSourceEvent { + readonly data?: unknown; + readonly lastEventId?: unknown; +} + +export type HostedCoordinationEventSourceListener = ( + event: HostedCoordinationEventSourceEvent +) => void; + +/** The deliberately small browser boundary used by the hosted SSE transport. */ +export interface HostedCoordinationEventSourceLike { + addEventListener(type: string, listener: HostedCoordinationEventSourceListener): void; + removeEventListener(type: string, listener: HostedCoordinationEventSourceListener): void; + close(): void; +} + +/** + * `lastEventId` is a transport hint for EventSource-like implementations that + * can set the Last-Event-ID request header. The browser implementation still + * resumes through the `after` query because native EventSource does not expose + * request headers when a new instance is created. + */ +export interface HostedCoordinationEventSourceInit { + readonly withCredentials: true; + readonly lastEventId: ReplayCursor; +} + +export type HostedCoordinationEventSourceConstructor = new ( + url: string, + init: HostedCoordinationEventSourceInit +) => HostedCoordinationEventSourceLike; + +export interface HostedCoordinationEventTimingPort { + schedule(delayMs: number, callback: () => void): () => void; +} + +export interface HostedCoordinationEventBackoffPort { + /** `null` deliberately stops reconnecting. Attempts are one-based. */ + nextDelayMs(attempt: number): number | null; +} + +export type HostedCoordinationEventDisposition = + | { + readonly kind: 'advance'; + /** The reconciler's authoritative cursor after consuming this event. */ + readonly resumeCursor: ReplayCursor; + } + | { readonly kind: 'stop' }; + +export interface HostedCoordinationEventTransportHandlers< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly onOpen?: () => void; + readonly onEvent: ( + event: HostedCoordinationEventEnvelope + ) => HostedCoordinationEventDisposition; + readonly onResyncRequired: (reason: HostedCoordinationResyncReason) => void; + readonly onReconnectScheduled?: (input: { + readonly attempt: number; + readonly delayMs: number; + }) => void; + readonly onError?: (error: Error) => void; +} + +export interface HostedCoordinationEventTransportConnectInput< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> { + readonly resumeCursor: ReplayCursor; + readonly signal: AbortSignal; + readonly handlers: HostedCoordinationEventTransportHandlers; +} + +export interface HostedCoordinationEventConnection { + readonly cursor: ReplayCursor; + close(): void; +} + +export interface HostedCoordinationEventTransport { + connect( + input: HostedCoordinationEventTransportConnectInput + ): HostedCoordinationEventConnection; +} + +export type HostedCoordinationSnapshotResyncCause = 'initial' | HostedCoordinationResyncReason; + +export interface HostedCoordinationSnapshotResyncInput { + readonly scope: CoordinationEventScope; + readonly cause: HostedCoordinationSnapshotResyncCause; + readonly signal: AbortSignal; +} + +export interface HostedCoordinationSnapshotResyncPort { + loadSnapshot( + input: HostedCoordinationSnapshotResyncInput + ): Promise>; +} diff --git a/src/features/coordination-events/renderer/reconciliation/HostedCoordinationEventReconciler.ts b/src/features/coordination-events/renderer/reconciliation/HostedCoordinationEventReconciler.ts new file mode 100644 index 0000000000..4047d9a25b --- /dev/null +++ b/src/features/coordination-events/renderer/reconciliation/HostedCoordinationEventReconciler.ts @@ -0,0 +1,306 @@ +import { + COORDINATION_SNAPSHOT_SCHEMA_VERSION, + type CoordinationJsonValue, + type CoordinationResourceRevision, + type CoordinationSnapshotEnvelope, + type HostedCoordinationEventEnvelope, + type HostedCoordinationResyncReason, + type ReplayCursor, + SNAPSHOT_EVENT_HANDOFF_MODES, + type SnapshotEventHandoffMode, +} from '../../contracts'; + +const DEFAULT_PROCESSED_EVENT_WINDOW = 2_048; +const MAX_PROCESSED_EVENT_WINDOW = 10_000; +const MAX_REVISION_VECTOR_LENGTH = 10_000; +const MAX_IDENTIFIER_LENGTH = 256; +const MAX_CURSOR_LENGTH = 2_048; + +export interface HostedCoordinationEventReconciliationState { + /** Fences async work belonging to a prior login or selection. */ + readonly generation: number; + readonly deploymentId: string; + readonly eventEpoch: string; + readonly handoffMode: SnapshotEventHandoffMode; + readonly cursor: ReplayCursor; + readonly lastEventSequence: number | null; + readonly revisionVector: readonly CoordinationResourceRevision[]; + readonly processedEvents: readonly { + readonly eventId: string; + readonly eventCursor: ReplayCursor; + }[]; +} + +export type HostedCoordinationEventReconcileResult< + TPayload extends CoordinationJsonValue = CoordinationJsonValue, +> = + | { + readonly kind: 'applied'; + readonly state: HostedCoordinationEventReconciliationState; + readonly event: HostedCoordinationEventEnvelope; + } + | { + readonly kind: 'duplicate'; + readonly state: HostedCoordinationEventReconciliationState; + } + | { + readonly kind: 'resync_required'; + readonly state: HostedCoordinationEventReconciliationState; + readonly reason: HostedCoordinationResyncReason; + } + | { + readonly kind: 'stale_generation'; + readonly state: HostedCoordinationEventReconciliationState; + }; + +export interface HostedCoordinationEventReconcilerOptions { + readonly processedEventWindow?: number; +} + +function validIdentity(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_IDENTIFIER_LENGTH && + value.trim() === value && + !value.includes('\r') && + !value.includes('\n') + ); +} + +function validCursor(value: unknown): value is ReplayCursor { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_CURSOR_LENGTH && + value.trim() === value && + !value.includes('\r') && + !value.includes('\n') + ); +} + +function validSequence(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function materializeRevisionVector( + value: readonly CoordinationResourceRevision[] +): readonly CoordinationResourceRevision[] { + if (!Array.isArray(value) || value.length > MAX_REVISION_VECTOR_LENGTH) { + throw new Error('invalid_hosted_coordination_snapshot_revision_vector'); + } + const seen = new Set(); + const revisions = value.map((revision) => { + if ( + !revision || + !validIdentity(revision.resourceKey) || + !validSequence(revision.generation) || + !validSequence(revision.revision) || + seen.has(revision.resourceKey) + ) { + throw new Error('invalid_hosted_coordination_snapshot_revision_vector'); + } + seen.add(revision.resourceKey); + return Object.freeze({ + resourceKey: revision.resourceKey, + generation: revision.generation, + revision: revision.revision, + }); + }); + return Object.freeze(revisions); +} + +function rememberBoundedEvent( + values: HostedCoordinationEventReconciliationState['processedEvents'], + event: HostedCoordinationEventEnvelope, + maximum: number +): HostedCoordinationEventReconciliationState['processedEvents'] { + const next = [ + ...values, + Object.freeze({ eventId: event.eventId, eventCursor: event.eventCursor }), + ]; + if (next.length > maximum) next.splice(0, next.length - maximum); + return Object.freeze(next); +} + +function replacementRevisionVector( + state: HostedCoordinationEventReconciliationState, + revision: CoordinationResourceRevision | undefined +): readonly CoordinationResourceRevision[] | null { + if (!revision) return state.revisionVector; + if ( + !validIdentity(revision.resourceKey) || + !validSequence(revision.generation) || + !validSequence(revision.revision) + ) { + return null; + } + const currentIndex = state.revisionVector.findIndex( + ({ resourceKey }) => resourceKey === revision.resourceKey + ); + if (currentIndex === -1) { + if (state.revisionVector.length >= MAX_REVISION_VECTOR_LENGTH) return null; + return Object.freeze([...state.revisionVector, Object.freeze({ ...revision })]); + } + const next = [...state.revisionVector]; + next[currentIndex] = Object.freeze({ ...revision }); + return Object.freeze(next); +} + +function advanceState( + state: HostedCoordinationEventReconciliationState, + event: HostedCoordinationEventEnvelope, + processedEventWindow: number, + revisionVector = state.revisionVector +): HostedCoordinationEventReconciliationState { + return Object.freeze({ + ...state, + cursor: event.eventCursor, + lastEventSequence: event.eventSequence, + revisionVector, + processedEvents: rememberBoundedEvent(state.processedEvents, event, processedEventWindow), + }); +} + +function resync( + state: HostedCoordinationEventReconciliationState, + reason: HostedCoordinationResyncReason +): HostedCoordinationEventReconcileResult { + return Object.freeze({ kind: 'resync_required', state, reason }); +} + +/** + * Pure protocol reconciliation. The class owns no mutable stream state, so a + * result depends only on the snapshot/event inputs and configured window. + */ +export class HostedCoordinationEventReconciler { + private readonly processedEventWindow: number; + + constructor(options: HostedCoordinationEventReconcilerOptions = {}) { + const processedEventWindow = options.processedEventWindow ?? DEFAULT_PROCESSED_EVENT_WINDOW; + if ( + !Number.isSafeInteger(processedEventWindow) || + processedEventWindow <= 0 || + processedEventWindow > MAX_PROCESSED_EVENT_WINDOW + ) { + throw new Error('invalid_hosted_coordination_event_reconciler_options'); + } + this.processedEventWindow = processedEventWindow; + } + + fromSnapshot(input: { + readonly snapshot: CoordinationSnapshotEnvelope; + readonly generation: number; + }): HostedCoordinationEventReconciliationState { + const metadata = input.snapshot?.metadata; + if ( + !metadata || + metadata.schemaVersion !== COORDINATION_SNAPSHOT_SCHEMA_VERSION || + !validSequence(input.generation) || + !validIdentity(metadata.deploymentId) || + !validIdentity(metadata.eventEpoch) || + !SNAPSHOT_EVENT_HANDOFF_MODES.includes(metadata.handoffMode) || + !validCursor(metadata.replayCursor) + ) { + throw new Error('invalid_hosted_coordination_snapshot'); + } + return Object.freeze({ + generation: input.generation, + deploymentId: metadata.deploymentId, + eventEpoch: metadata.eventEpoch, + handoffMode: metadata.handoffMode, + cursor: metadata.replayCursor, + lastEventSequence: null, + revisionVector: materializeRevisionVector(metadata.revisionVector), + processedEvents: Object.freeze([]), + }); + } + + reconcile(input: { + readonly state: HostedCoordinationEventReconciliationState; + readonly event: HostedCoordinationEventEnvelope; + readonly generation: number; + }): HostedCoordinationEventReconcileResult { + const { state, event } = input; + if (input.generation !== state.generation) { + return Object.freeze({ kind: 'stale_generation', state }); + } + if (event.deploymentId !== state.deploymentId) return resync(state, 'foreign_deployment'); + if (event.eventEpoch !== state.eventEpoch) return resync(state, 'foreign_epoch'); + if ( + !validSequence(event.eventSequence) || + !validIdentity(event.eventId) || + !validCursor(event.previousEventCursor) || + !validCursor(event.eventCursor) + ) { + return resync(state, 'projection_invalid'); + } + + const hasExactEvent = state.processedEvents.some( + (processed) => + processed.eventId === event.eventId && processed.eventCursor === event.eventCursor + ); + const hasEventId = state.processedEvents.some( + (processed) => processed.eventId === event.eventId + ); + const hasEventCursor = state.processedEvents.some( + (processed) => processed.eventCursor === event.eventCursor + ); + if (hasEventId || hasEventCursor) { + return hasExactEvent + ? Object.freeze({ kind: 'duplicate', state }) + : resync(state, 'event_gap'); + } + if (event.eventCursor === state.cursor) { + return Object.freeze({ kind: 'duplicate', state }); + } + if ( + event.previousEventCursor !== state.cursor || + (state.lastEventSequence !== null && event.eventSequence <= state.lastEventSequence) + ) { + return resync(state, 'event_gap'); + } + + const nextRevision = event.resourceRevision; + if (nextRevision) { + if ( + !validIdentity(nextRevision.resourceKey) || + !validSequence(nextRevision.generation) || + !validSequence(nextRevision.revision) + ) { + return resync(state, 'projection_invalid'); + } + const currentRevision = state.revisionVector.find( + ({ resourceKey }) => resourceKey === nextRevision.resourceKey + ); + if (currentRevision) { + const older = + nextRevision.generation < currentRevision.generation || + (nextRevision.generation === currentRevision.generation && + nextRevision.revision < currentRevision.revision); + const equal = + nextRevision.generation === currentRevision.generation && + nextRevision.revision === currentRevision.revision; + if (older && state.handoffMode !== 'lower_barrier') return resync(state, 'event_gap'); + if (older || equal) { + return Object.freeze({ + kind: 'duplicate', + state: advanceState(state, event, this.processedEventWindow), + }); + } + if ( + (nextRevision.generation === currentRevision.generation && + nextRevision.revision !== currentRevision.revision + 1) || + nextRevision.generation > currentRevision.generation + 1 + ) { + return resync(state, 'event_gap'); + } + } + } + + const revisionVector = replacementRevisionVector(state, nextRevision); + if (revisionVector === null) return resync(state, 'event_gap'); + const nextState = advanceState(state, event, this.processedEventWindow, revisionVector); + return Object.freeze({ kind: 'applied', state: nextState, event }); + } +} diff --git a/src/features/coordination-events/renderer/transport/createHostedCoordinationEventBootstrapTransport.ts b/src/features/coordination-events/renderer/transport/createHostedCoordinationEventBootstrapTransport.ts new file mode 100644 index 0000000000..ffc41230df --- /dev/null +++ b/src/features/coordination-events/renderer/transport/createHostedCoordinationEventBootstrapTransport.ts @@ -0,0 +1,214 @@ +import { HOSTED_AUTH_HEADERS } from '@features/hosted-access/contracts'; +import { parseTeamId } from '@shared/contracts/hosted'; + +import { + COORDINATION_SNAPSHOT_SCHEMA_VERSION, + HOSTED_COORDINATION_EVENT_BOOTSTRAP_ROUTE, + HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION, +} from '../../contracts'; + +import type { + CoordinationResourceRevision, + CoordinationSnapshotEnvelope, + HostedCoordinationEventBootstrapSnapshot, + ReplayCursor, +} from '../../contracts'; +import type { HostedCoordinationSnapshotResyncPort } from '../ports/HostedCoordinationEventRendererPorts'; +import type { HostedCoordinationSnapshotResyncInput } from '../ports/HostedCoordinationEventRendererPorts'; + +const MAX_IDENTIFIER_LENGTH = 256; +const MAX_CURSOR_LENGTH = 2_048; +const MAX_REVISION_VECTOR_LENGTH = 10_000; + +export interface HostedCoordinationEventBootstrapHttpRequestInit { + readonly method: 'POST'; + readonly credentials: 'include'; + readonly cache: 'no-store'; + readonly headers: Readonly>; + readonly body: string; + readonly signal?: AbortSignal; +} + +export interface HostedCoordinationEventBootstrapHttpResponse { + readonly status: number; + json(): Promise; +} + +export type HostedCoordinationEventBootstrapFetchPort = ( + input: string, + init: HostedCoordinationEventBootstrapHttpRequestInit +) => Promise; + +export interface HostedCoordinationEventBootstrapTransportDependencies { + readonly fetch: HostedCoordinationEventBootstrapFetchPort; + readonly getCsrfToken: () => string | null; +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function validIdentifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_IDENTIFIER_LENGTH && + value.trim() === value && + !value.includes('\r') && + !value.includes('\n') + ); +} + +function parseReplayCursor(value: unknown): ReplayCursor | null { + return typeof value === 'string' && + value.length > 0 && + value.length <= MAX_CURSOR_LENGTH && + value.trim() === value && + !value.includes('\r') && + !value.includes('\n') + ? (value as ReplayCursor) + : null; +} + +function parseRevisionVector(value: unknown): readonly CoordinationResourceRevision[] | null { + if (!Array.isArray(value) || value.length > MAX_REVISION_VECTOR_LENGTH) return null; + const resourceKeys = new Set(); + const revisions: CoordinationResourceRevision[] = []; + for (const item of value) { + if ( + !isRecord(item) || + !hasExactKeys(item, ['resourceKey', 'generation', 'revision']) || + !validIdentifier(item.resourceKey) || + !Number.isSafeInteger(item.generation) || + (item.generation as number) < 0 || + !Number.isSafeInteger(item.revision) || + (item.revision as number) < 0 || + resourceKeys.has(item.resourceKey) + ) { + return null; + } + resourceKeys.add(item.resourceKey); + revisions.push( + Object.freeze({ + resourceKey: item.resourceKey, + generation: item.generation as number, + revision: item.revision as number, + }) + ); + } + return Object.freeze(revisions); +} + +function parseBootstrapEnvelope( + value: unknown, + requestedTeamId: string +): CoordinationSnapshotEnvelope | null { + if (!isRecord(value) || !hasExactKeys(value, ['metadata', 'snapshot'])) return null; + const metadata = value.metadata; + const snapshot = value.snapshot; + if ( + !isRecord(metadata) || + !hasExactKeys(metadata, [ + 'schemaVersion', + 'deploymentId', + 'eventEpoch', + 'handoffMode', + 'replayCursor', + 'revisionVector', + ]) || + metadata.schemaVersion !== COORDINATION_SNAPSHOT_SCHEMA_VERSION || + !validIdentifier(metadata.deploymentId) || + !validIdentifier(metadata.eventEpoch) || + metadata.handoffMode !== 'lower_barrier' || + !isRecord(snapshot) || + !hasExactKeys(snapshot, ['schemaVersion', 'kind', 'teamId']) || + snapshot.schemaVersion !== HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION || + snapshot.kind !== 'team_event_bootstrap' + ) { + return null; + } + const replayCursor = parseReplayCursor(metadata.replayCursor); + const revisionVector = parseRevisionVector(metadata.revisionVector); + let teamId; + try { + teamId = parseTeamId(snapshot.teamId); + } catch { + return null; + } + if (replayCursor === null || revisionVector === null || teamId !== requestedTeamId) return null; + return Object.freeze({ + metadata: Object.freeze({ + schemaVersion: COORDINATION_SNAPSHOT_SCHEMA_VERSION, + deploymentId: metadata.deploymentId, + eventEpoch: metadata.eventEpoch, + handoffMode: 'lower_barrier', + replayCursor, + revisionVector, + }), + snapshot: Object.freeze({ + schemaVersion: HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION, + kind: 'team_event_bootstrap', + teamId, + }), + }); +} + +export function createHostedCoordinationEventBootstrapTransport( + dependencies: HostedCoordinationEventBootstrapTransportDependencies +): HostedCoordinationSnapshotResyncPort { + if (!dependencies?.fetch || !dependencies.getCsrfToken) { + throw new TypeError('hosted-coordination-event-bootstrap-dependencies-invalid'); + } + return Object.freeze({ + async loadSnapshot({ scope, signal }: HostedCoordinationSnapshotResyncInput) { + if (scope.kind !== 'team') { + throw new Error('hosted-coordination-event-bootstrap-scope-invalid'); + } + const teamId = parseTeamId(scope.scopeId); + const csrfToken = dependencies.getCsrfToken(); + if (csrfToken === null) { + throw new Error('hosted-coordination-event-bootstrap-csrf-unavailable'); + } + const response = await dependencies.fetch(HOSTED_COORDINATION_EVENT_BOOTSTRAP_ROUTE, { + method: 'POST', + credentials: 'include', + cache: 'no-store', + headers: Object.freeze({ + Accept: 'application/json', + 'Content-Type': 'application/json', + [HOSTED_AUTH_HEADERS.csrf]: csrfToken, + }), + body: JSON.stringify({ + schemaVersion: HOSTED_COORDINATION_EVENT_BOOTSTRAP_SCHEMA_VERSION, + teamId, + }), + signal, + }); + if (response.status !== 200) { + throw new Error('hosted-coordination-event-bootstrap-unavailable'); + } + let value: unknown; + try { + value = await response.json(); + } catch { + throw new Error('hosted-coordination-event-bootstrap-response-invalid'); + } + const envelope = parseBootstrapEnvelope(value, teamId); + if (envelope === null) { + throw new Error('hosted-coordination-event-bootstrap-response-invalid'); + } + return envelope; + }, + }); +} diff --git a/src/features/coordination-events/renderer/transport/createHostedCoordinationEventTransport.ts b/src/features/coordination-events/renderer/transport/createHostedCoordinationEventTransport.ts new file mode 100644 index 0000000000..629b2078b6 --- /dev/null +++ b/src/features/coordination-events/renderer/transport/createHostedCoordinationEventTransport.ts @@ -0,0 +1,483 @@ +import { + COORDINATION_EVENT_SCOPE_KINDS, + type CoordinationEventScope, + type CoordinationJsonValue, + type CoordinationResourceRevision, + HOSTED_COORDINATION_EVENT_SSE_EVENT, + HOSTED_COORDINATION_EVENT_STREAM_ROUTE, + HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION, + HOSTED_COORDINATION_RESYNC_REASONS, + HOSTED_COORDINATION_RESYNC_SSE_EVENT, + type HostedCoordinationEventEnvelope, + type HostedCoordinationResyncReason, + type ReplayCursor, +} from '../../contracts'; + +import type { + HostedCoordinationEventBackoffPort, + HostedCoordinationEventConnection, + HostedCoordinationEventDisposition, + HostedCoordinationEventSourceConstructor, + HostedCoordinationEventSourceEvent, + HostedCoordinationEventSourceLike, + HostedCoordinationEventSourceListener, + HostedCoordinationEventTimingPort, + HostedCoordinationEventTransport, + HostedCoordinationEventTransportConnectInput, +} from '../ports/HostedCoordinationEventRendererPorts'; + +const DEFAULT_MAXIMUM_RECONNECT_DELAY_MS = 30_000; +const ABSOLUTE_MAXIMUM_RECONNECT_DELAY_MS = 60_000; +const DEFAULT_MAXIMUM_MESSAGE_BYTES = 256 * 1_024; +const MAXIMUM_MESSAGE_BYTES = 1024 * 1_024; +const MAX_IDENTIFIER_LENGTH = 256; +const MAX_CURSOR_LENGTH = 2_048; +const MAX_EVENT_TYPE_LENGTH = 256; +const MAX_JSON_DEPTH = 64; +const MAX_JSON_NODES = 50_000; +const UTF8_ENCODER = new TextEncoder(); + +export interface CreateHostedCoordinationEventTransportOptions { + readonly eventSourceConstructor: HostedCoordinationEventSourceConstructor; + readonly timing: HostedCoordinationEventTimingPort; + readonly backoff: HostedCoordinationEventBackoffPort; + /** Optional origin for shells whose EventSource requires an absolute URL. */ + readonly baseUrl?: string; + readonly maximumReconnectDelayMs?: number; + readonly maximumMessageBytes?: number; +} + +interface ActiveEventSource { + readonly source: HostedCoordinationEventSourceLike; + readonly token: number; + readonly onOpen: HostedCoordinationEventSourceListener; + readonly onError: HostedCoordinationEventSourceListener; + readonly onEvent: HostedCoordinationEventSourceListener; + readonly onResync: HostedCoordinationEventSourceListener; +} + +function positiveBounded(value: number, maximum: number, field: string): number { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw new Error(`invalid_hosted_coordination_event_transport_option:${field}`); + } + return value; +} + +function validBoundedString(value: unknown, maximum: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maximum && + value.trim() === value && + !value.includes('\r') && + !value.includes('\n') + ); +} + +function validCursor(value: unknown): value is ReplayCursor { + return validBoundedString(value, MAX_CURSOR_LENGTH); +} + +function validSequence(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function readScope(value: unknown): CoordinationEventScope | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const record = value as Record; + if ( + typeof record.kind !== 'string' || + !COORDINATION_EVENT_SCOPE_KINDS.includes( + record.kind as (typeof COORDINATION_EVENT_SCOPE_KINDS)[number] + ) || + !validBoundedString(record.scopeId, MAX_IDENTIFIER_LENGTH) + ) { + return null; + } + return Object.freeze({ + kind: record.kind as CoordinationEventScope['kind'], + scopeId: record.scopeId, + }); +} + +function readResourceRevision(value: unknown): CoordinationResourceRevision | null | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const record = value as Record; + if ( + !validBoundedString(record.resourceKey, MAX_IDENTIFIER_LENGTH) || + !validSequence(record.generation) || + !validSequence(record.revision) + ) { + return null; + } + return Object.freeze({ + resourceKey: record.resourceKey, + generation: record.generation, + revision: record.revision, + }); +} + +function materializeJson(value: unknown): CoordinationJsonValue { + let nodes = 0; + const visit = (current: unknown, depth: number): CoordinationJsonValue => { + nodes += 1; + if (nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) throw new Error('json_budget_exceeded'); + if (current === null || typeof current === 'boolean' || typeof current === 'string') { + return current; + } + if (typeof current === 'number') { + if (!Number.isFinite(current)) throw new Error('json_number_invalid'); + return current; + } + if (Array.isArray(current)) { + return Object.freeze(current.map((item) => visit(item, depth + 1))); + } + if (typeof current !== 'object') throw new Error('json_value_invalid'); + const output: Record = {}; + for (const [key, item] of Object.entries(current)) { + Object.defineProperty(output, key, { + configurable: false, + enumerable: true, + writable: false, + value: visit(item, depth + 1), + }); + } + return Object.freeze(output); + }; + return visit(value, 0); +} + +function parseEventEnvelope( + value: unknown, + lastEventId: unknown +): HostedCoordinationEventEnvelope | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const record = value as Record; + const scope = readScope(record.scope); + const resourceRevision = readResourceRevision(record.resourceRevision); + if ( + record.schemaVersion !== HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION || + record.kind !== HOSTED_COORDINATION_EVENT_SSE_EVENT || + !validBoundedString(record.deploymentId, MAX_IDENTIFIER_LENGTH) || + !validBoundedString(record.eventEpoch, MAX_IDENTIFIER_LENGTH) || + !validSequence(record.eventSequence) || + !validBoundedString(record.eventId, MAX_IDENTIFIER_LENGTH) || + !validCursor(record.previousEventCursor) || + !validCursor(record.eventCursor) || + scope === null || + !validBoundedString(record.eventType, MAX_EVENT_TYPE_LENGTH) || + resourceRevision === null || + !validBoundedString(record.emittedAt, 128) || + !Number.isFinite(Date.parse(record.emittedAt)) || + (lastEventId !== undefined && lastEventId !== '' && lastEventId !== record.eventCursor) + ) { + return null; + } + + let payload: CoordinationJsonValue; + try { + payload = materializeJson(record.payload); + } catch { + return null; + } + return Object.freeze({ + schemaVersion: HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION, + kind: HOSTED_COORDINATION_EVENT_SSE_EVENT, + deploymentId: record.deploymentId, + eventEpoch: record.eventEpoch, + eventSequence: record.eventSequence, + eventId: record.eventId, + previousEventCursor: record.previousEventCursor, + eventCursor: record.eventCursor, + scope, + eventType: record.eventType, + ...(resourceRevision === undefined ? {} : { resourceRevision }), + emittedAt: record.emittedAt, + payload, + }); +} + +function parseResyncReason(value: unknown): HostedCoordinationResyncReason | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const record = value as Record; + if ( + record.schemaVersion !== HOSTED_COORDINATION_EVENT_STREAM_SCHEMA_VERSION || + record.kind !== HOSTED_COORDINATION_RESYNC_SSE_EVENT || + typeof record.reason !== 'string' || + !HOSTED_COORDINATION_RESYNC_REASONS.includes(record.reason as HostedCoordinationResyncReason) + ) { + return null; + } + return record.reason as HostedCoordinationResyncReason; +} + +function parseData(event: HostedCoordinationEventSourceEvent, maximumBytes: number): unknown { + if (typeof event.data !== 'string' || UTF8_ENCODER.encode(event.data).byteLength > maximumBytes) { + throw new Error('hosted_coordination_event_message_invalid'); + } + try { + return JSON.parse(event.data) as unknown; + } catch { + throw new Error('hosted_coordination_event_message_invalid'); + } +} + +function streamUrl(baseUrl: string | undefined, cursor: ReplayCursor): string { + const relative = `${HOSTED_COORDINATION_EVENT_STREAM_ROUTE}?after=${encodeURIComponent(cursor)}`; + if (baseUrl === undefined) return relative; + try { + return new URL(relative, baseUrl).toString(); + } catch { + throw new Error('invalid_hosted_coordination_event_transport_option:baseUrl'); + } +} + +function transportError(message: string): Error { + return new Error(message); +} + +export function createHostedCoordinationEventTransport( + options: CreateHostedCoordinationEventTransportOptions +): HostedCoordinationEventTransport { + if (!options?.eventSourceConstructor || !options.timing || !options.backoff) { + throw new Error('invalid_hosted_coordination_event_transport_options'); + } + const maximumReconnectDelayMs = positiveBounded( + options.maximumReconnectDelayMs ?? DEFAULT_MAXIMUM_RECONNECT_DELAY_MS, + ABSOLUTE_MAXIMUM_RECONNECT_DELAY_MS, + 'maximumReconnectDelayMs' + ); + const maximumMessageBytes = positiveBounded( + options.maximumMessageBytes ?? DEFAULT_MAXIMUM_MESSAGE_BYTES, + MAXIMUM_MESSAGE_BYTES, + 'maximumMessageBytes' + ); + + return Object.freeze({ + connect( + input: HostedCoordinationEventTransportConnectInput + ): HostedCoordinationEventConnection { + if (!validCursor(input?.resumeCursor) || !input.signal || !input.handlers?.onEvent) { + throw new Error('invalid_hosted_coordination_event_connection_input'); + } + + let cursor = input.resumeCursor; + let active: ActiveEventSource | null = null; + let cancelReconnect = (): void => undefined; + let sourceToken = 0; + let reconnectAttempt = 0; + let closed = false; + + const reportError = (error: Error): void => { + try { + input.handlers.onError?.(error); + } catch { + // Error reporting cannot revive or disrupt the stream state machine. + } + }; + + const detachActive = (): void => { + const current = active; + active = null; + if (!current) return; + try { + current.source.removeEventListener('open', current.onOpen); + current.source.removeEventListener('error', current.onError); + current.source.removeEventListener(HOSTED_COORDINATION_EVENT_SSE_EVENT, current.onEvent); + current.source.removeEventListener( + HOSTED_COORDINATION_RESYNC_SSE_EVENT, + current.onResync + ); + } catch { + // The source is still closed below even if listener cleanup fails. + } + try { + current.source.close(); + } catch { + // A failed external close cannot be allowed to revive this token. + } + }; + + const close = (): void => { + if (closed) return; + closed = true; + cancelReconnect(); + cancelReconnect = (): void => undefined; + detachActive(); + input.signal.removeEventListener('abort', close); + }; + + const requireResync = (reason: HostedCoordinationResyncReason, error?: Error): void => { + if (closed) return; + if (error) reportError(error); + close(); + try { + input.handlers.onResyncRequired(reason); + } catch { + // The connection is already closed and cannot mutate future state. + } + }; + + let openSource = (): void => undefined; + const scheduleReconnect = (error: Error): void => { + if (closed || input.signal.aborted) return; + reportError(error); + reconnectAttempt += 1; + let proposedDelay: number | null; + try { + proposedDelay = options.backoff.nextDelayMs(reconnectAttempt); + } catch { + proposedDelay = null; + } + if (proposedDelay === null) { + close(); + return; + } + if (typeof proposedDelay !== 'number' || !Number.isFinite(proposedDelay)) { + reportError(transportError('hosted_coordination_event_backoff_invalid')); + close(); + return; + } + const delayMs = Math.min(maximumReconnectDelayMs, Math.max(0, Math.trunc(proposedDelay))); + try { + input.handlers.onReconnectScheduled?.({ attempt: reconnectAttempt, delayMs }); + } catch { + // Status observation is not part of transport correctness. + } + try { + const cancel = options.timing.schedule(delayMs, () => { + cancelReconnect = (): void => undefined; + openSource(); + }); + cancelReconnect = () => { + try { + cancel(); + } catch { + // Cancellation is best-effort after the connection is fenced. + } + }; + } catch { + reportError(transportError('hosted_coordination_event_timing_failed')); + close(); + } + }; + + openSource = (): void => { + if (closed || input.signal.aborted) return; + cancelReconnect(); + cancelReconnect = (): void => undefined; + detachActive(); + const token = ++sourceToken; + let source: HostedCoordinationEventSourceLike; + try { + source = new options.eventSourceConstructor(streamUrl(options.baseUrl, cursor), { + withCredentials: true, + lastEventId: cursor, + }); + } catch { + scheduleReconnect(transportError('hosted_coordination_event_source_open_failed')); + return; + } + + const isCurrent = (): boolean => !closed && active?.token === token; + const onOpen: HostedCoordinationEventSourceListener = () => { + if (!isCurrent()) return; + reconnectAttempt = 0; + try { + input.handlers.onOpen?.(); + } catch { + // Status observation is not part of transport correctness. + } + }; + const onError: HostedCoordinationEventSourceListener = () => { + if (!isCurrent()) return; + detachActive(); + scheduleReconnect(transportError('hosted_coordination_event_stream_disconnected')); + }; + const onEvent: HostedCoordinationEventSourceListener = (message) => { + if (!isCurrent()) return; + let parsed: HostedCoordinationEventEnvelope | null = null; + try { + parsed = parseEventEnvelope( + parseData(message, maximumMessageBytes), + message.lastEventId + ); + } catch { + // Converted to a terminal projection resync below. + } + if (parsed === null) { + requireResync( + 'projection_invalid', + transportError('hosted_coordination_event_message_invalid') + ); + return; + } + let disposition: HostedCoordinationEventDisposition; + try { + disposition = input.handlers.onEvent( + parsed as HostedCoordinationEventEnvelope + ); + } catch { + requireResync( + 'projection_invalid', + transportError('hosted_coordination_event_consumer_failed') + ); + return; + } + if (disposition.kind === 'advance' && isCurrent()) { + if (!validCursor(disposition.resumeCursor)) { + requireResync( + 'projection_invalid', + transportError('hosted_coordination_event_consumer_cursor_invalid') + ); + return; + } + cursor = disposition.resumeCursor; + } else if (disposition.kind !== 'advance') { + close(); + } + }; + const onResync: HostedCoordinationEventSourceListener = (message) => { + if (!isCurrent()) return; + let reason: HostedCoordinationResyncReason | null = null; + try { + reason = parseResyncReason(parseData(message, maximumMessageBytes)); + } catch { + // Converted to a projection resync below. + } + requireResync( + reason ?? 'projection_invalid', + reason === null + ? transportError('hosted_coordination_event_resync_message_invalid') + : undefined + ); + }; + + active = { source, token, onOpen, onError, onEvent, onResync }; + try { + source.addEventListener('open', onOpen); + source.addEventListener('error', onError); + source.addEventListener(HOSTED_COORDINATION_EVENT_SSE_EVENT, onEvent); + source.addEventListener(HOSTED_COORDINATION_RESYNC_SSE_EVENT, onResync); + } catch { + detachActive(); + scheduleReconnect(transportError('hosted_coordination_event_source_open_failed')); + return; + } + if (closed || input.signal.aborted) close(); + }; + + input.signal.addEventListener('abort', close, { once: true }); + if (input.signal.aborted) close(); + else openSource(); + + return Object.freeze({ + get cursor(): ReplayCursor { + return cursor; + }, + close, + }); + }, + }); +} diff --git a/src/features/external-writer-coordination/contracts/externalWriterContracts.ts b/src/features/external-writer-coordination/contracts/externalWriterContracts.ts new file mode 100644 index 0000000000..f368febeed --- /dev/null +++ b/src/features/external-writer-coordination/contracts/externalWriterContracts.ts @@ -0,0 +1,263 @@ +import type { TeamId } from '@shared/contracts/hosted/identifiers'; + +export const EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION = 2 as const; + +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Public contract names distinguish persistence coordinates. +export type ObservationSequence = number; +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Public contract names distinguish persistence coordinates. +export type ObservationWatermark = number; +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Public contract names distinguish persistence coordinates. +export type FileWriterEpoch = number; +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Public contract name distinguishes a catalog key from arbitrary text. +export type ExternalWriterFeatureKey = string; +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Public contract name distinguishes a registered identity from a path. +export type ExternalFileKey = string; +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Public contract name distinguishes a digest from content or metadata. +export type ExternalContentChecksum = string; +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Public contract name distinguishes a durable deduplication coordinate from arbitrary text. +export type ExternalFileReconciliationId = string; + +export interface ExternalWriterScope { + teamId: TeamId; + featureKey: ExternalWriterFeatureKey; +} + +export interface ExternalFileRegistration { + scope: ExternalWriterScope; + fileKey: ExternalFileKey; + maxBytes: number; + attributionPolicy: 'external_file_only' | 'verified_run_evidence'; +} + +export type ExternalWriterNotificationKind = 'change' | 'rename' | 'delete'; + +export interface ExternalWriterNotification { + kind: ExternalWriterNotificationKind; + scope: ExternalWriterScope; + fileKey: ExternalFileKey; +} + +export interface ExternalWriterOverflowNotification { + scopes: readonly ExternalWriterScope[]; +} + +export interface ExternalWriterWatchCallbacks { + onNotification(notification: ExternalWriterNotification): void; + onOverflow(notification: ExternalWriterOverflowNotification): void; +} + +export type ExternalObservationCause = + | ExternalWriterNotificationKind + | 'startup_scan' + | 'periodic_scan' + | 'dirty_scope_rescan'; + +export type ExternalWriterDirtyReason = + | 'catalog_changed' + | 'corrupt' + | 'drain_budget_exhausted' + | 'notification_overflow' + | 'outside_containment' + | 'oversized' + | 'reconciliation_conflict' + | 'shutdown_handoff' + | 'unstable' + | 'unsupported_file_type'; + +export interface PendingFileObservation { + id: string; + scope: ExternalWriterScope; + fileKey: ExternalFileKey; + cause: ExternalObservationCause; + earliestSequence: ObservationSequence; + latestSequence: ObservationSequence; + fileWriterEpoch: FileWriterEpoch; + attempts: number; + reconciliation: PendingFileReconciliation | null; +} + +export interface PendingFileReconciliation { + reconciliationId: ExternalFileReconciliationId; + throughSequence: ObservationSequence; + fingerprint: ExternalFileSourceFingerprint; + actor: ExternalObservationActor; +} + +export interface DirtyObservationScope { + scope: ExternalWriterScope; + reasons: readonly ExternalWriterDirtyReason[]; + earliestSequence: ObservationSequence; + latestSequence: ObservationSequence; +} + +export interface ExternalFileStat { + kind: 'directory' | 'file' | 'missing' | 'other' | 'symlink'; + contained: boolean; + byteLength: number; + device: string | null; + inode: string | null; + modifiedTimeNs: string | null; + changedTimeNs: string | null; +} + +export interface ExternalFileStatIdentity { + byteLength: number; + device: string; + inode: string; + modifiedTimeNs: string; + changedTimeNs: string; +} + +export interface ExternalFileSourceFingerprint { + exists: boolean; + checksum: ExternalContentChecksum | null; + statIdentity: ExternalFileStatIdentity | null; +} + +export interface ObservedExternalFile { + scope: ExternalWriterScope; + fileKey: ExternalFileKey; + fingerprint: ExternalFileSourceFingerprint; + sourceGeneration: number; + fileWriterEpoch: FileWriterEpoch; + observationSequence: ObservationSequence; +} + +export interface ExternalSelfWriteIntent { + intentId: string; + scope: ExternalWriterScope; + fileKey: ExternalFileKey; + expectedChecksum: ExternalContentChecksum | null; + sourceGeneration: number; + fileWriterEpoch: FileWriterEpoch; + expiresAtMs: number; +} + +export interface FileWriterEpochRecord { + teamId: TeamId; + epoch: FileWriterEpoch; +} + +export interface TeamObservationWatermarkRecord { + teamId: TeamId; + lastObservationSequence: ObservationSequence; + observationWatermark: ObservationWatermark; +} + +export interface FileObservationStateCheckpoint { + schemaVersion: typeof EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION; + lastObservationSequence: ObservationSequence; + observationWatermark: ObservationWatermark; + fileWriterEpochs: readonly FileWriterEpochRecord[]; + teamObservationWatermarks: readonly TeamObservationWatermarkRecord[]; + pendingObservations: readonly PendingFileObservation[]; + dirtyScopes: readonly DirtyObservationScope[]; + selfWriteIntents: readonly ExternalSelfWriteIntent[]; + observedFiles: readonly ObservedExternalFile[]; +} + +export interface ExternalFileActor { + kind: 'external_file'; + teamId: TeamId; + featureKey: ExternalWriterFeatureKey; + fileKey: ExternalFileKey; + checksum: ExternalContentChecksum | null; + observationSequence: ObservationSequence; +} + +/** + * This actor may be returned only by a provider-specific verifier. Generic file + * content, selected UI state, and claimed JSON fields are never sufficient. + */ +export interface VerifiedRunActor { + kind: 'verified_run'; + teamId: TeamId; + runId: string; + runGeneration: number; + memberId: string | null; + evidenceRef: string; +} + +export type ExternalObservationActor = ExternalFileActor | VerifiedRunActor; + +export interface ExternalFileReconciliationRequest { + reconciliationId: ExternalFileReconciliationId; + registration: ExternalFileRegistration; + content: Uint8Array | null; + fingerprint: ExternalFileSourceFingerprint; + observationSequence: ObservationSequence; + fileWriterEpoch: FileWriterEpoch; + actor: ExternalObservationActor; +} + +export type ExternalFileReconciliationResult = + | { + outcome: 'accepted_change'; + sourceGeneration: number; + featureRevision: number; + } + | { + outcome: 'semantic_noop'; + sourceGeneration: number; + } + | { + outcome: 'invalid'; + diagnosticCode: string; + blocksDependentMutations: boolean; + } + | { + outcome: 'conflict'; + diagnosticCode: string; + }; + +export interface ExternalWriterObserverOptions { + maxPendingObservations: number; + maxSelfWriteIntents: number; + maxScopes: number; + maxObservedFiles: number; + maxFilesPerScope: number; + maxReadBytes: number; + maxStableReadAttempts: number; + maxObservationAttempts: number; + maxDrainPassObservations: number; + maxQuiescenceAttempts: number; + stableReadDeadlineMs: number; + retryDelayMs: number; + atomicReplaceDebounceMs: number; + shutdownDrainDeadlineMs: number; +} + +export type ExternalWriterObserverPhase = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped'; + +export interface ExternalWriterObserverSnapshot { + phase: ExternalWriterObserverPhase; + acceptingNotifications: boolean; + readiness: 'clean' | 'dirty'; + checkpoint: FileObservationStateCheckpoint; +} + +export interface ExternalWriterShutdownHandoff { + status: 'clean' | 'dirty' | 'deadline_exceeded'; + capturedSequence: ObservationSequence; + persistedWatermark: ObservationWatermark; + dirtyScopes: readonly DirtyObservationScope[]; + pendingObservationCount: number; +} + +export interface ExternalWriterQuiescenceProof { + teamId: TeamId; + fileWriterEpoch: FileWriterEpoch; + observationWatermark: ObservationWatermark; +} + +export type ExternalWriterQuiescenceResult = + | { + outcome: 'quiesced'; + proof: ExternalWriterQuiescenceProof; + } + | { + outcome: 'external_writer_busy'; + capturedSequence: ObservationSequence; + observationWatermark: ObservationWatermark; + dirtyScopes: readonly DirtyObservationScope[]; + }; diff --git a/src/features/external-writer-coordination/contracts/index.ts b/src/features/external-writer-coordination/contracts/index.ts new file mode 100644 index 0000000000..b90e50d0ae --- /dev/null +++ b/src/features/external-writer-coordination/contracts/index.ts @@ -0,0 +1 @@ +export * from './externalWriterContracts'; diff --git a/src/features/external-writer-coordination/core/application/ExternalWriterObserver.ts b/src/features/external-writer-coordination/core/application/ExternalWriterObserver.ts new file mode 100644 index 0000000000..1121a3933c --- /dev/null +++ b/src/features/external-writer-coordination/core/application/ExternalWriterObserver.ts @@ -0,0 +1,800 @@ +import { + type ExternalFileRegistration, + type ExternalObservationCause, + type ExternalSelfWriteIntent, + type ExternalWriterNotification, + type ExternalWriterObserverOptions, + type ExternalWriterObserverPhase, + type ExternalWriterObserverSnapshot, + type ExternalWriterOverflowNotification, + type ExternalWriterQuiescenceResult, + type ExternalWriterScope, + type ExternalWriterShutdownHandoff, + type FileWriterEpoch, + type ObservationSequence, + type PendingFileObservation, + type PendingFileReconciliation, +} from '../../contracts'; +import { buildExternalFileReconciliationId, FileObservationState } from '../domain'; + +import { + assertExternalWriterObserverOptions, + classifyExternalWriterActor, + DEFAULT_OPTIONS, + type ExternalWriterObserverDependencies, + ExternalWriterObserverError, + externalWriterStateLimits, + fingerprintsEqual, + isClosedReconciliationResult, + isSafePositiveInteger, + readStableExternalFile, + scopesEqual, + type TeamQuiescenceFence, +} from './externalWriterObserverSupport'; +import { + type ExternalWriterSelfWriteEffect, + ExternalWriterSelfWriteOperations, + nextPendingOutsideSelfWriteOperation, +} from './externalWriterSelfWriteOperations'; + +import type { + ExternalWriterCleanHandoffEligibilityPlan, + ExternalWriterWatchHandle, +} from './ports'; +import type { TeamId } from '@shared/contracts/hosted/identifiers'; + +export { type ExternalWriterObserverDependencies, ExternalWriterObserverError } from './externalWriterObserverSupport'; + +export class ExternalWriterObserver { + private readonly options: ExternalWriterObserverOptions; + private state: FileObservationState; + private phase: ExternalWriterObserverPhase = 'idle'; + private acceptingNotifications = false; + private watchHandle: ExternalWriterWatchHandle | null = null; + private operationTail: Promise = Promise.resolve(); + private readonly selfWriteOperations = new ExternalWriterSelfWriteOperations(); + private retryableCleanHandoff: { + readonly checkpoint: ReturnType; + readonly plan: ExternalWriterCleanHandoffEligibilityPlan | null; + readonly result: ExternalWriterShutdownHandoff; + } | null = null; + + constructor( + private readonly dependencies: ExternalWriterObserverDependencies, + options: Partial = {} + ) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + assertExternalWriterObserverOptions(this.options); + this.state = FileObservationState.create(externalWriterStateLimits(this.options)); + } + + start(): Promise { + if (this.phase !== 'idle') { + throw new ExternalWriterObserverError('already_started'); + } + this.phase = 'starting'; + return this.schedule(async () => { + let stateLoaded = false; + try { + const checkpoint = await this.dependencies.stateStore.load(); + this.state = FileObservationState.restore( + checkpoint, + externalWriterStateLimits(this.options) + ); + stateLoaded = true; + this.acceptingNotifications = true; + // The watch callback is live before the first catalog scan begins. + this.watchHandle = await this.dependencies.watch.start({ + onNotification: (notification) => this.acceptNotification(notification), + onOverflow: (notification) => this.acceptOverflow(notification), + }); + const scopes = await this.listScopes(); + for (const scope of scopes) { + await this.scanScopeInternal(scope, 'startup_scan'); + } + await this.drainAvailable(this.options.maxDrainPassObservations); + const persistedThrough = this.state.getLastObservationSequence(); + const startupOverflowScopes = this.state + .getDirtyScopes() + .filter((dirty) => dirty.reasons.includes('notification_overflow')) + .map((dirty) => dirty.scope); + await this.persist(); + this.phase = 'running'; + if ( + this.state.getLastObservationSequence() > persistedThrough || + startupOverflowScopes.length > 0 + ) { + this.finishStartupInBackground(persistedThrough, startupOverflowScopes); + } + return this.getSnapshot(); + } catch (error) { + this.acceptingNotifications = false; + this.phase = 'stopped'; + if (this.watchHandle) { + await this.watchHandle.close().catch(() => undefined); + } + if (stateLoaded) { + await this.persist().catch(() => undefined); + } + throw error; + } + }); + } + + acceptNotification(notification: ExternalWriterNotification): ObservationSequence { + if (!this.acceptingNotifications) { + const sequence = this.state.markScopeDirty(notification.scope, 'shutdown_handoff'); + this.persistInBackground(); + return sequence; + } + const queued = this.state.enqueueObservation({ + scope: notification.scope, + fileKey: notification.fileKey, + cause: notification.kind, + }); + if (this.phase === 'running') { + this.drainInBackground(notification.scope); + } + return queued.sequence; + } + + acceptOverflow(notification: ExternalWriterOverflowNotification): ObservationSequence { + const sequence = this.state.markOverflow(notification.scopes); + if (this.phase === 'running') { + for (const scope of notification.scopes) { + this.rescanInBackground(scope); + } + } else if (!this.acceptingNotifications) { + this.persistInBackground(); + } + return sequence; + } + + recordSelfWriteIntent(intent: ExternalSelfWriteIntent): Promise { + return this.schedule(async () => { + if (this.phase !== 'running' && this.phase !== 'starting') { + throw new ExternalWriterObserverError('not_running'); + } + if (this.state.getFileWriterEpoch(intent.scope.teamId) !== intent.fileWriterEpoch) { + throw new ExternalWriterObserverError('catalog_invalid'); + } + this.state.addSelfWriteIntent(intent); + await this.persist(); + }); + } + + beginSelfWriteOperation(operationId: string, scope: ExternalWriterScope): Promise { + return this.schedule(async () => { + if (this.phase !== 'running') throw new ExternalWriterObserverError('not_running'); + this.selfWriteOperations.begin(operationId, scope); + await this.persist(); + }); + } + + completeSelfWriteOperation( + operationId: string, + effects: readonly ExternalWriterSelfWriteEffect[] + ): Promise { + return this.schedule(async () => { + const intents = this.selfWriteOperations.prepareCompletion({ + operationId, + effects, + maximumEffects: this.options.maxFilesPerScope, + fileWriterEpoch: (scope) => this.state.getFileWriterEpoch(scope.teamId), + nextSourceGeneration: (scope, fileKey) => + (this.state.getObservedFile(scope, fileKey)?.sourceGeneration ?? -1) + 1, + expiresAtMs: this.dependencies.clock.nowMs() + 60_000, + }); + for (const intent of intents) this.state.addSelfWriteIntent(intent); + this.selfWriteOperations.release(operationId); + await this.persist(); + await this.drainAvailable(this.options.maxDrainPassObservations); + await this.persist(); + }); + } + + abortSelfWriteOperation(operationId: string): Promise { + return this.schedule(async () => { + if (!this.selfWriteOperations.release(operationId)) return; + await this.drainAvailable(this.options.maxDrainPassObservations); + await this.persist(); + }); + } + + rescanScope(scope: ExternalWriterScope): Promise { + return this.schedule(async () => { + if (this.phase !== 'running') { + throw new ExternalWriterObserverError('not_running'); + } + await this.scanScopeInternal(scope, 'periodic_scan'); + await this.persist(); + return this.getSnapshot(); + }); + } + + quiesceTeam(teamId: TeamId, deadlineMs: number): Promise { + return this.schedule(async () => { + if (this.phase !== 'running' || !Number.isFinite(deadlineMs)) { + throw new ExternalWriterObserverError('not_running'); + } + for (let attempt = 0; attempt < this.options.maxQuiescenceAttempts; attempt += 1) { + const capturedSequence = this.state.getLastTeamObservationSequence(teamId); + await this.drainTeamThrough(teamId, capturedSequence, deadlineMs); + if (this.dependencies.clock.nowMs() >= deadlineMs) { + break; + } + const teamScopes = (await this.listScopes()).filter((scope) => scope.teamId === teamId); + for (const scope of teamScopes) { + if (this.dependencies.clock.nowMs() >= deadlineMs) { + break; + } + await this.scanScopeInternal(scope, 'dirty_scope_rescan'); + } + const afterScan = this.state.getLastTeamObservationSequence(teamId); + await this.drainTeamThrough(teamId, afterScan, deadlineMs); + const beforePersistence = this.captureTeamQuiescenceFence(teamId); + if ( + beforePersistence.lastObservationSequence === afterScan && + beforePersistence.observationWatermark === beforePersistence.lastObservationSequence && + beforePersistence.clean + ) { + await this.persist(); + const afterPersistence = this.captureTeamQuiescenceFence(teamId); + if (this.sameTeamQuiescenceFence(beforePersistence, afterPersistence)) { + return { + outcome: 'quiesced', + proof: { + teamId, + fileWriterEpoch: afterPersistence.fileWriterEpoch, + observationWatermark: afterPersistence.observationWatermark, + }, + }; + } + } + if (this.dependencies.clock.nowMs() >= deadlineMs) { + break; + } + } + await this.persist(); + return { + outcome: 'external_writer_busy', + capturedSequence: this.state.getLastTeamObservationSequence(teamId), + observationWatermark: this.state.getTeamObservationWatermark(teamId), + dirtyScopes: this.state.getDirtyScopes(teamId), + }; + }); + } + + advanceFileWriterEpoch(input: { + teamId: TeamId; + expectedEpoch: FileWriterEpoch; + observationWatermark: ObservationSequence; + }): Promise { + return this.schedule(async () => { + if (this.phase !== 'running') { + throw new ExternalWriterObserverError('not_running'); + } + const epoch = this.state.advanceFileWriterEpoch({ + teamId: input.teamId, + expectedEpoch: input.expectedEpoch, + throughWatermark: input.observationWatermark, + }); + await this.persist(); + return epoch; + }); + } + + shutdown( + deadlineMs?: number, + cleanHandoffPlan?: ExternalWriterCleanHandoffEligibilityPlan + ): Promise { + if (this.phase !== 'running') { + throw new ExternalWriterObserverError('not_running'); + } + this.acceptingNotifications = false; + this.phase = 'stopping'; + return this.schedule(async () => { + const effectiveDeadline = + deadlineMs ?? this.dependencies.clock.nowMs() + this.options.shutdownDrainDeadlineMs; + let closeFailed = false; + try { + await this.watchHandle?.close(); + } catch { + closeFailed = true; + for (const scope of await this.listScopes().catch(() => [])) { + this.state.markScopeDirty(scope, 'shutdown_handoff'); + } + } + const capturedSequence = this.state.getLastObservationSequence(); + const drained = await this.drainThrough(capturedSequence, effectiveDeadline); + if (!drained) { + for (const pending of this.state.getPendingObservations()) { + if (!this.state.suspendPendingAsDirty(pending.id, 'shutdown_handoff')) { + this.state.failPendingAsDirty(pending.id, 'shutdown_handoff'); + } + } + } + const dirtyScopes = this.state.getDirtyScopes(); + const pendingObservationCount = this.state.getPendingObservationCount(); + this.state.pruneExpiredSelfWriteIntents(this.dependencies.clock.nowMs()); + const checkpoint = this.state.snapshot(); + const retiredTeams = new Set( + cleanHandoffPlan?.retirementProofs.map(({ teamId }) => teamId) ?? [] + ); + const retainedRegistrations = new Set( + cleanHandoffPlan?.retainedRegistrations.map( + ({ scope, fileKey }) => `${scope.teamId}\0${scope.featureKey}\0${fileKey}` + ) ?? [] + ); + const selfWriteIntentsSafeForHandoff = checkpoint.selfWriteIntents.every( + (intent) => + !retiredTeams.has(intent.scope.teamId) && + retainedRegistrations.has( + `${intent.scope.teamId}\0${intent.scope.featureKey}\0${intent.fileKey}` + ) + ); + const deadlineExceeded = !drained && this.dependencies.clock.nowMs() >= effectiveDeadline; + const clean = + !deadlineExceeded && + !closeFailed && + dirtyScopes.length === 0 && + pendingObservationCount === 0 && + (!cleanHandoffPlan || selfWriteIntentsSafeForHandoff); + const result: ExternalWriterShutdownHandoff = { + status: deadlineExceeded ? 'deadline_exceeded' : clean ? 'clean' : 'dirty', + capturedSequence, + persistedWatermark: this.state.getObservationWatermark(), + dirtyScopes, + pendingObservationCount, + }; + if (clean && cleanHandoffPlan) { + this.retryableCleanHandoff = { checkpoint, plan: cleanHandoffPlan, result }; + await this.dependencies.stateStore.saveCleanHandoffEligibility( + checkpoint, + cleanHandoffPlan + ); + } else { + this.retryableCleanHandoff = { checkpoint, plan: null, result }; + await this.persist(); + } + this.phase = 'stopped'; + this.retryableCleanHandoff = null; + return result; + }); + } + + /** Retries only the exact final checkpoint/plan after a lost storage response. */ + retryCleanHandoffEligibility(): Promise { + return this.schedule(async () => { + const retry = this.retryableCleanHandoff; + if (this.phase !== 'stopping' || !retry) { + throw new ExternalWriterObserverError('not_running'); + } + if (retry.plan === null) { + await this.dependencies.stateStore.save(retry.checkpoint); + } else { + await this.dependencies.stateStore.saveCleanHandoffEligibility( + retry.checkpoint, + retry.plan + ); + } + this.retryableCleanHandoff = null; + this.phase = 'stopped'; + return retry.result; + }); + } + + getSnapshot(): ExternalWriterObserverSnapshot { + const checkpoint = this.state.snapshot(); + return { + phase: this.phase, + acceptingNotifications: this.acceptingNotifications, + readiness: + checkpoint.dirtyScopes.length === 0 && checkpoint.pendingObservations.length === 0 + ? 'clean' + : 'dirty', + checkpoint, + }; + } + + private schedule(operation: () => Promise): Promise { + const result = this.operationTail.then(operation, operation); + this.operationTail = result.then( + () => undefined, + () => undefined + ); + return result; + } + + private drainInBackground(scope: ExternalWriterScope): void { + void this.schedule(async () => { + try { + await this.persist(); + await this.drainAvailable(this.options.maxDrainPassObservations); + } catch { + this.state.markScopeDirty(scope, 'unstable'); + } + await this.persist(); + }); + } + + private rescanInBackground(scope: ExternalWriterScope): void { + void this.schedule(async () => { + try { + await this.persist(); + await this.scanScopeInternal(scope, 'dirty_scope_rescan'); + } catch { + this.state.markScopeDirty(scope, 'unstable'); + } + await this.persist(); + }); + } + + private persistInBackground(): void { + void this.schedule(() => this.persist()); + } + + private finishStartupInBackground( + persistedThrough: ObservationSequence, + startupOverflowScopes: readonly ExternalWriterScope[] + ): void { + void this.schedule(async () => { + await this.persist(); + await this.drainAvailable(this.options.maxDrainPassObservations); + const dirtyScopes = [ + ...startupOverflowScopes, + ...this.state + .getDirtyScopes() + .filter((dirty) => dirty.latestSequence > persistedThrough) + .map((dirty) => dirty.scope), + ].filter( + (scope, index, scopes) => + scopes.findIndex((candidate) => scopesEqual(candidate, scope)) === index + ); + for (const scope of dirtyScopes) { + try { + await this.scanScopeInternal(scope, 'dirty_scope_rescan'); + } catch { + this.state.markScopeDirty(scope, 'unstable'); + } + } + await this.persist(); + }); + } + + private async scanScopeInternal( + scope: ExternalWriterScope, + cause: Extract< + ExternalObservationCause, + 'dirty_scope_rescan' | 'periodic_scan' | 'startup_scan' + > + ): Promise { + const repairThrough = this.state.getLastObservationSequence(); + let registrations: readonly ExternalFileRegistration[]; + try { + registrations = await this.listRegistrations(scope); + } catch (error) { + this.state.markScopeDirty(scope, 'catalog_changed'); + if (error instanceof ExternalWriterObserverError) { + return; + } + throw error; + } + let scanComplete = true; + for (const registration of registrations) { + const queued = this.state.enqueueObservation({ + scope, + fileKey: registration.fileKey, + cause, + }); + if (queued.outcome === 'overflow_dirty') { + scanComplete = false; + continue; + } + if (queued.id !== null) { + for (let attempt = 0; attempt < this.options.maxObservationAttempts; attempt += 1) { + const scanPending = this.state.getPendingObservation(queued.id); + if (!scanPending) { + break; + } + await this.processPending(scanPending); + } + } + if (queued.id !== null && this.state.getPendingObservation(queued.id)) { + scanComplete = false; + } + } + if (scanComplete) { + this.state.markScopeRescanned(scope, repairThrough); + } + } + + private async drainAvailable(maxObservations: number): Promise { + let processed = 0; + while (processed < maxObservations) { + const pending = nextPendingOutsideSelfWriteOperation({ + pending: this.state.getPendingObservations(), + maximumAttempts: this.options.maxObservationAttempts, + operations: this.selfWriteOperations, + }); + if (!pending) { + break; + } + await this.processPending(pending); + processed += 1; + } + if (processed >= maxObservations && this.state.getPendingObservationCount() > 0) { + for (const pending of this.state.getPendingObservations()) { + if (!this.state.suspendPendingAsDirty(pending.id, 'drain_budget_exhausted')) { + this.state.failPendingAsDirty(pending.id, 'drain_budget_exhausted'); + } + } + } + return processed; + } + + private async drainThrough(target: ObservationSequence, deadlineMs: number): Promise { + let processed = 0; + while ( + this.state.getObservationWatermark() < target && + processed < this.options.maxDrainPassObservations && + this.dependencies.clock.nowMs() < deadlineMs + ) { + const pending = this.state.takeNextPending(); + if (!pending) { + return false; + } + await this.processPending(pending); + processed += 1; + } + return this.state.getObservationWatermark() >= target; + } + + private async drainTeamThrough( + teamId: TeamId, + target: ObservationSequence, + deadlineMs: number + ): Promise { + let processed = 0; + while ( + this.state.getTeamObservationWatermark(teamId) < target && + processed < this.options.maxDrainPassObservations && + this.dependencies.clock.nowMs() < deadlineMs + ) { + const pending = this.state.takeNextPending(teamId); + if (!pending) { + return false; + } + await this.processPending(pending); + processed += 1; + } + return this.state.getTeamObservationWatermark(teamId) >= target; + } + + private async processPending(initialPending: PendingFileObservation): Promise { + if (this.selfWriteOperations.blocks(initialPending.scope)) return; + let pending = initialPending; + if (pending.reconciliation) { + let recovered: unknown; + try { + recovered = await this.dependencies.reconciliation.getResult( + pending.reconciliation.reconciliationId + ); + } catch { + await this.deferPending(pending.id); + return; + } + if (recovered !== null) { + this.settleReconciliation(pending, pending.reconciliation, recovered); + return; + } + this.state.clearPendingReconciliation(pending.id, pending.reconciliation.reconciliationId); + const refreshed = this.state.getPendingObservation(pending.id); + if (!refreshed) { + return; + } + pending = refreshed; + } + let registration: ExternalFileRegistration | null; + try { + registration = await this.findRegistration(pending); + } catch { + this.state.failPendingAsDirty(pending.id, 'catalog_changed'); + return; + } + if (!registration) { + this.state.failPendingAsDirty(pending.id, 'catalog_changed'); + return; + } + const stableRead = await readStableExternalFile(this.dependencies, this.options, registration); + if (stableRead.outcome === 'invalid') { + this.state.failPendingAsDirty(pending.id, stableRead.reason); + return; + } + if (stableRead.outcome === 'unstable') { + const result = this.state.deferPending(pending.id); + if (result === 'deferred') { + await this.dependencies.clock.sleep(this.options.retryDelayMs); + } + return; + } + const checksumMatch = this.state.matchSelfWriteChecksum({ + scope: pending.scope, + fileKey: pending.fileKey, + checksum: stableRead.fingerprint.checksum, + fileWriterEpoch: pending.fileWriterEpoch, + nowMs: this.dependencies.clock.nowMs(), + }); + if (checksumMatch.outcome === 'matched') { + this.state.recordObservedFile({ + scope: pending.scope, + fileKey: pending.fileKey, + fingerprint: stableRead.fingerprint, + sourceGeneration: checksumMatch.intent.sourceGeneration, + fileWriterEpoch: pending.fileWriterEpoch, + observationSequence: pending.latestSequence, + }); + this.state.completePending(pending.id, pending.latestSequence); + return; + } + const previous = this.state.getObservedFile(pending.scope, pending.fileKey); + if (previous && fingerprintsEqual(previous.fingerprint, stableRead.fingerprint)) { + this.state.recordObservedFile({ + scope: pending.scope, + fileKey: pending.fileKey, + fingerprint: stableRead.fingerprint, + sourceGeneration: previous.sourceGeneration, + fileWriterEpoch: pending.fileWriterEpoch, + observationSequence: pending.latestSequence, + }); + this.state.completePending(pending.id, pending.latestSequence); + return; + } + const actor = await classifyExternalWriterActor(this.dependencies, { + registration, + content: stableRead.content, + checksum: stableRead.fingerprint.checksum, + observationSequence: pending.latestSequence, + fileWriterEpoch: pending.fileWriterEpoch, + }); + const reconciliationAttempt = this.state.beginPendingReconciliation({ + pendingId: pending.id, + reconciliationId: buildExternalFileReconciliationId( + pending.scope, + pending.fileKey, + pending.fileWriterEpoch, + pending.earliestSequence + ), + throughSequence: pending.latestSequence, + fingerprint: stableRead.fingerprint, + actor, + }); + try { + // Write-ahead state makes the id/result lookup recoverable if the atomic + // feature commit succeeds but its response is lost. + await this.persist(); + const reconciliation: unknown = await this.dependencies.reconciliation.reconcile({ + reconciliationId: reconciliationAttempt.reconciliationId, + registration, + content: stableRead.content, + fingerprint: stableRead.fingerprint, + observationSequence: reconciliationAttempt.throughSequence, + fileWriterEpoch: pending.fileWriterEpoch, + actor, + }); + this.settleReconciliation(pending, reconciliationAttempt, reconciliation); + } catch { + await this.deferPending(pending.id); + } + } + + private settleReconciliation( + pending: PendingFileObservation, + attempt: PendingFileReconciliation, + reconciliation: unknown + ): void { + if (!isClosedReconciliationResult(reconciliation)) { + this.state.failPendingAsDirty(pending.id, 'reconciliation_conflict'); + return; + } + if (reconciliation.outcome === 'invalid') { + this.state.failPendingAsDirty(pending.id, 'corrupt'); + return; + } + if (reconciliation.outcome === 'conflict') { + this.state.failPendingAsDirty(pending.id, 'reconciliation_conflict'); + return; + } + const previous = this.state.getObservedFile(pending.scope, pending.fileKey); + if (previous && reconciliation.sourceGeneration < previous.sourceGeneration) { + this.state.failPendingAsDirty(pending.id, 'reconciliation_conflict'); + return; + } + this.state.recordObservedFile({ + scope: pending.scope, + fileKey: pending.fileKey, + fingerprint: attempt.fingerprint, + sourceGeneration: reconciliation.sourceGeneration, + fileWriterEpoch: pending.fileWriterEpoch, + observationSequence: attempt.throughSequence, + }); + this.state.completePending(pending.id, attempt.throughSequence); + } + + private async deferPending(pendingId: string): Promise { + const result = this.state.deferPending(pendingId); + if (result === 'deferred') { + await this.dependencies.clock.sleep(this.options.retryDelayMs); + } + } + + private async findRegistration( + pending: PendingFileObservation + ): Promise { + const registrations = await this.listRegistrations(pending.scope); + return registrations.find((registration) => registration.fileKey === pending.fileKey) ?? null; + } + + private async listScopes(): Promise { + const scopes = await this.dependencies.catalog.listScopes(); + if (scopes.length > this.options.maxScopes) { + throw new ExternalWriterObserverError('catalog_invalid'); + } + const seen = new Set(); + for (const scope of scopes) { + const key = `${scope.teamId.length}:${scope.teamId}${scope.featureKey.length}:${scope.featureKey}`; + if (scope.teamId.length === 0 || scope.featureKey.length === 0 || seen.has(key)) { + throw new ExternalWriterObserverError('catalog_invalid'); + } + seen.add(key); + } + return scopes; + } + + private async listRegistrations( + scope: ExternalWriterScope + ): Promise { + const registrations = await this.dependencies.catalog.listRegistrations(scope); + if (registrations.length > this.options.maxFilesPerScope) { + throw new ExternalWriterObserverError('catalog_invalid'); + } + const seen = new Set(); + for (const registration of registrations) { + if ( + !scopesEqual(registration.scope, scope) || + registration.fileKey.length === 0 || + !isSafePositiveInteger(registration.maxBytes) || + registration.maxBytes > this.options.maxReadBytes || + (registration.attributionPolicy !== 'external_file_only' && + registration.attributionPolicy !== 'verified_run_evidence') || + seen.has(registration.fileKey) + ) { + throw new ExternalWriterObserverError('catalog_invalid'); + } + seen.add(registration.fileKey); + } + return registrations; + } + + private async persist(): Promise { + await this.dependencies.stateStore.save(this.state.snapshot()); + } + + private captureTeamQuiescenceFence(teamId: TeamId): TeamQuiescenceFence { + return { + fileWriterEpoch: this.state.getFileWriterEpoch(teamId), + lastObservationSequence: this.state.getLastTeamObservationSequence(teamId), + observationWatermark: this.state.getTeamObservationWatermark(teamId), + clean: this.state.isTeamClean(teamId), + }; + } + + private sameTeamQuiescenceFence(left: TeamQuiescenceFence, right: TeamQuiescenceFence): boolean { + return ( + left.fileWriterEpoch === right.fileWriterEpoch && + left.lastObservationSequence === right.lastObservationSequence && + left.observationWatermark === right.observationWatermark && + left.clean === right.clean + ); + } +} diff --git a/src/features/external-writer-coordination/core/application/externalWriterObserverSupport.ts b/src/features/external-writer-coordination/core/application/externalWriterObserverSupport.ts new file mode 100644 index 0000000000..5544216b6f --- /dev/null +++ b/src/features/external-writer-coordination/core/application/externalWriterObserverSupport.ts @@ -0,0 +1,334 @@ +import { + type ExternalContentChecksum, + type ExternalFileActor, + type ExternalFileReconciliationResult, + type ExternalFileRegistration, + type ExternalFileSourceFingerprint, + type ExternalFileStat, + type ExternalFileStatIdentity, + type ExternalWriterDirtyReason, + type ExternalWriterObserverOptions, + type ExternalWriterScope, + type FileWriterEpoch, + type ObservationSequence, + type VerifiedRunActor, +} from '../../contracts'; + +import type { + ExternalContentChecksumPort, + ExternalFileObservationCatalog, + ExternalFileObservationSource, + ExternalFileReconciliationPort, + ExternalWriterObservationStateStore, + ExternalWriterObserverClock, + ExternalWriterWatchPort, + VerifiedRunEvidencePort, +} from './ports'; + +export const DEFAULT_OPTIONS: ExternalWriterObserverOptions = { + maxPendingObservations: 1_024, + maxSelfWriteIntents: 1_024, + maxScopes: 1_024, + maxObservedFiles: 100_000, + maxFilesPerScope: 10_000, + maxReadBytes: 4 * 1_024 * 1_024, + maxStableReadAttempts: 4, + maxObservationAttempts: 3, + maxDrainPassObservations: 20_000, + maxQuiescenceAttempts: 4, + stableReadDeadlineMs: 2_000, + retryDelayMs: 10, + atomicReplaceDebounceMs: 25, + shutdownDrainDeadlineMs: 5_000, +}; + +export type StableReadOutcome = + | { + outcome: 'stable'; + content: Uint8Array | null; + fingerprint: ExternalFileSourceFingerprint; + } + | { + outcome: 'invalid'; + reason: Extract< + ExternalWriterDirtyReason, + 'outside_containment' | 'oversized' | 'unsupported_file_type' + >; + } + | { outcome: 'unstable' }; + +export interface TeamQuiescenceFence { + fileWriterEpoch: FileWriterEpoch; + lastObservationSequence: ObservationSequence; + observationWatermark: ObservationSequence; + clean: boolean; +} + +export interface ExternalWriterObserverDependencies { + watch: ExternalWriterWatchPort; + catalog: ExternalFileObservationCatalog; + source: ExternalFileObservationSource; + checksums: ExternalContentChecksumPort; + reconciliation: ExternalFileReconciliationPort; + stateStore: ExternalWriterObservationStateStore; + clock: ExternalWriterObserverClock; + verifiedRunEvidence?: VerifiedRunEvidencePort; +} + +export class ExternalWriterObserverError extends Error { + constructor( + readonly code: 'already_started' | 'catalog_invalid' | 'not_running' | 'options_invalid' + ) { + super(`external-writer-observer:${code}`); + this.name = 'ExternalWriterObserverError'; + } +} + +export const scopesEqual = (left: ExternalWriterScope, right: ExternalWriterScope): boolean => + left.teamId === right.teamId && left.featureKey === right.featureKey; + +export const fingerprintsEqual = ( + left: ExternalFileSourceFingerprint, + right: ExternalFileSourceFingerprint +): boolean => left.exists === right.exists && left.checksum === right.checksum; + +export const isSafeNonNegativeInteger = (value: number): boolean => + Number.isSafeInteger(value) && value >= 0; + +export const isSafePositiveInteger = (value: number): boolean => + Number.isSafeInteger(value) && value > 0; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + +export const isClosedReconciliationResult = ( + value: unknown +): value is ExternalFileReconciliationResult => { + if (!isRecord(value)) { + return false; + } + switch (value.outcome) { + case 'accepted_change': + return ( + typeof value.sourceGeneration === 'number' && + isSafeNonNegativeInteger(value.sourceGeneration) && + typeof value.featureRevision === 'number' && + isSafeNonNegativeInteger(value.featureRevision) + ); + case 'semantic_noop': + return ( + typeof value.sourceGeneration === 'number' && + isSafeNonNegativeInteger(value.sourceGeneration) + ); + case 'invalid': + return ( + isNonEmptyString(value.diagnosticCode) && + typeof value.blocksDependentMutations === 'boolean' + ); + case 'conflict': + return isNonEmptyString(value.diagnosticCode); + default: + return false; + } +}; + +const statIdentity = (stat: ExternalFileStat): ExternalFileStatIdentity | null => { + if ( + stat.kind !== 'file' || + stat.device === null || + stat.inode === null || + stat.modifiedTimeNs === null || + stat.changedTimeNs === null + ) { + return null; + } + return { + byteLength: stat.byteLength, + device: stat.device, + inode: stat.inode, + modifiedTimeNs: stat.modifiedTimeNs, + changedTimeNs: stat.changedTimeNs, + }; +}; + +const statIdentitiesEqual = ( + left: ExternalFileStatIdentity, + right: ExternalFileStatIdentity +): boolean => + left.byteLength === right.byteLength && + left.device === right.device && + left.inode === right.inode && + left.modifiedTimeNs === right.modifiedTimeNs && + left.changedTimeNs === right.changedTimeNs; + +export async function classifyExternalWriterActor( + dependencies: ExternalWriterObserverDependencies, + input: { + readonly registration: ExternalFileRegistration; + readonly content: Uint8Array | null; + readonly checksum: ExternalContentChecksum | null; + readonly observationSequence: ObservationSequence; + readonly fileWriterEpoch: FileWriterEpoch; + } +): Promise { + const externalActor: ExternalFileActor = { + kind: 'external_file', + teamId: input.registration.scope.teamId, + featureKey: input.registration.scope.featureKey, + fileKey: input.registration.fileKey, + checksum: input.checksum, + observationSequence: input.observationSequence, + }; + if ( + input.registration.attributionPolicy !== 'verified_run_evidence' || + !dependencies.verifiedRunEvidence + ) { + return externalActor; + } + let verified: VerifiedRunActor | null; + try { + verified = await dependencies.verifiedRunEvidence.verify(input); + } catch { + return externalActor; + } + if (!verified) { + return externalActor; + } + if ( + verified.kind !== 'verified_run' || + verified.teamId !== input.registration.scope.teamId || + typeof verified.runId !== 'string' || + verified.runId.length === 0 || + (verified.memberId !== null && typeof verified.memberId !== 'string') || + typeof verified.evidenceRef !== 'string' || + verified.evidenceRef.length === 0 || + !isSafePositiveInteger(verified.runGeneration) + ) { + return externalActor; + } + return verified; +} + +export async function readStableExternalFile( + dependencies: ExternalWriterObserverDependencies, + options: ExternalWriterObserverOptions, + registration: ExternalFileRegistration +): Promise { + const startedAt = dependencies.clock.nowMs(); + for (let attempt = 0; attempt < options.maxStableReadAttempts; attempt += 1) { + try { + const before = await dependencies.source.stat(registration); + if (!before.contained) { + return { outcome: 'invalid', reason: 'outside_containment' }; + } + if (before.kind === 'missing') { + await dependencies.clock.sleep(options.atomicReplaceDebounceMs); + const confirmed = await dependencies.source.confirmAbsentByParentRescan(registration); + const afterConfirmation = await dependencies.source.stat(registration); + if (confirmed && afterConfirmation.kind === 'missing' && afterConfirmation.contained) { + return { + outcome: 'stable', + content: null, + fingerprint: { exists: false, checksum: null, statIdentity: null }, + }; + } + await retryStableRead(dependencies.clock, options, startedAt, attempt); + continue; + } + if (before.kind !== 'file') { + return { outcome: 'invalid', reason: 'unsupported_file_type' }; + } + const maximumBytes = Math.min(registration.maxBytes, options.maxReadBytes); + if (!isSafeNonNegativeInteger(before.byteLength) || before.byteLength > maximumBytes) { + return { outcome: 'invalid', reason: 'oversized' }; + } + const beforeIdentity = statIdentity(before); + if (!beforeIdentity) { + await retryStableRead(dependencies.clock, options, startedAt, attempt); + continue; + } + const content = await dependencies.source.read(registration, maximumBytes); + const after = await dependencies.source.stat(registration); + const afterIdentity = statIdentity(after); + if ( + !after.contained || + !afterIdentity || + content.byteLength !== before.byteLength || + !statIdentitiesEqual(beforeIdentity, afterIdentity) + ) { + await retryStableRead(dependencies.clock, options, startedAt, attempt); + continue; + } + const checksum = await dependencies.checksums.checksum(content); + if (checksum.length === 0) { + await retryStableRead(dependencies.clock, options, startedAt, attempt); + continue; + } + return { + outcome: 'stable', + content, + fingerprint: { exists: true, checksum, statIdentity: afterIdentity }, + }; + } catch { + await retryStableRead(dependencies.clock, options, startedAt, attempt); + } + } + return { outcome: 'unstable' }; +} + +async function retryStableRead( + clock: ExternalWriterObserverClock, + options: ExternalWriterObserverOptions, + startedAt: number, + attempt: number +): Promise { + if ( + attempt + 1 < options.maxStableReadAttempts && + clock.nowMs() - startedAt < options.stableReadDeadlineMs + ) { + await clock.sleep(options.retryDelayMs * (attempt + 1)); + } +} + +export function externalWriterStateLimits(options: ExternalWriterObserverOptions): { + maxPendingObservations: number; + maxSelfWriteIntents: number; + maxObservationAttempts: number; + maxScopes: number; + maxObservedFiles: number; +} { + return { + maxPendingObservations: options.maxPendingObservations, + maxSelfWriteIntents: options.maxSelfWriteIntents, + maxObservationAttempts: options.maxObservationAttempts, + maxScopes: options.maxScopes, + maxObservedFiles: options.maxObservedFiles, + }; +} + +export function assertExternalWriterObserverOptions(options: ExternalWriterObserverOptions): void { + const positiveIntegerOptions = [ + options.maxPendingObservations, + options.maxSelfWriteIntents, + options.maxScopes, + options.maxObservedFiles, + options.maxFilesPerScope, + options.maxReadBytes, + options.maxStableReadAttempts, + options.maxObservationAttempts, + options.maxDrainPassObservations, + options.maxQuiescenceAttempts, + ]; + if ( + positiveIntegerOptions.some((value) => !isSafePositiveInteger(value)) || + !isSafeNonNegativeInteger(options.stableReadDeadlineMs) || + !isSafeNonNegativeInteger(options.retryDelayMs) || + !isSafeNonNegativeInteger(options.atomicReplaceDebounceMs) || + !isSafeNonNegativeInteger(options.shutdownDrainDeadlineMs) + ) { + throw new ExternalWriterObserverError('options_invalid'); + } +} diff --git a/src/features/external-writer-coordination/core/application/externalWriterSelfWriteOperations.ts b/src/features/external-writer-coordination/core/application/externalWriterSelfWriteOperations.ts new file mode 100644 index 0000000000..4d15442f85 --- /dev/null +++ b/src/features/external-writer-coordination/core/application/externalWriterSelfWriteOperations.ts @@ -0,0 +1,86 @@ +import { ExternalWriterObserverError, scopesEqual } from './externalWriterObserverSupport'; + +import type { + ExternalContentChecksum, + ExternalSelfWriteIntent, + ExternalWriterScope, + FileWriterEpoch, + PendingFileObservation, +} from '../../contracts'; +import type { TeamId } from '@shared/contracts/hosted/identifiers'; + +export interface ExternalWriterSelfWriteEffect { + readonly fileKey: string; + readonly expectedChecksum: ExternalContentChecksum; +} + +export class ExternalWriterSelfWriteOperations { + private readonly active = new Map(); + + begin(operationId: string, scope: ExternalWriterScope): void { + if (operationId.length < 1 || operationId.length > 256 || this.active.has(operationId)) { + throw new ExternalWriterObserverError('not_running'); + } + this.active.set(operationId, Object.freeze({ ...scope })); + } + + prepareCompletion(input: { + readonly operationId: string; + readonly effects: readonly ExternalWriterSelfWriteEffect[]; + readonly maximumEffects: number; + readonly fileWriterEpoch: (scope: ExternalWriterScope) => FileWriterEpoch; + readonly nextSourceGeneration: (scope: ExternalWriterScope, fileKey: string) => number; + readonly expiresAtMs: number; + }): readonly ExternalSelfWriteIntent[] { + const scope = this.active.get(input.operationId); + if (!scope || input.effects.length > input.maximumEffects) { + throw new ExternalWriterObserverError('catalog_invalid'); + } + const fileWriterEpoch = input.fileWriterEpoch(scope); + return Object.freeze( + input.effects.map((effect, index) => { + if ( + effect.fileKey.length < 1 || + effect.fileKey.length > 256 || + effect.expectedChecksum.length < 1 || + effect.expectedChecksum.length > 256 + ) { + throw new ExternalWriterObserverError('catalog_invalid'); + } + return Object.freeze({ + intentId: `${input.operationId}:${index}`, + scope, + fileKey: effect.fileKey, + expectedChecksum: effect.expectedChecksum, + sourceGeneration: input.nextSourceGeneration(scope, effect.fileKey), + fileWriterEpoch, + expiresAtMs: input.expiresAtMs, + }); + }) + ); + } + + release(operationId: string): boolean { + return this.active.delete(operationId); + } + + blocks(scope: ExternalWriterScope): boolean { + return [...this.active.values()].some((active) => scopesEqual(active, scope)); + } +} + +export function nextPendingOutsideSelfWriteOperation(input: { + readonly pending: readonly PendingFileObservation[]; + readonly maximumAttempts: number; + readonly operations: ExternalWriterSelfWriteOperations; + readonly teamId?: TeamId; +}): PendingFileObservation | null { + return ( + input.pending.find( + (pending) => + pending.attempts < input.maximumAttempts && + (input.teamId === undefined || pending.scope.teamId === input.teamId) && + !input.operations.blocks(pending.scope) + ) ?? null + ); +} diff --git a/src/features/external-writer-coordination/core/application/index.ts b/src/features/external-writer-coordination/core/application/index.ts new file mode 100644 index 0000000000..9340dd2d83 --- /dev/null +++ b/src/features/external-writer-coordination/core/application/index.ts @@ -0,0 +1,2 @@ +export * from './ExternalWriterObserver'; +export type * from './ports'; diff --git a/src/features/external-writer-coordination/core/application/ports.ts b/src/features/external-writer-coordination/core/application/ports.ts new file mode 100644 index 0000000000..bdcc6238a2 --- /dev/null +++ b/src/features/external-writer-coordination/core/application/ports.ts @@ -0,0 +1,103 @@ +import type { + ExternalFileReconciliationId, + ExternalFileReconciliationRequest, + ExternalFileReconciliationResult, + ExternalFileRegistration, + ExternalFileStat, + ExternalWriterScope, + ExternalWriterWatchCallbacks, + FileObservationStateCheckpoint, + VerifiedRunActor, +} from '../../contracts'; + +export interface ExternalWriterWatchHandle { + close(): Promise; +} + +export interface ExternalWriterWatchPort { + start(callbacks: ExternalWriterWatchCallbacks): Promise; +} + +/** Catalogues only feature-owned identities. It does not expose a global root scan. */ +export interface ExternalFileObservationCatalog { + listScopes(): Promise; + listRegistrations(scope: ExternalWriterScope): Promise; +} + +/** + * Filesystem mechanics belong in a later adapter. Core receives already scoped + * registrations and never resolves paths or recursively scans a root. + */ +export interface ExternalFileObservationSource { + stat(registration: ExternalFileRegistration): Promise; + read(registration: ExternalFileRegistration, maxBytes: number): Promise; + confirmAbsentByParentRescan(registration: ExternalFileRegistration): Promise; +} + +export interface ExternalContentChecksumPort { + checksum(content: Uint8Array): Promise | string; +} + +export interface ExternalFileReconciliationPort { + /** + * Return the durable result for an earlier reconciliation. `null` is a proof + * from the same atomic store that the id never committed, not a cache miss. + */ + getResult( + reconciliationId: ExternalFileReconciliationId + ): Promise; + /** + * Parse, validate, and atomically commit the id, normalized input, + * projection, source/event evidence, and result. Reusing an id with the same + * input returns that result; reusing it with different input fails closed. + */ + reconcile(request: ExternalFileReconciliationRequest): Promise; +} + +export interface VerifiedRunEvidenceRequest { + registration: ExternalFileRegistration; + content: Uint8Array | null; + checksum: string | null; + observationSequence: number; + fileWriterEpoch: number; +} + +export interface VerifiedRunEvidencePort { + /** Returns null unless provider evidence independently proves the exact run actor. */ + verify(request: VerifiedRunEvidenceRequest): Promise; +} + +export interface ExternalWriterObservationStateStore { + load(): Promise; + /** Consumes a previously sealed catalog handoff before any new watcher starts. */ + consumeCleanHandoffEligibility(): Promise; + /** Returns only the bounded hot identities from the current checkpoint. */ + listHotTeamIds(): Promise; + save(checkpoint: FileObservationStateCheckpoint): Promise; + saveCleanHandoffEligibility( + checkpoint: FileObservationStateCheckpoint, + plan: ExternalWriterCleanHandoffEligibilityPlan + ): Promise; +} +export interface ExternalWriterRetiredTeamProof { + readonly teamId: FileObservationStateCheckpoint['fileWriterEpochs'][number]['teamId']; + readonly identityChecksum: string; + readonly tombstonedAt: string; +} + +export interface ExternalWriterCleanHandoffEligibilityPlan { + readonly handoffId: string; + readonly oldCatalogToken: string; + readonly nextCatalogToken: string; + readonly retainedRegistrations: readonly { + readonly scope: ExternalWriterScope; + readonly fileKey: string; + }[]; + readonly retirementProofs: readonly ExternalWriterRetiredTeamProof[]; + readonly createdAt: string; +} + +export interface ExternalWriterObserverClock { + nowMs(): number; + sleep(delayMs: number): Promise; +} diff --git a/src/features/external-writer-coordination/core/domain/fileObservationState.ts b/src/features/external-writer-coordination/core/domain/fileObservationState.ts new file mode 100644 index 0000000000..ff17932050 --- /dev/null +++ b/src/features/external-writer-coordination/core/domain/fileObservationState.ts @@ -0,0 +1,782 @@ +import { + type DirtyObservationScope, + EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION, + type ExternalContentChecksum, + type ExternalFileKey, + type ExternalFileSourceFingerprint, + type ExternalObservationActor, + type ExternalObservationCause, + type ExternalSelfWriteIntent, + type ExternalWriterDirtyReason, + type ExternalWriterScope, + type FileObservationStateCheckpoint, + type FileWriterEpoch, + type ObservationSequence, + type ObservedExternalFile, + type PendingFileObservation, + type PendingFileReconciliation, +} from '../../contracts'; + +import { + assertDirtyObservation, + assertFingerprint, + assertNonEmpty, + assertObservationActor, + assertPendingObservation, + assertReconciliationId, + assertScope, + buildExternalFileReconciliationId, + cloneScope, + type CompletePendingObservationOutcome, + copyDirty, + copyIntent, + copyObserved, + copyPending, + type EnqueueObservationOutcome, + fileKey, + FileObservationStateError, + type FileObservationStateLimits, + isSafeNonNegativeInteger, + isSafePositiveInteger, + scopeKey, + scopesEqual, + type SelfWriteChecksumMatch, +} from './fileObservationStateSupport'; + +export { + buildExternalFileReconciliationId, + type CompletePendingObservationOutcome, + type EnqueueObservationOutcome, + FileObservationStateError, + type FileObservationStateLimits, + type SelfWriteChecksumMatch, +} from './fileObservationStateSupport'; + +export class FileObservationState { + private lastObservationSequence = 0; + private observationWatermark = 0; + private readonly fileWriterEpochs = new Map(); + private readonly teamIds = new Map(); + private readonly teamLastObservationSequences = new Map(); + private readonly teamObservationWatermarks = new Map(); + private readonly pendingObservations = new Map(); + private readonly dirtyScopes = new Map(); + private readonly selfWriteIntents = new Map(); + private readonly observedFiles = new Map(); + + private constructor(private readonly limits: FileObservationStateLimits) { + if ( + !isSafePositiveInteger(limits.maxPendingObservations) || + !isSafePositiveInteger(limits.maxSelfWriteIntents) || + !isSafePositiveInteger(limits.maxObservationAttempts) || + !isSafePositiveInteger(limits.maxScopes) || + !isSafePositiveInteger(limits.maxObservedFiles) + ) { + throw new FileObservationStateError('limit_invalid'); + } + } + + static create(limits: FileObservationStateLimits): FileObservationState { + return new FileObservationState(limits); + } + + static restore( + checkpoint: FileObservationStateCheckpoint | null, + limits: FileObservationStateLimits + ): FileObservationState { + const state = new FileObservationState(limits); + if (!checkpoint) { + return state; + } + state.restoreCheckpoint(checkpoint); + return state; + } + + getLastObservationSequence(): ObservationSequence { + return this.lastObservationSequence; + } + + getObservationWatermark(): ObservationSequence { + return this.observationWatermark; + } + + getLastTeamObservationSequence(teamId: ExternalWriterScope['teamId']): ObservationSequence { + assertNonEmpty(teamId); + return this.teamLastObservationSequences.get(teamId) ?? 0; + } + + getTeamObservationWatermark(teamId: ExternalWriterScope['teamId']): ObservationSequence { + assertNonEmpty(teamId); + return this.teamObservationWatermarks.get(teamId) ?? 0; + } + + getPendingObservationCount(): number { + return this.pendingObservations.size; + } + + getPendingObservations(): readonly PendingFileObservation[] { + return [...this.pendingObservations.values()].map(copyPending); + } + + getPendingObservation(id: string): PendingFileObservation | null { + const pending = this.pendingObservations.get(id); + return pending ? copyPending(pending) : null; + } + + getDirtyScopes(teamId?: ExternalWriterScope['teamId']): readonly DirtyObservationScope[] { + return [...this.dirtyScopes.values()] + .filter((dirty) => teamId === undefined || dirty.scope.teamId === teamId) + .map(copyDirty); + } + + getFileWriterEpoch(teamId: ExternalWriterScope['teamId']): FileWriterEpoch { + assertNonEmpty(teamId); + const existing = this.fileWriterEpochs.get(teamId); + if (existing !== undefined) { + return existing; + } + if (this.fileWriterEpochs.size >= this.limits.maxScopes) { + throw new FileObservationStateError('tracked_state_limit_exceeded'); + } + this.fileWriterEpochs.set(teamId, 1); + this.teamIds.set(teamId, teamId); + return 1; + } + + enqueueObservation(input: { + scope: ExternalWriterScope; + fileKey: ExternalFileKey; + cause: ExternalObservationCause; + }): { outcome: EnqueueObservationOutcome; sequence: ObservationSequence; id: string | null } { + assertScope(input.scope); + assertNonEmpty(input.fileKey); + const id = fileKey(input.scope, input.fileKey); + const existing = this.pendingObservations.get(id); + if (existing) { + const sequence = this.allocateSequence([input.scope.teamId]); + existing.latestSequence = sequence; + existing.cause = input.cause; + this.recalculateWatermark(); + return { outcome: 'coalesced', sequence, id }; + } + if (this.pendingObservations.size >= this.limits.maxPendingObservations) { + this.assertCanTrackDirtyScopes([input.scope]); + const sequence = this.allocateSequence([input.scope.teamId]); + this.mergeDirtyScope(input.scope, 'notification_overflow', sequence, sequence); + this.recalculateWatermark(); + return { outcome: 'overflow_dirty', sequence, id: null }; + } + const fileWriterEpoch = this.getFileWriterEpoch(input.scope.teamId); + const sequence = this.allocateSequence([input.scope.teamId]); + this.pendingObservations.set(id, { + id, + scope: cloneScope(input.scope), + fileKey: input.fileKey, + cause: input.cause, + earliestSequence: sequence, + latestSequence: sequence, + fileWriterEpoch, + attempts: 0, + reconciliation: null, + }); + this.recalculateWatermark(); + return { outcome: 'enqueued', sequence, id }; + } + + markOverflow(scopes: readonly ExternalWriterScope[]): ObservationSequence { + if (scopes.length === 0 || scopes.length > this.limits.maxScopes) { + throw new FileObservationStateError('checkpoint_invalid'); + } + for (const scope of scopes) { + assertScope(scope); + } + this.assertCanTrackDirtyScopes(scopes); + const sequence = this.allocateSequence(scopes.map((scope) => scope.teamId)); + for (const scope of scopes) { + this.mergeDirtyScope(scope, 'notification_overflow', sequence, sequence); + } + this.recalculateWatermark(); + return sequence; + } + + markScopeDirty( + scope: ExternalWriterScope, + reason: ExternalWriterDirtyReason + ): ObservationSequence { + assertScope(scope); + this.assertCanTrackDirtyScopes([scope]); + const sequence = this.allocateSequence([scope.teamId]); + this.mergeDirtyScope(scope, reason, sequence, sequence); + this.recalculateWatermark(); + return sequence; + } + + takeNextPending(teamId?: ExternalWriterScope['teamId']): PendingFileObservation | null { + const next = [...this.pendingObservations.values()].find( + (pending) => + pending.attempts < this.limits.maxObservationAttempts && + (teamId === undefined || pending.scope.teamId === teamId) + ); + return next ? copyPending(next) : null; + } + + completePending( + id: string, + throughSequence: ObservationSequence + ): CompletePendingObservationOutcome { + const pending = this.pendingObservations.get(id); + if (!pending) { + return 'missing'; + } + if ( + !isSafePositiveInteger(throughSequence) || + throughSequence < pending.earliestSequence || + throughSequence > pending.latestSequence + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + if (pending.latestSequence > throughSequence) { + pending.earliestSequence = throughSequence + 1; + pending.attempts = 0; + pending.reconciliation = null; + this.recalculateWatermark(); + return 'newer_pending'; + } + this.pendingObservations.delete(id); + this.recalculateWatermark(); + return 'completed'; + } + + beginPendingReconciliation(input: { + pendingId: string; + reconciliationId: string; + throughSequence: ObservationSequence; + fingerprint: ExternalFileSourceFingerprint; + actor: ExternalObservationActor; + }): PendingFileReconciliation { + assertReconciliationId(input.reconciliationId); + assertFingerprint(input.fingerprint); + const pending = this.pendingObservations.get(input.pendingId); + if ( + !pending || + pending.reconciliation || + input.reconciliationId !== + buildExternalFileReconciliationId( + pending.scope, + pending.fileKey, + pending.fileWriterEpoch, + pending.earliestSequence + ) || + input.throughSequence < pending.earliestSequence || + input.throughSequence > pending.latestSequence + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + assertObservationActor(input.actor, pending.scope); + if ( + input.actor.kind === 'external_file' && + (input.actor.observationSequence !== input.throughSequence || + input.actor.fileKey !== pending.fileKey || + input.actor.checksum !== input.fingerprint.checksum) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + pending.reconciliation = { + reconciliationId: input.reconciliationId, + throughSequence: input.throughSequence, + fingerprint: { + ...input.fingerprint, + statIdentity: input.fingerprint.statIdentity ? { ...input.fingerprint.statIdentity } : null, + }, + actor: { ...input.actor }, + }; + return copyPending(pending).reconciliation!; + } + + clearPendingReconciliation(pendingId: string, reconciliationId: string): boolean { + const pending = this.pendingObservations.get(pendingId); + if (!pending) { + return false; + } + if (pending.reconciliation?.reconciliationId !== reconciliationId) { + throw new FileObservationStateError('checkpoint_invalid'); + } + pending.reconciliation = null; + return true; + } + + deferPending(id: string): 'deferred' | 'dirty' | 'missing' { + const pending = this.pendingObservations.get(id); + if (!pending) { + return 'missing'; + } + pending.attempts = Math.min(pending.attempts + 1, this.limits.maxObservationAttempts); + if (pending.attempts < this.limits.maxObservationAttempts) { + return 'deferred'; + } + if (pending.reconciliation) { + this.mergeDirtyScope( + pending.scope, + 'unstable', + pending.earliestSequence, + pending.latestSequence + ); + this.recalculateWatermark(); + return 'dirty'; + } + this.mergeDirtyScope( + pending.scope, + 'unstable', + pending.earliestSequence, + pending.latestSequence + ); + this.pendingObservations.delete(id); + this.recalculateWatermark(); + return 'dirty'; + } + + suspendPendingAsDirty(id: string, reason: ExternalWriterDirtyReason): boolean { + const pending = this.pendingObservations.get(id); + if (!pending?.reconciliation) { + return false; + } + pending.attempts = this.limits.maxObservationAttempts; + this.mergeDirtyScope(pending.scope, reason, pending.earliestSequence, pending.latestSequence); + this.recalculateWatermark(); + return true; + } + + failPendingAsDirty(id: string, reason: ExternalWriterDirtyReason): boolean { + const pending = this.pendingObservations.get(id); + if (!pending) { + return false; + } + this.mergeDirtyScope(pending.scope, reason, pending.earliestSequence, pending.latestSequence); + this.pendingObservations.delete(id); + this.recalculateWatermark(); + return true; + } + + markScopeRescanned(scope: ExternalWriterScope, throughSequence: ObservationSequence): boolean { + const key = scopeKey(scope); + const dirty = this.dirtyScopes.get(key); + if (!dirty || dirty.latestSequence > throughSequence) { + return false; + } + this.dirtyScopes.delete(key); + this.recalculateWatermark(); + return true; + } + + addSelfWriteIntent(intent: ExternalSelfWriteIntent): void { + assertScope(intent.scope); + assertNonEmpty(intent.fileKey); + assertNonEmpty(intent.intentId); + if ( + !isSafeNonNegativeInteger(intent.sourceGeneration) || + !isSafePositiveInteger(intent.fileWriterEpoch) || + !Number.isFinite(intent.expiresAtMs) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + if (intent.fileWriterEpoch !== this.getFileWriterEpoch(intent.scope.teamId)) { + throw new FileObservationStateError('epoch_stale'); + } + const observed = this.observedFiles.get(fileKey(intent.scope, intent.fileKey)); + if ( + observed && + (intent.sourceGeneration < observed.sourceGeneration || + intent.fileWriterEpoch < observed.fileWriterEpoch) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + for (const [intentId, existing] of this.selfWriteIntents) { + if (fileKey(existing.scope, existing.fileKey) === fileKey(intent.scope, intent.fileKey)) { + this.selfWriteIntents.delete(intentId); + } + } + if ( + !this.selfWriteIntents.has(intent.intentId) && + this.selfWriteIntents.size >= this.limits.maxSelfWriteIntents + ) { + throw new FileObservationStateError('self_write_limit_exceeded'); + } + this.selfWriteIntents.set(intent.intentId, copyIntent(intent)); + } + + matchSelfWriteChecksum(input: { + scope: ExternalWriterScope; + fileKey: ExternalFileKey; + checksum: ExternalContentChecksum | null; + fileWriterEpoch: FileWriterEpoch; + nowMs: number; + }): SelfWriteChecksumMatch { + this.pruneExpiredSelfWrites(input.nowMs); + const matchesFile = [...this.selfWriteIntents.values()].filter( + (intent) => scopesEqual(intent.scope, input.scope) && intent.fileKey === input.fileKey + ); + if (matchesFile.length === 0) { + return { outcome: 'none', intent: null }; + } + const matched = matchesFile.find( + (intent) => + intent.expectedChecksum === input.checksum && + intent.fileWriterEpoch === input.fileWriterEpoch + ); + for (const intent of matchesFile) { + this.selfWriteIntents.delete(intent.intentId); + } + return matched + ? { outcome: 'matched', intent: copyIntent(matched) } + : { outcome: 'mismatch', intent: null }; + } + + pruneExpiredSelfWriteIntents(nowMs: number): number { + const before = this.selfWriteIntents.size; + this.pruneExpiredSelfWrites(nowMs); + return before - this.selfWriteIntents.size; + } + + getObservedFile( + scope: ExternalWriterScope, + registeredFileKey: ExternalFileKey + ): ObservedExternalFile | null { + const observed = this.observedFiles.get(fileKey(scope, registeredFileKey)); + return observed ? copyObserved(observed) : null; + } + + recordObservedFile(input: { + scope: ExternalWriterScope; + fileKey: ExternalFileKey; + fingerprint: ExternalFileSourceFingerprint; + sourceGeneration: number; + fileWriterEpoch: FileWriterEpoch; + observationSequence: ObservationSequence; + }): void { + assertFingerprint(input.fingerprint); + if ( + !isSafeNonNegativeInteger(input.sourceGeneration) || + !isSafePositiveInteger(input.fileWriterEpoch) || + !isSafeNonNegativeInteger(input.observationSequence) || + input.observationSequence > this.lastObservationSequence + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + if (input.fileWriterEpoch !== this.getFileWriterEpoch(input.scope.teamId)) { + throw new FileObservationStateError('epoch_stale'); + } + const key = fileKey(input.scope, input.fileKey); + const existing = this.observedFiles.get(key); + if ( + existing && + (input.sourceGeneration < existing.sourceGeneration || + input.fileWriterEpoch < existing.fileWriterEpoch || + input.observationSequence < existing.observationSequence) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + if (!existing && this.observedFiles.size >= this.limits.maxObservedFiles) { + throw new FileObservationStateError('tracked_state_limit_exceeded'); + } + this.observedFiles.set(key, copyObserved({ ...input })); + } + + isTeamClean(teamId: ExternalWriterScope['teamId']): boolean { + return ( + ![...this.pendingObservations.values()].some((pending) => pending.scope.teamId === teamId) && + ![...this.dirtyScopes.values()].some((dirty) => dirty.scope.teamId === teamId) + ); + } + + advanceFileWriterEpoch(input: { + teamId: ExternalWriterScope['teamId']; + expectedEpoch: FileWriterEpoch; + throughWatermark: ObservationSequence; + }): FileWriterEpoch { + const current = this.getFileWriterEpoch(input.teamId); + const teamWatermark = this.getTeamObservationWatermark(input.teamId); + const teamLastSequence = this.getLastTeamObservationSequence(input.teamId); + if (current !== input.expectedEpoch) { + throw new FileObservationStateError('epoch_stale'); + } + if ( + input.throughWatermark !== teamWatermark || + input.throughWatermark !== teamLastSequence || + !this.isTeamClean(input.teamId) + ) { + throw new FileObservationStateError('epoch_not_quiescent'); + } + if (current >= Number.MAX_SAFE_INTEGER) { + throw new FileObservationStateError('sequence_exhausted'); + } + const next = current + 1; + this.fileWriterEpochs.set(input.teamId, next); + this.teamIds.set(input.teamId, input.teamId); + return next; + } + + snapshot(): FileObservationStateCheckpoint { + return { + schemaVersion: EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION, + lastObservationSequence: this.lastObservationSequence, + observationWatermark: this.observationWatermark, + fileWriterEpochs: [...this.fileWriterEpochs.entries()].map(([teamId, epoch]) => ({ + teamId: this.teamIds.get(teamId) ?? (teamId as ExternalWriterScope['teamId']), + epoch, + })), + teamObservationWatermarks: [...this.teamLastObservationSequences.entries()].map( + ([teamId, lastObservationSequence]) => ({ + teamId: this.teamIds.get(teamId) ?? (teamId as ExternalWriterScope['teamId']), + lastObservationSequence, + observationWatermark: this.teamObservationWatermarks.get(teamId) ?? 0, + }) + ), + pendingObservations: [...this.pendingObservations.values()].map(copyPending), + dirtyScopes: [...this.dirtyScopes.values()].map(copyDirty), + selfWriteIntents: [...this.selfWriteIntents.values()].map(copyIntent), + observedFiles: [...this.observedFiles.values()].map(copyObserved), + }; + } + + private allocateSequence(teamIds: readonly ExternalWriterScope['teamId'][]): ObservationSequence { + if (this.lastObservationSequence >= Number.MAX_SAFE_INTEGER) { + throw new FileObservationStateError('sequence_exhausted'); + } + const distinctTeamIds = new Set(teamIds); + for (const teamId of distinctTeamIds) { + assertNonEmpty(teamId); + } + const newTeamCount = [...distinctTeamIds].filter( + (teamId) => !this.teamLastObservationSequences.has(teamId) + ).length; + if (this.teamLastObservationSequences.size + newTeamCount > this.limits.maxScopes) { + throw new FileObservationStateError('tracked_state_limit_exceeded'); + } + this.lastObservationSequence += 1; + for (const teamId of distinctTeamIds) { + this.teamIds.set(teamId, teamId); + this.teamLastObservationSequences.set(teamId, this.lastObservationSequence); + if (!this.teamObservationWatermarks.has(teamId)) { + this.teamObservationWatermarks.set(teamId, 0); + } + } + return this.lastObservationSequence; + } + + private mergeDirtyScope( + scope: ExternalWriterScope, + reason: ExternalWriterDirtyReason, + earliestSequence: ObservationSequence, + latestSequence: ObservationSequence + ): void { + const key = scopeKey(scope); + const existing = this.dirtyScopes.get(key); + if (!existing) { + if (this.dirtyScopes.size >= this.limits.maxScopes) { + throw new FileObservationStateError('tracked_state_limit_exceeded'); + } + this.dirtyScopes.set(key, { + scope: cloneScope(scope), + reasons: [reason], + earliestSequence, + latestSequence, + }); + return; + } + existing.earliestSequence = Math.min(existing.earliestSequence, earliestSequence); + existing.latestSequence = Math.max(existing.latestSequence, latestSequence); + if (!existing.reasons.includes(reason)) { + existing.reasons = [...existing.reasons, reason]; + } + } + + private assertCanTrackDirtyScopes(scopes: readonly ExternalWriterScope[]): void { + const newScopeKeys = new Set(scopes.map(scopeKey).filter((key) => !this.dirtyScopes.has(key))); + if (this.dirtyScopes.size + newScopeKeys.size > this.limits.maxScopes) { + throw new FileObservationStateError('tracked_state_limit_exceeded'); + } + } + + private recalculateWatermark(): void { + let earliestOutstanding = Number.POSITIVE_INFINITY; + for (const pending of this.pendingObservations.values()) { + earliestOutstanding = Math.min(earliestOutstanding, pending.earliestSequence); + } + for (const dirty of this.dirtyScopes.values()) { + earliestOutstanding = Math.min(earliestOutstanding, dirty.earliestSequence); + } + const candidate = Number.isFinite(earliestOutstanding) + ? earliestOutstanding - 1 + : this.lastObservationSequence; + this.observationWatermark = Math.max(this.observationWatermark, candidate); + for (const [teamId, lastTeamSequence] of this.teamLastObservationSequences) { + let earliestTeamOutstanding = Number.POSITIVE_INFINITY; + for (const pending of this.pendingObservations.values()) { + if (pending.scope.teamId === teamId) { + earliestTeamOutstanding = Math.min(earliestTeamOutstanding, pending.earliestSequence); + } + } + for (const dirty of this.dirtyScopes.values()) { + if (dirty.scope.teamId === teamId) { + earliestTeamOutstanding = Math.min(earliestTeamOutstanding, dirty.earliestSequence); + } + } + const teamCandidate = Number.isFinite(earliestTeamOutstanding) + ? earliestTeamOutstanding - 1 + : lastTeamSequence; + this.teamObservationWatermarks.set( + teamId, + Math.max(this.teamObservationWatermarks.get(teamId) ?? 0, teamCandidate) + ); + } + } + + private pruneExpiredSelfWrites(nowMs: number): void { + for (const [intentId, intent] of this.selfWriteIntents) { + if (intent.expiresAtMs <= nowMs) { + this.selfWriteIntents.delete(intentId); + } + } + } + + private restoreCheckpoint(checkpoint: FileObservationStateCheckpoint): void { + if ( + checkpoint.schemaVersion !== EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION || + !isSafeNonNegativeInteger(checkpoint.lastObservationSequence) || + !isSafeNonNegativeInteger(checkpoint.observationWatermark) || + checkpoint.observationWatermark > checkpoint.lastObservationSequence || + checkpoint.pendingObservations.length > this.limits.maxPendingObservations || + checkpoint.selfWriteIntents.length > this.limits.maxSelfWriteIntents || + checkpoint.fileWriterEpochs.length > this.limits.maxScopes || + checkpoint.teamObservationWatermarks.length > this.limits.maxScopes || + checkpoint.dirtyScopes.length > this.limits.maxScopes || + checkpoint.observedFiles.length > this.limits.maxObservedFiles + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + this.lastObservationSequence = checkpoint.lastObservationSequence; + this.observationWatermark = checkpoint.observationWatermark; + for (const record of checkpoint.fileWriterEpochs) { + assertNonEmpty(record.teamId); + if (this.fileWriterEpochs.has(record.teamId) || !isSafePositiveInteger(record.epoch)) { + throw new FileObservationStateError('checkpoint_invalid'); + } + this.fileWriterEpochs.set(record.teamId, record.epoch); + this.teamIds.set(record.teamId, record.teamId); + } + for (const record of checkpoint.teamObservationWatermarks) { + assertNonEmpty(record.teamId); + if ( + this.teamLastObservationSequences.has(record.teamId) || + !isSafeNonNegativeInteger(record.lastObservationSequence) || + !isSafeNonNegativeInteger(record.observationWatermark) || + record.observationWatermark > record.lastObservationSequence || + record.lastObservationSequence > this.lastObservationSequence + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + this.teamLastObservationSequences.set(record.teamId, record.lastObservationSequence); + this.teamObservationWatermarks.set(record.teamId, record.observationWatermark); + this.teamIds.set(record.teamId, record.teamId); + } + for (const pending of checkpoint.pendingObservations) { + assertPendingObservation({ + pending, + lastObservationSequence: this.lastObservationSequence, + observationWatermark: this.observationWatermark, + limits: this.limits, + }); + if (this.pendingObservations.has(pending.id)) { + throw new FileObservationStateError('checkpoint_invalid'); + } + this.pendingObservations.set(pending.id, copyPending(pending)); + } + for (const dirty of checkpoint.dirtyScopes) { + assertDirtyObservation(dirty, this.lastObservationSequence, this.observationWatermark); + const key = scopeKey(dirty.scope); + if (this.dirtyScopes.has(key)) { + throw new FileObservationStateError('checkpoint_invalid'); + } + this.dirtyScopes.set(key, copyDirty(dirty)); + } + for (const intent of checkpoint.selfWriteIntents) { + assertScope(intent.scope); + assertNonEmpty(intent.intentId); + assertNonEmpty(intent.fileKey); + if ( + this.selfWriteIntents.has(intent.intentId) || + !isSafeNonNegativeInteger(intent.sourceGeneration) || + !isSafePositiveInteger(intent.fileWriterEpoch) || + !Number.isFinite(intent.expiresAtMs) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + this.selfWriteIntents.set(intent.intentId, copyIntent(intent)); + } + for (const observed of checkpoint.observedFiles) { + assertScope(observed.scope); + assertNonEmpty(observed.fileKey); + assertFingerprint(observed.fingerprint); + const key = fileKey(observed.scope, observed.fileKey); + if ( + this.observedFiles.has(key) || + !isSafeNonNegativeInteger(observed.sourceGeneration) || + !isSafePositiveInteger(observed.fileWriterEpoch) || + !isSafeNonNegativeInteger(observed.observationSequence) || + observed.observationSequence > this.lastObservationSequence + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + this.observedFiles.set(key, copyObserved(observed)); + } + for (const pending of this.pendingObservations.values()) { + const teamLastSequence = this.teamLastObservationSequences.get(pending.scope.teamId); + if ( + this.fileWriterEpochs.get(pending.scope.teamId) !== pending.fileWriterEpoch || + teamLastSequence === undefined || + teamLastSequence < pending.latestSequence + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + } + for (const dirty of this.dirtyScopes.values()) { + const teamLastSequence = this.teamLastObservationSequences.get(dirty.scope.teamId); + if (teamLastSequence === undefined || teamLastSequence < dirty.latestSequence) { + throw new FileObservationStateError('checkpoint_invalid'); + } + } + for (const intent of this.selfWriteIntents.values()) { + const epoch = this.fileWriterEpochs.get(intent.scope.teamId); + if (epoch === undefined || intent.fileWriterEpoch > epoch) { + throw new FileObservationStateError('checkpoint_invalid'); + } + } + for (const observed of this.observedFiles.values()) { + const epoch = this.fileWriterEpochs.get(observed.scope.teamId); + const teamLastSequence = this.teamLastObservationSequences.get(observed.scope.teamId); + if ( + epoch === undefined || + observed.fileWriterEpoch > epoch || + teamLastSequence === undefined || + teamLastSequence < observed.observationSequence + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + } + const restoredWatermark = this.observationWatermark; + const restoredTeamWatermarks = new Map(this.teamObservationWatermarks); + this.observationWatermark = 0; + for (const teamId of this.teamObservationWatermarks.keys()) { + this.teamObservationWatermarks.set(teamId, 0); + } + this.recalculateWatermark(); + if ( + restoredWatermark !== this.observationWatermark || + [...restoredTeamWatermarks].some( + ([teamId, watermark]) => this.teamObservationWatermarks.get(teamId) !== watermark + ) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + } +} diff --git a/src/features/external-writer-coordination/core/domain/fileObservationStateSupport.ts b/src/features/external-writer-coordination/core/domain/fileObservationStateSupport.ts new file mode 100644 index 0000000000..6b58b926d9 --- /dev/null +++ b/src/features/external-writer-coordination/core/domain/fileObservationStateSupport.ts @@ -0,0 +1,266 @@ +import { + type DirtyObservationScope, + type ExternalFileKey, + type ExternalFileReconciliationId, + type ExternalFileSourceFingerprint, + type ExternalObservationActor, + type ExternalSelfWriteIntent, + type ExternalWriterScope, + type FileWriterEpoch, + type ObservationSequence, + type ObservedExternalFile, + type PendingFileObservation, +} from '../../contracts'; + +export interface FileObservationStateLimits { + maxPendingObservations: number; + maxSelfWriteIntents: number; + maxObservationAttempts: number; + maxScopes: number; + maxObservedFiles: number; +} + +export type EnqueueObservationOutcome = 'coalesced' | 'enqueued' | 'overflow_dirty'; + +export type CompletePendingObservationOutcome = 'completed' | 'missing' | 'newer_pending'; + +export type SelfWriteChecksumMatch = + | { outcome: 'matched'; intent: ExternalSelfWriteIntent } + | { outcome: 'mismatch' | 'none'; intent: null }; + +export class FileObservationStateError extends Error { + constructor( + readonly code: + | 'checkpoint_invalid' + | 'epoch_not_quiescent' + | 'epoch_stale' + | 'limit_invalid' + | 'sequence_exhausted' + | 'self_write_limit_exceeded' + | 'tracked_state_limit_exceeded' + ) { + super(`external-writer-observation-state:${code}`); + this.name = 'FileObservationStateError'; + } +} + +export const cloneScope = (scope: ExternalWriterScope): ExternalWriterScope => ({ ...scope }); + +export const scopeKey = (scope: ExternalWriterScope): string => + `${scope.teamId.length}:${scope.teamId}${scope.featureKey.length}:${scope.featureKey}`; + +export const fileKey = (scope: ExternalWriterScope, registeredFileKey: ExternalFileKey): string => + `${scopeKey(scope)}${registeredFileKey.length}:${registeredFileKey}`; + +export const scopesEqual = (left: ExternalWriterScope, right: ExternalWriterScope): boolean => + left.teamId === right.teamId && left.featureKey === right.featureKey; + +export const isSafeNonNegativeInteger = (value: number): boolean => + Number.isSafeInteger(value) && value >= 0; + +export const isSafePositiveInteger = (value: number): boolean => + Number.isSafeInteger(value) && value > 0; + +export const MAX_STATE_STRING_LENGTH = 1_024; +const MAX_RECONCILIATION_ID_LENGTH = 4 * MAX_STATE_STRING_LENGTH + 128; + +export const buildExternalFileReconciliationId = ( + scope: ExternalWriterScope, + registeredFileKey: ExternalFileKey, + fileWriterEpoch: FileWriterEpoch, + earliestSequence: ObservationSequence +): ExternalFileReconciliationId => { + const canonicalFileIdentity = fileKey(scope, registeredFileKey); + return [ + 'external-writer-reconciliation', + 'v2', + canonicalFileIdentity.length, + canonicalFileIdentity, + fileWriterEpoch, + earliestSequence, + ].join(':'); +}; + +export const assertNonEmpty = (value: string): void => { + if (value.length === 0 || value.length > MAX_STATE_STRING_LENGTH) { + throw new FileObservationStateError('checkpoint_invalid'); + } +}; + +export const assertReconciliationId = (value: string): void => { + if (value.length === 0 || value.length > MAX_RECONCILIATION_ID_LENGTH) { + throw new FileObservationStateError('checkpoint_invalid'); + } +}; + +export const assertScope = (scope: ExternalWriterScope): void => { + assertNonEmpty(scope.teamId); + assertNonEmpty(scope.featureKey); +}; + +export const assertFingerprint = (fingerprint: ExternalFileSourceFingerprint): void => { + if (!fingerprint.exists) { + if (fingerprint.checksum !== null || fingerprint.statIdentity !== null) { + throw new FileObservationStateError('checkpoint_invalid'); + } + return; + } + const identity = fingerprint.statIdentity; + if ( + !fingerprint.checksum || + fingerprint.checksum.length > MAX_STATE_STRING_LENGTH || + !identity || + !isSafeNonNegativeInteger(identity.byteLength) || + identity.device.length === 0 || + identity.device.length > MAX_STATE_STRING_LENGTH || + identity.inode.length === 0 || + identity.inode.length > MAX_STATE_STRING_LENGTH || + identity.modifiedTimeNs.length === 0 || + identity.modifiedTimeNs.length > MAX_STATE_STRING_LENGTH || + identity.changedTimeNs.length === 0 || + identity.changedTimeNs.length > MAX_STATE_STRING_LENGTH + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } +}; + +export const copyPending = (pending: PendingFileObservation): PendingFileObservation => ({ + ...pending, + scope: cloneScope(pending.scope), + reconciliation: pending.reconciliation + ? { + ...pending.reconciliation, + fingerprint: { + ...pending.reconciliation.fingerprint, + statIdentity: pending.reconciliation.fingerprint.statIdentity + ? { ...pending.reconciliation.fingerprint.statIdentity } + : null, + }, + actor: { ...pending.reconciliation.actor }, + } + : null, +}); + +export const copyDirty = (dirty: DirtyObservationScope): DirtyObservationScope => ({ + ...dirty, + scope: cloneScope(dirty.scope), + reasons: [...dirty.reasons], +}); + +export const copyIntent = (intent: ExternalSelfWriteIntent): ExternalSelfWriteIntent => ({ + ...intent, + scope: cloneScope(intent.scope), +}); + +export const copyObserved = (observed: ObservedExternalFile): ObservedExternalFile => ({ + ...observed, + scope: cloneScope(observed.scope), + fingerprint: { + ...observed.fingerprint, + statIdentity: observed.fingerprint.statIdentity + ? { ...observed.fingerprint.statIdentity } + : null, + }, +}); + +export function assertPendingObservation(input: { + readonly pending: PendingFileObservation; + readonly lastObservationSequence: ObservationSequence; + readonly observationWatermark: ObservationSequence; + readonly limits: FileObservationStateLimits; +}): void { + const { pending } = input; + assertScope(pending.scope); + assertNonEmpty(pending.fileKey); + if ( + pending.id !== fileKey(pending.scope, pending.fileKey) || + !isSafePositiveInteger(pending.earliestSequence) || + !isSafePositiveInteger(pending.latestSequence) || + pending.earliestSequence > pending.latestSequence || + pending.latestSequence > input.lastObservationSequence || + pending.earliestSequence <= input.observationWatermark || + !isSafePositiveInteger(pending.fileWriterEpoch) || + !isSafeNonNegativeInteger(pending.attempts) || + pending.attempts > input.limits.maxObservationAttempts || + (pending.attempts === input.limits.maxObservationAttempts && pending.reconciliation === null) || + (pending.reconciliation !== null && + (pending.reconciliation.throughSequence < pending.earliestSequence || + pending.reconciliation.throughSequence > pending.latestSequence)) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + if (pending.reconciliation) { + assertReconciliationId(pending.reconciliation.reconciliationId); + assertFingerprint(pending.reconciliation.fingerprint); + assertObservationActor(pending.reconciliation.actor, pending.scope); + if ( + pending.reconciliation.reconciliationId !== + buildExternalFileReconciliationId( + pending.scope, + pending.fileKey, + pending.fileWriterEpoch, + pending.earliestSequence + ) || + (pending.reconciliation.actor.kind === 'external_file' && + (pending.reconciliation.actor.observationSequence !== + pending.reconciliation.throughSequence || + pending.reconciliation.actor.fileKey !== pending.fileKey || + pending.reconciliation.actor.checksum !== pending.reconciliation.fingerprint.checksum)) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + } +} + +export function assertDirtyObservation( + dirty: DirtyObservationScope, + lastObservationSequence: ObservationSequence, + observationWatermark: ObservationSequence +): void { + assertScope(dirty.scope); + if ( + dirty.reasons.length === 0 || + new Set(dirty.reasons).size !== dirty.reasons.length || + !isSafePositiveInteger(dirty.earliestSequence) || + !isSafePositiveInteger(dirty.latestSequence) || + dirty.earliestSequence > dirty.latestSequence || + dirty.latestSequence > lastObservationSequence || + dirty.earliestSequence <= observationWatermark + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } +} + +export function assertObservationActor( + actor: ExternalObservationActor, + scope: ExternalWriterScope +): void { + if (actor.teamId !== scope.teamId) { + throw new FileObservationStateError('checkpoint_invalid'); + } + if (actor.kind === 'external_file') { + assertNonEmpty(actor.featureKey); + assertNonEmpty(actor.fileKey); + if ( + actor.featureKey !== scope.featureKey || + !isSafePositiveInteger(actor.observationSequence) || + (actor.checksum !== null && + (actor.checksum.length === 0 || actor.checksum.length > MAX_STATE_STRING_LENGTH)) + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } + return; + } + if ( + actor.kind !== 'verified_run' || + actor.runId.length === 0 || + actor.runId.length > MAX_STATE_STRING_LENGTH || + !isSafePositiveInteger(actor.runGeneration) || + (actor.memberId !== null && + (actor.memberId.length === 0 || actor.memberId.length > MAX_STATE_STRING_LENGTH)) || + actor.evidenceRef.length === 0 || + actor.evidenceRef.length > MAX_STATE_STRING_LENGTH + ) { + throw new FileObservationStateError('checkpoint_invalid'); + } +} diff --git a/src/features/external-writer-coordination/core/domain/index.ts b/src/features/external-writer-coordination/core/domain/index.ts new file mode 100644 index 0000000000..32c99a2b2b --- /dev/null +++ b/src/features/external-writer-coordination/core/domain/index.ts @@ -0,0 +1 @@ +export * from './fileObservationState'; diff --git a/src/features/external-writer-coordination/index.ts b/src/features/external-writer-coordination/index.ts new file mode 100644 index 0000000000..a6c74fad00 --- /dev/null +++ b/src/features/external-writer-coordination/index.ts @@ -0,0 +1,3 @@ +export * from './contracts'; +export * from './core/application'; +export * from './core/domain'; diff --git a/src/features/external-writer-coordination/main/application/ExternalWriterReconciliationRouter.ts b/src/features/external-writer-coordination/main/application/ExternalWriterReconciliationRouter.ts new file mode 100644 index 0000000000..b8285e00ec --- /dev/null +++ b/src/features/external-writer-coordination/main/application/ExternalWriterReconciliationRouter.ts @@ -0,0 +1,121 @@ +import type { ExternalFileReconciliationResult, ExternalWriterFeatureKey } from '../../contracts'; +import type { ExternalFileReconciliationPort } from '../../core/application'; + +const RECONCILIATION_ID_PREFIX = 'external-writer-reconciliation:v2:'; +const MAX_RECONCILIATION_ID_LENGTH = 4 * 1_024 + 128; + +/** A feature-owned reconciliation port selected only by its registered feature key. */ +export interface ExternalWriterReconciliationRoute { + readonly featureKey: ExternalWriterFeatureKey; + readonly reconciliation: ExternalFileReconciliationPort; +} + +function conflict(diagnosticCode: string): ExternalFileReconciliationResult { + return Object.freeze({ outcome: 'conflict', diagnosticCode }); +} + +function isCanonicalPositiveInteger(value: string): boolean { + return /^(?:[1-9][0-9]*)$/.test(value) && Number.isSafeInteger(Number(value)); +} + +function readLengthPrefixedSegment( + value: string, + start: number +): { readonly value: string; readonly next: number } | null { + const separator = value.indexOf(':', start); + if (separator <= start) return null; + const encodedLength = value.slice(start, separator); + if (!isCanonicalPositiveInteger(encodedLength)) return null; + const contentStart = separator + 1; + const next = contentStart + Number(encodedLength); + if (next > value.length) return null; + return Object.freeze({ value: value.slice(contentStart, next), next }); +} + +/** + * Pending reconciliation IDs are durable observer coordinates. Their canonical + * scope encoding lets recovery query only the owning feature; probing every + * authority would let an unrelated outage block a recoverable result. + */ +function featureKeyFromReconciliationId(reconciliationId: string): ExternalWriterFeatureKey | null { + if ( + typeof reconciliationId !== 'string' || + reconciliationId.length === 0 || + reconciliationId.length > MAX_RECONCILIATION_ID_LENGTH || + !reconciliationId.startsWith(RECONCILIATION_ID_PREFIX) + ) { + return null; + } + const encodedIdentity = readLengthPrefixedSegment( + reconciliationId, + RECONCILIATION_ID_PREFIX.length + ); + if (encodedIdentity === null || reconciliationId.charAt(encodedIdentity.next) !== ':') { + return null; + } + const tail = reconciliationId.slice(encodedIdentity.next + 1).split(':'); + if ( + tail.length !== 2 || + !isCanonicalPositiveInteger(tail[0] ?? '') || + !isCanonicalPositiveInteger(tail[1] ?? '') + ) { + return null; + } + + const team = readLengthPrefixedSegment(encodedIdentity.value, 0); + if (team === null) return null; + const feature = readLengthPrefixedSegment(encodedIdentity.value, team.next); + if (feature === null) return null; + const file = readLengthPrefixedSegment(encodedIdentity.value, feature.next); + if (file === null || file.next !== encodedIdentity.value.length) return null; + return feature.value; +} + +/** + * Routes the shared observer to explicitly composed feature consumers. It + * contains no file, task, message, provider, or lifecycle knowledge. + */ +export class ExternalWriterReconciliationRouter implements ExternalFileReconciliationPort { + private readonly routesByFeatureKey: ReadonlyMap< + ExternalWriterFeatureKey, + ExternalFileReconciliationPort + >; + + constructor(routes: readonly ExternalWriterReconciliationRoute[]) { + if (!Array.isArray(routes) || routes.length === 0) { + throw new TypeError('external-writer-reconciliation-routes-invalid'); + } + const routesByFeatureKey = new Map(); + for (const route of routes) { + if ( + !route || + typeof route.featureKey !== 'string' || + route.featureKey.length === 0 || + !route.reconciliation || + typeof route.reconciliation.getResult !== 'function' || + typeof route.reconciliation.reconcile !== 'function' || + routesByFeatureKey.has(route.featureKey) + ) { + throw new TypeError('external-writer-reconciliation-routes-invalid'); + } + routesByFeatureKey.set(route.featureKey, route.reconciliation); + } + this.routesByFeatureKey = routesByFeatureKey; + } + + async getResult(reconciliationId: string): Promise { + const featureKey = featureKeyFromReconciliationId(reconciliationId); + const reconciliation = + featureKey === null ? undefined : this.routesByFeatureKey.get(featureKey); + return reconciliation ? reconciliation.getResult(reconciliationId) : null; + } + + reconcile( + request: Parameters[0] + ): Promise { + const reconciliation = this.routesByFeatureKey.get(request.registration.scope.featureKey); + return reconciliation + ? reconciliation.reconcile(request) + : Promise.resolve(conflict('external_writer_reconciliation_route_unregistered')); + } +} diff --git a/src/features/external-writer-coordination/main/application/externalWriterFileAdapterContracts.ts b/src/features/external-writer-coordination/main/application/externalWriterFileAdapterContracts.ts new file mode 100644 index 0000000000..971b699aa4 --- /dev/null +++ b/src/features/external-writer-coordination/main/application/externalWriterFileAdapterContracts.ts @@ -0,0 +1,139 @@ +import type { ExternalFileRegistration, ExternalWriterScope } from '../../contracts'; +import type { + ExternalContentChecksumPort, + ExternalFileObservationCatalog, + ExternalFileObservationSource, + ExternalWriterWatchHandle, + ExternalWriterWatchPort, +} from '../../core/application'; + +export interface RegisteredExternalFileDefinition { + registration: ExternalFileRegistration; + rootPath: string; + filePath: string; + /** Optional inventory admission fence, revalidated during catalog construction. */ + admittedRootIdentity?: Readonly<{ device: string; inode: string }>; + admittedParentIdentity?: Readonly<{ device: string; inode: string }>; +} + +export interface RegisteredExternalFile { + registration: ExternalFileRegistration; + rootPath: string; + realRootPath: string; + rootDevice: string; + rootInode: string; + filePath: string; + realFilePath: string; + parentPath: string; + realParentPath: string; + parentDevice: string; + parentInode: string; +} + +export interface NodeExternalWriterNativeWatcher { + on(event: 'error', listener: (error: Error) => void): NodeExternalWriterNativeWatcher; + close(): void; +} + +export type NodeExternalWriterWatchFactory = (input: { + parentPath: string; + persistent: boolean; + onEvent: (eventType: string, fileName: string | Buffer | null) => void; +}) => NodeExternalWriterNativeWatcher; + +export type NodeExternalWriterWatchInvalidationReason = + | 'native_watch_error' + | 'watched_identity_replaced'; + +export interface NodeExternalWriterWatchInvalidation { + kind: 'terminal_invalidation'; + reason: NodeExternalWriterWatchInvalidationReason; + reestablishment: 'construct_and_start_fresh_catalog_and_port'; + scopes: readonly ExternalWriterScope[]; +} + +export interface NodeExternalWriterWatchPortOptions { + onInvalidation?: (invalidation: NodeExternalWriterWatchInvalidation) => void; + persistent?: boolean; + watchFactory?: NodeExternalWriterWatchFactory; +} + +export interface NodeExternalWriterWatchHandle extends ExternalWriterWatchHandle { + /** Every entry is terminal for its scopes and can be repaired only with a fresh catalog/port. */ + getInvalidations(): readonly NodeExternalWriterWatchInvalidation[]; +} + +export interface ExternalWriterFileWatchPort extends ExternalWriterWatchPort { + start( + callbacks: Parameters[0] + ): Promise; + getInvalidations(): readonly NodeExternalWriterWatchInvalidation[]; +} + +export type ExternalWriterFileWatchHandle = NodeExternalWriterWatchHandle; + +export type NodeExternalFileObservationSourceErrorCode = + | 'invalid_max_bytes' + | 'outside_containment' + | 'oversized' + | 'symlink_not_allowed' + | 'unstable' + | 'unsupported_file_type'; + +export class NodeExternalFileObservationSourceError extends Error { + constructor(readonly code: NodeExternalFileObservationSourceErrorCode) { + super(`node-external-file-observation-source:${code}`); + this.name = 'NodeExternalFileObservationSourceError'; + } +} + +export type NodeExternalWriterWatchPortErrorCode = + | 'already_started' + | 'close_failed' + | 'start_failed'; + +export class NodeExternalWriterWatchPortError extends Error { + constructor(readonly code: NodeExternalWriterWatchPortErrorCode) { + super(`node-external-writer-watch:${code}`); + this.name = 'NodeExternalWriterWatchPortError'; + } +} + +export type RegisteredExternalFileCatalogErrorCode = + | 'duplicate_alias' + | 'duplicate_registration' + | 'invalid_registration' + | 'path_not_absolute' + | 'path_outside_root' + | 'root_not_directory' + | 'symlink_not_allowed' + | 'unsupported_file_type' + | 'watch_invalidated'; + +export class RegisteredExternalFileCatalogError extends Error { + constructor(readonly code: RegisteredExternalFileCatalogErrorCode) { + super(`registered-external-file-catalog:${code}`); + this.name = 'RegisteredExternalFileCatalogError'; + } +} + +export type ExternalWriterFileAdapterErrorCode = + | NodeExternalFileObservationSourceErrorCode + | NodeExternalWriterWatchPortErrorCode + | RegisteredExternalFileCatalogErrorCode; + +export interface ExternalWriterFileAdapterError extends Error { + readonly code: ExternalWriterFileAdapterErrorCode; +} + +export interface CreateExternalWriterFileAdaptersInput { + files: readonly RegisteredExternalFileDefinition[]; + watchOptions?: NodeExternalWriterWatchPortOptions; +} + +export interface ExternalWriterFileAdapters { + catalog: ExternalFileObservationCatalog; + watch: ExternalWriterFileWatchPort; + source: ExternalFileObservationSource; + checksums: ExternalContentChecksumPort; +} diff --git a/src/features/external-writer-coordination/main/composition/createExternalWriterFileAdapters.ts b/src/features/external-writer-coordination/main/composition/createExternalWriterFileAdapters.ts new file mode 100644 index 0000000000..2563c0602d --- /dev/null +++ b/src/features/external-writer-coordination/main/composition/createExternalWriterFileAdapters.ts @@ -0,0 +1,32 @@ +import { + NodeExternalContentChecksum, + NodeExternalFileObservationSource, + NodeExternalWriterWatchPort, + RegisteredExternalFileCatalog, +} from '../infrastructure'; + +import type { + CreateExternalWriterFileAdaptersInput, + ExternalWriterFileAdapters, +} from '../application/externalWriterFileAdapterContracts'; + +export type { + CreateExternalWriterFileAdaptersInput, + ExternalWriterFileAdapters, +} from '../application/externalWriterFileAdapterContracts'; + +/** + * Main-process composition boundary. Raw paths enter only here and are frozen + * into the validated catalog before any watcher or observation source exists. + */ +export const createExternalWriterFileAdapters = ( + input: CreateExternalWriterFileAdaptersInput +): ExternalWriterFileAdapters => { + const catalog = new RegisteredExternalFileCatalog(input.files); + return Object.freeze({ + catalog, + watch: new NodeExternalWriterWatchPort(catalog, input.watchOptions), + source: new NodeExternalFileObservationSource(catalog), + checksums: new NodeExternalContentChecksum(), + }); +}; diff --git a/src/features/external-writer-coordination/main/hosted.ts b/src/features/external-writer-coordination/main/hosted.ts new file mode 100644 index 0000000000..4d87423ce4 --- /dev/null +++ b/src/features/external-writer-coordination/main/hosted.ts @@ -0,0 +1,2 @@ +/** Hosted main-process entrypoint for the exact-file Node adapter composition. */ +export { createExternalWriterFileAdapters } from './composition/createExternalWriterFileAdapters'; diff --git a/src/features/external-writer-coordination/main/index.ts b/src/features/external-writer-coordination/main/index.ts new file mode 100644 index 0000000000..73a16d7770 --- /dev/null +++ b/src/features/external-writer-coordination/main/index.ts @@ -0,0 +1,28 @@ +export type { + CreateExternalWriterFileAdaptersInput, + ExternalWriterFileAdapterError, + ExternalWriterFileAdapterErrorCode, + ExternalWriterFileAdapters, + ExternalWriterFileWatchHandle, + ExternalWriterFileWatchPort, + NodeExternalFileObservationSourceErrorCode, + NodeExternalWriterNativeWatcher, + NodeExternalWriterWatchFactory, + NodeExternalWriterWatchHandle, + NodeExternalWriterWatchInvalidation, + NodeExternalWriterWatchInvalidationReason, + NodeExternalWriterWatchPortErrorCode, + NodeExternalWriterWatchPortOptions, + RegisteredExternalFile, + RegisteredExternalFileCatalogErrorCode, + RegisteredExternalFileDefinition, +} from './application/externalWriterFileAdapterContracts'; +export { + NodeExternalFileObservationSourceError, + NodeExternalWriterWatchPortError, + RegisteredExternalFileCatalogError, +} from './application/externalWriterFileAdapterContracts'; +export { + type ExternalWriterReconciliationRoute, + ExternalWriterReconciliationRouter, +} from './application/ExternalWriterReconciliationRouter'; diff --git a/src/features/external-writer-coordination/main/infrastructure/NodeExternalContentChecksum.ts b/src/features/external-writer-coordination/main/infrastructure/NodeExternalContentChecksum.ts new file mode 100644 index 0000000000..12bc39b755 --- /dev/null +++ b/src/features/external-writer-coordination/main/infrastructure/NodeExternalContentChecksum.ts @@ -0,0 +1,9 @@ +import { createHash } from 'node:crypto'; + +import type { ExternalContentChecksumPort } from '../../core/application'; + +export class NodeExternalContentChecksum implements ExternalContentChecksumPort { + checksum(content: Uint8Array): string { + return createHash('sha256').update(content).digest('hex'); + } +} diff --git a/src/features/external-writer-coordination/main/infrastructure/NodeExternalFileObservationSource.ts b/src/features/external-writer-coordination/main/infrastructure/NodeExternalFileObservationSource.ts new file mode 100644 index 0000000000..3f64a7d72d --- /dev/null +++ b/src/features/external-writer-coordination/main/infrastructure/NodeExternalFileObservationSource.ts @@ -0,0 +1,270 @@ +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +import { NodeExternalFileObservationSourceError } from '../application/externalWriterFileAdapterContracts'; + +import { + type RegisteredExternalFile, + RegisteredExternalFileCatalog, +} from './RegisteredExternalFileCatalog'; + +import type { + ExternalFileRegistration, + ExternalFileStat, + ExternalFileStatIdentity, +} from '../../contracts'; +import type { ExternalFileObservationSource } from '../../core/application'; + +export { + NodeExternalFileObservationSourceError, + type NodeExternalFileObservationSourceErrorCode, +} from '../application/externalWriterFileAdapterContracts'; + +interface BigIntStatIdentity { + byteLength: bigint; + device: bigint; + inode: bigint; + modifiedTimeNs: bigint; + changedTimeNs: bigint; +} + +const isMissingError = (error: unknown): error is NodeJS.ErrnoException => + error instanceof Error && 'code' in error && error.code === 'ENOENT'; + +const platformPathKey = (path: string): string => + process.platform === 'win32' ? path.toLocaleLowerCase('en-US') : path; + +const pathsEqual = (left: string, right: string): boolean => + platformPathKey(resolve(left)) === platformPathKey(resolve(right)); + +const isPathInsideOrEqual = (rootPath: string, candidatePath: string): boolean => { + if (pathsEqual(rootPath, candidatePath)) { + return true; + } + const relativePath = relative(rootPath, candidatePath); + return ( + relativePath.length > 0 && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath) + ); +}; + +const safeByteLength = (size: bigint): number => + size > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(size); + +const toBigIntIdentity = (stat: { + size: bigint; + dev: bigint; + ino: bigint; + mtimeNs: bigint; + ctimeNs: bigint; +}): BigIntStatIdentity => ({ + byteLength: stat.size, + device: stat.dev, + inode: stat.ino, + modifiedTimeNs: stat.mtimeNs, + changedTimeNs: stat.ctimeNs, +}); + +const toExternalIdentity = (identity: BigIntStatIdentity): ExternalFileStatIdentity => ({ + byteLength: safeByteLength(identity.byteLength), + device: identity.device.toString(), + inode: identity.inode.toString(), + modifiedTimeNs: identity.modifiedTimeNs.toString(), + changedTimeNs: identity.changedTimeNs.toString(), +}); + +const identitiesEqual = (left: BigIntStatIdentity, right: BigIntStatIdentity): boolean => + left.byteLength === right.byteLength && + left.device === right.device && + left.inode === right.inode && + left.modifiedTimeNs === right.modifiedTimeNs && + left.changedTimeNs === right.changedTimeNs; + +const unavailableStat = (kind: ExternalFileStat['kind'], contained: boolean): ExternalFileStat => ({ + kind, + contained, + byteLength: 0, + device: null, + inode: null, + modifiedTimeNs: null, + changedTimeNs: null, +}); + +export class NodeExternalFileObservationSource implements ExternalFileObservationSource { + constructor(private readonly catalog: RegisteredExternalFileCatalog) {} + + async stat(registration: ExternalFileRegistration): Promise { + const file = this.catalog.getRegisteredFile(registration); + if (!(await this.hasStableRootAndParent(file))) { + return unavailableStat('other', false); + } + + let observed; + try { + observed = await lstat(file.filePath, { bigint: true }); + } catch (error) { + if (isMissingError(error)) { + return unavailableStat('missing', true); + } + throw error; + } + + if (observed.isSymbolicLink()) { + const targetPath = await realpath(file.filePath).catch(() => null); + return unavailableStat( + 'symlink', + targetPath === null || + (pathsEqual(targetPath, file.realFilePath) && + isPathInsideOrEqual(file.realRootPath, targetPath)) + ); + } + + const targetPath = await realpath(file.filePath).catch(() => null); + const contained = + targetPath !== null && + pathsEqual(targetPath, file.realFilePath) && + isPathInsideOrEqual(file.realRootPath, targetPath); + if (!observed.isFile()) { + return unavailableStat(observed.isDirectory() ? 'directory' : 'other', contained); + } + const identity = toExternalIdentity(toBigIntIdentity(observed)); + return { + kind: 'file', + contained, + ...identity, + }; + } + + async read(registration: ExternalFileRegistration, maxBytes: number): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new NodeExternalFileObservationSourceError('invalid_max_bytes'); + } + const file = this.catalog.getRegisteredFile(registration); + const effectiveMaxBytes = Math.min(maxBytes, file.registration.maxBytes); + const before = await this.stat(file.registration); + this.assertReadableStat(before, effectiveMaxBytes); + + const handle = await open(file.filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); + try { + const openedStat = await handle.stat({ bigint: true }); + if (!openedStat.isFile()) { + throw new NodeExternalFileObservationSourceError('unsupported_file_type'); + } + const openedIdentity = toBigIntIdentity(openedStat); + if (openedIdentity.byteLength > BigInt(effectiveMaxBytes)) { + throw new NodeExternalFileObservationSourceError('oversized'); + } + if ( + openedIdentity.device.toString() !== before.device || + openedIdentity.inode.toString() !== before.inode || + !this.externalAndBigIntIdentitiesEqual(before, openedIdentity) + ) { + throw new NodeExternalFileObservationSourceError('unstable'); + } + await this.assertOpenPathContained(file, openedIdentity); + + const expectedLength = Number(openedIdentity.byteLength); + const content = Buffer.alloc(expectedLength); + let offset = 0; + while (offset < expectedLength) { + const result = await handle.read(content, offset, expectedLength - offset, offset); + if (result.bytesRead === 0) { + throw new NodeExternalFileObservationSourceError('unstable'); + } + offset += result.bytesRead; + } + + const afterHandleStat = await handle.stat({ bigint: true }); + const afterIdentity = toBigIntIdentity(afterHandleStat); + if (afterIdentity.byteLength > BigInt(effectiveMaxBytes)) { + throw new NodeExternalFileObservationSourceError('oversized'); + } + if (!afterHandleStat.isFile() || !identitiesEqual(openedIdentity, afterIdentity)) { + throw new NodeExternalFileObservationSourceError('unstable'); + } + await this.assertOpenPathContained(file, afterIdentity); + return new Uint8Array(content); + } finally { + await handle.close(); + } + } + + async confirmAbsentByParentRescan(registration: ExternalFileRegistration): Promise { + const file = this.catalog.getRegisteredFile(registration); + if (!(await this.hasStableRootAndParent(file))) { + return false; + } + try { + await lstat(file.filePath); + return false; + } catch (error) { + return isMissingError(error); + } + } + + private assertReadableStat(stat: ExternalFileStat, maxBytes: number): void { + if (stat.kind === 'symlink') { + throw new NodeExternalFileObservationSourceError('symlink_not_allowed'); + } + if (!stat.contained) { + throw new NodeExternalFileObservationSourceError('outside_containment'); + } + if (stat.kind !== 'file') { + throw new NodeExternalFileObservationSourceError('unsupported_file_type'); + } + if (!Number.isSafeInteger(stat.byteLength) || stat.byteLength > maxBytes) { + throw new NodeExternalFileObservationSourceError('oversized'); + } + } + + private externalAndBigIntIdentitiesEqual( + external: ExternalFileStat, + identity: BigIntStatIdentity + ): boolean { + return ( + external.byteLength === safeByteLength(identity.byteLength) && + external.device === identity.device.toString() && + external.inode === identity.inode.toString() && + external.modifiedTimeNs === identity.modifiedTimeNs.toString() && + external.changedTimeNs === identity.changedTimeNs.toString() + ); + } + + private async hasStableRootAndParent(file: RegisteredExternalFile): Promise { + return this.catalog.isRootAndParentCurrent(file); + } + + private async assertOpenPathContained( + file: RegisteredExternalFile, + openedIdentity: BigIntStatIdentity + ): Promise { + if (!(await this.hasStableRootAndParent(file))) { + throw new NodeExternalFileObservationSourceError('outside_containment'); + } + let targetStat; + let observedFilePath: string; + try { + [targetStat, observedFilePath] = await Promise.all([ + lstat(file.filePath, { bigint: true }), + realpath(file.filePath), + ]); + } catch { + throw new NodeExternalFileObservationSourceError('unstable'); + } + if (targetStat.isSymbolicLink()) { + throw new NodeExternalFileObservationSourceError('symlink_not_allowed'); + } + if (!targetStat.isFile() || !identitiesEqual(toBigIntIdentity(targetStat), openedIdentity)) { + throw new NodeExternalFileObservationSourceError('unstable'); + } + if ( + !pathsEqual(observedFilePath, file.realFilePath) || + !isPathInsideOrEqual(file.realRootPath, observedFilePath) + ) { + throw new NodeExternalFileObservationSourceError('outside_containment'); + } + } +} diff --git a/src/features/external-writer-coordination/main/infrastructure/NodeExternalWriterWatchPort.ts b/src/features/external-writer-coordination/main/infrastructure/NodeExternalWriterWatchPort.ts new file mode 100644 index 0000000000..45ab4c2b72 --- /dev/null +++ b/src/features/external-writer-coordination/main/infrastructure/NodeExternalWriterWatchPort.ts @@ -0,0 +1,359 @@ +import { watch } from 'node:fs'; +import { basename } from 'node:path'; + +import { NodeExternalWriterWatchPortError } from '../application/externalWriterFileAdapterContracts'; + +import type { ExternalWriterScope, ExternalWriterWatchCallbacks } from '../../contracts'; +import type { ExternalWriterWatchPort } from '../../core/application'; +import type { + NodeExternalWriterNativeWatcher, + NodeExternalWriterWatchFactory, + NodeExternalWriterWatchHandle, + NodeExternalWriterWatchInvalidation, + NodeExternalWriterWatchInvalidationReason, + NodeExternalWriterWatchPortOptions, +} from '../application/externalWriterFileAdapterContracts'; +import type { + RegisteredExternalFile, + RegisteredExternalFileCatalog, +} from './RegisteredExternalFileCatalog'; + +export { + type NodeExternalWriterNativeWatcher, + type NodeExternalWriterWatchFactory, + type NodeExternalWriterWatchHandle, + type NodeExternalWriterWatchInvalidation, + type NodeExternalWriterWatchInvalidationReason, + NodeExternalWriterWatchPortError, + type NodeExternalWriterWatchPortErrorCode, + type NodeExternalWriterWatchPortOptions, +} from '../application/externalWriterFileAdapterContracts'; + +interface WatchGroup { + parentPath: string; + filesByName: ReadonlyMap; + scopes: readonly ExternalWriterScope[]; +} + +interface ActiveWatchGroup { + group: WatchGroup; + watcher: NodeExternalWriterNativeWatcher | null; + invalidated: boolean; + watcherClosed: boolean; + periodicRescan: { + cancelCompletionTracking: (() => void) | null; + requestPending: boolean; + trailingRequested: boolean; + }; +} + +// A native watch remains attached to the old inode when its path is renamed. +// Node does not expose the object identity retained by FSWatcher, so matching +// path identities before and after watch() cannot prove attachment: A may be +// replaced by B for watch() and restored before the second check. Each tick +// therefore both invalidates visible identity replacement and requests a +// bounded catalog-scope rescan. The rescan is the fail-closed attachment fence +// for an undetectable ABA; it observes writes to the current expected object +// even if the native watcher was attached to the transient replacement. +const IDENTITY_CHECK_INTERVAL_MS = 1_000; + +const platformNameKey = (name: string): string => + process.platform === 'win32' ? name.toLocaleLowerCase('en-US') : name; + +const scopeKey = (scope: ExternalWriterScope): string => + `${scope.teamId.length}:${scope.teamId}${scope.featureKey.length}:${scope.featureKey}`; + +const defaultWatchFactory: NodeExternalWriterWatchFactory = ({ parentPath, persistent, onEvent }) => + watch( + parentPath, + { + encoding: 'utf8', + persistent, + recursive: false, + }, + onEvent + ); + +const buildWatchGroups = (catalog: RegisteredExternalFileCatalog): readonly WatchGroup[] => { + const filesByParent = new Map(); + for (const file of catalog.listRegisteredFiles()) { + const files = filesByParent.get(file.realParentPath) ?? []; + files.push(file); + filesByParent.set(file.realParentPath, files); + } + + return Object.freeze( + [...filesByParent].map(([parentPath, files]) => { + const scopesByKey = new Map(); + const filesByName = new Map(); + for (const file of files) { + filesByName.set(platformNameKey(basename(file.realFilePath)), file); + scopesByKey.set(scopeKey(file.registration.scope), file.registration.scope); + } + return Object.freeze({ + parentPath, + filesByName, + scopes: Object.freeze([...scopesByKey.values()]), + }); + }) + ); +}; + +/** + * Watches only parent directories that contain registered files. Native events + * are filtered back to exact catalog identities before crossing the port. + */ +export class NodeExternalWriterWatchPort implements ExternalWriterWatchPort { + private readonly catalog: RegisteredExternalFileCatalog; + private readonly groups: readonly WatchGroup[]; + private readonly onInvalidation: + | ((invalidation: NodeExternalWriterWatchInvalidation) => void) + | undefined; + private readonly invalidations: NodeExternalWriterWatchInvalidation[] = []; + private readonly persistent: boolean; + private readonly watchFactory: NodeExternalWriterWatchFactory; + private started = false; + + constructor( + catalog: RegisteredExternalFileCatalog, + options: NodeExternalWriterWatchPortOptions = {} + ) { + this.catalog = catalog; + this.groups = buildWatchGroups(catalog); + this.onInvalidation = options.onInvalidation; + this.persistent = options.persistent ?? false; + this.watchFactory = options.watchFactory ?? defaultWatchFactory; + } + + getInvalidations(): readonly NodeExternalWriterWatchInvalidation[] { + return Object.freeze([...this.invalidations]); + } + + async start(callbacks: ExternalWriterWatchCallbacks): Promise { + if (this.started) { + throw new NodeExternalWriterWatchPortError('already_started'); + } + this.started = true; + const activeWatches: ActiveWatchGroup[] = []; + let closed = false; + let closeFailed = false; + let identityCheckTimer: NodeJS.Timeout | null = null; + + const reportOverflow = (scopes: readonly ExternalWriterScope[]): boolean => { + if (closed || scopes.length === 0) { + return false; + } + try { + callbacks.onOverflow({ scopes }); + return true; + } catch { + // The native callback must never throw into Node's watcher event loop. + return false; + } + }; + + const isGroupCurrent = (group: WatchGroup): boolean => + [...group.filesByName.values()].every((file) => this.catalog.isRootAndParentCurrent(file)); + + const isGroupCatalogAdmitted = (group: WatchGroup): boolean => + group.scopes.every((scope) => !this.catalog.isWatchScopeInvalidated(scope)); + + const closeNativeWatch = (activeWatch: ActiveWatchGroup): void => { + if (activeWatch.watcherClosed || activeWatch.watcher === null) { + return; + } + activeWatch.watcherClosed = true; + try { + activeWatch.watcher.close(); + } catch { + closeFailed = true; + } + }; + + const cancelPeriodicRescan = (activeWatch: ActiveWatchGroup): void => { + activeWatch.periodicRescan.cancelCompletionTracking?.(); + activeWatch.periodicRescan.cancelCompletionTracking = null; + activeWatch.periodicRescan.requestPending = false; + activeWatch.periodicRescan.trailingRequested = false; + }; + + const beginPeriodicRescan = (activeWatch: ActiveWatchGroup): void => { + const state = activeWatch.periodicRescan; + state.requestPending = true; + state.cancelCompletionTracking = this.catalog.onNextScopeScansCompleted( + activeWatch.group.scopes, + () => { + state.cancelCompletionTracking = null; + state.requestPending = false; + if (closed || activeWatch.invalidated) { + state.trailingRequested = false; + return; + } + if (state.trailingRequested) { + state.trailingRequested = false; + beginPeriodicRescan(activeWatch); + } + } + ); + if (!reportOverflow(activeWatch.group.scopes)) { + cancelPeriodicRescan(activeWatch); + } + }; + + const requestPeriodicRescan = (activeWatch: ActiveWatchGroup): void => { + if (closed || activeWatch.invalidated) { + return; + } + if (activeWatch.periodicRescan.requestPending) { + // Retain exactly one trailing edge. It is requested only after the + // current catalog-scope scan, including reconciliation, completes. + activeWatch.periodicRescan.trailingRequested = true; + return; + } + beginPeriodicRescan(activeWatch); + }; + + const invalidateWatch = ( + activeWatch: ActiveWatchGroup, + reason: NodeExternalWriterWatchInvalidationReason + ): void => { + if (closed || activeWatch.invalidated) { + return; + } + activeWatch.invalidated = true; + const invalidation = Object.freeze({ + kind: 'terminal_invalidation' as const, + reason, + reestablishment: 'construct_and_start_fresh_catalog_and_port' as const, + scopes: activeWatch.group.scopes, + }); + // Retire the catalog before reporting overflow. The observer's recovery + // rescan must fail closed instead of clearing dirty state while this + // group is permanently detached from the watched path. + this.catalog.invalidateWatchScopes(activeWatch.group.scopes); + cancelPeriodicRescan(activeWatch); + closeNativeWatch(activeWatch); + this.invalidations.push(invalidation); + try { + this.onInvalidation?.(invalidation); + } catch { + // Invalidation remains recorded even when a diagnostic consumer fails. + } + reportOverflow(activeWatch.group.scopes); + }; + + const verifyWatchIdentity = (activeWatch: ActiveWatchGroup): void => { + if (!closed && !activeWatch.invalidated && !isGroupCurrent(activeWatch.group)) { + invalidateWatch(activeWatch, 'watched_identity_replaced'); + } + }; + + try { + this.catalog.assertAllPathIdentitiesCurrent(); + for (const group of this.groups) { + if (!isGroupCatalogAdmitted(group) || !isGroupCurrent(group)) { + throw new NodeExternalWriterWatchPortError('start_failed'); + } + const activeWatch: ActiveWatchGroup = { + group, + watcher: null, + invalidated: false, + watcherClosed: false, + periodicRescan: { + cancelCompletionTracking: null, + requestPending: false, + trailingRequested: false, + }, + }; + const watcher = this.watchFactory({ + parentPath: group.parentPath, + persistent: this.persistent, + onEvent: (eventType, fileName) => { + if (closed || activeWatch.invalidated) { + return; + } + if (!isGroupCurrent(group)) { + invalidateWatch(activeWatch, 'watched_identity_replaced'); + return; + } + if (fileName === null) { + reportOverflow(group.scopes); + return; + } + const decodedName = Buffer.isBuffer(fileName) ? fileName.toString('utf8') : fileName; + const file = group.filesByName.get(platformNameKey(decodedName)); + if (!file) { + return; + } + const kind = + eventType === 'change' ? 'change' : eventType === 'rename' ? 'rename' : null; + if (kind === null) { + reportOverflow(Object.freeze([file.registration.scope])); + return; + } + try { + callbacks.onNotification({ + kind, + scope: file.registration.scope, + fileKey: file.registration.fileKey, + }); + } catch { + reportOverflow(Object.freeze([file.registration.scope])); + } + }, + }); + activeWatch.watcher = watcher; + activeWatches.push(activeWatch); + watcher.on('error', () => invalidateWatch(activeWatch, 'native_watch_error')); + if (!isGroupCatalogAdmitted(group) || !isGroupCurrent(group)) { + throw new NodeExternalWriterWatchPortError('start_failed'); + } + } + if (activeWatches.length > 0) { + identityCheckTimer = setInterval(() => { + for (const activeWatch of activeWatches) { + verifyWatchIdentity(activeWatch); + if (!closed && !activeWatch.invalidated) { + requestPeriodicRescan(activeWatch); + } + } + }, IDENTITY_CHECK_INTERVAL_MS); + identityCheckTimer.unref(); + } + } catch { + this.started = false; + closed = true; + if (identityCheckTimer !== null) { + clearInterval(identityCheckTimer); + identityCheckTimer = null; + } + for (const activeWatch of activeWatches) { + cancelPeriodicRescan(activeWatch); + closeNativeWatch(activeWatch); + } + throw new NodeExternalWriterWatchPortError('start_failed'); + } + + return Object.freeze({ + getInvalidations: (): readonly NodeExternalWriterWatchInvalidation[] => + this.getInvalidations(), + close: async (): Promise => { + if (closed) { + return; + } + closed = true; + if (identityCheckTimer !== null) { + clearInterval(identityCheckTimer); + identityCheckTimer = null; + } + for (const activeWatch of activeWatches) { + cancelPeriodicRescan(activeWatch); + closeNativeWatch(activeWatch); + } + if (closeFailed) { + throw new NodeExternalWriterWatchPortError('close_failed'); + } + }, + }); + } +} diff --git a/src/features/external-writer-coordination/main/infrastructure/RegisteredExternalFileCatalog.ts b/src/features/external-writer-coordination/main/infrastructure/RegisteredExternalFileCatalog.ts new file mode 100644 index 0000000000..7cea797fbb --- /dev/null +++ b/src/features/external-writer-coordination/main/infrastructure/RegisteredExternalFileCatalog.ts @@ -0,0 +1,492 @@ +import { lstatSync, realpathSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +import { RegisteredExternalFileCatalogError } from '../application/externalWriterFileAdapterContracts'; + +import type { ExternalFileRegistration, ExternalWriterScope } from '../../contracts'; +import type { ExternalFileObservationCatalog } from '../../core/application'; +import type { + RegisteredExternalFile, + RegisteredExternalFileDefinition, +} from '../application/externalWriterFileAdapterContracts'; + +export { + type RegisteredExternalFile, + RegisteredExternalFileCatalogError, + type RegisteredExternalFileCatalogErrorCode, + type RegisteredExternalFileDefinition, +} from '../application/externalWriterFileAdapterContracts'; + +interface RegisteredRoot { + rootPath: string; + realRootPath: string; + rootDevice: string; + rootInode: string; +} + +const isMissingError = (error: unknown): error is NodeJS.ErrnoException => + error instanceof Error && 'code' in error && error.code === 'ENOENT'; + +const platformPathKey = (path: string): string => + process.platform === 'win32' ? path.toLocaleLowerCase('en-US') : path; + +const pathsEqual = (left: string, right: string): boolean => + platformPathKey(resolve(left)) === platformPathKey(resolve(right)); + +const isPathInside = (rootPath: string, candidatePath: string): boolean => { + const relativePath = relative(rootPath, candidatePath); + return ( + relativePath.length > 0 && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath) + ); +}; + +const scopeKey = (scope: ExternalWriterScope): string => + `${scope.teamId.length}:${scope.teamId}${scope.featureKey.length}:${scope.featureKey}`; + +const registrationKey = (registration: ExternalFileRegistration): string => + `${scopeKey(registration.scope)}${registration.fileKey.length}:${registration.fileKey}`; + +const freezeScope = (scope: ExternalWriterScope): ExternalWriterScope => + Object.freeze({ + teamId: scope.teamId, + featureKey: scope.featureKey, + }); + +const freezeRegistration = (registration: ExternalFileRegistration): ExternalFileRegistration => + Object.freeze({ + scope: freezeScope(registration.scope), + fileKey: registration.fileKey, + maxBytes: registration.maxBytes, + attributionPolicy: registration.attributionPolicy, + }); + +const assertRegistration = (registration: ExternalFileRegistration): void => { + if ( + registration.scope.teamId.length === 0 || + registration.scope.featureKey.length === 0 || + registration.fileKey.length === 0 || + !Number.isSafeInteger(registration.maxBytes) || + registration.maxBytes <= 0 || + (registration.attributionPolicy !== 'external_file_only' && + registration.attributionPolicy !== 'verified_run_evidence') + ) { + throw new RegisteredExternalFileCatalogError('invalid_registration'); + } +}; + +const registerRoot = ( + rootPath: string, + admittedIdentity?: Readonly<{ device: string; inode: string }> +): RegisteredRoot => { + if (!isAbsolute(rootPath)) { + throw new RegisteredExternalFileCatalogError('path_not_absolute'); + } + const normalizedRootPath = resolve(rootPath); + let rootStat: ReturnType; + try { + rootStat = lstatSync(normalizedRootPath, { bigint: true }); + } catch { + throw new RegisteredExternalFileCatalogError('root_not_directory'); + } + if (rootStat.isSymbolicLink()) { + throw new RegisteredExternalFileCatalogError('symlink_not_allowed'); + } + if (!rootStat.isDirectory()) { + throw new RegisteredExternalFileCatalogError('root_not_directory'); + } + if ( + admittedIdentity !== undefined && + (rootStat.dev.toString() !== admittedIdentity.device || + rootStat.ino.toString() !== admittedIdentity.inode) + ) { + throw new RegisteredExternalFileCatalogError('watch_invalidated'); + } + const realRootPath = realpathSync.native(normalizedRootPath); + if (!pathsEqual(normalizedRootPath, realRootPath)) { + throw new RegisteredExternalFileCatalogError('symlink_not_allowed'); + } + return Object.freeze({ + rootPath: normalizedRootPath, + realRootPath, + rootDevice: rootStat.dev.toString(), + rootInode: rootStat.ino.toString(), + }); +}; + +const registerFile = ( + definition: RegisteredExternalFileDefinition, + root: RegisteredRoot +): { + file: RegisteredExternalFile; + inodeAliasKey: string | null; +} => { + if (!isAbsolute(definition.filePath)) { + throw new RegisteredExternalFileCatalogError('path_not_absolute'); + } + const filePath = resolve(definition.filePath); + if (!isPathInside(root.rootPath, filePath)) { + throw new RegisteredExternalFileCatalogError('path_outside_root'); + } + + const relativeFilePath = relative(root.rootPath, filePath); + const realFilePath = resolve(root.realRootPath, relativeFilePath); + if (!isPathInside(root.realRootPath, realFilePath)) { + throw new RegisteredExternalFileCatalogError('path_outside_root'); + } + + const parentPath = resolve(filePath, '..'); + const relativeParentPath = relative(root.rootPath, parentPath); + const expectedRealParentPath = + relativeParentPath.length === 0 + ? root.realRootPath + : resolve(root.realRootPath, relativeParentPath); + let parentStat: ReturnType; + try { + parentStat = lstatSync(parentPath, { bigint: true }); + } catch { + throw new RegisteredExternalFileCatalogError('path_outside_root'); + } + if (parentStat.isSymbolicLink()) { + throw new RegisteredExternalFileCatalogError('symlink_not_allowed'); + } + if (!parentStat.isDirectory()) { + throw new RegisteredExternalFileCatalogError('path_outside_root'); + } + if ( + definition.admittedParentIdentity !== undefined && + (parentStat.dev.toString() !== definition.admittedParentIdentity.device || + parentStat.ino.toString() !== definition.admittedParentIdentity.inode) + ) { + throw new RegisteredExternalFileCatalogError('watch_invalidated'); + } + const realParentPath = realpathSync.native(parentPath); + if ( + !pathsEqual(realParentPath, expectedRealParentPath) || + (!pathsEqual(realParentPath, root.realRootPath) && + !isPathInside(root.realRootPath, realParentPath)) + ) { + throw new RegisteredExternalFileCatalogError('path_outside_root'); + } + + let inodeAliasKey: string | null = null; + try { + const fileStat = lstatSync(filePath, { bigint: true }); + if (fileStat.isSymbolicLink()) { + throw new RegisteredExternalFileCatalogError('symlink_not_allowed'); + } + if (!fileStat.isFile()) { + throw new RegisteredExternalFileCatalogError('unsupported_file_type'); + } + const observedRealFilePath = realpathSync.native(filePath); + if ( + !pathsEqual(observedRealFilePath, realFilePath) || + !isPathInside(root.realRootPath, observedRealFilePath) + ) { + throw new RegisteredExternalFileCatalogError('path_outside_root'); + } + inodeAliasKey = `${fileStat.dev}:${fileStat.ino}`; + } catch (error) { + if (!isMissingError(error)) { + throw error; + } + } + + return { + file: Object.freeze({ + registration: freezeRegistration(definition.registration), + rootPath: root.rootPath, + realRootPath: root.realRootPath, + rootDevice: root.rootDevice, + rootInode: root.rootInode, + filePath, + realFilePath, + parentPath, + realParentPath, + parentDevice: parentStat.dev.toString(), + parentInode: parentStat.ino.toString(), + }), + inodeAliasKey, + }; +}; + +const registrationsEqual = ( + left: ExternalFileRegistration, + right: ExternalFileRegistration +): boolean => + left.scope.teamId === right.scope.teamId && + left.scope.featureKey === right.scope.featureKey && + left.fileKey === right.fileKey && + left.maxBytes === right.maxBytes && + left.attributionPolicy === right.attributionPolicy; + +interface PendingScopeScanCompletion { + active: boolean; + complete(): void; +} + +const trackCompletedScopeScan = ( + registrations: readonly ExternalFileRegistration[], + completions: readonly PendingScopeScanCompletion[] +): readonly ExternalFileRegistration[] => { + let completedIterations = 0; + const tracked = [...registrations]; + Object.defineProperty(tracked, Symbol.iterator, { + configurable: false, + enumerable: false, + writable: false, + value: function* trackedScopeScanIterator(): IterableIterator { + let exhausted = false; + try { + yield* registrations; + exhausted = true; + } finally { + if (exhausted) { + completedIterations += 1; + // ExternalWriterObserver first validates the catalog collection, then + // consumes that same collection for the actual scoped scan. Waiting + // for both complete iterations keeps reconciliation inside the + // tracked interval; nested registration lookups use Array.find and + // therefore cannot prematurely acknowledge the scan. + if (completedIterations === 2) { + for (const completion of completions) { + if (completion.active) { + completion.active = false; + completion.complete(); + } + } + } + } + } + }, + }); + return Object.freeze(tracked); +}; + +/** + * Immutable allowlist of exact feature-owned files. Construction performs all + * path discovery; runtime callers can only resolve a registered scope/file key. + */ +export class RegisteredExternalFileCatalog implements ExternalFileObservationCatalog { + private readonly scopes: readonly ExternalWriterScope[]; + private readonly files: readonly RegisteredExternalFile[]; + private readonly registrationsByScope: ReadonlyMap; + private readonly filesByRegistration: ReadonlyMap; + private readonly invalidatedWatchScopes = new Set(); + private readonly pendingScanCompletions = new Map(); + + constructor(definitions: readonly RegisteredExternalFileDefinition[]) { + const rootsByPath = new Map(); + const filesByRegistration = new Map(); + const filesByAlias = new Map(); + const filesByInode = new Map(); + const registrationsByScope = new Map(); + const scopesByKey = new Map(); + const files: RegisteredExternalFile[] = []; + + for (const definition of definitions) { + assertRegistration(definition.registration); + const key = registrationKey(definition.registration); + if (filesByRegistration.has(key)) { + throw new RegisteredExternalFileCatalogError('duplicate_registration'); + } + + const normalizedRootPath = isAbsolute(definition.rootPath) + ? resolve(definition.rootPath) + : definition.rootPath; + let root = rootsByPath.get(platformPathKey(normalizedRootPath)); + if (!root) { + root = registerRoot(definition.rootPath, definition.admittedRootIdentity); + rootsByPath.set(platformPathKey(root.rootPath), root); + } else if ( + definition.admittedRootIdentity !== undefined && + (root.rootDevice !== definition.admittedRootIdentity.device || + root.rootInode !== definition.admittedRootIdentity.inode) + ) { + throw new RegisteredExternalFileCatalogError('watch_invalidated'); + } + const registered = registerFile(definition, root); + const aliasKey = platformPathKey(registered.file.realFilePath); + if ( + filesByAlias.has(aliasKey) || + (registered.inodeAliasKey !== null && filesByInode.has(registered.inodeAliasKey)) + ) { + throw new RegisteredExternalFileCatalogError('duplicate_alias'); + } + + const fileScopeKey = scopeKey(registered.file.registration.scope); + const scope = scopesByKey.get(fileScopeKey) ?? registered.file.registration.scope; + scopesByKey.set(fileScopeKey, scope); + const registrations = registrationsByScope.get(fileScopeKey) ?? []; + registrations.push(registered.file.registration); + registrationsByScope.set(fileScopeKey, registrations); + filesByRegistration.set(key, registered.file); + filesByAlias.set(aliasKey, registered.file); + if (registered.inodeAliasKey !== null) { + filesByInode.set(registered.inodeAliasKey, registered.file); + } + files.push(registered.file); + } + + this.scopes = Object.freeze([...scopesByKey.values()]); + this.files = Object.freeze(files); + this.registrationsByScope = new Map( + [...registrationsByScope].map(([key, registrations]) => [key, Object.freeze(registrations)]) + ); + this.filesByRegistration = filesByRegistration; + Object.freeze(this); + } + + listScopes(): Promise { + return Promise.resolve(this.scopes); + } + + listRegistrations(scope: ExternalWriterScope): Promise { + if (this.isWatchScopeInvalidated(scope)) { + return Promise.reject(new RegisteredExternalFileCatalogError('watch_invalidated')); + } + const key = scopeKey(scope); + const registrations = this.registrationsByScope.get(key) ?? Object.freeze([]); + const completions = this.pendingScanCompletions.get(key); + if (!completions || completions.length === 0) { + return Promise.resolve(registrations); + } + this.pendingScanCompletions.delete(key); + return Promise.resolve(trackCompletedScopeScan(registrations, completions)); + } + + listRegisteredFiles(): readonly RegisteredExternalFile[] { + return this.files; + } + + assertAllPathIdentitiesCurrent(): void { + if (this.files.some((file) => !this.isRootAndParentCurrent(file))) { + throw new RegisteredExternalFileCatalogError('watch_invalidated'); + } + } + + /** + * Coordinates a native watch group's periodic request with completion of the + * resulting registered-scope scans. It observes only the immutable catalog + * collections already consumed by the core observer; it neither scans paths + * nor changes observer scheduling. + */ + onNextScopeScansCompleted( + scopes: readonly ExternalWriterScope[], + onCompleted: () => void + ): () => void { + const keys = [...new Set(scopes.map(scopeKey))].filter((key) => + this.registrationsByScope.has(key) + ); + if (keys.length === 0) { + onCompleted(); + return () => undefined; + } + + let remaining = keys.length; + let active = true; + const pending: Array<{ key: string; completion: PendingScopeScanCompletion }> = []; + for (const key of keys) { + const completion: PendingScopeScanCompletion = { + active: true, + complete: () => { + if (!active) { + return; + } + remaining -= 1; + if (remaining === 0) { + active = false; + onCompleted(); + } + }, + }; + const completions = this.pendingScanCompletions.get(key) ?? []; + completions.push(completion); + this.pendingScanCompletions.set(key, completions); + pending.push({ key, completion }); + } + + return () => { + if (!active) { + return; + } + active = false; + for (const { key, completion } of pending) { + completion.active = false; + const completions = this.pendingScanCompletions.get(key); + if (!completions) { + continue; + } + const retained = completions.filter((candidate) => candidate !== completion); + if (retained.length === 0) { + this.pendingScanCompletions.delete(key); + } else { + this.pendingScanCompletions.set(key, retained); + } + } + }; + } + + getRegisteredFile(registration: ExternalFileRegistration): RegisteredExternalFile { + this.assertWatchScopeCurrent(registration.scope); + const registered = this.filesByRegistration.get(registrationKey(registration)); + if (!registered || !registrationsEqual(registered.registration, registration)) { + throw new RegisteredExternalFileCatalogError('invalid_registration'); + } + return registered; + } + + /** + * Permanently retires the affected catalog scopes after their native watcher + * dies. There is deliberately no reset operation: re-establishment requires + * a new catalog so every root and parent identity is captured again. + */ + invalidateWatchScopes(scopes: readonly ExternalWriterScope[]): void { + for (const scope of scopes) { + const key = scopeKey(scope); + if (this.registrationsByScope.has(key)) { + this.invalidatedWatchScopes.add(key); + } + } + } + + isWatchScopeInvalidated(scope: ExternalWriterScope): boolean { + return this.invalidatedWatchScopes.has(scopeKey(scope)); + } + + isRootAndParentCurrent(file: RegisteredExternalFile): boolean { + if (this.filesByRegistration.get(registrationKey(file.registration)) !== file) { + return false; + } + try { + const rootStat = lstatSync(file.rootPath, { bigint: true }); + const parentStat = lstatSync(file.parentPath, { bigint: true }); + const observedRootPath = realpathSync.native(file.rootPath); + const observedParentPath = realpathSync.native(file.parentPath); + return ( + rootStat.isDirectory() && + !rootStat.isSymbolicLink() && + rootStat.dev.toString() === file.rootDevice && + rootStat.ino.toString() === file.rootInode && + parentStat.isDirectory() && + !parentStat.isSymbolicLink() && + parentStat.dev.toString() === file.parentDevice && + parentStat.ino.toString() === file.parentInode && + pathsEqual(observedRootPath, file.realRootPath) && + pathsEqual(observedParentPath, file.realParentPath) && + (pathsEqual(observedParentPath, file.realRootPath) || + isPathInside(file.realRootPath, observedParentPath)) + ); + } catch { + return false; + } + } + + private assertWatchScopeCurrent(scope: ExternalWriterScope): void { + if (this.isWatchScopeInvalidated(scope)) { + throw new RegisteredExternalFileCatalogError('watch_invalidated'); + } + } +} diff --git a/src/features/external-writer-coordination/main/infrastructure/index.ts b/src/features/external-writer-coordination/main/infrastructure/index.ts new file mode 100644 index 0000000000..08c92abe1c --- /dev/null +++ b/src/features/external-writer-coordination/main/infrastructure/index.ts @@ -0,0 +1,24 @@ +export { NodeExternalContentChecksum } from './NodeExternalContentChecksum'; +export { + NodeExternalFileObservationSource, + NodeExternalFileObservationSourceError, + type NodeExternalFileObservationSourceErrorCode, +} from './NodeExternalFileObservationSource'; +export { + type NodeExternalWriterNativeWatcher, + type NodeExternalWriterWatchFactory, + type NodeExternalWriterWatchHandle, + type NodeExternalWriterWatchInvalidation, + type NodeExternalWriterWatchInvalidationReason, + NodeExternalWriterWatchPort, + NodeExternalWriterWatchPortError, + type NodeExternalWriterWatchPortErrorCode, + type NodeExternalWriterWatchPortOptions, +} from './NodeExternalWriterWatchPort'; +export { + type RegisteredExternalFile, + RegisteredExternalFileCatalog, + RegisteredExternalFileCatalogError, + type RegisteredExternalFileCatalogErrorCode, + type RegisteredExternalFileDefinition, +} from './RegisteredExternalFileCatalog'; diff --git a/src/features/hosted-access/contracts/auth.ts b/src/features/hosted-access/contracts/auth.ts new file mode 100644 index 0000000000..7ee50c8ccc --- /dev/null +++ b/src/features/hosted-access/contracts/auth.ts @@ -0,0 +1,123 @@ +declare const hostedIdentityBrand: unique symbol; + +type HostedIdentityString = string & { + readonly [hostedIdentityBrand]: Name; +}; + +export type UserId = HostedIdentityString<'UserId'>; +export type HostedSessionId = HostedIdentityString<'HostedSessionId'>; +export type OidcLoginAttemptId = HostedIdentityString<'OidcLoginAttemptId'>; +export type AuditEventId = HostedIdentityString<'AuditEventId'>; +export type HostedWorkspaceId = HostedIdentityString<'HostedWorkspaceId'>; + +const IDENTIFIER_PATTERN = /^[a-z][a-z0-9-]*_[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/; + +function parseHostedIdentityId( + value: unknown, + errorName: string +): HostedIdentityString { + if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { + throw new TypeError(`hosted-access-${errorName}-invalid`); + } + return value as HostedIdentityString; +} + +export const parseUserId = (value: unknown): UserId => + parseHostedIdentityId<'UserId'>(value, 'user-id'); +export const parseHostedSessionId = (value: unknown): HostedSessionId => + parseHostedIdentityId<'HostedSessionId'>(value, 'hosted-session-id'); +export const parseOidcLoginAttemptId = (value: unknown): OidcLoginAttemptId => + parseHostedIdentityId<'OidcLoginAttemptId'>(value, 'oidc-login-attempt-id'); +export const parseAuditEventId = (value: unknown): AuditEventId => + parseHostedIdentityId<'AuditEventId'>(value, 'audit-event-id'); +export const parseHostedWorkspaceId = (value: unknown): HostedWorkspaceId => { + if (typeof value !== 'string' || !/^workspace_[a-f0-9]{32}$/.test(value)) { + throw new TypeError('hosted-access-workspace-id-invalid'); + } + return value as HostedWorkspaceId; +}; + +export const HOSTED_AUTH_MODES = Object.freeze(['personal', 'oidc'] as const); +export type HostedAuthMode = (typeof HOSTED_AUTH_MODES)[number]; + +export const HOSTED_ROLES = Object.freeze(['owner', 'admin', 'member', 'viewer'] as const); +export type HostedRole = (typeof HOSTED_ROLES)[number]; + +export const HOSTED_PERMISSIONS = Object.freeze([ + 'hosted.query', + 'hosted.events', + 'hosted.command', + 'hosted.manage', + 'workspace.manage', + 'identity.manage', +] as const); +export type HostedPermission = (typeof HOSTED_PERMISSIONS)[number]; + +export interface HostedPrincipal { + readonly userId: UserId; + readonly displayName: string; + readonly role: HostedRole; + readonly permissions: readonly HostedPermission[]; + readonly authenticationMethod: 'desktop-local-owner' | 'personal' | 'oidc'; + readonly sessionId: HostedSessionId | null; +} + +export interface HostedAuthStatus { + readonly mode: HostedAuthMode; + readonly authenticated: boolean; + readonly principal: HostedPrincipal | null; + /** + * Returned only in a JSON response after authentication. The renderer keeps + * this value in module memory and never persists it. + */ + readonly csrfToken: string | null; + readonly oidcProviderName: string | null; + /** Present only for an authenticated response; safe immutable readiness fences, never secrets. */ + readonly deploymentId: string | null; + readonly bootId: string | null; +} + +export interface PersonalPairingRequest { + readonly pairingCode: string; +} + +export interface HostedWorkspace { + /** Opaque, immutable identifier safe to expose to a hosted browser. */ + readonly workspaceId: HostedWorkspaceId; + readonly displayName: string; + readonly registeredAt: number; + readonly registeredBy: UserId | null; +} + +export interface BeginLoginResult { + readonly redirectUrl: string; + readonly attemptId: OidcLoginAttemptId; + readonly state: string; +} + +export interface CompleteLoginInput { + readonly callbackUrl: URL; + readonly expectedState: string; + readonly attemptId: OidcLoginAttemptId; +} + +export interface IdentityProviderLogoutResult { + readonly redirectUrl: string | null; +} + +export const HOSTED_AUTH_ROUTES = Object.freeze({ + status: '/api/auth/status', + pair: '/api/auth/personal/pair', + login: '/api/auth/oidc/login', + callback: '/api/auth/oidc/callback', + logout: '/api/auth/logout', + forgetDevice: '/api/auth/personal/forget-device', + backchannelLogout: '/api/auth/oidc/backchannel-logout', +} as const); + +export const HOSTED_AUTH_HEADERS = Object.freeze({ + csrf: 'x-agent-teams-csrf', + lifecycleOwnerReadiness: 'x-agent-teams-lifecycle-owner-readiness', + taskBoardMutationAdvertisement: 'x-agent-teams-task-board-mutation-advertisement', + teamMessageSendAdvertisement: 'x-agent-teams-team-message-send-advertisement', +} as const); diff --git a/src/features/hosted-access/contracts/authority.ts b/src/features/hosted-access/contracts/authority.ts new file mode 100644 index 0000000000..f3a645a1da --- /dev/null +++ b/src/features/hosted-access/contracts/authority.ts @@ -0,0 +1,149 @@ +declare const hostedAccessBrand: unique symbol; + +type BrandedString = string & { + readonly [hostedAccessBrand]: Name; +}; + +export type OperatorId = BrandedString<'OperatorId'>; +export type PairingChallengeId = BrandedString<'PairingChallengeId'>; +export type DeviceFamilyId = BrandedString<'DeviceFamilyId'>; +export type DeviceGrantId = BrandedString<'DeviceGrantId'>; +export type OperatorSessionId = BrandedString<'OperatorSessionId'>; +export type AuthKeyringId = BrandedString<'AuthKeyringId'>; +export type AuthorityDeploymentId = BrandedString<'AuthorityDeploymentId'>; +export type OpaqueAuthoritySecret = BrandedString<'OpaqueAuthoritySecret'>; +export type KeyedSecretHash = BrandedString<'KeyedSecretHash'>; +export type AuthorityKeyMaterial = BrandedString<'AuthorityKeyMaterial'>; +export type CsrfToken = BrandedString<'CsrfToken'>; + +const ID_PATTERN = /^[a-z][a-z0-9-]*_[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/; +const HASH_PATTERN = /^hmac-sha256:[0-9a-f]{64}$/; +const SECRET_PATTERN = /^[A-Za-z0-9_-]{32,512}$/; + +function parseId(value: unknown, errorName: string): BrandedString { + if (typeof value !== 'string' || !ID_PATTERN.test(value)) { + throw new TypeError(`hosted-access-${errorName}-invalid`); + } + return value as BrandedString; +} + +export const parseOperatorId = (value: unknown): OperatorId => + parseId<'OperatorId'>(value, 'operator-id'); +export const parsePairingChallengeId = (value: unknown): PairingChallengeId => + parseId<'PairingChallengeId'>(value, 'pairing-challenge-id'); +export const parseDeviceFamilyId = (value: unknown): DeviceFamilyId => + parseId<'DeviceFamilyId'>(value, 'device-family-id'); +export const parseDeviceGrantId = (value: unknown): DeviceGrantId => + parseId<'DeviceGrantId'>(value, 'device-grant-id'); +export const parseOperatorSessionId = (value: unknown): OperatorSessionId => + parseId<'OperatorSessionId'>(value, 'operator-session-id'); +export const parseAuthKeyringId = (value: unknown): AuthKeyringId => + parseId<'AuthKeyringId'>(value, 'auth-keyring-id'); +export const parseAuthorityDeploymentId = (value: unknown): AuthorityDeploymentId => + parseId<'AuthorityDeploymentId'>(value, 'authority-deployment-id'); + +export function parseOpaqueAuthoritySecret(value: unknown): OpaqueAuthoritySecret { + if (typeof value !== 'string' || !SECRET_PATTERN.test(value)) { + throw new TypeError('hosted-access-opaque-secret-invalid'); + } + return value as OpaqueAuthoritySecret; +} + +export function parseCsrfToken(value: unknown): CsrfToken { + if (typeof value !== 'string' || !SECRET_PATTERN.test(value)) { + throw new TypeError('hosted-access-csrf-token-invalid'); + } + return value as CsrfToken; +} + +export function parseKeyedSecretHash(value: unknown): KeyedSecretHash { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + throw new TypeError('hosted-access-keyed-secret-hash-invalid'); + } + return value as KeyedSecretHash; +} + +export function parseAuthorityKeyMaterial(value: unknown): AuthorityKeyMaterial { + if (typeof value !== 'string' || !SECRET_PATTERN.test(value)) { + throw new TypeError('hosted-access-key-material-invalid'); + } + return value as AuthorityKeyMaterial; +} + +export interface AuthorityBinding { + readonly deploymentId: AuthorityDeploymentId; + /** + * Host-owned restore fence. It must change when coordination state is restored + * into a replacement deployment. + */ + readonly restoreGeneration: number; +} + +export interface HostedAccessAuthorityPolicy { + readonly pairingChallengeTtlMs: number; + readonly pairingMaxAttempts: number; + readonly deviceIdleTtlMs: number; + readonly deviceAbsoluteTtlMs: number; + readonly deviceRenewalTtlMs: number; + readonly sessionIdleTtlMs: number; + readonly sessionAbsoluteTtlMs: number; + readonly sessionRenewalTtlMs: number; + readonly predecessorGraceMs: number; + readonly predecessorMaxUses: number; + readonly retainedDeviceGenerations: number; + readonly compareAndSwapAttempts: number; +} + +export type HostedAccessRejectionCode = + | 'authority_state_corrupt' + | 'authority_store_conflict' + | 'authority_store_unavailable' + | 'challenge_attempts_exhausted' + | 'challenge_delivery_unavailable' + | 'challenge_expired' + | 'challenge_invalid' + | 'challenge_not_issued' + | 'csrf_invalid' + | 'device_absolute_expired' + | 'device_family_revoked' + | 'device_idle_expired' + | 'device_invalid' + | 'keyring_corrupt' + | 'keyring_missing' + | 'keyring_mismatch' + | 'keyring_unavailable' + | 'pairing_already_established' + | 'pairing_drain_unconfirmed' + | 'reset_generation_not_newer' + | 'reset_in_progress' + | 'reset_stage_unavailable' + | 'restore_binding_mismatch' + | 'session_absolute_expired' + | 'session_idle_expired' + | 'session_invalid' + | 'session_renewal_required'; + +export type HostedAccessResult = + | { readonly ok: true; readonly code: Code; readonly value: Value } + | { readonly ok: false; readonly code: HostedAccessRejectionCode }; + +export interface PairingCredentials { + readonly operatorId: OperatorId; + readonly deviceFamilyId: DeviceFamilyId; + readonly deviceGeneration: number; + readonly deviceSecret: OpaqueAuthoritySecret; + readonly sessionId: OperatorSessionId; + readonly sessionSecret: OpaqueAuthoritySecret; + readonly csrfToken: CsrfToken; +} + +export interface RenewedCredentials extends PairingCredentials { + readonly acceptedDeviceGeneration: number; + readonly acceptedVia: 'current' | 'predecessor'; +} + +export interface AuthenticatedOperator { + readonly operatorId: OperatorId; + readonly deviceFamilyId: DeviceFamilyId; + readonly sessionId: OperatorSessionId; +} diff --git a/src/features/hosted-access/contracts/index.ts b/src/features/hosted-access/contracts/index.ts new file mode 100644 index 0000000000..a09d66010b --- /dev/null +++ b/src/features/hosted-access/contracts/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './authority'; diff --git a/src/features/hosted-access/core/application/AuthorityCore.ts b/src/features/hosted-access/core/application/AuthorityCore.ts new file mode 100644 index 0000000000..13bb57bffa --- /dev/null +++ b/src/features/hosted-access/core/application/AuthorityCore.ts @@ -0,0 +1,291 @@ +import { + type AuthKeyringId, + type AuthorityBinding, + type HostedAccessAuthorityPolicy, + type HostedAccessRejectionCode, + parseAuthKeyringId, + parseAuthorityKeyMaterial, + parseOpaqueAuthoritySecret, +} from '../../contracts'; +import { + assertAuthorityBinding, + assertHostedAccessAuthorityPolicy, + type HostedAccessAuthorityState, + isRecoverableAuthorityState, + nextAuthorityState, +} from '../domain'; + +import type { + AuthKeyringEnvelope, + AuthKeyringPort, + AuthoritySecretDerivationPurpose, + HostedAccessAuthorityRepositoryPort, + HostedAccessClockPort, + HostedAccessCryptoPort, + HostedAccessRandomPort, + PairingChallengeDeliveryPort, + PairingDrainProofPort, +} from './ports'; + +export interface HostedAccessAuthorityDependencies { + readonly clock: HostedAccessClockPort; + readonly random: HostedAccessRandomPort; + readonly crypto: HostedAccessCryptoPort; + readonly repository: HostedAccessAuthorityRepositoryPort; + readonly keyrings: AuthKeyringPort; + readonly challengeDelivery: PairingChallengeDeliveryPort; + readonly drainProof: PairingDrainProofPort; + readonly policy: HostedAccessAuthorityPolicy; +} + +export type LoadedAuthority = + | { + readonly ok: true; + readonly state: HostedAccessAuthorityState; + readonly keyring: AuthKeyringEnvelope; + } + | { readonly ok: false; readonly code: HostedAccessRejectionCode }; + +export type LoadedState = + | { readonly ok: true; readonly state: HostedAccessAuthorityState } + | { readonly ok: false; readonly code: HostedAccessRejectionCode | 'authority_state_empty' }; + +export class AuthorityCore { + readonly dependencies: HostedAccessAuthorityDependencies; + + constructor(dependencies: HostedAccessAuthorityDependencies) { + assertHostedAccessAuthorityPolicy(dependencies.policy); + this.dependencies = dependencies; + } + + now(): number { + const now = this.dependencies.clock.now(); + if (!Number.isSafeInteger(now) || now < 0) { + throw new TypeError('hosted-access-clock-invalid'); + } + return now; + } + + async loadState(): Promise { + const result = await this.dependencies.repository.load(); + if (result.status !== 'available') { + if (result.status === 'empty') { + return { + ok: false, + code: + result.rollbackFenceRevision === null + ? 'authority_state_empty' + : 'authority_state_corrupt', + }; + } + if (result.status === 'corrupt') { + return { ok: false, code: 'authority_state_corrupt' }; + } + return { ok: false, code: 'authority_store_unavailable' }; + } + if ( + !Number.isSafeInteger(result.rollbackFenceRevision) || + result.rollbackFenceRevision < 0 || + result.rollbackFenceRevision !== result.state.revision || + !isRecoverableAuthorityState(result.state, this.dependencies.policy) + ) { + return { ok: false, code: 'authority_state_corrupt' }; + } + return { ok: true, state: result.state }; + } + + async loadRegular(binding: AuthorityBinding): Promise { + assertAuthorityBinding(binding); + for (let attempt = 0; attempt < this.dependencies.policy.compareAndSwapAttempts; attempt += 1) { + const loaded = await this.loadState(); + if (!loaded.ok) { + return { + ok: false, + code: loaded.code === 'authority_state_empty' ? 'authority_state_corrupt' : loaded.code, + }; + } + if (loaded.state.resetIntent !== null) { + return { ok: false, code: 'reset_in_progress' }; + } + if (!bindingsEqual(loaded.state.binding, binding)) { + return { ok: false, code: 'restore_binding_mismatch' }; + } + const cleanup = await this.reconcileChallengeDeliveryCleanup(loaded.state); + if (cleanup === 'conflict' || cleanup === 'committed') continue; + if (cleanup === 'unavailable') { + return { ok: false, code: 'challenge_delivery_unavailable' }; + } + const keyring = await this.dependencies.keyrings.loadActive(); + if (keyring.status !== 'available') { + return { ok: false, code: keyringReadCode(keyring.status) }; + } + if (!isKeyringEnvelopeValid(keyring.envelope)) { + return { ok: false, code: 'keyring_corrupt' }; + } + if (!bindingsEqual(keyring.envelope.binding, binding)) { + return { ok: false, code: 'restore_binding_mismatch' }; + } + if (keyring.envelope.keyringId !== loaded.state.expectedKeyringId) { + return { ok: false, code: 'keyring_mismatch' }; + } + return { ok: true, state: loaded.state, keyring: keyring.envelope }; + } + return { ok: false, code: 'authority_store_conflict' }; + } + + async createKeyring( + binding: AuthorityBinding, + reservedKeyringId?: AuthKeyringId + ): Promise { + assertAuthorityBinding(binding); + const keyringId = + reservedKeyringId ?? + parseAuthKeyringId(await this.dependencies.random.randomId('auth-keyring')); + const hashKey = parseAuthorityKeyMaterial( + await this.dependencies.random.randomSecret('hash-key', 32) + ); + const csrfKey = parseAuthorityKeyMaterial( + await this.dependencies.random.randomSecret('csrf-key', 32) + ); + return Object.freeze({ + format: 'hosted-access-keyring/v1', + keyringId, + binding: Object.freeze({ ...binding }), + createdAt: this.now(), + hashKey, + csrfKey, + }); + } + + async randomAuthoritySecret(kind: 'device-grant' | 'pairing-challenge' | 'session') { + return parseOpaqueAuthoritySecret(await this.dependencies.random.randomSecret(kind, 32)); + } + + async deriveAuthoritySecret(input: { + readonly key: AuthKeyringEnvelope['hashKey']; + readonly purpose: AuthoritySecretDerivationPurpose; + readonly sourceSecret: import('../../contracts').OpaqueAuthoritySecret; + readonly context: string; + }) { + return parseOpaqueAuthoritySecret(await this.dependencies.crypto.deriveSecret(input)); + } + + async commit( + expected: HostedAccessAuthorityState, + nextState: HostedAccessAuthorityState + ): Promise<'committed' | 'conflict' | 'unavailable'> { + if (nextState.revision !== expected.revision + 1) { + throw new TypeError('hosted-access-revision-transition-invalid'); + } + if (!isRecoverableAuthorityState(nextState, this.dependencies.policy)) { + throw new TypeError('hosted-access-state-transition-invalid'); + } + const result = await this.dependencies.repository.compareAndSwap({ + expectedRevision: expected.revision, + expectedRollbackFenceRevision: expected.revision, + nextState, + nextRollbackFenceRevision: nextState.revision, + }); + return result.status; + } + + async reconcileChallengeDeliveryCleanup( + state: HostedAccessAuthorityState + ): Promise<'clean' | 'committed' | 'conflict' | 'unavailable'> { + const pending = state.pairingChallenges.filter( + ({ deliveryCleanupPending }) => deliveryCleanupPending + ); + if (pending.length === 0) return 'clean'; + for (const challenge of pending) { + const removed = await this.dependencies.challengeDelivery.remove(challenge.challengeId); + if (removed.status === 'unavailable' || removed.status === 'conflict') { + const observed = await this.dependencies.challengeDelivery.status(challenge.challengeId); + if (observed.status !== 'missing') return 'unavailable'; + } + } + const next = nextAuthorityState(state, { + pairingChallenges: state.pairingChallenges.map((challenge) => + challenge.deliveryCleanupPending + ? Object.freeze({ ...challenge, deliveryCleanupPending: false }) + : challenge + ), + }); + const committed = await this.commit(state, next); + if (committed !== 'unavailable') return committed; + const recovered = await this.loadState(); + if (!recovered.ok) return 'unavailable'; + const pendingIds = new Set(pending.map(({ challengeId }) => challengeId)); + return recovered.state.pairingChallenges.some( + ({ challengeId, deliveryCleanupPending }) => + pendingIds.has(challengeId) && deliveryCleanupPending + ) + ? 'unavailable' + : 'committed'; + } +} + +export function bindingsEqual(left: AuthorityBinding, right: AuthorityBinding): boolean { + return ( + left.deploymentId === right.deploymentId && left.restoreGeneration === right.restoreGeneration + ); +} + +export function isKeyringEnvelopeValid(value: unknown): value is AuthKeyringEnvelope { + try { + if ( + !isExactRecord(value, [ + 'format', + 'keyringId', + 'binding', + 'createdAt', + 'hashKey', + 'csrfKey', + ]) || + !isExactRecord(value.binding, ['deploymentId', 'restoreGeneration']) + ) { + return false; + } + const envelope = value as unknown as AuthKeyringEnvelope; + if (envelope.format !== 'hosted-access-keyring/v1') return false; + parseAuthKeyringId(envelope.keyringId); + assertAuthorityBinding(envelope.binding); + parseAuthorityKeyMaterial(envelope.hashKey); + parseAuthorityKeyMaterial(envelope.csrfKey); + return Number.isSafeInteger(envelope.createdAt) && envelope.createdAt >= 0; + } catch { + return false; + } +} + +function isExactRecord( + value: unknown, + expectedKeys: readonly string[] +): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const actualKeys = Reflect.ownKeys(value); + if (actualKeys.length !== expectedKeys.length) return false; + const expected = new Set(expectedKeys); + return actualKeys.every((key) => typeof key === 'string' && expected.has(key)); +} + +export function keyringReadCode( + status: 'missing' | 'corrupt' | 'unavailable' +): 'keyring_missing' | 'keyring_corrupt' | 'keyring_unavailable' { + if (status === 'missing') return 'keyring_missing'; + if (status === 'corrupt') return 'keyring_corrupt'; + return 'keyring_unavailable'; +} + +export async function findHashMatch( + crypto: HostedAccessCryptoPort, + candidates: readonly T[], + presentedHash: string +): Promise { + let match: T | null = null; + for (const candidate of candidates) { + if (await crypto.secureEqual(candidate.secretHash, presentedHash)) { + match = candidate; + } + } + return match; +} diff --git a/src/features/hosted-access/core/application/HostedAccessAuthority.ts b/src/features/hosted-access/core/application/HostedAccessAuthority.ts new file mode 100644 index 0000000000..c02f83f3ab --- /dev/null +++ b/src/features/hosted-access/core/application/HostedAccessAuthority.ts @@ -0,0 +1,82 @@ +import { AuthorityCore, type HostedAccessAuthorityDependencies } from './AuthorityCore'; +import { PairingAuthority } from './PairingAuthority'; +import { ResetAuthority } from './ResetAuthority'; +import { SessionAuthority } from './SessionAuthority'; + +import type { AuthorityBinding, CsrfToken, OpaqueAuthoritySecret } from '../../contracts'; +import type { PersonalOwnerPreparationPort } from './ports'; + +/** + * Pure Phase 6 operator-authority facade. + * + * All effects cross the narrow ports in HostedAccessAuthorityDependencies. + * Transport adapters are deliberately outside this slice. + */ +export class HostedAccessAuthority { + private readonly pairing: PairingAuthority; + private readonly sessions: SessionAuthority; + private readonly reset: ResetAuthority; + + constructor(dependencies: HostedAccessAuthorityDependencies) { + const core = new AuthorityCore(dependencies); + this.pairing = new PairingAuthority(core); + this.sessions = new SessionAuthority(core); + this.reset = new ResetAuthority(core); + } + + initialize(binding: AuthorityBinding) { + return this.pairing.initialize(binding); + } + + issueInitialChallenge(binding: AuthorityBinding) { + return this.pairing.issueInitialChallenge(binding); + } + + pair( + binding: AuthorityBinding, + pairingSecret: OpaqueAuthoritySecret, + ownerPreparation?: PersonalOwnerPreparationPort + ) { + return this.pairing.pair(binding, pairingSecret, ownerPreparation); + } + + renew( + binding: AuthorityBinding, + deviceSecret: OpaqueAuthoritySecret, + ownerPreparation?: PersonalOwnerPreparationPort + ) { + return this.sessions.renew(binding, deviceSecret, ownerPreparation); + } + + authenticate(binding: AuthorityBinding, sessionSecret: OpaqueAuthoritySecret) { + return this.sessions.authenticate(binding, sessionSecret); + } + + bootstrapSession( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret, + allowRenewal = true + ) { + return this.sessions.bootstrapSession(binding, sessionSecret, allowRenewal); + } + + verifyCsrf( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret, + csrfToken: CsrfToken + ) { + return this.sessions.verifyCsrf(binding, sessionSecret, csrfToken); + } + + logout(binding: AuthorityBinding, sessionSecret: OpaqueAuthoritySecret) { + return this.sessions.logout(binding, sessionSecret); + } + + forgetDevice(binding: AuthorityBinding, sessionSecret: OpaqueAuthoritySecret) { + return this.sessions.forgetDevice(binding, sessionSecret); + } + + consumeResetGeneration(binding: AuthorityBinding, resetGeneration: number) { + return this.reset.consumeResetGeneration(binding, resetGeneration); + } +} diff --git a/src/features/hosted-access/core/application/HostedAuthenticationProviders.ts b/src/features/hosted-access/core/application/HostedAuthenticationProviders.ts new file mode 100644 index 0000000000..d661d925ab --- /dev/null +++ b/src/features/hosted-access/core/application/HostedAuthenticationProviders.ts @@ -0,0 +1,482 @@ +import { + type HostedAccessRejectionCode, + type HostedAuthMode, + type HostedPrincipal, + type OidcLoginAttemptId, + type OpaqueAuthoritySecret, + parseCsrfToken, + parseOpaqueAuthoritySecret, +} from '../../contracts'; + +import { principal } from './HostedIdentityService'; + +import type { AuthorityBinding, PairingCredentials } from '../../contracts'; +import type { HostedAccessAuthority } from './HostedAccessAuthority'; +import type { + AuthenticateHostedSessionResult, + HostedIdentityService, + IssuedHostedSession, +} from './HostedIdentityService'; +import type { HostedAuditEvent, HostedPersonalOwnerRecord } from './identityPorts'; + +export interface HostedAuthenticationContext { + readonly principal: HostedPrincipal; + /** Server-only session identity; never serialize this context to a browser response. */ + readonly authenticatedSessionId?: + | Exclude + | PairingCredentials['sessionId']; + readonly sessionSecret: string; + readonly csrfToken: string; +} + +/** The secret-free authentication evidence available to trusted server composition. */ +export interface HostedAuthenticatedPrincipal { + readonly principal: HostedPrincipal; + readonly authenticatedSessionId: + | Exclude + | PairingCredentials['sessionId']; +} + +export function sanitizeHostedAuthenticatedPrincipal( + context: HostedAuthenticationContext +): HostedAuthenticatedPrincipal | null { + const authenticatedSessionId = context.authenticatedSessionId; + if (authenticatedSessionId === undefined) return null; + const source = context.principal; + return Object.freeze({ + principal: Object.freeze({ + userId: source.userId, + displayName: source.displayName, + role: source.role, + permissions: Object.freeze([...source.permissions]), + authenticationMethod: source.authenticationMethod, + sessionId: source.sessionId, + }), + authenticatedSessionId, + }); +} + +export type HostedProviderAuthenticationResult = + | { + readonly authenticated: true; + readonly context: HostedAuthenticationContext; + readonly replacementDeviceSecret: string | null; + } + | { + readonly authenticated: false; + readonly reason: string; + }; + +export interface HostedAuthenticationProvider { + readonly mode: HostedAuthMode; + readonly displayName: string; + authenticate(input: { + readonly sessionSecret?: string; + readonly deviceSecret?: string; + readonly allowRenewal: boolean; + readonly sourceIp?: string; + }): Promise; + verifyCsrf(context: HostedAuthenticationContext, presented: string): Promise; + logout(input: { + readonly context: HostedAuthenticationContext; + readonly global: boolean; + readonly postLogoutRedirectUri: string; + readonly sourceIp?: string; + }): Promise<{ readonly redirectUrl: string | null }>; + auditAuthorization( + input: Parameters[0] + ): Promise; +} + +export type HostedPersonalPairResult = + | { + readonly ok: true; + readonly code: 'paired'; + readonly value: PairingCredentials & { + readonly principal: HostedPrincipal; + }; + } + | { + readonly ok: false; + readonly code: string; + }; + +export interface PersonalAuthenticationCapability extends HostedAuthenticationProvider { + readonly mode: 'personal'; + pair(pairingSecret: OpaqueAuthoritySecret): Promise; + forgetDevice( + context: HostedAuthenticationContext + ): ReturnType; + auditPersonalAuthentication(input: { + readonly userId: HostedPrincipal['userId'] | null; + readonly action: + | 'auth.personal.pair' + | 'auth.personal.renew' + | 'auth.personal.logout' + | 'auth.personal.forget-device'; + readonly outcome: HostedAuditEvent['outcome']; + readonly sourceIp?: string; + readonly reason?: string; + }): Promise; +} + +export interface OidcAuthenticationCapability extends HostedAuthenticationProvider { + readonly mode: 'oidc'; + beginLogin(returnTo: string): ReturnType; + completeLogin(input: { + readonly callbackUrl: URL; + readonly expectedState: string; + readonly attemptId: OidcLoginAttemptId; + readonly sourceIp?: string; + }): Promise; + backchannelLogout(token: string): Promise; +} + +function personalPrincipal(owner: HostedPersonalOwnerRecord): HostedPrincipal { + return principal(owner.user.userId, owner.user.displayName, 'owner', null, 'personal'); +} + +function assertPersonalOwnerActive(owner: HostedPersonalOwnerRecord): void { + if (owner.user.status !== 'active') throw new Error('personal_user_disabled'); +} + +function assertPersonalOwnerBinding( + owner: HostedPersonalOwnerRecord, + expectedOperatorId: HostedPersonalOwnerRecord['operatorId'] +): void { + if (owner.operatorId !== expectedOperatorId) { + throw new Error('personal_identity_binding_mismatch'); + } +} + +const PERSONAL_AUTHORITY_UNAVAILABLE_CODES = new Set([ + 'authority_state_corrupt', + 'authority_store_conflict', + 'authority_store_unavailable', + 'challenge_delivery_unavailable', + 'keyring_corrupt', + 'keyring_missing', + 'keyring_mismatch', + 'keyring_unavailable', + 'pairing_drain_unconfirmed', + 'reset_in_progress', + 'reset_stage_unavailable', + 'restore_binding_mismatch', +]); + +function personalAuthorityUnavailable(code: HostedAccessRejectionCode): boolean { + return PERSONAL_AUTHORITY_UNAVAILABLE_CODES.has(code); +} + +function throwPersonalAuthorityUnavailable(cause: unknown): never { + throw new Error('personal_authority_unavailable', { + cause: cause instanceof Error ? cause : new Error(String(cause)), + }); +} + +export class HostedPersonalAuthenticationProvider implements PersonalAuthenticationCapability { + readonly mode = 'personal' as const; + readonly displayName = 'Personal pairing'; + + constructor( + private readonly binding: AuthorityBinding, + private readonly authority: HostedAccessAuthority, + private readonly identities: HostedIdentityService + ) {} + + async pair(pairingSecret: OpaqueAuthoritySecret): Promise { + let owner: HostedPersonalOwnerRecord | null = null; + let result: Awaited>; + try { + result = await this.authority.pair(this.binding, pairingSecret, { + prepare: async (proposedOperatorId) => { + try { + owner = await this.identities.ensurePersonalOwner(proposedOperatorId); + assertPersonalOwnerActive(owner); + return owner.operatorId; + } catch (error) { + if (error instanceof Error && error.message === 'personal_user_disabled') throw error; + throw new Error('personal_identity_storage_unavailable', { cause: error }); + } + }, + }); + } catch (error) { + if ( + error instanceof Error && + (error.message === 'personal_identity_storage_unavailable' || + error.message === 'personal_user_disabled') + ) { + throw error; + } + throwPersonalAuthorityUnavailable(error); + } + if (!result.ok) { + if (personalAuthorityUnavailable(result.code)) { + throwPersonalAuthorityUnavailable(new Error(result.code)); + } + return result; + } + if (owner === null) throw new Error('personal_identity_storage_unavailable'); + return Object.freeze({ + ...result, + value: Object.freeze({ + ...result.value, + principal: personalPrincipal(owner), + }), + }); + } + + async authenticate(input: { + readonly sessionSecret?: string; + readonly deviceSecret?: string; + readonly allowRenewal: boolean; + }): Promise { + if (input.sessionSecret) { + let parsedSessionSecret: OpaqueAuthoritySecret | null = null; + try { + parsedSessionSecret = parseOpaqueAuthoritySecret(input.sessionSecret); + } catch { + // An invalid short-session cookie may still be recovered by a valid + // durable device credential. + } + if (parsedSessionSecret !== null) { + let result: Awaited>; + try { + result = await this.authority.bootstrapSession( + this.binding, + parsedSessionSecret, + input.allowRenewal + ); + } catch (error) { + throwPersonalAuthorityUnavailable(error); + } + if (result.ok) { + let owner: HostedPersonalOwnerRecord; + try { + owner = await this.identities.ensurePersonalOwner(result.value.operatorId); + assertPersonalOwnerBinding(owner, result.value.operatorId); + } catch (error) { + throw new Error('personal_identity_storage_unavailable', { cause: error }); + } + if (owner.user.status !== 'active') { + return Object.freeze({ authenticated: false, reason: 'user-disabled' }); + } + return Object.freeze({ + authenticated: true, + context: Object.freeze({ + principal: personalPrincipal(owner), + authenticatedSessionId: result.value.sessionId, + sessionSecret: input.sessionSecret, + csrfToken: result.value.csrfToken, + }), + replacementDeviceSecret: null, + }); + } + if (personalAuthorityUnavailable(result.code)) { + throwPersonalAuthorityUnavailable(new Error(result.code)); + } + } + } + if (!input.allowRenewal || !input.deviceSecret) { + return Object.freeze({ authenticated: false, reason: 'session_invalid' }); + } + let parsedDeviceSecret: OpaqueAuthoritySecret; + try { + parsedDeviceSecret = parseOpaqueAuthoritySecret(input.deviceSecret); + } catch { + return Object.freeze({ authenticated: false, reason: 'device_invalid' }); + } + let owner: HostedPersonalOwnerRecord | null = null; + try { + const renewed = await this.authority.renew(this.binding, parsedDeviceSecret, { + prepare: async (operatorId) => { + try { + owner = await this.identities.ensurePersonalOwner(operatorId); + assertPersonalOwnerBinding(owner, operatorId); + assertPersonalOwnerActive(owner); + return owner.operatorId; + } catch (error) { + if (error instanceof Error && error.message === 'personal_user_disabled') throw error; + throw new Error('personal_identity_storage_unavailable', { cause: error }); + } + }, + }); + if (!renewed.ok) { + if (personalAuthorityUnavailable(renewed.code)) { + throwPersonalAuthorityUnavailable(new Error(renewed.code)); + } + return Object.freeze({ authenticated: false, reason: renewed.code }); + } + if (owner === null) throw new Error('personal_identity_storage_unavailable'); + return Object.freeze({ + authenticated: true, + context: Object.freeze({ + principal: personalPrincipal(owner), + authenticatedSessionId: renewed.value.sessionId, + sessionSecret: renewed.value.sessionSecret, + csrfToken: renewed.value.csrfToken, + }), + replacementDeviceSecret: renewed.value.deviceSecret, + }); + } catch (error) { + if (error instanceof Error && error.message === 'personal_identity_storage_unavailable') { + throw error; + } + if (error instanceof Error && error.message === 'personal_user_disabled') { + return Object.freeze({ authenticated: false, reason: 'user-disabled' }); + } + if (error instanceof Error && error.message === 'personal_authority_unavailable') { + throw error; + } + throwPersonalAuthorityUnavailable(error); + } + } + + async verifyCsrf(context: HostedAuthenticationContext, presented: string): Promise { + let sessionSecret: OpaqueAuthoritySecret; + let csrfToken: ReturnType; + try { + sessionSecret = parseOpaqueAuthoritySecret(context.sessionSecret); + csrfToken = parseCsrfToken(presented); + } catch { + return false; + } + let result: Awaited>; + try { + result = await this.authority.verifyCsrf(this.binding, sessionSecret, csrfToken); + } catch (error) { + throwPersonalAuthorityUnavailable(error); + } + if (!result.ok && personalAuthorityUnavailable(result.code)) { + throwPersonalAuthorityUnavailable(new Error(result.code)); + } + return result.ok; + } + + async logout(input: { + readonly context: HostedAuthenticationContext; + }): Promise<{ readonly redirectUrl: null }> { + const result = await this.authority.logout( + this.binding, + parseOpaqueAuthoritySecret(input.context.sessionSecret) + ); + if (!result.ok && result.code !== 'session_invalid') { + throw new Error('personal_logout_unavailable', { + cause: new Error(result.code), + }); + } + return Object.freeze({ redirectUrl: null }); + } + + forgetDevice( + context: HostedAuthenticationContext + ): ReturnType { + return this.authority.forgetDevice( + this.binding, + parseOpaqueAuthoritySecret(context.sessionSecret) + ); + } + + auditAuthorization( + input: Parameters[0] + ): Promise { + return this.identities.auditAuthorization(input); + } + + auditPersonalAuthentication( + input: Parameters[0] + ): Promise { + return this.identities.auditPersonalAuthentication(input); + } +} + +export class HostedOidcAuthenticationProvider implements OidcAuthenticationCapability { + readonly mode = 'oidc' as const; + + constructor( + readonly displayName: string, + private readonly identities: HostedIdentityService + ) {} + + beginLogin(returnTo: string): ReturnType { + return this.identities.beginOidcLogin(returnTo); + } + + completeLogin( + input: Parameters[0] + ): Promise { + return this.identities.completeOidcLogin(input); + } + + backchannelLogout(token: string): Promise { + return this.identities.backchannelLogout(token); + } + + async authenticate(input: { + readonly sessionSecret?: string; + readonly sourceIp?: string; + readonly allowRenewal?: boolean; + }): Promise { + if (!input.sessionSecret) { + return Object.freeze({ authenticated: false, reason: 'invalid' }); + } + let result: AuthenticateHostedSessionResult; + try { + result = await this.identities.authenticate( + input.sessionSecret, + input.sourceIp, + input.allowRenewal ?? true + ); + } catch (error) { + // Invalid, expired and revoked credentials are result values. Any throw + // means the local authentication subsystem could not make a trustworthy + // decision, so the HTTP boundary must fail unavailable instead of + // silently treating an outage as an anonymous session. + throw new Error('oidc_authentication_unavailable', { cause: error }); + } + if (!result.authenticated) return result; + const authenticatedSessionId = result.principal.sessionId; + if (authenticatedSessionId === null) { + throw new Error('oidc_authentication_unavailable'); + } + return Object.freeze({ + authenticated: true, + context: Object.freeze({ + principal: result.principal, + authenticatedSessionId, + sessionSecret: input.sessionSecret, + csrfToken: result.csrfToken, + }), + replacementDeviceSecret: null, + }); + } + + verifyCsrf(context: HostedAuthenticationContext, presented: string): Promise { + if (context.principal.sessionId === null) return Promise.resolve(false); + return this.identities.verifyCsrf( + context.principal.sessionId, + context.sessionSecret, + presented + ); + } + + logout(input: { + readonly context: HostedAuthenticationContext; + readonly global: boolean; + readonly postLogoutRedirectUri: string; + readonly sourceIp?: string; + }): Promise<{ readonly redirectUrl: string | null }> { + return this.identities.logout({ + sessionSecret: input.context.sessionSecret, + global: input.global, + postLogoutRedirectUri: input.postLogoutRedirectUri, + sourceIp: input.sourceIp, + }); + } + + auditAuthorization( + input: Parameters[0] + ): Promise { + return this.identities.auditAuthorization(input); + } +} diff --git a/src/features/hosted-access/core/application/HostedIdentityService.ts b/src/features/hosted-access/core/application/HostedIdentityService.ts new file mode 100644 index 0000000000..1f35cc5e46 --- /dev/null +++ b/src/features/hosted-access/core/application/HostedIdentityService.ts @@ -0,0 +1,783 @@ +import { parseTeamId, type TeamId } from '@shared/contracts/hosted'; + +import { + type HostedPrincipal, + type HostedRole, + type HostedSessionId, + type OidcLoginAttemptId, + type OperatorId, + parseAuditEventId, + parseHostedSessionId, + parseHostedWorkspaceId, + parseOidcLoginAttemptId, + parseUserId, +} from '../../contracts'; +import { + createHostedWorkspaceProjectionScope, + permissionsForRole, + projectHostedPayload, +} from '../domain'; + +import { + type HostedWorkspaceGrantSetFence, + HostedWorkspaceGrantSetFenceRegistry, +} from './HostedWorkspaceGrantSetFence'; + +import type { + HostedAuditEvent, + HostedIdentityCryptoPort, + HostedIdentityRepositoryPort, + HostedOperatorSessionRecord, + HostedPersonalOwnerRecord, + HostedWorkspaceGrant, + OidcIdentityProvider, + OidcLoginAttemptRecord, +} from './identityPorts'; + +export type HostedTeamWorkspaceAttribution = + | Readonly<{ + kind: 'found'; + runtimeWorkspaceId: string; + /** Monotonic identity/snapshot binding; changes on tombstone or workspace/mount rebind. */ + attributionRevision: string; + /** Exact checksum from the canonical committed team identity record. */ + identityChecksum: string; + }> + | Readonly<{ kind: 'not_found' }> + | Readonly<{ kind: 'unavailable' }>; + +export interface HostedIdentityServicePolicy { + readonly oidcLoginTtlMs: number; + readonly sessionIdleTtlMs: number; + readonly sessionAbsoluteTtlMs: number; + readonly restoreGeneration: number; +} + +export interface HostedIdentityServiceDependencies { + readonly repository: HostedIdentityRepositoryPort; + readonly crypto: HostedIdentityCryptoPort; + readonly provider: OidcIdentityProvider | null; + readonly policy: HostedIdentityServicePolicy; + readonly now: () => number; +} + +export interface IssuedHostedSession { + readonly sessionSecret: string; + readonly csrfToken: string; + readonly principal: HostedPrincipal; + readonly session: HostedOperatorSessionRecord; + readonly returnTo: string; +} + +export type AuthenticateHostedSessionResult = + | { + readonly authenticated: true; + readonly principal: HostedPrincipal; + readonly csrfToken: string; + } + | { + readonly authenticated: false; + readonly reason: 'invalid' | 'expired' | 'revoked' | 'user-disabled'; + }; + +export class HostedIdentityService { + constructor(private readonly dependencies: HostedIdentityServiceDependencies) { + if ( + !Number.isSafeInteger(dependencies.policy.restoreGeneration) || + dependencies.policy.restoreGeneration < 0 + ) { + throw new TypeError('hosted_identity_restore_generation_invalid'); + } + } + + async createWorkspaceId() { + return parseHostedWorkspaceId(await this.dependencies.crypto.randomId('workspace')); + } + + async beginOidcLogin(returnTo: string): Promise<{ + readonly redirectUrl: string; + readonly attemptId: OidcLoginAttemptId; + readonly state: string; + }> { + const provider = this.oidcProvider(); + const now = this.dependencies.now(); + const attemptId = parseOidcLoginAttemptId( + await this.dependencies.crypto.randomId('oidc-attempt') + ); + const begun = await provider.beginLogin({ attemptId, returnTo }); + const record: OidcLoginAttemptRecord = Object.freeze({ + attemptId, + providerId: provider.id, + stateHash: await this.hashOidcState(begun.state), + nonce: begun.nonce, + pkceVerifierCiphertext: await this.dependencies.crypto.encryptLoginSecret(begun.pkceVerifier), + returnTo, + createdAt: now, + expiresAt: now + this.dependencies.policy.oidcLoginTtlMs, + consumedAt: null, + }); + const created = await this.dependencies.repository.createOidcLoginAttempt(record); + if (created !== 'created') { + throw new Error( + created === 'capacity' ? 'oidc_login_capacity_exceeded' : 'oidc_login_attempt_conflict' + ); + } + return Object.freeze({ + redirectUrl: begun.redirectUrl, + attemptId, + state: begun.state, + }); + } + + async completeOidcLogin(input: { + readonly callbackUrl: URL; + readonly expectedState: string; + readonly attemptId: OidcLoginAttemptId; + readonly sourceIp?: string; + }): Promise { + const provider = this.oidcProvider(); + const now = this.dependencies.now(); + const callbackState = input.callbackUrl.searchParams.get('state'); + if ( + callbackState === null || + !(await this.dependencies.crypto.secureEqual(callbackState, input.expectedState)) + ) { + await this.audit(null, null, 'auth.oidc.callback', 'denied', input.sourceIp, { + reason: 'state_mismatch', + }); + throw new Error('oidc_state_mismatch'); + } + const stateHash = await this.hashOidcState(callbackState); + const attempt = await this.dependencies.repository.consumeOidcLoginAttempt({ + attemptId: input.attemptId, + providerId: provider.id, + stateHash, + now, + }); + if (attempt === null) { + await this.audit(null, null, 'auth.oidc.callback', 'denied', input.sourceIp, { + reason: 'state_invalid_or_replayed', + }); + throw new Error('oidc_state_invalid_or_replayed'); + } + + let claims; + try { + claims = await provider.completeLogin({ + callbackUrl: input.callbackUrl, + expectedState: input.expectedState, + attemptId: input.attemptId, + nonce: attempt.nonce, + pkceVerifier: await this.dependencies.crypto.decryptLoginSecret( + attempt.pkceVerifierCiphertext + ), + }); + } catch (error) { + await this.audit(null, null, 'auth.oidc.callback', 'failure', input.sourceIp, { + reason: + error instanceof Error && /^oidc_[a-z0-9_]+$/.test(error.message) + ? error.message + : 'provider_failure', + }); + throw error; + } + + const proposedUserId = parseUserId(await this.dependencies.crypto.randomId('user')); + const binding = await this.dependencies.repository.bindExternalIdentity({ + identity: { + issuer: claims.issuer, + subject: claims.subject, + providerId: provider.id, + createdAt: now, + lastAuthenticatedAt: now, + }, + proposedUser: { + userId: proposedUserId, + displayName: claims.displayName, + status: 'active', + createdAt: now, + updatedAt: now, + }, + }); + if (binding.user.status !== 'active') { + await this.audit(binding.user.userId, null, 'auth.oidc.login', 'denied', input.sourceIp, { + reason: 'user_disabled', + }); + throw new Error('oidc_user_disabled'); + } + const localRole = await this.dependencies.repository.getLocalRoleAssignment( + binding.user.userId + ); + const effectiveRole = localRole?.role ?? claims.role; + const issued = await this.issueSession({ + userId: binding.user.userId, + displayName: binding.user.displayName, + role: effectiveRole, + roleSource: localRole === null ? 'oidc-claim' : 'local-cli', + issuer: claims.issuer, + subject: claims.subject, + providerSessionId: claims.providerSessionId, + }); + await this.audit( + issued.principal.userId, + issued.session.sessionId, + 'auth.oidc.login', + 'success', + input.sourceIp, + { + provider: provider.id, + role: effectiveRole, + roleSource: localRole === null ? 'oidc-claim' : 'local-cli', + } + ); + return Object.freeze({ ...issued, returnTo: attempt.returnTo }); + } + + async authenticate( + sessionSecret: string, + sourceIp?: string, + allowRenewal = true + ): Promise { + const now = this.dependencies.now(); + const secretHash = await this.hashSessionSecret(sessionSecret); + const session = await this.dependencies.repository.findSessionBySecretHash(secretHash); + if (session === null) return Object.freeze({ authenticated: false, reason: 'invalid' }); + if (session.status !== 'active') { + return Object.freeze({ authenticated: false, reason: 'revoked' }); + } + if (now >= session.idleExpiresAt || now >= session.absoluteExpiresAt) { + await this.dependencies.repository.revokeSession({ + sessionId: session.sessionId, + now, + reason: 'expired', + }); + await this.audit( + session.userId, + session.sessionId, + 'auth.session.authenticate', + 'denied', + sourceIp, + { reason: 'expired' } + ); + return Object.freeze({ authenticated: false, reason: 'expired' }); + } + const user = await this.dependencies.repository.getUser(session.userId); + if (user?.status !== 'active') { + return Object.freeze({ authenticated: false, reason: 'user-disabled' }); + } + if (!allowRenewal) { + return Object.freeze({ + authenticated: true, + principal: principal( + user.userId, + user.displayName, + session.roleSnapshot.role, + session.sessionId + ), + csrfToken: await this.dependencies.crypto.deriveCsrf(session.sessionId, sessionSecret), + }); + } + const idleExpiresAt = Math.min( + now + this.dependencies.policy.sessionIdleTtlMs, + session.absoluteExpiresAt + ); + const touched = await this.dependencies.repository.touchSession({ + sessionId: session.sessionId, + expectedLastUsedAt: session.lastUsedAt, + lastUsedAt: now, + idleExpiresAt, + }); + if (!touched) { + // The read and idle-extension write are deliberately a CAS. A concurrent + // logout, disable, back-channel logout, or expiry must win and this + // request must not authenticate from its stale pre-revocation snapshot. + return Object.freeze({ authenticated: false, reason: 'revoked' }); + } + return Object.freeze({ + authenticated: true, + principal: principal( + user.userId, + user.displayName, + session.roleSnapshot.role, + session.sessionId + ), + csrfToken: await this.dependencies.crypto.deriveCsrf(session.sessionId, sessionSecret), + }); + } + + async verifyCsrf( + sessionId: HostedSessionId, + sessionSecret: string, + presentedCsrf: string + ): Promise { + const expected = await this.dependencies.crypto.deriveCsrf(sessionId, sessionSecret); + return this.dependencies.crypto.secureEqual(expected, presentedCsrf); + } + + async logout(input: { + readonly sessionSecret: string; + readonly global: boolean; + readonly postLogoutRedirectUri: string; + readonly sourceIp?: string; + }): Promise<{ readonly redirectUrl: string | null }> { + const provider = this.oidcProvider(); + const secretHash = await this.hashSessionSecret(input.sessionSecret); + const session = await this.dependencies.repository.findSessionBySecretHash(secretHash); + if (session === null) return Object.freeze({ redirectUrl: null }); + await this.dependencies.repository.revokeSession({ + sessionId: session.sessionId, + now: this.dependencies.now(), + reason: input.global ? 'provider_logout' : 'local_logout', + }); + if (!input.global) { + await this.audit( + session.userId, + session.sessionId, + 'auth.logout', + 'success', + input.sourceIp, + {} + ); + return Object.freeze({ redirectUrl: null }); + } + try { + const result = await provider.logout({ + session, + postLogoutRedirectUri: input.postLogoutRedirectUri, + }); + await this.audit( + session.userId, + session.sessionId, + 'auth.oidc.global-logout', + 'success', + input.sourceIp, + {} + ); + return result; + } catch (error) { + await this.audit( + session.userId, + session.sessionId, + 'auth.oidc.global-logout', + 'failure', + input.sourceIp, + { + localSessionRevoked: true, + reason: + error instanceof Error && error.message === 'oidc_provider_unavailable' + ? 'provider_unavailable' + : 'provider_failure', + } + ); + throw error; + } + } + + async backchannelLogout(token: string): Promise { + const provider = this.oidcProvider(); + const logout = await provider.verifyBackchannelLogout(token); + const applied = await this.dependencies.repository.applyBackchannelLogout({ + providerId: provider.id, + issuer: logout.issuer, + subject: logout.subject, + providerSessionId: logout.providerSessionId, + jti: logout.jti, + expiresAt: logout.expiresAt, + consumedAt: this.dependencies.now(), + reason: 'backchannel_logout', + }); + if (!applied.consumed) throw new Error('oidc_backchannel_logout_replayed'); + await this.audit(null, null, 'auth.oidc.backchannel-logout', 'success', undefined, { + revoked: applied.revoked, + }); + return applied.revoked; + } + + async ensurePersonalOwner( + operatorId: OperatorId, + displayName = 'Personal owner' + ): Promise { + const now = this.dependencies.now(); + return this.dependencies.repository.ensurePersonalOwner({ + operatorId, + user: { + userId: parseUserId(`usr_${operatorId.replace(/^[^_]+_/, '')}`), + displayName, + status: 'active', + createdAt: now, + updatedAt: now, + }, + }); + } + + async auditAuthorization(input: { + readonly principal: HostedPrincipal; + readonly sourceIp?: string; + readonly reason: 'permission_denied' | 'origin_invalid' | 'csrf_invalid' | 'workspace_denied'; + readonly method: string; + readonly permission: string; + }): Promise { + await this.audit( + input.principal.userId, + input.principal.sessionId, + 'auth.http.authorize', + 'denied', + input.sourceIp, + { + reason: input.reason, + method: input.method, + permission: input.permission, + } + ); + } + + async auditLocalControl( + action: string, + outcome: HostedAuditEvent['outcome'], + details: HostedAuditEvent['details'] + ): Promise { + if (!/^auth\.local\.[a-z-]+$/.test(action)) { + throw new TypeError('hosted_local_control_audit_action_invalid'); + } + await this.dependencies.repository.appendAudit( + await this.createLocalControlAuditEvent(action, outcome, details) + ); + } + + async createLocalControlAuditEvent( + action: string, + outcome: HostedAuditEvent['outcome'], + details: HostedAuditEvent['details'] + ): Promise { + if (!/^auth\.local\.[a-z-]+$/.test(action)) { + throw new TypeError('hosted_local_control_audit_action_invalid'); + } + return this.createAuditEvent(null, null, action, outcome, undefined, { + ...details, + actor: 'local-cli', + }); + } + + async auditPersonalAuthentication(input: { + readonly userId: ReturnType | null; + readonly action: + | 'auth.personal.pair' + | 'auth.personal.renew' + | 'auth.personal.logout' + | 'auth.personal.forget-device'; + readonly outcome: HostedAuditEvent['outcome']; + readonly sourceIp?: string; + readonly reason?: string; + }): Promise { + await this.audit(input.userId, null, input.action, input.outcome, input.sourceIp, { + ...(input.reason === undefined ? {} : { reason: input.reason }), + }); + } + + private async issueSession(input: { + readonly userId: ReturnType; + readonly displayName: string; + readonly role: HostedRole; + readonly roleSource: 'oidc-claim' | 'local-cli'; + readonly issuer: string; + readonly subject: string; + readonly providerSessionId: string | null; + }): Promise { + const provider = this.oidcProvider(); + const now = this.dependencies.now(); + const sessionId = parseHostedSessionId(await this.dependencies.crypto.randomId('session')); + const sessionSecret = await this.dependencies.crypto.randomSecret('session'); + const session: HostedOperatorSessionRecord = Object.freeze({ + sessionId, + userId: input.userId, + secretHash: await this.hashSessionSecret(sessionSecret), + authenticationMethod: 'oidc', + providerId: provider.id, + providerIssuer: input.issuer, + providerSubject: input.subject, + providerSessionId: input.providerSessionId, + roleSnapshot: Object.freeze({ + role: input.role, + source: input.roleSource, + capturedAt: now, + }), + issuedAt: now, + lastUsedAt: now, + idleExpiresAt: now + this.dependencies.policy.sessionIdleTtlMs, + absoluteExpiresAt: now + this.dependencies.policy.sessionAbsoluteTtlMs, + status: 'active', + revokedAt: null, + revocationReason: null, + }); + await this.dependencies.repository.createSession(session); + return Object.freeze({ + sessionSecret, + csrfToken: await this.dependencies.crypto.deriveCsrf(sessionId, sessionSecret), + principal: principal(input.userId, input.displayName, input.role, sessionId), + session, + returnTo: '/', + }); + } + + private async audit( + userId: ReturnType | null, + sessionId: HostedSessionId | null, + action: string, + outcome: HostedAuditEvent['outcome'], + sourceIp: string | undefined, + details: HostedAuditEvent['details'] + ): Promise { + await this.dependencies.repository.appendAudit( + await this.createAuditEvent(userId, sessionId, action, outcome, sourceIp, details) + ); + } + + private async createAuditEvent( + userId: ReturnType | null, + sessionId: HostedSessionId | null, + action: string, + outcome: HostedAuditEvent['outcome'], + sourceIp: string | undefined, + details: HostedAuditEvent['details'] + ): Promise { + return Object.freeze({ + eventId: parseAuditEventId(await this.dependencies.crypto.randomId('audit-event')), + occurredAt: this.dependencies.now(), + userId, + sessionId, + action, + outcome, + sourceIpHash: sourceIp + ? await this.dependencies.crypto.hashSecret('source-ip', sourceIp) + : null, + details: Object.freeze({ ...details }), + }); + } + + private oidcProvider(): OidcIdentityProvider { + if (this.dependencies.provider === null) { + throw new Error('oidc_capability_unavailable'); + } + return this.dependencies.provider; + } + + private hashOidcState(state: string): Promise { + return this.dependencies.crypto.hashSecret('oidc-state', this.bindToRestoreGeneration(state)); + } + + private hashSessionSecret(sessionSecret: string): Promise { + return this.dependencies.crypto.hashSecret( + 'session', + this.bindToRestoreGeneration(sessionSecret) + ); + } + + private bindToRestoreGeneration(value: string): string { + return `${this.dependencies.policy.restoreGeneration}\0${value}`; + } +} + +export class HostedWorkspaceAccessService { + private readonly workspaceGrantSetFences: HostedWorkspaceGrantSetFenceRegistry; + + constructor( + private readonly repository: HostedIdentityRepositoryPort, + private readonly restoreGeneration: number + ) { + if (!Number.isSafeInteger(restoreGeneration) || restoreGeneration < 0) { + throw new TypeError('hosted_workspace_restore_generation_invalid'); + } + this.workspaceGrantSetFences = new HostedWorkspaceGrantSetFenceRegistry((userId) => + this.grants(userId) + ); + } + + async resolvePublicGrant(userId: ReturnType, workspaceId: string) { + return (await this.grants(userId)).find((grant) => grant.workspaceId === workspaceId) ?? null; + } + + async projectWorkspaceId(userId: ReturnType, runtimeWorkspaceId: string) { + return ( + (await this.grants(userId)).find((grant) => grant.runtimeWorkspaceId === runtimeWorkspaceId) + ?.workspaceId ?? null + ); + } + + async hasTeamWorkspaceGrant( + userId: ReturnType, + teamIdValue: unknown, + resolveTeamWorkspaceId: + | ((teamId: TeamId) => Promise) + | undefined + ): Promise { + let teamId: TeamId; + try { + teamId = parseTeamId(teamIdValue); + } catch { + return false; + } + if (resolveTeamWorkspaceId === undefined) return false; + let attribution: HostedTeamWorkspaceAttribution; + try { + attribution = await resolveTeamWorkspaceId(teamId); + } catch { + throw new Error('workspace_attribution_unavailable'); + } + if (attribution.kind === 'unavailable') throw new Error('workspace_attribution_unavailable'); + if (attribution.kind === 'not_found') return false; + try { + return (await this.projectWorkspaceId(userId, attribution.runtimeWorkspaceId)) !== null; + } catch { + throw new Error('identity_storage_unavailable'); + } + } + + async captureTeamWorkspaceGrantFence( + userId: ReturnType, + teamIdValue: unknown, + resolveTeamWorkspaceId: + | ((teamId: TeamId) => Promise) + | undefined + ): Promise { + let teamId: TeamId; + try { + teamId = parseTeamId(teamIdValue); + } catch { + return null; + } + if (resolveTeamWorkspaceId === undefined) return null; + const attribution = await resolveTeamWorkspaceId(teamId).catch(() => { + throw new Error('workspace_attribution_unavailable'); + }); + if (attribution.kind === 'unavailable') throw new Error('workspace_attribution_unavailable'); + if ( + attribution.kind !== 'found' || + !/^[0-9a-f]{64}$/u.test(attribution.attributionRevision) || + !/^[0-9a-f]{64}$/u.test(attribution.identityChecksum) + ) { + return null; + } + const grant = (await this.grants(userId)).find( + (candidate) => candidate.runtimeWorkspaceId === attribution.runtimeWorkspaceId + ); + if (grant === undefined || !/^[0-9a-f]{64}$/u.test(grant.grantRevision)) return null; + return Object.freeze({ + userId, + teamId, + runtimeWorkspaceId: attribution.runtimeWorkspaceId, + attributionRevision: attribution.attributionRevision, + identityChecksum: attribution.identityChecksum, + workspaceId: grant.workspaceId, + grantGeneration: grant.grantGeneration, + grantRevision: grant.grantRevision, + grantedAt: grant.grantedAt, + }); + } + + async revalidateTeamWorkspaceGrantFence( + fence: HostedTeamWorkspaceGrantFence, + resolveTeamWorkspaceId: + | ((teamId: TeamId) => Promise) + | undefined + ): Promise { + const current = await this.captureTeamWorkspaceGrantFence( + fence.userId, + fence.teamId, + resolveTeamWorkspaceId + ); + return ( + current !== null && + current.userId === fence.userId && + current.teamId === fence.teamId && + current.runtimeWorkspaceId === fence.runtimeWorkspaceId && + current.attributionRevision === fence.attributionRevision && + current.identityChecksum === fence.identityChecksum && + current.workspaceId === fence.workspaceId && + current.grantGeneration === fence.grantGeneration && + current.grantRevision === fence.grantRevision && + current.grantedAt === fence.grantedAt + ); + } + + async captureWorkspaceGrantSetFence( + userId: ReturnType + ): Promise { + return this.workspaceGrantSetFences.capture(userId); + } + + async revalidateWorkspaceGrantSetFence(fence: HostedWorkspaceGrantSetFence): Promise { + return this.workspaceGrantSetFences.revalidate(fence); + } + + async projectPayload(userId: ReturnType, payload: unknown): Promise { + const [grants, workspaces] = await Promise.all([ + this.grants(userId), + this.repository.listWorkspaces(), + ]); + return projectHostedPayload(payload, createHostedWorkspaceProjectionScope(grants, workspaces)); + } + + async projectEvent( + userId: ReturnType, + runtimeWorkspaceId: string, + payload: unknown + ): Promise { + const grants = await this.grants(userId); + const admittedGrant = grants.find((grant) => grant.runtimeWorkspaceId === runtimeWorkspaceId); + if (admittedGrant === undefined) return null; + const workspaces = await this.repository.listWorkspaces(); + const currentGrants = await this.grants(userId); + const currentGrant = currentGrants.find( + (grant) => grant.runtimeWorkspaceId === runtimeWorkspaceId + ); + if ( + currentGrant === undefined || + currentGrant.grantRevision !== admittedGrant.grantRevision || + currentGrant.workspaceId !== admittedGrant.workspaceId || + currentGrant.grantGeneration !== admittedGrant.grantGeneration || + currentGrant.grantedAt !== admittedGrant.grantedAt + ) { + return null; + } + return projectHostedPayload( + payload, + createHostedWorkspaceProjectionScope(currentGrants, workspaces) + ); + } + + private grants(userId: ReturnType): Promise { + return this.repository.listWorkspaceGrants({ + userId, + grantGeneration: this.restoreGeneration, + }); + } +} + +export interface HostedTeamWorkspaceGrantFence { + readonly userId: ReturnType; + readonly teamId: TeamId; + readonly runtimeWorkspaceId: string; + readonly attributionRevision: string; + readonly identityChecksum: string; + readonly workspaceId: HostedWorkspaceGrant['workspaceId']; + readonly grantGeneration: number; + readonly grantRevision: string; + readonly grantedAt: number; +} + +export function principal( + userId: ReturnType, + displayName: string, + role: HostedRole, + sessionId: HostedSessionId | null, + authenticationMethod: HostedPrincipal['authenticationMethod'] = 'oidc' +): HostedPrincipal { + return Object.freeze({ + userId, + displayName, + role, + permissions: permissionsForRole(role), + authenticationMethod, + sessionId, + }); +} diff --git a/src/features/hosted-access/core/application/HostedLocalAdministration.ts b/src/features/hosted-access/core/application/HostedLocalAdministration.ts new file mode 100644 index 0000000000..375045d206 --- /dev/null +++ b/src/features/hosted-access/core/application/HostedLocalAdministration.ts @@ -0,0 +1,401 @@ +import { + type AuthorityBinding, + HOSTED_ROLES, + type HostedAuthMode, + type HostedRole, + parseUserId, + type UserId, +} from '../../contracts'; + +import type { HostedAccessAuthority } from './HostedAccessAuthority'; +import type { HostedIdentityService } from './HostedIdentityService'; +import type { + HostedAuditEvent, + HostedAuthModeResetResult, + HostedIdentityRepositoryPort, + HostedLocalRoleAssignment, + HostedWorkspaceRegistration, +} from './identityPorts'; +import type { PairingDrainProofPort } from './ports'; + +const WORKSPACE_ID_PATTERN = /^-?[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/; + +export interface HostedLocalUserView { + readonly userId: UserId; + readonly displayName: string; + readonly status: 'active' | 'disabled'; + readonly localRole: HostedRole | null; +} + +export interface HostedLocalAdministrationDependencies { + readonly mode: HostedAuthMode; + readonly binding: AuthorityBinding; + readonly authority: HostedAccessAuthority | null; + readonly identities: HostedIdentityService; + readonly repository: HostedIdentityRepositoryPort; + readonly drainProof: PairingDrainProofPort; + readonly now: () => number; + readonly runWithBrowserStreamsDrained: (operation: () => Promise) => Promise; + readonly blockPublicAccess: () => Promise; + readonly restorePublicAccess: () => void; + readonly performAuthModeReset: (input: { + readonly targetMode: HostedAuthMode; + readonly resetGeneration: number; + readonly auditEvent: HostedAuditEvent; + }) => Promise; +} + +function localControlError(code: string): Error { + return new Error(`hosted_local_control_${code}`); +} + +function validateWorkspaceId(workspaceId: string): string { + if (!WORKSPACE_ID_PATTERN.test(workspaceId) || workspaceId === '.' || workspaceId === '..') { + throw localControlError('workspace_id_invalid'); + } + return workspaceId; +} + +function validateDisplayName(displayName: string): string { + const value = displayName.trim(); + const hasControlCharacter = [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); + if (value.length === 0 || value.length > 256 || hasControlCharacter) { + throw localControlError('display_name_invalid'); + } + return value; +} + +/** + * Local-only application boundary for deployment administration. The Unix + * socket adapter is deliberately separate from the public HTTP controller. + */ +export class HostedLocalAdministration { + private authModeResetInProgress = false; + private personalResetInProgress = false; + + constructor(private readonly dependencies: HostedLocalAdministrationDependencies) {} + + async listUsers(): Promise { + this.assertAdministrationActive(); + const users = await this.dependencies.repository.listUsers(); + return Promise.all( + users.map(async (user) => { + const assignment = await this.dependencies.repository.getLocalRoleAssignment(user.userId); + return Object.freeze({ + userId: user.userId, + displayName: user.displayName, + status: user.status, + localRole: assignment?.role ?? null, + }); + }) + ); + } + + async setUserStatus(userIdValue: string, status: 'active' | 'disabled'): Promise { + this.assertAdministrationActive(); + if (this.dependencies.mode !== 'oidc') throw localControlError('oidc_mode_required'); + const userId = parseUserId(userIdValue); + const changed = await this.dependencies.repository.setUserStatus({ + userId, + status, + now: this.dependencies.now(), + }); + await this.dependencies.identities.auditLocalControl( + `auth.local.user-${status === 'active' ? 'enable' : 'disable'}`, + changed ? 'success' : 'denied', + { userId, reason: changed ? null : 'user_not_found' } + ); + return changed; + } + + async setLocalRole(userIdValue: string, role: HostedRole): Promise { + this.assertAdministrationActive(); + if (this.dependencies.mode !== 'oidc') throw localControlError('oidc_mode_required'); + if (!HOSTED_ROLES.includes(role)) throw localControlError('role_invalid'); + const userId = parseUserId(userIdValue); + const user = await this.dependencies.repository.getUser(userId); + if (user === null) throw localControlError('user_not_found'); + if (user.status !== 'active') throw localControlError('user_disabled'); + const assignment: HostedLocalRoleAssignment = Object.freeze({ + userId, + role, + assignedAt: this.dependencies.now(), + assignedBy: 'local-cli', + }); + await this.dependencies.repository.setLocalRoleAssignment(assignment); + await this.dependencies.identities.auditLocalControl('auth.local.role-set', 'success', { + userId, + role, + effectiveAfter: 'reauthentication', + }); + } + + async clearLocalRole(userIdValue: string): Promise { + this.assertAdministrationActive(); + if (this.dependencies.mode !== 'oidc') throw localControlError('oidc_mode_required'); + const userId = parseUserId(userIdValue); + if ((await this.dependencies.repository.getUser(userId)) === null) { + throw localControlError('user_not_found'); + } + const cleared = await this.dependencies.repository.clearLocalRoleAssignment(userId); + await this.dependencies.identities.auditLocalControl('auth.local.role-clear', 'success', { + userId, + cleared, + effectiveAfter: 'reauthentication', + }); + return cleared; + } + + listWorkspaces(): Promise { + this.assertAdministrationActive(); + return this.dependencies.repository.listWorkspaces(); + } + + async registerWorkspace( + runtimeWorkspaceIdValue: string, + displayNameValue: string + ): Promise { + this.assertAdministrationActive(); + const runtimeWorkspaceId = validateWorkspaceId(runtimeWorkspaceIdValue); + const displayName = validateDisplayName(displayNameValue); + const registration = await this.dependencies.repository.registerWorkspace({ + runtimeWorkspaceId, + workspaceId: await this.dependencies.identities.createWorkspaceId(), + displayName, + registeredAt: this.dependencies.now(), + registeredBy: null, + }); + await this.dependencies.identities.auditLocalControl( + 'auth.local.workspace-register', + 'success', + { workspaceId: registration.workspaceId } + ); + return registration; + } + + async disableWorkspace(runtimeWorkspaceIdValue: string): Promise { + this.assertAdministrationActive(); + const runtimeWorkspaceId = validateWorkspaceId(runtimeWorkspaceIdValue); + const disabled = await this.dependencies.repository.disableWorkspace(runtimeWorkspaceId); + await this.dependencies.identities.auditLocalControl( + 'auth.local.workspace-disable', + disabled ? 'success' : 'denied', + { reason: disabled ? null : 'workspace_not_found_or_disabled' } + ); + return disabled; + } + + async grantWorkspace(userIdValue: string, runtimeWorkspaceIdValue: string) { + this.assertAdministrationActive(); + const userId = parseUserId(userIdValue); + const runtimeWorkspaceId = validateWorkspaceId(runtimeWorkspaceIdValue); + const user = await this.dependencies.repository.getUser(userId); + if (user === null) throw localControlError('user_not_found'); + if (user.status !== 'active') throw localControlError('user_disabled'); + const grant = await this.dependencies.repository.grantWorkspace({ + userId, + runtimeWorkspaceId, + grantGeneration: this.dependencies.binding.restoreGeneration, + grantedAt: this.dependencies.now(), + grantedBy: 'local-cli', + }); + await this.dependencies.identities.auditLocalControl('auth.local.workspace-grant', 'success', { + userId, + workspaceId: grant.workspaceId, + grantGeneration: grant.grantGeneration, + }); + return grant; + } + + async revokeWorkspaceGrant( + userIdValue: string, + runtimeWorkspaceIdValue: string + ): Promise { + this.assertAdministrationActive(); + const userId = parseUserId(userIdValue); + const runtimeWorkspaceId = validateWorkspaceId(runtimeWorkspaceIdValue); + if ((await this.dependencies.repository.getUser(userId)) === null) { + throw localControlError('user_not_found'); + } + const revoked = await this.dependencies.repository.revokeWorkspaceGrant({ + userId, + runtimeWorkspaceId, + }); + await this.dependencies.identities.auditLocalControl( + 'auth.local.workspace-grant-revoke', + revoked ? 'success' : 'denied', + { userId, reason: revoked ? null : 'grant_not_found' } + ); + return revoked; + } + + async resetPersonal(resetGeneration: number): Promise<{ readonly resetGeneration: number }> { + this.assertAdministrationActive(); + const authority = this.dependencies.authority; + if (this.dependencies.mode !== 'personal' || authority === null) { + throw localControlError('personal_mode_required'); + } + if (!Number.isSafeInteger(resetGeneration) || resetGeneration <= 0) { + throw localControlError('reset_generation_invalid'); + } + this.personalResetInProgress = true; + try { + const drain = await this.dependencies.drainProof.confirmDrained({ + binding: this.dependencies.binding, + purpose: 'host_reset', + resetGeneration, + }); + if (drain.status !== 'drained') { + await this.dependencies.identities.auditLocalControl( + 'auth.local.personal-reset', + 'denied', + { + resetGeneration, + reason: `drain_${drain.status}`, + } + ); + throw localControlError('pairing_drain_unconfirmed'); + } + return await this.dependencies.runWithBrowserStreamsDrained(async () => { + await this.dependencies.blockPublicAccess(); + const result = await authority.consumeResetGeneration( + this.dependencies.binding, + resetGeneration + ); + if (!result.ok) { + await this.dependencies.identities.auditLocalControl( + 'auth.local.personal-reset', + 'denied', + { + resetGeneration, + reason: result.code, + } + ); + throw localControlError(result.code); + } + await this.dependencies.identities.auditLocalControl( + 'auth.local.personal-reset', + 'success', + { + resetGeneration, + } + ); + this.dependencies.restorePublicAccess(); + return Object.freeze({ resetGeneration: result.value.resetGeneration }); + }); + } finally { + this.personalResetInProgress = false; + } + } + + async resetAuthMode( + targetMode: HostedAuthMode, + resetGeneration: number + ): Promise<{ + readonly mode: HostedAuthMode; + readonly resetGeneration: number; + readonly restartRequired: true; + }> { + if (this.personalResetInProgress) throw localControlError('personal_reset_in_progress'); + if (targetMode !== 'personal' && targetMode !== 'oidc') { + throw localControlError('auth_mode_invalid'); + } + if (targetMode === this.dependencies.mode) { + throw localControlError('auth_mode_unchanged'); + } + if (!Number.isSafeInteger(resetGeneration) || resetGeneration <= 0) { + throw localControlError('reset_generation_invalid'); + } + if (this.authModeResetInProgress) throw localControlError('auth_mode_reset_in_progress'); + this.authModeResetInProgress = true; + let auditEvent: HostedAuditEvent; + try { + const configuration = await this.dependencies.repository.readAuthConfiguration(); + if ( + configuration === null || + configuration.mode !== this.dependencies.mode || + resetGeneration <= configuration.resetGeneration + ) { + throw localControlError( + configuration !== null && resetGeneration <= configuration.resetGeneration + ? 'reset_generation_not_newer' + : 'auth_mode_mismatch' + ); + } + const drain = await this.dependencies.drainProof.confirmDrained({ + binding: this.dependencies.binding, + purpose: 'auth_mode_reset', + resetGeneration, + targetAuthMode: targetMode, + }); + if (drain.status !== 'drained') { + await this.dependencies.identities.auditLocalControl( + 'auth.local.auth-mode-reset', + 'denied', + { + targetMode, + resetGeneration, + reason: `drain_${drain.status}`, + } + ); + throw localControlError('auth_mode_drain_unconfirmed'); + } + auditEvent = await this.dependencies.identities.createLocalControlAuditEvent( + 'auth.local.auth-mode-reset', + 'success', + { + targetMode, + resetGeneration, + drainEvidenceRef: drain.evidenceRef, + restartRequired: true, + } + ); + } catch (error) { + this.authModeResetInProgress = false; + throw error; + } + + return this.dependencies.runWithBrowserStreamsDrained(async () => { + await this.dependencies.blockPublicAccess(); + let result: HostedAuthModeResetResult; + try { + result = await this.dependencies.performAuthModeReset({ + targetMode, + resetGeneration, + auditEvent, + }); + } catch { + // An indeterminate storage or secret-staging outcome remains closed. + // Restart recovery decides whether the durable transition committed. + throw localControlError('auth_mode_reset_indeterminate'); + } + if (result !== 'committed') { + this.authModeResetInProgress = false; + this.dependencies.restorePublicAccess(); + await this.dependencies.identities.auditLocalControl( + 'auth.local.auth-mode-reset', + 'denied', + { targetMode, resetGeneration, reason: result } + ); + throw localControlError(result); + } + return Object.freeze({ + mode: targetMode, + resetGeneration, + restartRequired: true as const, + }); + }); + } + + private assertAdministrationActive(): void { + if (this.personalResetInProgress) { + throw localControlError('personal_reset_in_progress'); + } + if (this.authModeResetInProgress) { + throw localControlError('auth_mode_reset_requires_restart'); + } + } +} diff --git a/src/features/hosted-access/core/application/HostedWorkspaceGrantSetFence.ts b/src/features/hosted-access/core/application/HostedWorkspaceGrantSetFence.ts new file mode 100644 index 0000000000..c853e1b48b --- /dev/null +++ b/src/features/hosted-access/core/application/HostedWorkspaceGrantSetFence.ts @@ -0,0 +1,86 @@ +import type { UserId } from '../../contracts'; +import type { HostedWorkspaceGrant } from './identityPorts'; + +interface HostedWorkspaceGrantRevision { + readonly runtimeWorkspaceId: string; + readonly workspaceId: HostedWorkspaceGrant['workspaceId']; + readonly grantGeneration: number; + readonly grantRevision: string; + readonly grantedAt: number; +} + +export interface HostedWorkspaceGrantSetFence { + readonly userId: UserId; + readonly grants: readonly HostedWorkspaceGrantRevision[]; +} + +export class HostedWorkspaceGrantSetFenceRegistry { + private readonly fences = new WeakSet(); + + constructor( + private readonly readGrants: (userId: UserId) => Promise + ) {} + + async capture(userId: UserId): Promise { + const fence = Object.freeze({ userId, grants: await this.readRevisionSet(userId) }); + this.fences.add(fence); + return fence; + } + + async revalidate(fence: HostedWorkspaceGrantSetFence): Promise { + if (!this.fences.has(fence)) return false; + const current = await this.readRevisionSet(fence.userId); + return ( + current.length === fence.grants.length && + current.every((grant, index) => sameRevision(grant, fence.grants[index])) + ); + } + + private async readRevisionSet(userId: UserId): Promise { + const revisions = (await this.readGrants(userId)) + .map((grant) => { + if (grant.userId !== userId || !/^[0-9a-f]{64}$/u.test(grant.grantRevision)) { + throw new Error('hosted_workspace_grant_revision_invalid'); + } + return Object.freeze({ + runtimeWorkspaceId: grant.runtimeWorkspaceId, + workspaceId: grant.workspaceId, + grantGeneration: grant.grantGeneration, + grantRevision: grant.grantRevision, + grantedAt: grant.grantedAt, + }); + }) + .sort((left, right) => + left.runtimeWorkspaceId === right.runtimeWorkspaceId + ? left.workspaceId.localeCompare(right.workspaceId) + : left.runtimeWorkspaceId.localeCompare(right.runtimeWorkspaceId) + ); + const runtimeWorkspaceIds = new Set(); + const publicWorkspaceIds = new Set(); + for (const revision of revisions) { + if ( + runtimeWorkspaceIds.has(revision.runtimeWorkspaceId) || + publicWorkspaceIds.has(revision.workspaceId) + ) { + throw new Error('hosted_workspace_grant_set_ambiguous'); + } + runtimeWorkspaceIds.add(revision.runtimeWorkspaceId); + publicWorkspaceIds.add(revision.workspaceId); + } + return Object.freeze(revisions); + } +} + +function sameRevision( + left: HostedWorkspaceGrantRevision, + right: HostedWorkspaceGrantRevision | undefined +): boolean { + return ( + right !== undefined && + left.runtimeWorkspaceId === right.runtimeWorkspaceId && + left.workspaceId === right.workspaceId && + left.grantGeneration === right.grantGeneration && + left.grantRevision === right.grantRevision && + left.grantedAt === right.grantedAt + ); +} diff --git a/src/features/hosted-access/core/application/PairingAuthority.ts b/src/features/hosted-access/core/application/PairingAuthority.ts new file mode 100644 index 0000000000..2719e8d342 --- /dev/null +++ b/src/features/hosted-access/core/application/PairingAuthority.ts @@ -0,0 +1,561 @@ +import { + type AuthorityBinding, + type HostedAccessResult, + type OpaqueAuthoritySecret, + type PairingChallengeId, + type PairingCredentials, + parseDeviceFamilyId, + parseDeviceGrantId, + parseOperatorId, + parseOperatorSessionId, + parsePairingChallengeId, +} from '../../contracts'; +import { + addDuration, + assertAuthorityBinding, + createInitialAuthorityState, + extendIdleDeadline, + type HostedAccessAuthorityState, + nextAuthorityState, + type OperatorDeviceFamily, + type OperatorDeviceGrant, + type OperatorSession, + type PairingChallenge, +} from '../domain'; + +import { + AuthorityCore, + bindingsEqual, + findHashMatch, + isKeyringEnvelopeValid, + keyringReadCode, +} from './AuthorityCore'; +import { accepted, rejected } from './results'; + +import type { PersonalOwnerPreparationPort } from './ports'; + +interface ChallengeReference { + readonly challengeId: PairingChallengeId; +} + +export class PairingAuthority { + constructor(private readonly core: AuthorityCore) {} + + async initialize( + binding: AuthorityBinding + ): Promise< + HostedAccessResult< + { readonly resetPending: boolean }, + 'authority_ready' | 'authority_reset_pending' + > + > { + assertAuthorityBinding(binding); + for ( + let attempt = 0; + attempt < this.core.dependencies.policy.compareAndSwapAttempts; + attempt += 1 + ) { + const loaded = await this.core.loadState(); + if (loaded.ok) { + if (loaded.state.resetIntent !== null) { + if (!bindingsEqual(loaded.state.resetIntent.requestedBinding, binding)) { + return rejected('restore_binding_mismatch'); + } + const cleanup = await this.core.reconcileChallengeDeliveryCleanup(loaded.state); + if (cleanup === 'conflict' || cleanup === 'committed') continue; + if (cleanup === 'unavailable') { + return rejected('challenge_delivery_unavailable'); + } + return accepted('authority_reset_pending', { resetPending: true }); + } + if (!bindingsEqual(loaded.state.binding, binding)) { + return rejected('restore_binding_mismatch'); + } + const regular = await this.core.loadRegular(binding); + if (!regular.ok) return regular; + return accepted('authority_ready', { resetPending: false }); + } + if (loaded.code !== 'authority_state_empty') return rejected(loaded.code); + + let activeKeyring = await this.core.dependencies.keyrings.loadActive(); + if (activeKeyring.status === 'missing') { + const envelope = await this.core.createKeyring(binding); + const created = await this.core.dependencies.keyrings.createInitial(envelope); + if (created.status === 'unavailable') { + activeKeyring = await this.core.dependencies.keyrings.loadActive(); + if (activeKeyring.status === 'missing') { + return rejected('keyring_unavailable'); + } + } else if (created.status === 'conflict') { + activeKeyring = await this.core.dependencies.keyrings.loadActive(); + } else { + activeKeyring = { status: 'available', envelope }; + } + } + if (activeKeyring.status !== 'available') { + return rejected(keyringReadCode(activeKeyring.status)); + } + if (!isKeyringEnvelopeValid(activeKeyring.envelope)) { + return rejected('keyring_corrupt'); + } + if (!bindingsEqual(activeKeyring.envelope.binding, binding)) { + return rejected('restore_binding_mismatch'); + } + const initial = createInitialAuthorityState({ + binding, + keyringId: activeKeyring.envelope.keyringId, + }); + const initialized = await this.core.dependencies.repository.initialize(initial); + if (initialized.status === 'unavailable') { + const recovered = await this.core.loadState(); + if (!recovered.ok) return rejected('authority_store_unavailable'); + continue; + } + if (initialized.status === 'conflict') continue; + return accepted('authority_ready', { resetPending: false }); + } + return rejected('authority_store_conflict'); + } + + async issueInitialChallenge( + binding: AuthorityBinding + ): Promise< + HostedAccessResult + > { + assertAuthorityBinding(binding); + const { compareAndSwapAttempts } = this.core.dependencies.policy; + for (let attempt = 0; attempt < compareAndSwapAttempts; attempt += 1) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const state = loaded.state; + if (state.deviceFamilies.some(({ status }) => status === 'active')) { + return rejected('pairing_already_established'); + } + const existing = newestOpenChallenge(state); + if (existing !== null) { + const recovered = await this.recoverExistingChallenge(binding, state, existing); + if (recovered === 'retry') continue; + return recovered; + } + const drain = await this.core.dependencies.drainProof.confirmDrained({ + binding, + purpose: 'initial_pairing', + resetGeneration: state.consumedResetGeneration, + }); + if (drain.status !== 'drained') return rejected('pairing_drain_unconfirmed'); + + const challengeId = parsePairingChallengeId( + await this.core.dependencies.random.randomId('pairing-challenge') + ); + const secret = await this.core.randomAuthoritySecret('pairing-challenge'); + const secretHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'pairing-challenge', + secret, + }); + const now = this.core.now(); + const challenge: PairingChallenge = Object.freeze({ + challengeId, + secretHash, + keyringId: loaded.keyring.keyringId, + resetGeneration: state.consumedResetGeneration, + issuedAt: now, + expiresAt: addDuration(now, this.core.dependencies.policy.pairingChallengeTtlMs), + failedAttempts: 0, + maxAttempts: this.core.dependencies.policy.pairingMaxAttempts, + status: 'pending_delivery', + consumedAt: null, + revokedAt: null, + revocationReason: null, + pairedDeviceFamilyId: null, + pairedDeviceGrantId: null, + pairedSessionId: null, + deliveryCleanupPending: false, + }); + const next = nextAuthorityState(state, { + pairingChallenges: [...state.pairingChallenges, challenge], + }); + const committed = await this.core.commit(state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + const published = await this.core.dependencies.challengeDelivery.publish({ + challengeId, + secret, + expiresAt: challenge.expiresAt, + }); + if (published.status === 'unavailable' || published.status === 'conflict') { + return rejected('challenge_delivery_unavailable'); + } + return await this.markChallengeIssued(binding, challengeId); + } + return rejected('authority_store_conflict'); + } + + async pair( + binding: AuthorityBinding, + presentedSecret: OpaqueAuthoritySecret, + ownerPreparation?: PersonalOwnerPreparationPort + ): Promise> { + assertAuthorityBinding(binding); + const { compareAndSwapAttempts, policy } = { + compareAndSwapAttempts: this.core.dependencies.policy.compareAndSwapAttempts, + policy: this.core.dependencies.policy, + }; + for (let attempt = 0; attempt < compareAndSwapAttempts; attempt += 1) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const state = loaded.state; + const presentedHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'pairing-challenge', + secret: presentedSecret, + }); + if (state.deviceFamilies.some(({ status }) => status === 'active')) { + return rejected('pairing_already_established'); + } + const challenge = await findHashMatch( + this.core.dependencies.crypto, + state.pairingChallenges, + presentedHash + ); + if (challenge === null || challenge.status !== 'issued') { + const failed = await this.recordFailedPairingAttempt(state); + if (failed === 'conflict') continue; + if (failed === 'unavailable') return rejected('authority_store_unavailable'); + if (failed === 'exhausted' || failed === 'expired') { + const reconciled = await this.core.loadRegular(binding); + if (!reconciled.ok) return reconciled; + } + if (failed === 'expired') return rejected('challenge_expired'); + return rejected( + failed === 'exhausted' ? 'challenge_attempts_exhausted' : 'challenge_invalid' + ); + } + const now = this.core.now(); + if (now >= challenge.expiresAt) { + const expired = revokeChallenge(challenge, now, 'expired', true); + const next = replaceChallenge(state, expired); + const committed = await this.core.commit(state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + const reconciled = await this.core.loadRegular(binding); + return reconciled.ok ? rejected('challenge_expired') : rejected(reconciled.code); + } + + const proposedOperatorId = + state.operatorId ?? + parseOperatorId(await this.core.dependencies.random.randomId('operator')); + const operatorId = + ownerPreparation === undefined + ? proposedOperatorId + : await ownerPreparation.prepare(proposedOperatorId); + const familyId = parseDeviceFamilyId( + await this.core.dependencies.random.randomId('device-family') + ); + const grantId = parseDeviceGrantId( + await this.core.dependencies.random.randomId('device-grant') + ); + const sessionId = parseOperatorSessionId( + await this.core.dependencies.random.randomId('session') + ); + const deviceSecret = await this.core.deriveAuthoritySecret({ + key: loaded.keyring.hashKey, + purpose: 'pairing-device-grant', + sourceSecret: presentedSecret, + context: `${challenge.challengeId}:${grantId}`, + }); + const sessionSecret = await this.core.deriveAuthoritySecret({ + key: loaded.keyring.hashKey, + purpose: 'pairing-session', + sourceSecret: presentedSecret, + context: `${challenge.challengeId}:${sessionId}`, + }); + const deviceHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'device-grant', + secret: deviceSecret, + }); + const sessionHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'operator-session', + secret: sessionSecret, + }); + const csrfToken = await this.core.dependencies.crypto.deriveCsrf({ + key: loaded.keyring.csrfKey, + sessionId, + sessionSecret, + }); + const family: OperatorDeviceFamily = Object.freeze({ + familyId, + operatorId, + issuedAt: now, + lastUsedAt: now, + idleExpiresAt: addDuration(now, policy.deviceIdleTtlMs), + absoluteExpiresAt: addDuration(now, policy.deviceAbsoluteTtlMs), + currentGeneration: 1, + status: 'active', + revokedAt: null, + revocationReason: null, + }); + const grant: OperatorDeviceGrant = Object.freeze({ + grantId, + familyId, + generation: 1, + renewedFromGrantId: null, + secretHash: deviceHash, + keyringId: loaded.keyring.keyringId, + issuedAt: now, + renewalExpiresAt: addDuration(now, policy.deviceRenewalTtlMs), + status: 'current', + predecessorGraceExpiresAt: null, + predecessorUsesRemaining: 0, + retiredAt: null, + revokedAt: null, + revocationReason: null, + }); + const session = createSession({ + sessionId, + operatorId, + familyId, + deviceGeneration: 1, + secretHash: sessionHash, + keyringId: loaded.keyring.keyringId, + now, + familyAbsoluteExpiresAt: family.absoluteExpiresAt, + policy, + }); + const removed = await this.core.dependencies.challengeDelivery.remove(challenge.challengeId); + if (removed.status === 'unavailable' || removed.status === 'conflict') { + const observed = await this.core.dependencies.challengeDelivery.status( + challenge.challengeId + ); + if (observed.status !== 'missing') { + return rejected('challenge_delivery_unavailable'); + } + } + const consumed: PairingChallenge = Object.freeze({ + ...challenge, + status: 'consumed', + consumedAt: now, + pairedDeviceFamilyId: familyId, + pairedDeviceGrantId: grantId, + pairedSessionId: sessionId, + deliveryCleanupPending: false, + }); + const next = nextAuthorityState(state, { + operatorId, + pairingChallenges: state.pairingChallenges.map((item) => + item.challengeId === challenge.challengeId ? consumed : item + ), + deviceFamilies: [...state.deviceFamilies, family], + deviceGrants: [...state.deviceGrants, grant], + sessions: [...state.sessions, session], + }); + const committed = await this.core.commit(state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') { + const recovered = await this.core.loadState(); + if ( + !recovered.ok || + !recovered.state.sessions.some(({ sessionId: id }) => id === sessionId) + ) { + return rejected('authority_store_unavailable'); + } + } + return accepted('paired', { + operatorId, + deviceFamilyId: familyId, + deviceGeneration: 1, + deviceSecret, + sessionId, + sessionSecret, + csrfToken, + }); + } + return rejected('authority_store_conflict'); + } + + private async recoverExistingChallenge( + binding: AuthorityBinding, + state: HostedAccessAuthorityState, + challenge: PairingChallenge + ): Promise< + | HostedAccessResult + | 'retry' + > { + const now = this.core.now(); + if (now >= challenge.expiresAt) { + const next = replaceChallenge(state, revokeChallenge(challenge, now, 'expired', true)); + const committed = await this.core.commit(state, next); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return 'retry'; + } + const delivery = await this.core.dependencies.challengeDelivery.status(challenge.challengeId); + if (delivery.status === 'unavailable') { + return rejected('challenge_delivery_unavailable'); + } + if (delivery.status === 'missing') { + const revoked = revokeChallenge( + challenge, + now, + challenge.status === 'pending_delivery' + ? 'publish_not_observed' + : 'issued_delivery_missing', + false + ); + const committed = await this.core.commit(state, replaceChallenge(state, revoked)); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return 'retry'; + } + if (challenge.status === 'issued') { + return accepted('challenge_already_issued', { + challengeId: challenge.challengeId, + }); + } + return await this.markChallengeIssued(binding, challenge.challengeId); + } + + private async markChallengeIssued( + binding: AuthorityBinding, + challengeId: PairingChallengeId + ): Promise> { + for ( + let attempt = 0; + attempt < this.core.dependencies.policy.compareAndSwapAttempts; + attempt += 1 + ) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const challenge = loaded.state.pairingChallenges.find( + (item) => item.challengeId === challengeId + ); + if (challenge?.status === 'issued') { + return accepted('challenge_issued', { challengeId }); + } + if (challenge?.status !== 'pending_delivery') { + return rejected('challenge_invalid'); + } + const issued: PairingChallenge = Object.freeze({ + ...challenge, + status: 'issued', + }); + const next = replaceChallenge(loaded.state, issued); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return accepted('challenge_issued', { challengeId }); + } + return rejected('authority_store_conflict'); + } + + private async recordFailedPairingAttempt( + state: HostedAccessAuthorityState + ): Promise<'recorded' | 'expired' | 'exhausted' | 'conflict' | 'unavailable'> { + const challenge = newestOpenChallenge(state); + if (challenge?.status !== 'issued') return 'recorded'; + const now = this.core.now(); + if (now >= challenge.expiresAt) { + const committed = await this.core.commit( + state, + replaceChallenge(state, revokeChallenge(challenge, now, 'expired', true)) + ); + return committed === 'committed' ? 'expired' : committed; + } + const failedAttempts = challenge.failedAttempts + 1; + const exhausted = failedAttempts >= challenge.maxAttempts; + const updated: PairingChallenge = Object.freeze({ + ...challenge, + failedAttempts, + status: exhausted ? 'revoked' : challenge.status, + revokedAt: exhausted ? now : challenge.revokedAt, + revocationReason: exhausted ? 'attempts_exhausted' : challenge.revocationReason, + deliveryCleanupPending: exhausted, + }); + const committed = await this.core.commit(state, replaceChallenge(state, updated)); + if (committed !== 'committed') return committed; + if (exhausted) { + return 'exhausted'; + } + return 'recorded'; + } +} + +function newestOpenChallenge(state: HostedAccessAuthorityState): PairingChallenge | null { + return ( + [...state.pairingChallenges] + .reverse() + .find(({ status }) => status === 'pending_delivery' || status === 'issued') ?? null + ); +} + +function replaceChallenge( + state: HostedAccessAuthorityState, + challenge: PairingChallenge +): HostedAccessAuthorityState { + return nextAuthorityState(state, { + pairingChallenges: state.pairingChallenges.map((item) => + item.challengeId === challenge.challengeId ? challenge : item + ), + }); +} + +function revokeChallenge( + challenge: PairingChallenge, + now: number, + reason: string, + deliveryCleanupPending: boolean +): PairingChallenge { + return Object.freeze({ + ...challenge, + status: 'revoked', + revokedAt: now, + revocationReason: reason, + deliveryCleanupPending, + }); +} + +export function createSession(input: { + readonly sessionId: OperatorSession['sessionId']; + readonly operatorId: OperatorSession['operatorId']; + readonly familyId: OperatorSession['familyId']; + readonly deviceGeneration: number; + readonly secretHash: OperatorSession['secretHash']; + readonly keyringId: OperatorSession['keyringId']; + readonly now: number; + readonly familyAbsoluteExpiresAt: number; + readonly policy: import('../../contracts').HostedAccessAuthorityPolicy; +}): OperatorSession { + return Object.freeze({ + sessionId: input.sessionId, + operatorId: input.operatorId, + familyId: input.familyId, + deviceGeneration: input.deviceGeneration, + secretHash: input.secretHash, + keyringId: input.keyringId, + issuedAt: input.now, + lastUsedAt: input.now, + deadlines: Object.freeze({ + idleExpiresAt: extendIdleDeadline( + input.now, + input.policy.sessionIdleTtlMs, + Math.min( + addDuration(input.now, input.policy.sessionAbsoluteTtlMs), + input.familyAbsoluteExpiresAt + ) + ), + absoluteExpiresAt: Math.min( + addDuration(input.now, input.policy.sessionAbsoluteTtlMs), + input.familyAbsoluteExpiresAt + ), + renewalExpiresAt: Math.min( + addDuration(input.now, input.policy.sessionRenewalTtlMs), + input.familyAbsoluteExpiresAt + ), + }), + status: 'active', + revokedAt: null, + revocationReason: null, + }); +} diff --git a/src/features/hosted-access/core/application/ResetAuthority.ts b/src/features/hosted-access/core/application/ResetAuthority.ts new file mode 100644 index 0000000000..a0b68ec1ae --- /dev/null +++ b/src/features/hosted-access/core/application/ResetAuthority.ts @@ -0,0 +1,594 @@ +import { + type AuthorityBinding, + type HostedAccessRejectionCode, + type HostedAccessResult, + type PairingChallengeId, + parseAuthKeyringId, + parsePairingChallengeId, +} from '../../contracts'; +import { + addDuration, + assertAuthorityBinding, + type AuthResetIntent, + type HostedAccessAuthorityState, + nextAuthorityState, + type PairingChallenge, +} from '../domain'; + +import { + AuthorityCore, + bindingsEqual, + isKeyringEnvelopeValid, + keyringReadCode, +} from './AuthorityCore'; +import { accepted, rejected } from './results'; + +interface ResetCompletion { + readonly resetGeneration: number; + readonly challengeId: PairingChallengeId; +} + +export class ResetAuthority { + constructor(private readonly core: AuthorityCore) {} + + async consumeResetGeneration( + binding: AuthorityBinding, + resetGeneration: number + ): Promise> { + assertAuthorityBinding(binding); + if (!Number.isSafeInteger(resetGeneration) || resetGeneration <= 0) { + return rejected('reset_generation_not_newer'); + } + const maxSteps = this.core.dependencies.policy.compareAndSwapAttempts * 4; + for (let step = 0; step < maxSteps; step += 1) { + const loaded = await this.core.loadState(); + if (!loaded.ok) { + return rejected( + loaded.code === 'authority_state_empty' ? 'authority_state_corrupt' : loaded.code + ); + } + const state = loaded.state; + const intent = state.resetIntent; + if (intent === null) { + if (resetGeneration < state.consumedResetGeneration) { + return rejected('reset_generation_not_newer'); + } + if (resetGeneration === state.consumedResetGeneration) { + const recovered = await this.recoverCompletedReset(state, binding); + if (recovered === 'retry') continue; + return recovered; + } + const requested: AuthResetIntent = Object.freeze({ + resetGeneration, + requestedBinding: Object.freeze({ ...binding }), + requestedAt: this.core.now(), + stage: 'requested', + drainEvidenceRef: null, + stagedKeyringId: null, + challengeId: null, + }); + const committed = await this.core.commit( + state, + nextAuthorityState(state, { resetIntent: requested }) + ); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + continue; + } + if (intent.resetGeneration !== resetGeneration) { + return rejected('reset_in_progress'); + } + if (!bindingsEqual(intent.requestedBinding, binding)) { + return rejected('restore_binding_mismatch'); + } + + const transition = await this.advanceReset(state, intent); + if (transition === 'retry') continue; + if (!transition.ok) return transition; + if (transition.code === 'reset_completed') return transition; + } + return rejected('authority_store_conflict'); + } + + private async advanceReset( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + switch (intent.stage) { + case 'requested': + return await this.confirmDrain(state, intent); + case 'drain_confirmed': + return await this.reserveKeyringStage(state, intent); + case 'key_stage_reserved': + return await this.stageKeyring(state, intent); + case 'new_key_staged': + return await this.revokeOldAuthority(state, intent); + case 'authority_revoked': + return await this.activateKeyring(state, intent); + case 'key_activated': + case 'delivery_recovery': + return await this.createResetChallenge(state, intent); + case 'challenge_pending': + return await this.recoverPendingChallenge(state, intent); + case 'challenge_issued': + return await this.completeReset(state, intent); + } + } + + private async reserveKeyringStage( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + const stagedKeyringId = parseAuthKeyringId( + await this.core.dependencies.random.randomId('auth-keyring') + ); + return await this.commitForRetry( + state, + Object.freeze({ + ...intent, + stage: 'key_stage_reserved', + stagedKeyringId, + }) + ); + } + + private async confirmDrain( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + const drain = await this.core.dependencies.drainProof.confirmDrained({ + binding: intent.requestedBinding, + purpose: 'host_reset', + resetGeneration: intent.resetGeneration, + }); + if (drain.status !== 'drained') return rejected('pairing_drain_unconfirmed'); + if (drain.evidenceRef.length === 0 || drain.evidenceRef.length > 256) { + return rejected('pairing_drain_unconfirmed'); + } + const nextIntent: AuthResetIntent = Object.freeze({ + ...intent, + stage: 'drain_confirmed', + drainEvidenceRef: drain.evidenceRef, + }); + return await this.commitForRetry(state, nextIntent); + } + + private async stageKeyring( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + if (intent.stagedKeyringId === null) { + return rejected('authority_state_corrupt'); + } + const existing = await this.core.dependencies.keyrings.loadStaged(intent.stagedKeyringId); + if (existing.status === 'available') { + if (!this.isExpectedStagedKeyring(existing.envelope, intent)) { + return rejected('reset_stage_unavailable'); + } + return await this.commitForRetry( + state, + Object.freeze({ ...intent, stage: 'new_key_staged' }) + ); + } + if (existing.status !== 'missing') { + return rejected('reset_stage_unavailable'); + } + const envelope = await this.core.createKeyring(intent.requestedBinding, intent.stagedKeyringId); + const staged = await this.core.dependencies.keyrings.stageReplacement(envelope); + if (staged.status === 'unavailable' || staged.status === 'conflict') { + const recovered = await this.core.dependencies.keyrings.loadStaged(intent.stagedKeyringId); + if ( + recovered.status !== 'available' || + !this.isExpectedStagedKeyring(recovered.envelope, intent) + ) { + return rejected('reset_stage_unavailable'); + } + } + const nextIntent: AuthResetIntent = Object.freeze({ + ...intent, + stage: 'new_key_staged', + }); + return await this.commitForRetry(state, nextIntent); + } + + private async revokeOldAuthority( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + if (intent.stagedKeyringId === null) return rejected('authority_state_corrupt'); + const staged = await this.core.dependencies.keyrings.loadStaged(intent.stagedKeyringId); + if ( + staged.status !== 'available' || + !isKeyringEnvelopeValid(staged.envelope) || + staged.envelope.keyringId !== intent.stagedKeyringId || + !bindingsEqual(staged.envelope.binding, intent.requestedBinding) + ) { + return rejected('reset_stage_unavailable'); + } + const now = this.core.now(); + const nextIntent: AuthResetIntent = Object.freeze({ + ...intent, + stage: 'authority_revoked', + }); + const next = nextAuthorityState(state, { + binding: intent.requestedBinding, + expectedKeyringId: intent.stagedKeyringId, + consumedResetGeneration: intent.resetGeneration, + pairingChallenges: state.pairingChallenges.map((challenge) => + challenge.status === 'consumed' || challenge.status === 'revoked' + ? Object.freeze({ + ...challenge, + deliveryCleanupPending: true, + }) + : Object.freeze({ + ...challenge, + status: 'revoked' as const, + revokedAt: now, + revocationReason: 'host_reset', + deliveryCleanupPending: true, + }) + ), + deviceFamilies: state.deviceFamilies.map((family) => + Object.freeze({ + ...family, + status: 'revoked' as const, + revokedAt: family.revokedAt ?? now, + revocationReason: family.revocationReason ?? 'host_reset', + }) + ), + deviceGrants: state.deviceGrants.map((grant) => + Object.freeze({ + ...grant, + status: 'revoked' as const, + predecessorGraceExpiresAt: null, + predecessorUsesRemaining: 0, + revokedAt: grant.revokedAt ?? now, + revocationReason: grant.revocationReason ?? 'host_reset', + }) + ), + sessions: state.sessions.map((session) => + Object.freeze({ + ...session, + status: 'revoked' as const, + revokedAt: session.revokedAt ?? now, + revocationReason: session.revocationReason ?? 'host_reset', + }) + ), + resetIntent: nextIntent, + }); + const committed = await this.core.commit(state, next); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return 'retry'; + } + + private async activateKeyring( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + if (intent.stagedKeyringId === null) return rejected('authority_state_corrupt'); + const cleanup = await this.core.reconcileChallengeDeliveryCleanup(state); + if (cleanup === 'conflict' || cleanup === 'committed') return 'retry'; + if (cleanup === 'unavailable') { + return rejected('challenge_delivery_unavailable'); + } + const activated = await this.core.dependencies.keyrings.activateStaged(intent.stagedKeyringId); + if (activated.status === 'unavailable' || activated.status === 'conflict') { + const recovered = await this.core.dependencies.keyrings.loadActive(); + if ( + recovered.status !== 'available' || + !this.isExpectedStagedKeyring(recovered.envelope, intent) + ) { + return rejected('reset_stage_unavailable'); + } + } + const active = await this.core.dependencies.keyrings.loadActive(); + if ( + active.status !== 'available' || + !isKeyringEnvelopeValid(active.envelope) || + active.envelope.keyringId !== intent.stagedKeyringId || + !bindingsEqual(active.envelope.binding, intent.requestedBinding) + ) { + return rejected('reset_stage_unavailable'); + } + return await this.commitForRetry(state, Object.freeze({ ...intent, stage: 'key_activated' })); + } + + private async createResetChallenge( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + const cleanup = await this.core.reconcileChallengeDeliveryCleanup(state); + if (cleanup === 'conflict' || cleanup === 'committed') return 'retry'; + if (cleanup === 'unavailable') { + return rejected('challenge_delivery_unavailable'); + } + const drain = await this.core.dependencies.drainProof.confirmDrained({ + binding: intent.requestedBinding, + purpose: 'host_reset', + resetGeneration: intent.resetGeneration, + }); + if ( + drain.status !== 'drained' || + drain.evidenceRef.length === 0 || + drain.evidenceRef.length > 256 + ) { + return rejected('pairing_drain_unconfirmed'); + } + const active = await this.core.dependencies.keyrings.loadActive(); + if ( + active.status !== 'available' || + !isKeyringEnvelopeValid(active.envelope) || + active.envelope.keyringId !== state.expectedKeyringId || + !bindingsEqual(active.envelope.binding, intent.requestedBinding) + ) { + return rejected('keyring_mismatch'); + } + const challengeId = parsePairingChallengeId( + await this.core.dependencies.random.randomId('pairing-challenge') + ); + const secret = await this.core.randomAuthoritySecret('pairing-challenge'); + const secretHash = await this.core.dependencies.crypto.keyedHash({ + key: active.envelope.hashKey, + purpose: 'pairing-challenge', + secret, + }); + const now = this.core.now(); + const challenge: PairingChallenge = Object.freeze({ + challengeId, + secretHash, + keyringId: active.envelope.keyringId, + resetGeneration: intent.resetGeneration, + issuedAt: now, + expiresAt: addDuration(now, this.core.dependencies.policy.pairingChallengeTtlMs), + failedAttempts: 0, + maxAttempts: this.core.dependencies.policy.pairingMaxAttempts, + status: 'pending_delivery', + consumedAt: null, + revokedAt: null, + revocationReason: null, + pairedDeviceFamilyId: null, + pairedDeviceGrantId: null, + pairedSessionId: null, + deliveryCleanupPending: false, + }); + const nextIntent: AuthResetIntent = Object.freeze({ + ...intent, + stage: 'challenge_pending', + drainEvidenceRef: drain.evidenceRef, + challengeId, + }); + const next = nextAuthorityState(state, { + pairingChallenges: [...state.pairingChallenges, challenge], + resetIntent: nextIntent, + }); + const committed = await this.core.commit(state, next); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + const published = await this.core.dependencies.challengeDelivery.publish({ + challengeId, + secret, + expiresAt: challenge.expiresAt, + }); + if (published.status === 'unavailable' || published.status === 'conflict') { + return rejected('challenge_delivery_unavailable'); + } + return 'retry'; + } + + private async recoverPendingChallenge( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + if (intent.challengeId === null) return rejected('authority_state_corrupt'); + const challenge = state.pairingChallenges.find( + ({ challengeId }) => challengeId === intent.challengeId + ); + if (challenge?.status !== 'pending_delivery') { + return rejected('authority_state_corrupt'); + } + const now = this.core.now(); + if (now >= challenge.expiresAt) { + return await this.returnToChallengeCreation(state, intent, challenge, now, 'expired', true); + } + const delivery = await this.core.dependencies.challengeDelivery.status(intent.challengeId); + if (delivery.status === 'unavailable') { + return rejected('challenge_delivery_unavailable'); + } + if (delivery.status === 'missing') { + return await this.returnToChallengeCreation( + state, + intent, + challenge, + now, + 'publish_not_observed', + false + ); + } + const issued: PairingChallenge = Object.freeze({ + ...challenge, + status: 'issued', + }); + const nextIntent: AuthResetIntent = Object.freeze({ + ...intent, + stage: 'challenge_issued', + }); + const next = nextAuthorityState(state, { + pairingChallenges: state.pairingChallenges.map((item) => + item.challengeId === issued.challengeId ? issued : item + ), + resetIntent: nextIntent, + }); + const committed = await this.core.commit(state, next); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return 'retry'; + } + + private async completeReset( + state: HostedAccessAuthorityState, + intent: AuthResetIntent + ): Promise | 'retry'> { + if (intent.challengeId === null) return rejected('authority_state_corrupt'); + const keyringError = await this.activeKeyringError(state, intent.requestedBinding); + if (keyringError !== null) return rejected(keyringError); + const challenge = state.pairingChallenges.find( + ({ challengeId }) => challengeId === intent.challengeId + ); + if (challenge?.status !== 'issued') return rejected('authority_state_corrupt'); + const delivery = await this.core.dependencies.challengeDelivery.status(challenge.challengeId); + if (delivery.status === 'unavailable') { + return rejected('challenge_delivery_unavailable'); + } + if (delivery.status === 'missing') { + return await this.returnToChallengeCreation( + state, + intent, + challenge, + this.core.now(), + 'issued_delivery_missing', + false + ); + } + const committed = await this.core.commit( + state, + nextAuthorityState(state, { resetIntent: null }) + ); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return accepted('reset_completed', { + resetGeneration: intent.resetGeneration, + challengeId: intent.challengeId, + }); + } + + private async commitForRetry( + state: HostedAccessAuthorityState, + resetIntent: AuthResetIntent + ): Promise | 'retry'> { + const committed = await this.core.commit(state, nextAuthorityState(state, { resetIntent })); + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return 'retry'; + } + + private isExpectedStagedKeyring( + envelope: import('./ports').AuthKeyringEnvelope, + intent: AuthResetIntent + ): boolean { + return ( + intent.stagedKeyringId !== null && + isKeyringEnvelopeValid(envelope) && + envelope.keyringId === intent.stagedKeyringId && + bindingsEqual(envelope.binding, intent.requestedBinding) + ); + } + + private async returnToChallengeCreation( + state: HostedAccessAuthorityState, + intent: AuthResetIntent, + challenge: PairingChallenge, + now: number, + reason: string, + deliveryCleanupPending: boolean + ): Promise | 'retry'> { + const revoked: PairingChallenge = Object.freeze({ + ...challenge, + status: 'revoked', + consumedAt: null, + revokedAt: now, + revocationReason: reason, + deliveryCleanupPending, + }); + const resetIntent: AuthResetIntent = Object.freeze({ + ...intent, + stage: 'key_activated', + challengeId: null, + }); + const committed = await this.core.commit( + state, + nextAuthorityState(state, { + pairingChallenges: state.pairingChallenges.map((item) => + item.challengeId === challenge.challengeId ? revoked : item + ), + resetIntent, + }) + ); + if (committed === 'unavailable') { + return rejected('authority_store_unavailable'); + } + return 'retry'; + } + + private async recoverCompletedReset( + state: HostedAccessAuthorityState, + binding: AuthorityBinding + ): Promise | 'retry'> { + if (!bindingsEqual(state.binding, binding)) { + return rejected('restore_binding_mismatch'); + } + const keyringError = await this.activeKeyringError(state, binding); + if (keyringError !== null) return rejected(keyringError); + const challenge = [...state.pairingChallenges] + .reverse() + .find( + (item) => item.resetGeneration === state.consumedResetGeneration && item.status === 'issued' + ); + if (challenge !== undefined) { + const delivery = await this.core.dependencies.challengeDelivery.status(challenge.challengeId); + if (delivery.status === 'unavailable') { + return rejected('challenge_delivery_unavailable'); + } + if (delivery.status === 'present') { + return accepted('reset_completed', { + resetGeneration: state.consumedResetGeneration, + challengeId: challenge.challengeId, + }); + } + const now = this.core.now(); + const revoked: PairingChallenge = Object.freeze({ + ...challenge, + status: 'revoked', + consumedAt: null, + revokedAt: now, + revocationReason: 'issued_delivery_missing', + deliveryCleanupPending: false, + }); + const recoveryIntent: AuthResetIntent = Object.freeze({ + resetGeneration: state.consumedResetGeneration, + requestedBinding: Object.freeze({ ...binding }), + requestedAt: now, + stage: 'delivery_recovery', + drainEvidenceRef: null, + stagedKeyringId: state.expectedKeyringId, + challengeId: null, + }); + const committed = await this.core.commit( + state, + nextAuthorityState(state, { + pairingChallenges: state.pairingChallenges.map((item) => + item.challengeId === challenge.challengeId ? revoked : item + ), + resetIntent: recoveryIntent, + }) + ); + if (committed === 'unavailable') { + return rejected('authority_store_unavailable'); + } + return 'retry'; + } + return rejected('reset_generation_not_newer'); + } + + private async activeKeyringError( + state: HostedAccessAuthorityState, + binding: AuthorityBinding + ): Promise { + const active = await this.core.dependencies.keyrings.loadActive(); + if (active.status !== 'available') return keyringReadCode(active.status); + if (!isKeyringEnvelopeValid(active.envelope)) return 'keyring_corrupt'; + if (!bindingsEqual(active.envelope.binding, binding)) { + return 'restore_binding_mismatch'; + } + return active.envelope.keyringId === state.expectedKeyringId ? null : 'keyring_mismatch'; + } +} diff --git a/src/features/hosted-access/core/application/SessionAuthority.ts b/src/features/hosted-access/core/application/SessionAuthority.ts new file mode 100644 index 0000000000..5730d27de9 --- /dev/null +++ b/src/features/hosted-access/core/application/SessionAuthority.ts @@ -0,0 +1,761 @@ +import { + type AuthenticatedOperator, + type AuthorityBinding, + type CsrfToken, + type HostedAccessRejectionCode, + type HostedAccessResult, + type OpaqueAuthoritySecret, + parseDeviceGrantId, + parseOperatorSessionId, + type RenewedCredentials, +} from '../../contracts'; +import { + addDuration, + assertAuthorityBinding, + extendIdleDeadline, + type HostedAccessAuthorityState, + nextAuthorityState, + type OperatorDeviceFamily, + type OperatorDeviceGrant, + type OperatorSession, +} from '../domain'; + +import { AuthorityCore, findHashMatch } from './AuthorityCore'; +import { createSession } from './PairingAuthority'; +import { accepted, rejected } from './results'; + +import type { PersonalOwnerPreparationPort } from './ports'; + +export class SessionAuthority { + constructor(private readonly core: AuthorityCore) {} + + async renew( + binding: AuthorityBinding, + deviceSecret: OpaqueAuthoritySecret, + ownerPreparation?: PersonalOwnerPreparationPort + ): Promise> { + assertAuthorityBinding(binding); + const policy = this.core.dependencies.policy; + for (let attempt = 0; attempt < policy.compareAndSwapAttempts; attempt += 1) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const presentedHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'device-grant', + secret: deviceSecret, + }); + const presentedGrant = await findHashMatch( + this.core.dependencies.crypto, + loaded.state.deviceGrants, + presentedHash + ); + if (presentedGrant === null) return rejected('device_invalid'); + const family = loaded.state.deviceFamilies.find( + ({ familyId }) => familyId === presentedGrant.familyId + ); + if (family?.status !== 'active') return rejected('device_family_revoked'); + const now = this.core.now(); + const familyExpiry = classifyFamilyExpiry(family, now); + if (familyExpiry !== null) { + const next = revokeDeviceFamily(loaded.state, family.familyId, now, familyExpiry); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return rejected(familyExpiry); + } + + const acceptedVia = classifyPresentedGrant(presentedGrant, now); + if (acceptedVia === null) { + const next = revokeDeviceFamily( + loaded.state, + family.familyId, + now, + 'predecessor_replay_outside_grace' + ); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return rejected('device_family_revoked'); + } + if (now >= presentedGrant.renewalExpiresAt) { + const next = revokeDeviceFamily( + loaded.state, + family.familyId, + now, + 'device_generation_renewal_expired' + ); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return rejected('device_invalid'); + } + if (ownerPreparation !== undefined) { + const preparedOperatorId = await ownerPreparation.prepare(family.operatorId); + if (preparedOperatorId !== family.operatorId) { + return rejected('authority_state_corrupt'); + } + } + if (acceptedVia === 'predecessor') { + const recovered = await this.recoverPredecessorRenewal( + loaded, + family, + presentedGrant, + deviceSecret, + now + ); + if (recovered === 'retry') continue; + return recovered; + } + + const generation = family.currentGeneration + 1; + const grantId = parseDeviceGrantId( + await this.core.dependencies.random.randomId('device-grant') + ); + const sessionId = parseOperatorSessionId( + await this.core.dependencies.random.randomId('session') + ); + const nextDeviceSecret = await this.core.deriveAuthoritySecret({ + key: loaded.keyring.hashKey, + purpose: 'renewed-device-grant', + sourceSecret: deviceSecret, + context: `${grantId}:${generation}`, + }); + const sessionSecret = await this.core.deriveAuthoritySecret({ + key: loaded.keyring.hashKey, + purpose: 'renewed-session', + sourceSecret: deviceSecret, + context: `${sessionId}:${generation}`, + }); + const grantHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'device-grant', + secret: nextDeviceSecret, + }); + const sessionHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'operator-session', + secret: sessionSecret, + }); + const csrfToken = await this.core.dependencies.crypto.deriveCsrf({ + key: loaded.keyring.csrfKey, + sessionId, + sessionSecret, + }); + const nextGrant: OperatorDeviceGrant = Object.freeze({ + grantId, + familyId: family.familyId, + generation, + renewedFromGrantId: presentedGrant.grantId, + secretHash: grantHash, + keyringId: loaded.keyring.keyringId, + issuedAt: now, + renewalExpiresAt: Math.min( + addDuration(now, policy.deviceRenewalTtlMs), + family.absoluteExpiresAt + ), + status: 'current', + predecessorGraceExpiresAt: null, + predecessorUsesRemaining: 0, + retiredAt: null, + revokedAt: null, + revocationReason: null, + }); + const rotatedGrants = rotateCurrentGeneration( + loaded.state.deviceGrants, + family.familyId, + presentedGrant.grantId, + now, + policy.predecessorGraceMs, + policy.predecessorMaxUses + ); + const retainedGrants = retainRecentGenerations( + [...rotatedGrants, nextGrant], + family.familyId, + policy.retainedDeviceGenerations + ); + const nextFamily: OperatorDeviceFamily = Object.freeze({ + ...family, + lastUsedAt: now, + idleExpiresAt: extendIdleDeadline(now, policy.deviceIdleTtlMs, family.absoluteExpiresAt), + currentGeneration: generation, + }); + const session = createSession({ + sessionId, + operatorId: family.operatorId, + familyId: family.familyId, + deviceGeneration: generation, + secretHash: sessionHash, + keyringId: loaded.keyring.keyringId, + now, + familyAbsoluteExpiresAt: family.absoluteExpiresAt, + policy, + }); + const rotatedSessions = loaded.state.sessions.map((item) => + item.familyId === family.familyId && item.status === 'active' + ? revokeSession(item, now, 'session_rotation') + : item + ); + const next = nextAuthorityState(loaded.state, { + deviceFamilies: loaded.state.deviceFamilies.map((item) => + item.familyId === family.familyId ? nextFamily : item + ), + deviceGrants: retainedGrants, + sessions: pruneSessionHistory([...rotatedSessions, session]), + }); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return accepted('renewed', { + operatorId: family.operatorId, + deviceFamilyId: family.familyId, + acceptedDeviceGeneration: presentedGrant.generation, + acceptedVia, + deviceGeneration: generation, + deviceSecret: nextDeviceSecret, + sessionId, + sessionSecret, + csrfToken, + }); + } + return rejected('authority_store_conflict'); + } + + private async recoverPredecessorRenewal( + loaded: Extract>, { ok: true }>, + family: OperatorDeviceFamily, + predecessor: OperatorDeviceGrant, + presentedSecret: OpaqueAuthoritySecret, + now: number + ): Promise | 'retry'> { + const current = loaded.state.deviceGrants.find( + ({ familyId, status }) => familyId === family.familyId && status === 'current' + ); + const session = loaded.state.sessions.find( + ({ familyId, deviceGeneration, status }) => + familyId === family.familyId && + deviceGeneration === current?.generation && + status === 'active' + ); + if ( + current === undefined || + current.renewedFromGrantId !== predecessor.grantId || + current.generation !== predecessor.generation + 1 || + session === undefined + ) { + const committed = await this.core.commit( + loaded.state, + revokeDeviceFamily(loaded.state, family.familyId, now, 'predecessor_replay_non_monotonic') + ); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return rejected('device_family_revoked'); + } + + const deviceSecret = await this.core.deriveAuthoritySecret({ + key: loaded.keyring.hashKey, + purpose: 'renewed-device-grant', + sourceSecret: presentedSecret, + context: `${current.grantId}:${current.generation}`, + }); + const sessionSecret = await this.core.deriveAuthoritySecret({ + key: loaded.keyring.hashKey, + purpose: 'renewed-session', + sourceSecret: presentedSecret, + context: `${session.sessionId}:${session.deviceGeneration}`, + }); + const [deviceHash, sessionHash] = await Promise.all([ + this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'device-grant', + secret: deviceSecret, + }), + this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'operator-session', + secret: sessionSecret, + }), + ]); + if ( + !(await this.core.dependencies.crypto.secureEqual(deviceHash, current.secretHash)) || + !(await this.core.dependencies.crypto.secureEqual(sessionHash, session.secretHash)) + ) { + return rejected('authority_state_corrupt'); + } + + const remaining = predecessor.predecessorUsesRemaining - 1; + const consumedPredecessor: OperatorDeviceGrant = + remaining === 0 + ? Object.freeze({ + ...predecessor, + status: 'retired', + predecessorGraceExpiresAt: null, + predecessorUsesRemaining: 0, + retiredAt: now, + }) + : Object.freeze({ + ...predecessor, + predecessorUsesRemaining: remaining, + }); + const next = nextAuthorityState(loaded.state, { + deviceGrants: loaded.state.deviceGrants.map((grant) => + grant.grantId === predecessor.grantId ? consumedPredecessor : grant + ), + }); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') return 'retry'; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + const csrfToken = await this.core.dependencies.crypto.deriveCsrf({ + key: loaded.keyring.csrfKey, + sessionId: session.sessionId, + sessionSecret, + }); + return accepted('renewed', { + operatorId: family.operatorId, + deviceFamilyId: family.familyId, + acceptedDeviceGeneration: predecessor.generation, + acceptedVia: 'predecessor', + deviceGeneration: current.generation, + deviceSecret, + sessionId: session.sessionId, + sessionSecret, + csrfToken, + }); + } + + async authenticate( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret + ): Promise> { + assertAuthorityBinding(binding); + const result = await this.authenticateAndTouch(binding, sessionSecret); + if (!result.ok) return result; + return accepted('authenticated', result.value.identity); + } + + async bootstrapSession( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret, + allowRenewal = true + ): Promise< + HostedAccessResult< + AuthenticatedOperator & { readonly csrfToken: CsrfToken }, + 'session_bootstrapped' + > + > { + assertAuthorityBinding(binding); + if (!allowRenewal) { + const validated = await this.validateSessionWithoutTouch(binding, sessionSecret); + if (!validated.ok) return validated; + const csrfToken = await this.core.dependencies.crypto.deriveCsrf({ + key: validated.value.keyring.csrfKey, + sessionId: validated.value.session.sessionId, + sessionSecret, + }); + return accepted('session_bootstrapped', { + ...validated.value.identity, + csrfToken, + }); + } + const result = await this.authenticateAndTouch(binding, sessionSecret); + if (!result.ok) return result; + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const current = loaded.state.sessions.find( + ({ sessionId, status }) => + sessionId === result.value.identity.sessionId && status === 'active' + ); + if (current === undefined) return rejected('session_invalid'); + const csrfToken = await this.core.dependencies.crypto.deriveCsrf({ + key: loaded.keyring.csrfKey, + sessionId: current.sessionId, + sessionSecret, + }); + return accepted('session_bootstrapped', { + ...result.value.identity, + csrfToken, + }); + } + + async verifyCsrf( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret, + presentedCsrf: CsrfToken + ): Promise> { + assertAuthorityBinding(binding); + const validated = await this.validateSessionWithoutTouch(binding, sessionSecret); + if (!validated.ok) return validated; + const expectedCsrf = await this.core.dependencies.crypto.deriveCsrf({ + key: validated.value.keyring.csrfKey, + sessionId: validated.value.session.sessionId, + sessionSecret, + }); + if (!(await this.core.dependencies.crypto.secureEqual(expectedCsrf, presentedCsrf))) { + return rejected('csrf_invalid'); + } + const touched = await this.authenticateAndTouch(binding, sessionSecret); + if (!touched.ok) return touched; + return accepted('csrf_verified', touched.value.identity); + } + + async logout( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret + ): Promise> { + assertAuthorityBinding(binding); + for ( + let attempt = 0; + attempt < this.core.dependencies.policy.compareAndSwapAttempts; + attempt += 1 + ) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const session = await this.findSession(loaded, sessionSecret); + if (session?.status !== 'active') return rejected('session_invalid'); + const next = nextAuthorityState(loaded.state, { + sessions: loaded.state.sessions.map((item) => + item.sessionId === session.sessionId + ? revokeSession(item, this.core.now(), 'logout') + : item + ), + }); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return accepted('logged_out', { sessionId: session.sessionId }); + } + return rejected('authority_store_conflict'); + } + + async forgetDevice( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret + ): Promise> { + assertAuthorityBinding(binding); + for ( + let attempt = 0; + attempt < this.core.dependencies.policy.compareAndSwapAttempts; + attempt += 1 + ) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const session = await this.findSession(loaded, sessionSecret); + if (session?.status !== 'active') return rejected('session_invalid'); + const next = revokeDeviceFamily( + loaded.state, + session.familyId, + this.core.now(), + 'forget_device' + ); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return accepted('device_forgotten', { deviceFamilyId: session.familyId }); + } + return rejected('authority_store_conflict'); + } + + private async authenticateAndTouch( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret + ): Promise< + HostedAccessResult< + { + readonly identity: AuthenticatedOperator; + readonly session: OperatorSession; + }, + 'authenticated' + > + > { + for ( + let attempt = 0; + attempt < this.core.dependencies.policy.compareAndSwapAttempts; + attempt += 1 + ) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const session = await this.findSession(loaded, sessionSecret); + if (session?.status !== 'active') return rejected('session_invalid'); + const family = loaded.state.deviceFamilies.find( + ({ familyId }) => familyId === session.familyId + ); + if (family?.status !== 'active') return rejected('device_family_revoked'); + const now = this.core.now(); + const familyExpiry = classifyFamilyExpiry(family, now); + if (familyExpiry !== null) { + const next = revokeDeviceFamily(loaded.state, family.familyId, now, familyExpiry); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') { + return rejected('authority_store_unavailable'); + } + return rejected(familyExpiry); + } + const expiry = classifySessionExpiry(session, now); + if (expiry !== null) { + const next = nextAuthorityState(loaded.state, { + sessions: loaded.state.sessions.map((item) => + item.sessionId === session.sessionId ? revokeSession(item, now, expiry) : item + ), + }); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return rejected(expiry); + } + const touched: OperatorSession = Object.freeze({ + ...session, + lastUsedAt: now, + deadlines: Object.freeze({ + ...session.deadlines, + idleExpiresAt: extendIdleDeadline( + now, + this.core.dependencies.policy.sessionIdleTtlMs, + session.deadlines.absoluteExpiresAt + ), + }), + }); + const next = nextAuthorityState(loaded.state, { + sessions: loaded.state.sessions.map((item) => + item.sessionId === session.sessionId ? touched : item + ), + }); + const committed = await this.core.commit(loaded.state, next); + if (committed === 'conflict') continue; + if (committed === 'unavailable') return rejected('authority_store_unavailable'); + return accepted('authenticated', { + identity: { + operatorId: session.operatorId, + deviceFamilyId: session.familyId, + sessionId: session.sessionId, + }, + session: touched, + }); + } + return rejected('authority_store_conflict'); + } + + private async validateSessionWithoutTouch( + binding: AuthorityBinding, + sessionSecret: OpaqueAuthoritySecret + ): Promise< + HostedAccessResult< + { + readonly identity: AuthenticatedOperator; + readonly session: OperatorSession; + readonly keyring: Extract< + Awaited>, + { ok: true } + >['keyring']; + }, + 'authenticated' + > + > { + for ( + let attempt = 0; + attempt < this.core.dependencies.policy.compareAndSwapAttempts; + attempt += 1 + ) { + const loaded = await this.core.loadRegular(binding); + if (!loaded.ok) return loaded; + const session = await this.findSession(loaded, sessionSecret); + if (session?.status !== 'active') return rejected('session_invalid'); + const family = loaded.state.deviceFamilies.find( + ({ familyId }) => familyId === session.familyId + ); + if (family?.status !== 'active') return rejected('device_family_revoked'); + const now = this.core.now(); + const familyExpiry = classifyFamilyExpiry(family, now); + if (familyExpiry !== null) { + const committed = await this.core.commit( + loaded.state, + revokeDeviceFamily(loaded.state, family.familyId, now, familyExpiry) + ); + if (committed === 'conflict') continue; + if (committed === 'unavailable') { + return rejected('authority_store_unavailable'); + } + return rejected(familyExpiry); + } + const expiry = classifySessionExpiry(session, now); + if (expiry !== null) { + const committed = await this.core.commit( + loaded.state, + nextAuthorityState(loaded.state, { + sessions: loaded.state.sessions.map((item) => + item.sessionId === session.sessionId ? revokeSession(item, now, expiry) : item + ), + }) + ); + if (committed === 'conflict') continue; + if (committed === 'unavailable') { + return rejected('authority_store_unavailable'); + } + return rejected(expiry); + } + return accepted('authenticated', { + identity: { + operatorId: session.operatorId, + deviceFamilyId: session.familyId, + sessionId: session.sessionId, + }, + session, + keyring: loaded.keyring, + }); + } + return rejected('authority_store_conflict'); + } + + private async findSession( + loaded: Extract>, { ok: true }>, + sessionSecret: OpaqueAuthoritySecret + ): Promise { + const presentedHash = await this.core.dependencies.crypto.keyedHash({ + key: loaded.keyring.hashKey, + purpose: 'operator-session', + secret: sessionSecret, + }); + return await findHashMatch(this.core.dependencies.crypto, loaded.state.sessions, presentedHash); + } +} + +function classifyFamilyExpiry( + family: OperatorDeviceFamily, + now: number +): 'device_absolute_expired' | 'device_idle_expired' | null { + if (now >= family.absoluteExpiresAt) return 'device_absolute_expired'; + if (now >= family.idleExpiresAt) return 'device_idle_expired'; + return null; +} + +function classifySessionExpiry( + session: OperatorSession, + now: number +): 'session_absolute_expired' | 'session_idle_expired' | 'session_renewal_required' | null { + if (now >= session.deadlines.absoluteExpiresAt) return 'session_absolute_expired'; + if (now >= session.deadlines.idleExpiresAt) return 'session_idle_expired'; + if (now >= session.deadlines.renewalExpiresAt) return 'session_renewal_required'; + return null; +} + +function classifyPresentedGrant( + grant: OperatorDeviceGrant, + now: number +): 'current' | 'predecessor' | null { + if (grant.status === 'current') return 'current'; + if ( + grant.status === 'predecessor' && + grant.predecessorGraceExpiresAt !== null && + now < grant.predecessorGraceExpiresAt && + grant.predecessorUsesRemaining > 0 + ) { + return 'predecessor'; + } + return null; +} + +function rotateCurrentGeneration( + grants: readonly OperatorDeviceGrant[], + familyId: string, + presentedGrantId: string, + now: number, + predecessorGraceMs: number, + predecessorMaxUses: number +): readonly OperatorDeviceGrant[] { + return grants.map((grant) => { + if (grant.familyId !== familyId) return grant; + if (grant.status === 'current' && grant.grantId === presentedGrantId) { + return Object.freeze({ + ...grant, + status: 'predecessor' as const, + predecessorGraceExpiresAt: Math.min( + addDuration(now, predecessorGraceMs), + grant.renewalExpiresAt + ), + predecessorUsesRemaining: predecessorMaxUses, + }); + } + if (grant.status === 'predecessor') { + return Object.freeze({ + ...grant, + status: 'retired' as const, + predecessorGraceExpiresAt: null, + predecessorUsesRemaining: 0, + retiredAt: now, + }); + } + return grant; + }); +} + +const MAX_RETAINED_SESSION_RECORDS = 64; + +function pruneSessionHistory(sessions: readonly OperatorSession[]): readonly OperatorSession[] { + if (sessions.length <= MAX_RETAINED_SESSION_RECORDS) return sessions; + const active = sessions.filter(({ status }) => status === 'active'); + const inactive = [...sessions] + .reverse() + .filter(({ status }) => status !== 'active') + .sort((left, right) => right.issuedAt - left.issuedAt); + return [ + ...inactive.slice(0, Math.max(0, MAX_RETAINED_SESSION_RECORDS - active.length)), + ...active, + ]; +} + +function retainRecentGenerations( + grants: readonly OperatorDeviceGrant[], + familyId: string, + retainedCount: number +): readonly OperatorDeviceGrant[] { + const familyGrants = grants + .filter((grant) => grant.familyId === familyId) + .sort((left, right) => right.generation - left.generation); + const retainedIds = new Set(familyGrants.slice(0, retainedCount).map(({ grantId }) => grantId)); + return grants.filter((grant) => grant.familyId !== familyId || retainedIds.has(grant.grantId)); +} + +export function revokeDeviceFamily( + state: HostedAccessAuthorityState, + familyId: string, + now: number, + reason: string | HostedAccessRejectionCode +): HostedAccessAuthorityState { + return nextAuthorityState(state, { + deviceFamilies: state.deviceFamilies.map((family) => + family.familyId === familyId + ? Object.freeze({ + ...family, + status: 'revoked' as const, + revokedAt: now, + revocationReason: reason, + }) + : family + ), + deviceGrants: state.deviceGrants.map((grant) => + grant.familyId === familyId + ? Object.freeze({ + ...grant, + status: 'revoked' as const, + predecessorGraceExpiresAt: null, + predecessorUsesRemaining: 0, + revokedAt: now, + revocationReason: reason, + }) + : grant + ), + sessions: state.sessions.map((session) => + session.familyId === familyId && session.status === 'active' + ? revokeSession(session, now, reason) + : session + ), + }); +} + +function revokeSession(session: OperatorSession, now: number, reason: string): OperatorSession { + return Object.freeze({ + ...session, + status: 'revoked', + revokedAt: now, + revocationReason: reason, + }); +} diff --git a/src/features/hosted-access/core/application/identityPorts.ts b/src/features/hosted-access/core/application/identityPorts.ts new file mode 100644 index 0000000000..0749ad8165 --- /dev/null +++ b/src/features/hosted-access/core/application/identityPorts.ts @@ -0,0 +1,365 @@ +import type { + AuditEventId, + AuthKeyringId, + BeginLoginResult, + CompleteLoginInput, + HostedAuthMode, + HostedRole, + HostedSessionId, + HostedWorkspace, + HostedWorkspaceId, + IdentityProviderLogoutResult, + OidcLoginAttemptId, + OperatorId, + UserId, +} from '../../contracts'; +import type { HostedAccessAuthorityState } from '../domain'; + +export interface HostedUserRecord { + readonly userId: UserId; + readonly displayName: string; + readonly status: 'active' | 'disabled'; + readonly createdAt: number; + readonly updatedAt: number; +} + +export interface HostedPersonalOwnerRecord { + readonly operatorId: OperatorId; + readonly user: HostedUserRecord; +} + +export interface ExternalIdentityRecord { + readonly issuer: string; + readonly subject: string; + readonly userId: UserId; + readonly providerId: string; + readonly createdAt: number; + readonly lastAuthenticatedAt: number; +} + +export interface HostedRoleSnapshot { + readonly role: HostedRole; + readonly source: 'personal-owner' | 'oidc-claim' | 'local-cli'; + readonly capturedAt: number; +} + +export interface HostedLocalRoleAssignment { + readonly userId: UserId; + readonly role: HostedRole; + readonly assignedAt: number; + readonly assignedBy: 'local-cli'; +} + +export interface HostedWorkspaceRegistration extends HostedWorkspace { + /** Local scanner identifier. It is never serialized to HTTP or SSE. */ + readonly runtimeWorkspaceId: string; + readonly status: 'active' | 'disabled'; +} + +export interface HostedWorkspaceGrant { + readonly userId: UserId; + readonly workspaceId: HostedWorkspaceId; + readonly runtimeWorkspaceId: string; + readonly displayName: string; + readonly grantGeneration: number; + /** Fresh random revision on every grant/regrant; prevents revoke/regrant ABA. */ + readonly grantRevision: string; + readonly grantedAt: number; + readonly grantedBy: 'local-cli'; +} + +export interface HostedOperatorSessionRecord { + readonly sessionId: HostedSessionId; + readonly userId: UserId; + readonly secretHash: string; + readonly authenticationMethod: 'oidc'; + readonly providerId: string; + readonly providerIssuer: string; + readonly providerSubject: string; + readonly providerSessionId: string | null; + readonly roleSnapshot: HostedRoleSnapshot; + readonly issuedAt: number; + readonly lastUsedAt: number; + readonly idleExpiresAt: number; + readonly absoluteExpiresAt: number; + readonly status: 'active' | 'revoked'; + readonly revokedAt: number | null; + readonly revocationReason: string | null; +} + +export interface OidcLoginAttemptRecord { + readonly attemptId: OidcLoginAttemptId; + readonly providerId: string; + readonly stateHash: string; + readonly nonce: string; + readonly pkceVerifierCiphertext: string; + readonly returnTo: string; + readonly createdAt: number; + readonly expiresAt: number; + readonly consumedAt: number | null; +} + +export interface HostedAuditEvent { + readonly eventId: AuditEventId; + readonly occurredAt: number; + readonly userId: UserId | null; + readonly sessionId: HostedSessionId | null; + readonly action: string; + readonly outcome: 'success' | 'denied' | 'failure'; + readonly sourceIpHash: string | null; + readonly details: Readonly>; +} + +export interface HostedAuthConfiguration { + readonly mode: HostedAuthMode; + readonly configuredAt: number; + readonly resetGeneration: number; + readonly secretsRotatedGeneration: number; + readonly pendingPersonalKeyringId: AuthKeyringId | null; +} + +export type HostedAuthModeResetResult = + | 'committed' + | 'mode_mismatch' + | 'generation_not_newer' + | 'authority_conflict'; + +export interface HostedIdentityRepositoryPort { + readAuthConfiguration(): Promise; + resetAuthMode(input: { + readonly currentMode: HostedAuthMode; + readonly targetMode: HostedAuthMode; + readonly resetGeneration: number; + readonly resetAt: number; + readonly expectedAuthorityRevision: number | null; + readonly nextAuthorityState: HostedAccessAuthorityState; + readonly pendingPersonalKeyringId: AuthKeyringId; + readonly auditEvent: HostedAuditEvent; + }): Promise; + markAuthSecretsRotated(input: { + readonly mode: HostedAuthMode; + readonly resetGeneration: number; + readonly pendingPersonalKeyringId: AuthKeyringId; + }): Promise; + createOidcLoginAttempt( + attempt: OidcLoginAttemptRecord + ): Promise<'created' | 'conflict' | 'capacity'>; + consumeOidcLoginAttempt(input: { + readonly attemptId: OidcLoginAttemptId; + readonly providerId: string; + readonly stateHash: string; + readonly now: number; + }): Promise; + bindExternalIdentity(input: { + readonly identity: Omit; + readonly proposedUser: HostedUserRecord; + }): Promise<{ readonly user: HostedUserRecord; readonly identity: ExternalIdentityRecord }>; + ensurePersonalOwner(input: { + readonly user: HostedUserRecord; + readonly operatorId: OperatorId; + }): Promise; + createSession(session: HostedOperatorSessionRecord): Promise; + findSessionBySecretHash(secretHash: string): Promise; + touchSession(input: { + readonly sessionId: HostedSessionId; + readonly expectedLastUsedAt: number; + readonly lastUsedAt: number; + readonly idleExpiresAt: number; + }): Promise; + revokeSession(input: { + readonly sessionId: HostedSessionId; + readonly now: number; + readonly reason: string; + }): Promise; + applyBackchannelLogout(input: { + readonly providerId: string; + readonly issuer: string; + readonly subject?: string; + readonly providerSessionId?: string; + readonly jti: string; + readonly expiresAt: number; + readonly consumedAt: number; + readonly reason: string; + }): Promise<{ readonly consumed: boolean; readonly revoked: number }>; + getUser(userId: UserId): Promise; + listUsers(): Promise; + setUserStatus(input: { + readonly userId: UserId; + readonly status: 'active' | 'disabled'; + readonly now: number; + }): Promise; + getLocalRoleAssignment(userId: UserId): Promise; + setLocalRoleAssignment(assignment: HostedLocalRoleAssignment): Promise; + clearLocalRoleAssignment(userId: UserId): Promise; + isWorkspaceRegistered(runtimeWorkspaceId: string): Promise; + listWorkspaces(): Promise; + registerWorkspace(input: { + readonly runtimeWorkspaceId: string; + readonly workspaceId: HostedWorkspaceId; + readonly displayName: string; + readonly registeredAt: number; + readonly registeredBy: UserId | null; + }): Promise; + disableWorkspace(runtimeWorkspaceId: string): Promise; + listWorkspaceGrants(input: { + readonly userId: UserId; + readonly grantGeneration: number; + }): Promise; + grantWorkspace(input: { + readonly userId: UserId; + readonly runtimeWorkspaceId: string; + readonly grantGeneration: number; + readonly grantedAt: number; + readonly grantedBy: 'local-cli'; + }): Promise; + revokeWorkspaceGrant(input: { + readonly userId: UserId; + readonly runtimeWorkspaceId: string; + }): Promise; + appendAudit(event: HostedAuditEvent): Promise; +} + +export interface HostedIdentityCryptoPort { + randomId( + kind: 'user' | 'session' | 'oidc-attempt' | 'audit-event' | 'workspace' + ): Promise; + randomSecret(kind: 'session' | 'csrf'): Promise; + hashSecret(purpose: 'session' | 'oidc-state' | 'source-ip', secret: string): Promise; + deriveCsrf(sessionId: HostedSessionId, sessionSecret: string): Promise; + encryptLoginSecret(secret: string): Promise; + decryptLoginSecret(ciphertext: string): Promise; + secureEqual(left: string, right: string): Promise; +} + +export interface HostedAuthPathStat { + readonly dev: number; + readonly ino: number; + readonly uid: number; + readonly mode: number; + isDirectory(): boolean; + isFile(): boolean; + isSocket(): boolean; + isSymbolicLink(): boolean; +} + +/** + * Process-owned host capabilities used by the production adapters. Keeping + * these operations behind a port leaves feature composition independent of + * Node and Electron while preserving durable file and cryptographic behavior. + */ +export interface HostedAuthHostPlatform { + readonly uid: number | undefined; + readonly pid: number; + join(...segments: readonly string[]): string; + dirname(path: string): string; + isAbsolute(path: string): boolean; + byteLength(value: string): number; + mkdir(path: string, mode: number): Promise; + lstat(path: string): Promise; + openReadOnlyNoFollow(path: string): Promise; + chmod(path: string, mode: number): Promise; + writeTextDurable( + path: string, + body: string, + options: { readonly exclusive: boolean; readonly mode: number } + ): Promise; + rename(source: string, destination: string): Promise; + remove( + path: string, + options?: { readonly force?: boolean; readonly recursive?: boolean } + ): Promise; + randomBytes(size: number): Uint8Array; + base64UrlEncode(bytes: Uint8Array): string; + base64UrlDecode(value: string): Uint8Array; + hmacSha256(key: Uint8Array, parts: readonly string[], encoding: 'hex' | 'base64url'): string; + hkdfSha256(input: Uint8Array, salt: Uint8Array, info: string, length: number): Uint8Array; + sha256Base64Url(value: string): string; + verifyOidcSignature(input: { + readonly algorithm: string; + readonly jwk: Readonly>; + readonly signingInput: string; + readonly signature: Uint8Array; + }): boolean; + encryptAes256Gcm(input: { + readonly key: Uint8Array; + readonly nonce: Uint8Array; + readonly aad: string; + readonly plaintext: string; + }): { readonly ciphertext: Uint8Array; readonly tag: Uint8Array }; + decryptAes256Gcm(input: { + readonly key: Uint8Array; + readonly nonce: Uint8Array; + readonly aad: string; + readonly ciphertext: Uint8Array; + readonly tag: Uint8Array; + }): string; + secureEqual(left: string, right: string): boolean; +} + +export interface HostedAuthReadHandle { + stat(): Promise; + readTextBounded(maximumBytes: number): Promise; + close(): Promise; +} + +export interface HostedAuthLocalControlTransport { + start(handler: (requestBody: string) => Promise): Promise; + close(): Promise; +} + +export interface HostedAuthLocalControlTransportFactory { + create(options: { + readonly socketPath: string; + readonly maximumRequestBytes: number; + readonly requestTimeoutMs: number; + }): HostedAuthLocalControlTransport; +} + +export interface IdentityProviderClaims { + readonly issuer: string; + readonly subject: string; + readonly displayName: string; + readonly role: HostedRole; + readonly providerSessionId: string | null; +} + +export interface IdentityProviderBeginContext { + readonly attemptId: OidcLoginAttemptId; + readonly returnTo: string; +} + +export interface IdentityProviderBeginResult extends BeginLoginResult { + readonly nonce: string; + readonly pkceVerifier: string; +} + +export interface IdentityProviderCompleteContext extends CompleteLoginInput { + readonly nonce: string; + readonly pkceVerifier: string; +} + +export interface IdentityProviderLogoutContext { + readonly session: HostedOperatorSessionRecord; + readonly postLogoutRedirectUri: string; +} + +export interface IdentityProviderBackchannelLogout { + readonly issuer: string; + readonly subject?: string; + readonly providerSessionId?: string; + readonly jti: string; + readonly expiresAt: number; +} + +/** + * Provider-neutral boundary. Keycloak uses the same OIDC implementation as any + * standards-compliant external provider. + */ +export interface OidcIdentityProvider { + readonly id: string; + readonly displayName: string; + beginLogin(context: IdentityProviderBeginContext): Promise; + completeLogin(context: IdentityProviderCompleteContext): Promise; + logout(context: IdentityProviderLogoutContext): Promise; + verifyBackchannelLogout(token: string): Promise; +} diff --git a/src/features/hosted-access/core/application/index.ts b/src/features/hosted-access/core/application/index.ts new file mode 100644 index 0000000000..552dbf9e70 --- /dev/null +++ b/src/features/hosted-access/core/application/index.ts @@ -0,0 +1,8 @@ +export * from './AuthorityCore'; +export * from './HostedAccessAuthority'; +export * from './HostedAuthenticationProviders'; +export * from './HostedIdentityService'; +export * from './HostedLocalAdministration'; +export * from './HostedWorkspaceGrantSetFence'; +export type * from './identityPorts'; +export type * from './ports'; diff --git a/src/features/hosted-access/core/application/ports.ts b/src/features/hosted-access/core/application/ports.ts new file mode 100644 index 0000000000..f43eaeaee2 --- /dev/null +++ b/src/features/hosted-access/core/application/ports.ts @@ -0,0 +1,189 @@ +import type { + AuthKeyringId, + AuthorityBinding, + AuthorityKeyMaterial, + CsrfToken, + KeyedSecretHash, + OpaqueAuthoritySecret, + OperatorId, + PairingChallengeId, +} from '../../contracts'; +import type { HostedAccessAuthorityState } from '../domain'; + +export type AuthorityRandomIdKind = + | 'auth-keyring' + | 'device-family' + | 'device-grant' + | 'operator' + | 'pairing-challenge' + | 'session'; + +export type AuthorityRandomSecretKind = + | 'csrf-key' + | 'device-grant' + | 'hash-key' + | 'pairing-challenge' + | 'session'; + +export interface HostedAccessClockPort { + now(): number; +} + +export interface HostedAccessRandomPort { + randomId(kind: AuthorityRandomIdKind): Promise; + randomSecret( + kind: AuthorityRandomSecretKind, + byteLength: 32 + ): Promise; +} + +export type KeyedHashPurpose = 'device-grant' | 'operator-session' | 'pairing-challenge'; +export type AuthoritySecretDerivationPurpose = + | 'pairing-device-grant' + | 'pairing-session' + | 'renewed-device-grant' + | 'renewed-session'; + +export interface HostedAccessCryptoPort { + keyedHash(input: { + readonly key: AuthorityKeyMaterial; + readonly purpose: KeyedHashPurpose; + readonly secret: OpaqueAuthoritySecret; + }): Promise; + /** + * Stable, domain-separated PRF derivation. Implementations must return the + * same opaque secret after restart and must never return the source secret. + */ + deriveSecret(input: { + readonly key: AuthorityKeyMaterial; + readonly purpose: AuthoritySecretDerivationPurpose; + readonly sourceSecret: OpaqueAuthoritySecret; + readonly context: string; + }): Promise; + deriveCsrf(input: { + readonly key: AuthorityKeyMaterial; + readonly sessionId: string; + readonly sessionSecret: OpaqueAuthoritySecret; + }): Promise; + secureEqual(left: string, right: string): Promise; +} + +export interface AuthKeyringEnvelope { + readonly format: 'hosted-access-keyring/v1'; + readonly keyringId: AuthKeyringId; + readonly binding: AuthorityBinding; + readonly createdAt: number; + readonly hashKey: AuthorityKeyMaterial; + readonly csrfKey: AuthorityKeyMaterial; +} + +export type AuthKeyringReadResult = + | { readonly status: 'available'; readonly envelope: AuthKeyringEnvelope } + | { readonly status: 'missing' | 'corrupt' | 'unavailable' }; + +export type AuthKeyringWriteResult = + | { readonly status: 'created' | 'staged' | 'activated' | 'already_applied' } + | { readonly status: 'conflict' | 'unavailable' }; + +/** + * The adapter owns exclusive-create, mode checks, file and parent fsync, and + * same-directory activation. Core never imports a filesystem API. + */ +export interface AuthKeyringPort { + loadActive(): Promise; + createInitial(envelope: AuthKeyringEnvelope): Promise; + loadStaged(keyringId: AuthKeyringId): Promise; + stageReplacement(envelope: AuthKeyringEnvelope): Promise; + activateStaged(keyringId: AuthKeyringId): Promise; +} + +export type AuthorityRepositoryReadResult = + | { + readonly status: 'available'; + readonly state: HostedAccessAuthorityState; + readonly rollbackFenceRevision: number; + } + | { + readonly status: 'empty'; + /** + * Null only for a never-initialized authority. A non-null value proves + * that the rollback-resistant fence survived loss of the projection. + */ + readonly rollbackFenceRevision: number | null; + } + | { readonly status: 'corrupt' | 'unavailable' }; + +export type AuthorityRepositoryWriteResult = + | { readonly status: 'committed' } + | { readonly status: 'conflict' | 'unavailable' }; + +/** + * The rollback fence must live outside the projection's snapshot/restore + * domain and must never decrease or be deleted after initialization. + * + * initialize must atomically create revision zero and fence zero only when + * neither exists. compareAndSwap must atomically re-check both expected + * revisions, commit the complete next projection, and advance the durable + * fence by exactly one. A projection/fence mismatch must be returned as + * `corrupt`, never repaired by lowering or recreating the fence. + */ +export interface HostedAccessAuthorityRepositoryPort { + load(): Promise; + initialize(state: HostedAccessAuthorityState): Promise; + compareAndSwap(input: { + readonly expectedRevision: number; + readonly expectedRollbackFenceRevision: number; + readonly nextState: HostedAccessAuthorityState; + readonly nextRollbackFenceRevision: number; + }): Promise; +} + +export type PairingChallengeDeliveryStatus = + | { readonly status: 'present' | 'missing' } + | { readonly status: 'unavailable' }; + +export type PairingChallengeDeliveryWriteResult = + | { readonly status: 'published' | 'already_published' | 'removed' | 'already_missing' } + | { readonly status: 'conflict' | 'unavailable' }; + +/** + * The adapter atomically publishes the one plaintext pairing value to its + * operator-only channel. It must never place the value in coordination state. + */ +export interface PairingChallengeDeliveryPort { + status(challengeId: PairingChallengeId): Promise; + publish(input: { + readonly challengeId: PairingChallengeId; + readonly secret: OpaqueAuthoritySecret; + readonly expiresAt: number; + }): Promise; + remove(challengeId: PairingChallengeId): Promise; +} + +export type PairingDrainPurpose = 'initial_pairing' | 'host_reset' | 'auth_mode_reset'; + +export type PairingDrainResult = + | { readonly status: 'drained'; readonly evidenceRef: string } + | { readonly status: 'residual' | 'unclassified' | 'unavailable' }; + +/** + * Public, authority-neutral residual proof. An adapter may translate the + * process controller's public evidence without exposing runtime internals. + */ +export interface PairingDrainProofPort { + confirmDrained(input: { + readonly binding: AuthorityBinding; + readonly purpose: PairingDrainPurpose; + readonly resetGeneration: number; + readonly targetAuthMode?: 'personal' | 'oidc'; + }): Promise; +} + +/** + * Prepares the immutable personal-owner identity before a transition rotates + * or consumes browser credentials. Implementations may return an already + * prepared operator after a prior store-success/authority-conflict retry. + */ +export interface PersonalOwnerPreparationPort { + prepare(proposedOperatorId: OperatorId): Promise; +} diff --git a/src/features/hosted-access/core/application/results.ts b/src/features/hosted-access/core/application/results.ts new file mode 100644 index 0000000000..25c2136aa0 --- /dev/null +++ b/src/features/hosted-access/core/application/results.ts @@ -0,0 +1,12 @@ +import type { HostedAccessRejectionCode, HostedAccessResult } from '../../contracts'; + +export function accepted( + code: Code, + value: Value +): HostedAccessResult { + return { ok: true, code, value }; +} + +export function rejected(code: HostedAccessRejectionCode): HostedAccessResult { + return { ok: false, code }; +} diff --git a/src/features/hosted-access/core/domain/authorityState.ts b/src/features/hosted-access/core/domain/authorityState.ts new file mode 100644 index 0000000000..bf43a6a668 --- /dev/null +++ b/src/features/hosted-access/core/domain/authorityState.ts @@ -0,0 +1,778 @@ +import { + type AuthKeyringId, + type AuthorityBinding, + type DeviceFamilyId, + type DeviceGrantId, + type HostedAccessAuthorityPolicy, + type KeyedSecretHash, + type OperatorId, + type OperatorSessionId, + type PairingChallengeId, + parseAuthKeyringId, + parseAuthorityDeploymentId, + parseDeviceFamilyId, + parseDeviceGrantId, + parseKeyedSecretHash, + parseOperatorId, + parseOperatorSessionId, + parsePairingChallengeId, +} from '../../contracts'; + +import { + AUTHORITY_PERSISTED_KEYS, + hasCoherentPersistedRevocationStatus as hasCoherentRevocationStatus, + hasExactlyPersistedKeys, + isNonNegativeSafeInteger, + isNullablePersistedInstant as isNullableInstant, + isNullablePersistedReason as isNullableReason, + isPersistedInstant as isInstant, + isPersistedRecord as isRecord, + isPositiveSafeInteger, + isWithinPolicyDuration, + persistedBindingsEqual as bindingsEqual, +} from './policy'; + +export interface ExpiryDeadlines { + readonly idleExpiresAt: number; + readonly absoluteExpiresAt: number; + readonly renewalExpiresAt: number; +} + +export type PairingChallengeStatus = 'pending_delivery' | 'issued' | 'consumed' | 'revoked'; + +export interface PairingChallenge { + readonly challengeId: PairingChallengeId; + readonly secretHash: KeyedSecretHash; + readonly keyringId: AuthKeyringId; + readonly resetGeneration: number; + readonly issuedAt: number; + readonly expiresAt: number; + readonly failedAttempts: number; + readonly maxAttempts: number; + readonly status: PairingChallengeStatus; + readonly consumedAt: number | null; + readonly revokedAt: number | null; + readonly revocationReason: string | null; + readonly pairedDeviceFamilyId: DeviceFamilyId | null; + readonly pairedDeviceGrantId: DeviceGrantId | null; + readonly pairedSessionId: OperatorSessionId | null; + /** + * Durable cross-store cleanup intent. A consumed/revoked challenge is not + * fully reconciled until its plaintext delivery has been confirmed absent. + */ + readonly deliveryCleanupPending: boolean; +} + +export interface OperatorDeviceFamily { + readonly familyId: DeviceFamilyId; + readonly operatorId: OperatorId; + readonly issuedAt: number; + readonly lastUsedAt: number; + readonly idleExpiresAt: number; + readonly absoluteExpiresAt: number; + readonly currentGeneration: number; + readonly status: 'active' | 'revoked'; + readonly revokedAt: number | null; + readonly revocationReason: string | null; +} + +export type DeviceGrantStatus = 'current' | 'predecessor' | 'retired' | 'revoked'; + +export interface OperatorDeviceGrant { + readonly grantId: DeviceGrantId; + readonly familyId: DeviceFamilyId; + readonly generation: number; + readonly renewedFromGrantId: DeviceGrantId | null; + readonly secretHash: KeyedSecretHash; + readonly keyringId: AuthKeyringId; + readonly issuedAt: number; + readonly renewalExpiresAt: number; + readonly status: DeviceGrantStatus; + readonly predecessorGraceExpiresAt: number | null; + readonly predecessorUsesRemaining: number; + readonly retiredAt: number | null; + readonly revokedAt: number | null; + readonly revocationReason: string | null; +} + +export interface OperatorSession { + readonly sessionId: OperatorSessionId; + readonly operatorId: OperatorId; + readonly familyId: DeviceFamilyId; + readonly deviceGeneration: number; + readonly secretHash: KeyedSecretHash; + readonly keyringId: AuthKeyringId; + readonly issuedAt: number; + readonly lastUsedAt: number; + readonly deadlines: ExpiryDeadlines; + readonly status: 'active' | 'revoked'; + readonly revokedAt: number | null; + readonly revocationReason: string | null; +} + +export type AuthResetStage = + | 'requested' + | 'drain_confirmed' + | 'key_stage_reserved' + | 'new_key_staged' + | 'authority_revoked' + | 'key_activated' + | 'delivery_recovery' + | 'challenge_pending' + | 'challenge_issued'; + +export interface AuthResetIntent { + readonly resetGeneration: number; + readonly requestedBinding: AuthorityBinding; + readonly requestedAt: number; + readonly stage: AuthResetStage; + readonly drainEvidenceRef: string | null; + readonly stagedKeyringId: AuthKeyringId | null; + readonly challengeId: PairingChallengeId | null; +} + +export interface HostedAccessAuthorityState { + readonly schemaVersion: 1; + readonly revision: number; + readonly binding: AuthorityBinding; + readonly expectedKeyringId: AuthKeyringId; + readonly consumedResetGeneration: number; + readonly operatorId: OperatorId | null; + readonly pairingChallenges: readonly PairingChallenge[]; + readonly deviceFamilies: readonly OperatorDeviceFamily[]; + readonly deviceGrants: readonly OperatorDeviceGrant[]; + readonly sessions: readonly OperatorSession[]; + readonly resetIntent: AuthResetIntent | null; +} + +export function createInitialAuthorityState(input: { + readonly binding: AuthorityBinding; + readonly keyringId: AuthKeyringId; +}): HostedAccessAuthorityState { + assertAuthorityBinding(input.binding); + parseAuthKeyringId(input.keyringId); + return freezeAuthorityState({ + schemaVersion: 1, + revision: 0, + binding: input.binding, + expectedKeyringId: input.keyringId, + consumedResetGeneration: 0, + operatorId: null, + pairingChallenges: [], + deviceFamilies: [], + deviceGrants: [], + sessions: [], + resetIntent: null, + }); +} + +export function freezeAuthorityState( + state: HostedAccessAuthorityState +): HostedAccessAuthorityState { + return Object.freeze({ + ...state, + binding: Object.freeze({ ...state.binding }), + pairingChallenges: Object.freeze( + state.pairingChallenges.map((item) => Object.freeze({ ...item })) + ), + deviceFamilies: Object.freeze(state.deviceFamilies.map((item) => Object.freeze({ ...item }))), + deviceGrants: Object.freeze(state.deviceGrants.map((item) => Object.freeze({ ...item }))), + sessions: Object.freeze( + state.sessions.map((item) => + Object.freeze({ ...item, deadlines: Object.freeze({ ...item.deadlines }) }) + ) + ), + resetIntent: + state.resetIntent === null + ? null + : Object.freeze({ + ...state.resetIntent, + requestedBinding: Object.freeze({ ...state.resetIntent.requestedBinding }), + }), + }); +} + +export function nextAuthorityState( + current: HostedAccessAuthorityState, + patch: Partial> +): HostedAccessAuthorityState { + return freezeAuthorityState({ + ...current, + ...patch, + schemaVersion: 1, + revision: current.revision + 1, + }); +} + +export function hasPersistedAuthority(state: HostedAccessAuthorityState): boolean { + return ( + state.operatorId !== null || + state.pairingChallenges.length > 0 || + state.deviceFamilies.length > 0 || + state.deviceGrants.length > 0 || + state.sessions.length > 0 || + state.resetIntent !== null + ); +} + +export function isRecoverableAuthorityState( + value: unknown, + policy: HostedAccessAuthorityPolicy +): value is HostedAccessAuthorityState { + if ( + !isRecord(value) || + !hasExactlyPersistedKeys(value, AUTHORITY_PERSISTED_KEYS.state) || + !isRecord(value.binding) || + !hasExactlyPersistedKeys(value.binding, AUTHORITY_PERSISTED_KEYS.binding) + ) { + return false; + } + try { + parseAuthorityDeploymentId(value.binding.deploymentId); + parseAuthKeyringId(value.expectedKeyringId); + if (value.operatorId !== null) parseOperatorId(value.operatorId); + } catch { + return false; + } + if ( + value.schemaVersion !== 1 || + !isNonNegativeSafeInteger(value.revision) || + !isNonNegativeSafeInteger(value.binding.restoreGeneration) || + !isNonNegativeSafeInteger(value.consumedResetGeneration) || + !Array.isArray(value.pairingChallenges) || + !Array.isArray(value.deviceFamilies) || + !Array.isArray(value.deviceGrants) || + !Array.isArray(value.sessions) + ) { + return false; + } + return ( + value.pairingChallenges.every((item) => isRecoverablePairingChallenge(item, policy)) && + value.deviceFamilies.every((item) => isRecoverableDeviceFamily(item, policy)) && + value.deviceGrants.every((item) => isRecoverableDeviceGrant(item, policy)) && + value.sessions.every((item) => isRecoverableSession(item, policy)) && + isRecoverableResetIntent(value.resetIntent) && + hasConsistentRelationships(value as unknown as HostedAccessAuthorityState, policy) + ); +} + +export function assertAuthorityBinding(binding: AuthorityBinding): void { + if (!isRecord(binding) || !hasExactlyPersistedKeys(binding, AUTHORITY_PERSISTED_KEYS.binding)) { + throw new TypeError('hosted-access-authority-binding-invalid'); + } + parseAuthorityDeploymentId(binding.deploymentId); + if (!isNonNegativeSafeInteger(binding.restoreGeneration)) { + throw new TypeError('hosted-access-restore-generation-invalid'); + } +} + +function isRecoverablePairingChallenge( + value: unknown, + policy: HostedAccessAuthorityPolicy +): value is PairingChallenge { + if ( + !isRecord(value) || + !hasExactlyPersistedKeys(value, AUTHORITY_PERSISTED_KEYS.pairingChallenge) + ) { + return false; + } + try { + parsePairingChallengeId(value.challengeId); + parseKeyedSecretHash(value.secretHash); + parseAuthKeyringId(value.keyringId); + if (value.pairedDeviceFamilyId !== null) parseDeviceFamilyId(value.pairedDeviceFamilyId); + if (value.pairedDeviceGrantId !== null) parseDeviceGrantId(value.pairedDeviceGrantId); + if (value.pairedSessionId !== null) parseOperatorSessionId(value.pairedSessionId); + } catch { + return false; + } + return ( + isNonNegativeSafeInteger(value.resetGeneration) && + isInstant(value.issuedAt) && + isInstant(value.expiresAt) && + value.expiresAt > value.issuedAt && + isWithinPolicyDuration(value.issuedAt, value.expiresAt, policy.pairingChallengeTtlMs) && + isNonNegativeSafeInteger(value.failedAttempts) && + isPositiveSafeInteger(value.maxAttempts) && + value.maxAttempts <= policy.pairingMaxAttempts && + value.failedAttempts <= value.maxAttempts && + ['pending_delivery', 'issued', 'consumed', 'revoked'].includes(String(value.status)) && + isNullableInstant(value.consumedAt) && + isNullableInstant(value.revokedAt) && + isNullableReason(value.revocationReason) && + typeof value.deliveryCleanupPending === 'boolean' && + hasCoherentChallengeStatus(value) + ); +} + +function isRecoverableDeviceFamily( + value: unknown, + policy: HostedAccessAuthorityPolicy +): value is OperatorDeviceFamily { + if (!isRecord(value) || !hasExactlyPersistedKeys(value, AUTHORITY_PERSISTED_KEYS.deviceFamily)) { + return false; + } + try { + parseDeviceFamilyId(value.familyId); + parseOperatorId(value.operatorId); + } catch { + return false; + } + return ( + isInstant(value.issuedAt) && + isInstant(value.lastUsedAt) && + isInstant(value.idleExpiresAt) && + isInstant(value.absoluteExpiresAt) && + value.issuedAt <= value.lastUsedAt && + value.lastUsedAt <= value.idleExpiresAt && + value.idleExpiresAt <= value.absoluteExpiresAt && + isWithinPolicyDuration(value.issuedAt, value.absoluteExpiresAt, policy.deviceAbsoluteTtlMs) && + isWithinPolicyDuration(value.lastUsedAt, value.idleExpiresAt, policy.deviceIdleTtlMs) && + isPositiveSafeInteger(value.currentGeneration) && + (value.status === 'active' || value.status === 'revoked') && + isNullableInstant(value.revokedAt) && + isNullableReason(value.revocationReason) && + hasCoherentRevocationStatus(value) + ); +} + +function isRecoverableDeviceGrant( + value: unknown, + policy: HostedAccessAuthorityPolicy +): value is OperatorDeviceGrant { + if (!isRecord(value) || !hasExactlyPersistedKeys(value, AUTHORITY_PERSISTED_KEYS.deviceGrant)) { + return false; + } + try { + parseDeviceGrantId(value.grantId); + parseDeviceFamilyId(value.familyId); + parseKeyedSecretHash(value.secretHash); + parseAuthKeyringId(value.keyringId); + if (value.renewedFromGrantId !== null) parseDeviceGrantId(value.renewedFromGrantId); + } catch { + return false; + } + return ( + isPositiveSafeInteger(value.generation) && + isInstant(value.issuedAt) && + isInstant(value.renewalExpiresAt) && + value.issuedAt <= value.renewalExpiresAt && + isWithinPolicyDuration(value.issuedAt, value.renewalExpiresAt, policy.deviceRenewalTtlMs) && + ['current', 'predecessor', 'retired', 'revoked'].includes(String(value.status)) && + isNullableInstant(value.predecessorGraceExpiresAt) && + isNonNegativeSafeInteger(value.predecessorUsesRemaining) && + value.predecessorUsesRemaining <= policy.predecessorMaxUses && + isNullableInstant(value.retiredAt) && + isNullableInstant(value.revokedAt) && + isNullableReason(value.revocationReason) && + hasCoherentGrantStatus(value) + ); +} + +function isRecoverableSession( + value: unknown, + policy: HostedAccessAuthorityPolicy +): value is OperatorSession { + if ( + !isRecord(value) || + !hasExactlyPersistedKeys(value, AUTHORITY_PERSISTED_KEYS.session) || + !isRecord(value.deadlines) || + !hasExactlyPersistedKeys(value.deadlines, AUTHORITY_PERSISTED_KEYS.deadlines) + ) { + return false; + } + try { + parseOperatorSessionId(value.sessionId); + parseOperatorId(value.operatorId); + parseDeviceFamilyId(value.familyId); + parseKeyedSecretHash(value.secretHash); + parseAuthKeyringId(value.keyringId); + } catch { + return false; + } + return ( + isPositiveSafeInteger(value.deviceGeneration) && + isInstant(value.issuedAt) && + isInstant(value.lastUsedAt) && + isInstant(value.deadlines.idleExpiresAt) && + isInstant(value.deadlines.absoluteExpiresAt) && + isInstant(value.deadlines.renewalExpiresAt) && + value.issuedAt <= value.lastUsedAt && + value.lastUsedAt <= value.deadlines.idleExpiresAt && + value.deadlines.idleExpiresAt <= value.deadlines.absoluteExpiresAt && + isWithinPolicyDuration( + value.lastUsedAt, + value.deadlines.idleExpiresAt, + policy.sessionIdleTtlMs + ) && + isWithinPolicyDuration( + value.issuedAt, + value.deadlines.absoluteExpiresAt, + policy.sessionAbsoluteTtlMs + ) && + value.issuedAt <= value.deadlines.renewalExpiresAt && + value.deadlines.renewalExpiresAt <= value.deadlines.absoluteExpiresAt && + isWithinPolicyDuration( + value.issuedAt, + value.deadlines.renewalExpiresAt, + policy.sessionRenewalTtlMs + ) && + (value.status === 'active' || value.status === 'revoked') && + isNullableInstant(value.revokedAt) && + isNullableReason(value.revocationReason) && + hasCoherentRevocationStatus(value) + ); +} + +function isRecoverableResetIntent(value: unknown): value is AuthResetIntent | null { + if (value === null) return true; + if ( + !isRecord(value) || + !hasExactlyPersistedKeys(value, AUTHORITY_PERSISTED_KEYS.resetIntent) || + !isRecord(value.requestedBinding) || + !hasExactlyPersistedKeys(value.requestedBinding, AUTHORITY_PERSISTED_KEYS.binding) + ) { + return false; + } + try { + assertAuthorityBinding(value.requestedBinding as unknown as AuthorityBinding); + if (value.stagedKeyringId !== null) parseAuthKeyringId(value.stagedKeyringId); + if (value.challengeId !== null) parsePairingChallengeId(value.challengeId); + } catch { + return false; + } + return ( + isPositiveSafeInteger(value.resetGeneration) && + isInstant(value.requestedAt) && + [ + 'requested', + 'drain_confirmed', + 'key_stage_reserved', + 'new_key_staged', + 'authority_revoked', + 'key_activated', + 'delivery_recovery', + 'challenge_pending', + 'challenge_issued', + ].includes(String(value.stage)) && + (value.drainEvidenceRef === null || + (typeof value.drainEvidenceRef === 'string' && + value.drainEvidenceRef.length > 0 && + value.drainEvidenceRef.length <= 256)) + ); +} + +function hasConsistentRelationships( + state: HostedAccessAuthorityState, + policy: HostedAccessAuthorityPolicy +): boolean { + const familyIds = new Set(state.deviceFamilies.map(({ familyId }) => familyId)); + const challengeIds = new Set(state.pairingChallenges.map(({ challengeId }) => challengeId)); + const grantsById = new Map(state.deviceGrants.map((grant) => [grant.grantId, grant])); + const sessionsById = new Map(state.sessions.map((session) => [session.sessionId, session])); + if ( + familyIds.size !== state.deviceFamilies.length || + challengeIds.size !== state.pairingChallenges.length || + new Set(state.deviceGrants.map(({ grantId }) => grantId)).size !== state.deviceGrants.length || + new Set(state.sessions.map(({ sessionId }) => sessionId)).size !== state.sessions.length + ) { + return false; + } + for (const challenge of state.pairingChallenges) { + if (challenge.status !== 'consumed') continue; + if ( + challenge.pairedDeviceFamilyId === null || + challenge.pairedDeviceGrantId === null || + challenge.pairedSessionId === null + ) { + return false; + } + const pairedFamily = state.deviceFamilies.find( + ({ familyId }) => familyId === challenge.pairedDeviceFamilyId + ); + const pairedGrant = grantsById.get(challenge.pairedDeviceGrantId); + const pairedSession = sessionsById.get(challenge.pairedSessionId); + if ( + pairedFamily === undefined || + (pairedGrant !== undefined && + (pairedGrant.familyId !== challenge.pairedDeviceFamilyId || + pairedGrant.keyringId !== challenge.keyringId)) || + (pairedSession !== undefined && + (pairedSession.familyId !== challenge.pairedDeviceFamilyId || + pairedSession.keyringId !== challenge.keyringId)) || + (pairedFamily.status === 'active' && + pairedFamily.currentGeneration === 1 && + (pairedGrant === undefined || pairedSession === undefined)) + ) { + return false; + } + } + if (state.deviceFamilies.filter(({ status }) => status === 'active').length > 1) { + return false; + } + if (state.operatorId === null && (state.deviceFamilies.length > 0 || state.sessions.length > 0)) { + return false; + } + const openChallenges = state.pairingChallenges.filter( + ({ status }) => status === 'pending_delivery' || status === 'issued' + ); + if ( + openChallenges.length > 1 || + openChallenges.some( + ({ keyringId, resetGeneration }) => + keyringId !== state.expectedKeyringId || resetGeneration !== state.consumedResetGeneration + ) || + state.pairingChallenges.some( + ({ resetGeneration }) => resetGeneration > state.consumedResetGeneration + ) + ) { + return false; + } + if ( + state.deviceGrants.some(({ familyId }) => !familyIds.has(familyId)) || + state.sessions.some(({ familyId }) => !familyIds.has(familyId)) + ) { + return false; + } + for (const family of state.deviceFamilies) { + if (state.operatorId !== family.operatorId) return false; + const familyGrants = state.deviceGrants.filter((grant) => grant.familyId === family.familyId); + if ( + new Set(familyGrants.map(({ generation }) => generation)).size !== familyGrants.length || + new Set(familyGrants.map(({ keyringId }) => keyringId)).size > 1 + ) { + return false; + } + const familyKeyringId = familyGrants[0]?.keyringId; + const current = state.deviceGrants.filter( + (grant) => grant.familyId === family.familyId && grant.status === 'current' + ); + const predecessor = familyGrants.filter(({ status }) => status === 'predecessor'); + if (family.status === 'active' && current.length !== 1) return false; + if (family.status === 'revoked' && current.length !== 0) return false; + if (predecessor.length > 1) return false; + if ( + current.length === 1 && + (current[0]?.generation !== family.currentGeneration || + current[0].keyringId !== state.expectedKeyringId) + ) { + return false; + } + if (current.length === 1) { + const source = + current[0].renewedFromGrantId === null + ? undefined + : familyGrants.find(({ grantId }) => grantId === current[0].renewedFromGrantId); + if ( + (current[0].generation === 1 && current[0].renewedFromGrantId !== null) || + (current[0].generation > 1 && + (source === undefined || + source.generation + 1 !== current[0].generation || + (source.status !== 'predecessor' && source.status !== 'retired'))) || + (predecessor.length === 1 && predecessor[0].grantId !== source?.grantId) + ) { + return false; + } + } + if ( + predecessor.length === 1 && + (current.length !== 1 || + !isWithinPolicyDuration( + current[0].issuedAt, + predecessor[0].predecessorGraceExpiresAt!, + policy.predecessorGraceMs + )) + ) { + return false; + } + if ( + familyGrants.some( + (grant) => + grant.generation > family.currentGeneration || + grant.renewalExpiresAt > family.absoluteExpiresAt || + (family.status === 'active' && + (grant.status === 'current' || grant.status === 'predecessor') && + grant.keyringId !== state.expectedKeyringId) + ) + ) { + return false; + } + const familySessions = state.sessions.filter((session) => session.familyId === family.familyId); + if (familySessions.filter(({ status }) => status === 'active').length > 1) { + return false; + } + for (const session of familySessions) { + if ( + session.operatorId !== family.operatorId || + session.deviceGeneration > family.currentGeneration || + session.deadlines.absoluteExpiresAt > family.absoluteExpiresAt || + familyKeyringId === undefined || + session.keyringId !== familyKeyringId + ) { + return false; + } + if (session.status === 'active') { + const matchingCurrent = current[0]; + if ( + family.status !== 'active' || + session.keyringId !== state.expectedKeyringId || + session.deviceGeneration !== family.currentGeneration || + matchingCurrent?.generation !== session.deviceGeneration || + matchingCurrent.keyringId !== session.keyringId + ) { + return false; + } + } + } + } + return hasCoherentResetIntent(state, challengeIds); +} + +function hasCoherentChallengeStatus(value: Record): boolean { + const status = value.status; + const hasPairingOutcome = + value.pairedDeviceFamilyId !== null || + value.pairedDeviceGrantId !== null || + value.pairedSessionId !== null; + if (status === 'pending_delivery' || status === 'issued') { + return ( + value.consumedAt === null && + value.revokedAt === null && + value.revocationReason === null && + value.deliveryCleanupPending === false && + !hasPairingOutcome && + (value.failedAttempts as number) < (value.maxAttempts as number) + ); + } + if (status === 'consumed') { + return ( + isInstant(value.consumedAt) && + value.consumedAt >= (value.issuedAt as number) && + value.consumedAt < (value.expiresAt as number) && + value.revokedAt === null && + value.revocationReason === null && + value.pairedDeviceFamilyId !== null && + value.pairedDeviceGrantId !== null && + value.pairedSessionId !== null && + (value.failedAttempts as number) < (value.maxAttempts as number) + ); + } + if (status === 'revoked') { + return ( + value.consumedAt === null && + isInstant(value.revokedAt) && + value.revokedAt >= (value.issuedAt as number) && + typeof value.revocationReason === 'string' && + !hasPairingOutcome + ); + } + return false; +} + +function hasCoherentGrantStatus(value: Record): boolean { + if (value.status === 'current') { + return ( + value.predecessorGraceExpiresAt === null && + value.predecessorUsesRemaining === 0 && + value.retiredAt === null && + value.revokedAt === null && + value.revocationReason === null + ); + } + if (value.status === 'predecessor') { + return ( + isInstant(value.predecessorGraceExpiresAt) && + value.predecessorGraceExpiresAt > (value.issuedAt as number) && + value.predecessorGraceExpiresAt <= (value.renewalExpiresAt as number) && + isPositiveSafeInteger(value.predecessorUsesRemaining) && + value.retiredAt === null && + value.revokedAt === null && + value.revocationReason === null + ); + } + if (value.status === 'retired') { + return ( + value.predecessorGraceExpiresAt === null && + value.predecessorUsesRemaining === 0 && + isInstant(value.retiredAt) && + value.retiredAt >= (value.issuedAt as number) && + value.revokedAt === null && + value.revocationReason === null + ); + } + return ( + value.status === 'revoked' && + value.predecessorGraceExpiresAt === null && + value.predecessorUsesRemaining === 0 && + isInstant(value.revokedAt) && + value.revokedAt >= (value.issuedAt as number) && + typeof value.revocationReason === 'string' + ); +} + +function hasCoherentResetIntent( + state: HostedAccessAuthorityState, + challengeIds: ReadonlySet +): boolean { + const intent = state.resetIntent; + if (intent === null) return true; + const postRevocation = [ + 'authority_revoked', + 'key_activated', + 'delivery_recovery', + 'challenge_pending', + 'challenge_issued', + ].includes(intent.stage); + if ( + intent.resetGeneration < state.consumedResetGeneration || + (intent.resetGeneration === state.consumedResetGeneration) !== postRevocation + ) { + return false; + } + const hasDrain = intent.drainEvidenceRef !== null; + const hasKey = intent.stagedKeyringId !== null; + const hasChallenge = intent.challengeId !== null; + switch (intent.stage) { + case 'requested': + return !hasDrain && !hasKey && !hasChallenge; + case 'drain_confirmed': + return hasDrain && !hasKey && !hasChallenge; + case 'key_stage_reserved': + case 'new_key_staged': + return hasDrain && hasKey && !hasChallenge; + case 'authority_revoked': + case 'key_activated': + return ( + hasDrain && + hasKey && + !hasChallenge && + state.consumedResetGeneration === intent.resetGeneration && + bindingsEqual(state.binding, intent.requestedBinding) && + state.expectedKeyringId === intent.stagedKeyringId + ); + case 'delivery_recovery': + return ( + !hasDrain && + hasKey && + !hasChallenge && + state.consumedResetGeneration === intent.resetGeneration && + bindingsEqual(state.binding, intent.requestedBinding) && + state.expectedKeyringId === intent.stagedKeyringId + ); + case 'challenge_pending': + case 'challenge_issued': { + const challenge = state.pairingChallenges.find( + ({ challengeId }) => challengeId === intent.challengeId + ); + return ( + hasDrain && + hasKey && + hasChallenge && + bindingsEqual(state.binding, intent.requestedBinding) && + state.expectedKeyringId === intent.stagedKeyringId && + challengeIds.has(intent.challengeId) && + challenge?.status === (intent.stage === 'challenge_pending' ? 'pending_delivery' : 'issued') + ); + } + } +} diff --git a/src/features/hosted-access/core/domain/authorization.ts b/src/features/hosted-access/core/domain/authorization.ts new file mode 100644 index 0000000000..b4d2f0ab58 --- /dev/null +++ b/src/features/hosted-access/core/domain/authorization.ts @@ -0,0 +1,624 @@ +import { parseTeamId } from '@shared/contracts/hosted'; + +import { + HOSTED_PERMISSIONS, + HOSTED_ROLES, + type HostedPermission, + type HostedRole, + type HostedWorkspaceId, +} from '../../contracts'; + +const ROLE_PERMISSION_MATRIX: Readonly> = + Object.freeze({ + owner: HOSTED_PERMISSIONS, + admin: Object.freeze([ + 'hosted.query', + 'hosted.events', + 'hosted.command', + 'hosted.manage', + 'workspace.manage', + ]), + member: Object.freeze(['hosted.query', 'hosted.events', 'hosted.command']), + viewer: Object.freeze(['hosted.query', 'hosted.events']), + }); + +export function isHostedRole(value: unknown): value is HostedRole { + return typeof value === 'string' && HOSTED_ROLES.includes(value as HostedRole); +} + +export function permissionsForRole(role: HostedRole): readonly HostedPermission[] { + return ROLE_PERMISSION_MATRIX[role]; +} + +export function roleAllows(role: HostedRole, permission: HostedPermission): boolean { + return ROLE_PERMISSION_MATRIX[role].includes(permission); +} + +export interface HostedHttpRequest { + readonly ip: string; + readonly url: string; + readonly method: string; + readonly body: unknown; + readonly query: unknown; + readonly params: unknown; + readonly headers: Readonly> & { + readonly cookie?: string; + readonly origin?: string; + readonly 'sec-fetch-site'?: string; + }; +} + +export interface HostedHttpReply { + readonly sent: boolean; + readonly raw: { + once(event: 'close', listener: () => void): unknown; + }; + code(statusCode: number): HostedHttpReply; + send(payload?: unknown): unknown; + header(name: string, value: unknown): HostedHttpReply; + headers(values: Readonly>): HostedHttpReply; + redirect(url: string): unknown; +} + +export interface HostedHttpApplication { + hasContentTypeParser(contentType: string): boolean; + addContentTypeParser( + contentType: string, + options: { readonly parseAs: 'string' }, + parser: ( + request: unknown, + body: unknown, + done: (error: Error | null, value: unknown) => void + ) => void + ): void; + addHook( + name: 'preHandler', + handler: (request: HostedHttpRequest, reply: HostedHttpReply) => Promise + ): void; + addHook( + name: 'onSend', + handler: ( + request: HostedHttpRequest, + reply: HostedHttpReply, + payload: unknown + ) => Promise + ): void; + addHook( + name: 'onResponse', + handler: (request: HostedHttpRequest, reply: HostedHttpReply) => Promise + ): void; + get( + route: string, + handler: (request: HostedHttpRequest, reply: HostedHttpReply) => unknown + ): void; + post( + route: string, + handler: (request: HostedHttpRequest, reply: HostedHttpReply) => unknown + ): void; +} + +export const SESSION_COOKIE = '__Host-agent-teams-session'; +export const DEVICE_COOKIE = '__Host-agent-teams-device'; +export const OIDC_ATTEMPT_COOKIE = '__Host-agent-teams-oidc-attempt'; +export const OIDC_STATE_COOKIE = '__Host-agent-teams-oidc-state'; +export const OIDC_LOGIN_WINDOW_MS = 60_000; +export const OIDC_LOGIN_LIMIT_PER_SOURCE = 30; +export const OIDC_LOGIN_SOURCE_CAPACITY = 4_096; +export const OIDC_BACKCHANNEL_LIMIT_PER_SOURCE = 120; +export const OIDC_BACKCHANNEL_GLOBAL_LIMIT = 600; +export const OIDC_BACKCHANNEL_MAX_CONCURRENCY = 8; + +export interface AdmissionWindow { + startedAt: number; + count: number; +} + +export function admitFixedWindow( + admission: Map, + source: string, + now: number, + limit: number +): boolean { + const existing = admission.get(source); + if (existing && now - existing.startedAt < OIDC_LOGIN_WINDOW_MS) { + if (existing.count >= limit) return false; + admission.set(source, { startedAt: existing.startedAt, count: existing.count + 1 }); + return true; + } + if (admission.size >= OIDC_LOGIN_SOURCE_CAPACITY) { + for (const [key, window] of admission) { + if (now - window.startedAt >= OIDC_LOGIN_WINDOW_MS) admission.delete(key); + } + if (admission.size >= OIDC_LOGIN_SOURCE_CAPACITY && !admission.has(source)) return false; + } + admission.set(source, { startedAt: now, count: 1 }); + return true; +} + +export function parseCookies(header: string | undefined): ReadonlyMap { + const cookies = new Map(); + for (const part of header?.split(';') ?? []) { + const separator = part.indexOf('='); + if (separator <= 0) continue; + const name = part.slice(0, separator).trim(); + const value = part.slice(separator + 1).trim(); + if (!cookies.has(name) && /^[A-Za-z0-9._~-]*$/.test(value)) cookies.set(name, value); + } + return cookies; +} + +export function cookie( + name: string, + value: string, + options: { + readonly maxAge: number; + readonly secure: boolean; + readonly sameSite: 'Strict' | 'Lax'; + } +): string { + return [ + `${name}=${value}`, + 'Path=/', + 'HttpOnly', + options.secure ? 'Secure' : '', + `SameSite=${options.sameSite}`, + `Max-Age=${Math.max(0, Math.floor(options.maxAge))}`, + ] + .filter(Boolean) + .join('; '); +} + +export function clearCookie(name: string, secure: boolean): string { + return cookie(name, '', { maxAge: 0, secure, sameSite: 'Strict' }); +} + +export function bodyRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function safeReturnTo(value: unknown, publicOrigin: string): string { + if ( + typeof value !== 'string' || + value.length > 1024 || + !value.startsWith('/') || + value.startsWith('//') || + value.includes('\\') || + value.includes('?') || + value.includes('#') + ) { + return '/'; + } + try { + return new URL(value, publicOrigin).origin === publicOrigin ? value : '/'; + } catch { + return '/'; + } +} + +export type HostedHttpAuthorization = + | { readonly kind: 'public' } + | { readonly kind: 'forbidden' } + | { + readonly kind: 'authenticated'; + readonly permission: HostedPermission; + readonly csrfRequired: boolean; + readonly workspaceRequired: boolean; + readonly teamWorkspaceRequired?: true; + }; + +const PUBLIC_AUTH_ROUTES = new Set([ + 'GET /api/auth/status', + 'POST /api/auth/personal/pair', + 'GET /api/auth/oidc/login', + 'GET /api/auth/oidc/callback', + 'POST /api/auth/oidc/backchannel-logout', +]); + +const WORKSPACE_QUERY_PATHS = Object.freeze([ + /^\/api\/projects\/[^/]+\/search$/, + /^\/api\/projects\/[^/]+\/sessions$/, + /^\/api\/projects\/[^/]+\/sessions-paginated$/, + /^\/api\/projects\/[^/]+\/sessions\/[^/]+$/, + /^\/api\/projects\/[^/]+\/sessions\/[^/]+\/(?:groups|metrics|waterfall)$/, + /^\/api\/projects\/[^/]+\/sessions\/[^/]+\/subagents\/[^/]+$/, + /^\/api\/worktrees\/[^/]+\/sessions$/, +]); + +const DEPLOYMENT_QUERY_PATHS = new Set([ + '/api/config', + '/api/dashboard/recent-projects', + '/api/projects', + '/api/repository-groups', + '/api/search', + '/api/version', +]); + +const WORKSPACE_QUERY_POST_PATH = /^\/api\/projects\/[^/]+\/sessions-by-ids$/; +const WORKSPACE_CONFIG_MUTATION_PATH = + /^\/api\/config\/(?:pin-session|unpin-session|hide-session|unhide-session|hide-sessions|unhide-sessions)$/; +const HOSTED_TASK_BOARD_PAGE_PATH = '/api/hosted/v1/team-task-board/page'; +const HOSTED_TASK_BOARD_MUTATION_PATH = '/api/hosted/v1/team-task-board/mutations'; +const HOSTED_MEMBER_LOG_PAGE_PATH = '/api/hosted/v1/member-log/page'; +const HOSTED_TEAM_APPROVAL_QUERY_PATHS = new Set([ + '/api/hosted/v1/team-approvals/page', + '/api/hosted/v1/team-approvals/preview', +]); +const HOSTED_TEAM_APPROVAL_DECISION_PATH = '/api/hosted/v1/team-approvals/decisions'; +const HOSTED_OPERATIONS_DIAGNOSTICS_PATH = '/api/hosted/v1/operations/diagnostics'; +const HOSTED_COORDINATION_EVENTS_PATH = '/api/hosted/v1/events'; +const HOSTED_COORDINATION_EVENTS_BOOTSTRAP_PATH = '/api/hosted/v1/events/bootstrap'; +const HOSTED_LIFECYCLE_COMMAND_PATHS = new Set([ + '/api/hosted/v1/team-lifecycle/launch', + '/api/hosted/v1/team-lifecycle/cancel', + '/api/hosted/v1/team-lifecycle/stop', + '/api/hosted/v1/team-lifecycle/recover', +]); +const HOSTED_LIFECYCLE_QUERY_PATHS = new Set([ + '/api/hosted/v1/team-lifecycle/control-state', + '/api/hosted/v1/team-lifecycle/prepare', + '/api/hosted/v1/team-lifecycle/progress', +]); + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +function canonicalAuthorizationPath(requestTarget: string): string | null { + const rawPath = requestTarget.split('?', 1)[0] ?? requestTarget; + if ( + !rawPath.startsWith('/') || + rawPath.startsWith('//') || + rawPath.includes('\\') || + rawPath.includes('#') || + hasControlCharacter(rawPath) + ) { + return null; + } + let decodedPath: string; + try { + decodedPath = decodeURIComponent(rawPath); + } catch { + return null; + } + if ( + !decodedPath.startsWith('/') || + decodedPath.startsWith('//') || + decodedPath.includes('\\') || + decodedPath.includes('?') || + decodedPath.includes('#') || + hasControlCharacter(decodedPath) + ) { + return null; + } + return new URL(decodedPath, 'https://hosted-authorization.invalid').pathname; +} + +/** + * Complete fail-closed policy for the legacy Fastify route surface. + * + * Route-specific registrations remain responsible for input validation. This + * classifier owns only authentication, CSRF, role and workspace admission. + * New `/api` routes are denied until this inventory explicitly classifies + * them. Method alone never grants admission. + */ +export function classifyHostedHttpAuthorization( + methodValue: string, + pathValue: string +): HostedHttpAuthorization { + const method = methodValue.toUpperCase(); + const path = canonicalAuthorizationPath(pathValue); + if (path === null) return Object.freeze({ kind: 'forbidden' }); + if (PUBLIC_AUTH_ROUTES.has(`${method} ${path}`)) return Object.freeze({ kind: 'public' }); + // The exact API root is reserved too. Treating only `/api/*` as protected + // would let a newly registered `/api` handler bypass the fail-closed + // inventory while every child route remained protected. + if (path !== '/api' && !path.startsWith('/api/')) { + return Object.freeze({ kind: 'public' }); + } + + if ((path === '/api/events' || path === HOSTED_COORDINATION_EVENTS_PATH) && method === 'GET') { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.events', + csrfRequired: false, + workspaceRequired: false, + }); + } + + if (path === HOSTED_COORDINATION_EVENTS_BOOTSTRAP_PATH && method === 'POST') { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: true, + workspaceRequired: false, + teamWorkspaceRequired: true, + }); + } + + if ( + method === 'GET' && + (DEPLOYMENT_QUERY_PATHS.has(path) || + WORKSPACE_QUERY_PATHS.some((pattern) => pattern.test(path))) + ) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: false, + workspaceRequired: WORKSPACE_QUERY_PATHS.some((pattern) => pattern.test(path)), + }); + } + + if (path === '/api/auth/logout' || path === '/api/auth/personal/forget-device') { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: true, + workspaceRequired: false, + }); + } + + if ( + method === 'POST' && + (WORKSPACE_QUERY_POST_PATH.test(path) || path === '/api/teams/lifecycle/read') + ) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: true, + workspaceRequired: WORKSPACE_QUERY_POST_PATH.test(path), + }); + } + + if ( + method === 'POST' && + (path === HOSTED_TASK_BOARD_PAGE_PATH || path === HOSTED_TASK_BOARD_MUTATION_PATH) + ) { + return Object.freeze({ + kind: 'authenticated', + permission: path === HOSTED_TASK_BOARD_PAGE_PATH ? 'hosted.query' : 'hosted.command', + csrfRequired: true, + workspaceRequired: false, + teamWorkspaceRequired: true, + }); + } + + if (method === 'POST' && path === HOSTED_MEMBER_LOG_PAGE_PATH) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: true, + workspaceRequired: false, + }); + } + + if (method === 'POST' && HOSTED_TEAM_APPROVAL_QUERY_PATHS.has(path)) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: true, + workspaceRequired: false, + teamWorkspaceRequired: true, + }); + } + + if (method === 'POST' && path === HOSTED_TEAM_APPROVAL_DECISION_PATH) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.command', + csrfRequired: true, + workspaceRequired: false, + teamWorkspaceRequired: true, + }); + } + + if (method === 'POST' && HOSTED_LIFECYCLE_COMMAND_PATHS.has(path)) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.command', + csrfRequired: true, + workspaceRequired: false, + teamWorkspaceRequired: true, + }); + } + + if (method === 'POST' && HOSTED_LIFECYCLE_QUERY_PATHS.has(path)) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: true, + workspaceRequired: false, + teamWorkspaceRequired: true, + }); + } + + if (method === 'POST' && path === HOSTED_OPERATIONS_DIAGNOSTICS_PATH) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.query', + csrfRequired: true, + workspaceRequired: false, + }); + } + + if (method === 'POST' && WORKSPACE_CONFIG_MUTATION_PATH.test(path)) { + return Object.freeze({ + kind: 'authenticated', + permission: 'hosted.command', + csrfRequired: true, + workspaceRequired: true, + }); + } + + return Object.freeze({ kind: 'forbidden' }); +} + +const OMIT_HOSTED_VALUE = Symbol('omit-hosted-value'); +const WORKSPACE_ID_KEYS = new Set(['id', 'projectId', 'workspaceId', 'worktreeId', 'repositoryId']); +const PRIVATE_HOST_KEYS = new Set([ + 'cwd', + 'gitBranch', + 'gitDir', + 'gitRemote', + 'homeDirectory', + 'identity', + 'localRoot', + 'mainGitDir', + 'origin', + 'originUrl', + 'remoteUrl', + 'remotes', + 'repo', + 'repoPath', + 'repository', + 'repositoryIdentity', + 'repositoryPath', + 'repositoryUrl', + 'rootPath', + 'runtimeRoot', + 'worktreePath', +]); +const PATH_KEYS = new Set([ + 'associatedPaths', + 'claudeRootPath', + 'filePath', + 'fullPath', + 'path', + 'primaryPath', + 'projectPath', +]); + +export interface HostedWorkspaceProjectionEntry { + readonly workspaceId: HostedWorkspaceId; + readonly runtimeWorkspaceId: string; +} + +export interface HostedWorkspaceProjectionScope { + readonly grantedByRuntime: ReadonlyMap; + readonly grantedByPublic: ReadonlyMap; + readonly registeredByRuntime: ReadonlyMap; + readonly registeredPublicIds: ReadonlySet; +} + +export function createHostedWorkspaceProjectionScope( + grants: readonly HostedWorkspaceProjectionEntry[], + workspaces: readonly HostedWorkspaceProjectionEntry[] +): HostedWorkspaceProjectionScope { + return Object.freeze({ + grantedByRuntime: new Map(grants.map((grant) => [grant.runtimeWorkspaceId, grant])), + grantedByPublic: new Map(grants.map((grant) => [grant.workspaceId, grant])), + registeredByRuntime: new Map( + workspaces.map((workspace) => [workspace.runtimeWorkspaceId, workspace.workspaceId]) + ), + registeredPublicIds: new Set(workspaces.map((workspace) => workspace.workspaceId)), + }); +} + +function looksLikePrivateHostString(value: string): boolean { + return ( + /(?:^|[\s"'`(=])\/(?!\/)[^\s"'`]+/u.test(value) || + /(?:^|[\s"'`(=])\\\\[^\\\s]+\\/u.test(value) || + /(?:^|[\s"'`(=])[A-Za-z]:[\\/]/u.test(value) || + /[a-z][a-z0-9+.-]*:\/\/\S+/iu.test(value) || + /(?:^|\s)[^@\s]+@[^:\s]+:\S+/u.test(value) + ); +} + +function projectPublicTeamIdentity( + value: unknown, + scope: HostedWorkspaceProjectionScope +): unknown | typeof OMIT_HOSTED_VALUE { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return OMIT_HOSTED_VALUE; + } + const identity = value as Record; + const keys = Object.keys(identity); + if ( + keys.length !== 2 || + !Object.prototype.hasOwnProperty.call(identity, 'workspaceId') || + !Object.prototype.hasOwnProperty.call(identity, 'teamId') + ) { + return OMIT_HOSTED_VALUE; + } + const workspaceId = + typeof identity.workspaceId === 'string' + ? ( + scope.grantedByRuntime.get(identity.workspaceId) ?? + scope.grantedByPublic.get(identity.workspaceId) + )?.workspaceId + : undefined; + if (workspaceId === undefined) return OMIT_HOSTED_VALUE; + try { + return Object.freeze({ workspaceId, teamId: parseTeamId(identity.teamId) }); + } catch { + return OMIT_HOSTED_VALUE; + } +} + +function projectHostedValue( + value: unknown, + scope: HostedWorkspaceProjectionScope, + key?: string +): unknown | typeof OMIT_HOSTED_VALUE { + if (typeof value === 'string') { + const grant = scope.grantedByRuntime.get(value); + if (grant) return grant.workspaceId; + if (scope.grantedByPublic.has(value)) return value; + if (scope.registeredByRuntime.has(value) || scope.registeredPublicIds.has(value)) { + return OMIT_HOSTED_VALUE; + } + return looksLikePrivateHostString(value) || + (key !== undefined && (PATH_KEYS.has(key) || PRIVATE_HOST_KEYS.has(key))) + ? OMIT_HOSTED_VALUE + : value; + } + if (value === null || ['number', 'boolean', 'undefined'].includes(typeof value)) return value; + if (Array.isArray(value)) { + return value + .map((item) => projectHostedValue(item, scope, key)) + .filter((item) => item !== OMIT_HOSTED_VALUE); + } + if (typeof value !== 'object') return OMIT_HOSTED_VALUE; + const source = value as Record; + for (const identifierKey of WORKSPACE_ID_KEYS) { + const identifier = source[identifierKey]; + if ( + typeof identifier === 'string' && + (scope.registeredByRuntime.has(identifier) || scope.registeredPublicIds.has(identifier)) && + !scope.grantedByRuntime.has(identifier) && + !scope.grantedByPublic.has(identifier) + ) { + return OMIT_HOSTED_VALUE; + } + } + const projected: Record = {}; + for (const [sourceKey, sourceValue] of Object.entries(source)) { + if (PRIVATE_HOST_KEYS.has(sourceKey)) { + if (sourceKey !== 'identity') continue; + const publicIdentity = projectPublicTeamIdentity(sourceValue, scope); + if (publicIdentity !== OMIT_HOSTED_VALUE) projected.identity = publicIdentity; + continue; + } + const projectedKey = + scope.grantedByRuntime.get(sourceKey)?.workspaceId ?? + (scope.registeredByRuntime.has(sourceKey) ? null : sourceKey); + if (projectedKey === null) continue; + const projectedValue = projectHostedValue(sourceValue, scope, sourceKey); + if (projectedValue !== OMIT_HOSTED_VALUE && projectedValue !== undefined) { + projected[projectedKey] = projectedValue; + } + } + return projected; +} + +export function projectHostedPayload( + payload: unknown, + scope: HostedWorkspaceProjectionScope +): unknown { + const projected = projectHostedValue(payload, scope); + return projected === OMIT_HOSTED_VALUE ? null : projected; +} diff --git a/src/features/hosted-access/core/domain/index.ts b/src/features/hosted-access/core/domain/index.ts new file mode 100644 index 0000000000..5033f60917 --- /dev/null +++ b/src/features/hosted-access/core/domain/index.ts @@ -0,0 +1,3 @@ +export * from './authorityState'; +export * from './authorization'; +export * from './policy'; diff --git a/src/features/hosted-access/core/domain/policy.ts b/src/features/hosted-access/core/domain/policy.ts new file mode 100644 index 0000000000..9c437ea6d1 --- /dev/null +++ b/src/features/hosted-access/core/domain/policy.ts @@ -0,0 +1,187 @@ +import type { AuthorityBinding, HostedAccessAuthorityPolicy } from '../../contracts'; + +export const AUTHORITY_PERSISTED_KEYS = { + binding: ['deploymentId', 'restoreGeneration'], + state: [ + 'schemaVersion', + 'revision', + 'binding', + 'expectedKeyringId', + 'consumedResetGeneration', + 'operatorId', + 'pairingChallenges', + 'deviceFamilies', + 'deviceGrants', + 'sessions', + 'resetIntent', + ], + pairingChallenge: [ + 'challengeId', + 'secretHash', + 'keyringId', + 'resetGeneration', + 'issuedAt', + 'expiresAt', + 'failedAttempts', + 'maxAttempts', + 'status', + 'consumedAt', + 'revokedAt', + 'revocationReason', + 'pairedDeviceFamilyId', + 'pairedDeviceGrantId', + 'pairedSessionId', + 'deliveryCleanupPending', + ], + deviceFamily: [ + 'familyId', + 'operatorId', + 'issuedAt', + 'lastUsedAt', + 'idleExpiresAt', + 'absoluteExpiresAt', + 'currentGeneration', + 'status', + 'revokedAt', + 'revocationReason', + ], + deviceGrant: [ + 'grantId', + 'familyId', + 'generation', + 'renewedFromGrantId', + 'secretHash', + 'keyringId', + 'issuedAt', + 'renewalExpiresAt', + 'status', + 'predecessorGraceExpiresAt', + 'predecessorUsesRemaining', + 'retiredAt', + 'revokedAt', + 'revocationReason', + ], + session: [ + 'sessionId', + 'operatorId', + 'familyId', + 'deviceGeneration', + 'secretHash', + 'keyringId', + 'issuedAt', + 'lastUsedAt', + 'deadlines', + 'status', + 'revokedAt', + 'revocationReason', + ], + deadlines: ['idleExpiresAt', 'absoluteExpiresAt', 'renewalExpiresAt'], + resetIntent: [ + 'resetGeneration', + 'requestedBinding', + 'requestedAt', + 'stage', + 'drainEvidenceRef', + 'stagedKeyringId', + 'challengeId', + ], +} as const; + +export function hasExactlyPersistedKeys( + value: Record, + expectedKeys: readonly string[] +): boolean { + const actualKeys = Reflect.ownKeys(value); + if (actualKeys.length !== expectedKeys.length) return false; + const expected = new Set(expectedKeys); + return actualKeys.every((key) => typeof key === 'string' && expected.has(key)); +} + +export function isWithinPolicyDuration( + start: number, + end: number, + maximumDuration: number +): boolean { + return end >= start && end - start <= maximumDuration; +} + +export function isPersistedRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isPersistedInstant(value: unknown): value is number { + return isNonNegativeSafeInteger(value); +} + +export function isNullablePersistedInstant(value: unknown): value is number | null { + return value === null || isPersistedInstant(value); +} + +export function isNullablePersistedReason(value: unknown): value is string | null { + return value === null || (typeof value === 'string' && value.length > 0 && value.length <= 256); +} + +export function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +export function isPositiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +export function persistedBindingsEqual(left: AuthorityBinding, right: AuthorityBinding): boolean { + return ( + left.deploymentId === right.deploymentId && left.restoreGeneration === right.restoreGeneration + ); +} + +export function hasCoherentPersistedRevocationStatus(value: Record): boolean { + if (value.status === 'active') { + return value.revokedAt === null && value.revocationReason === null; + } + return ( + value.status === 'revoked' && + isPersistedInstant(value.revokedAt) && + value.revokedAt >= + (isPersistedInstant(value.lastUsedAt) ? value.lastUsedAt : (value.issuedAt as number)) && + typeof value.revocationReason === 'string' + ); +} + +export function assertHostedAccessAuthorityPolicy(policy: HostedAccessAuthorityPolicy): void { + for (const [name, value] of Object.entries(policy)) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`hosted-access-policy-${name}-invalid`); + } + } + if ( + policy.sessionIdleTtlMs > policy.sessionAbsoluteTtlMs || + policy.sessionRenewalTtlMs > policy.sessionAbsoluteTtlMs || + policy.deviceIdleTtlMs > policy.deviceAbsoluteTtlMs || + policy.deviceRenewalTtlMs > policy.deviceAbsoluteTtlMs + ) { + throw new TypeError('hosted-access-policy-deadline-order-invalid'); + } + if ( + policy.retainedDeviceGenerations < 3 || + policy.retainedDeviceGenerations < policy.predecessorMaxUses + 2 + ) { + throw new TypeError('hosted-access-policy-device-retention-invalid'); + } +} + +export function addDuration(now: number, durationMs: number): number { + const result = now + durationMs; + if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(result)) { + throw new TypeError('hosted-access-instant-invalid'); + } + return result; +} + +export function extendIdleDeadline( + now: number, + idleTtlMs: number, + absoluteExpiresAt: number +): number { + return Math.min(addDuration(now, idleTtlMs), absoluteExpiresAt); +} diff --git a/src/features/hosted-access/index.ts b/src/features/hosted-access/index.ts new file mode 100644 index 0000000000..a6c74fad00 --- /dev/null +++ b/src/features/hosted-access/index.ts @@ -0,0 +1,3 @@ +export * from './contracts'; +export * from './core/application'; +export * from './core/domain'; diff --git a/src/features/hosted-access/main/adapters/input/http/HostedAuthHttpController.ts b/src/features/hosted-access/main/adapters/input/http/HostedAuthHttpController.ts new file mode 100644 index 0000000000..7cee13005b --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedAuthHttpController.ts @@ -0,0 +1,798 @@ +import { + HOSTED_AUTH_HEADERS, + HOSTED_AUTH_ROUTES, + type HostedPrincipal, + parseOidcLoginAttemptId, + parseOpaqueAuthoritySecret, +} from '../../../../contracts'; +import { + type HostedAuthenticatedPrincipal, + type HostedAuthenticationContext, + HostedWorkspaceAccessService, + sanitizeHostedAuthenticatedPrincipal, +} from '../../../../core/application'; +import { + type AdmissionWindow, + admitFixedWindow, + bodyRecord, + classifyHostedHttpAuthorization, + clearCookie, + cookie, + DEVICE_COOKIE, + type HostedHttpApplication, + type HostedHttpReply, + type HostedHttpRequest, + OIDC_ATTEMPT_COOKIE, + OIDC_BACKCHANNEL_GLOBAL_LIMIT, + OIDC_BACKCHANNEL_LIMIT_PER_SOURCE, + OIDC_BACKCHANNEL_MAX_CONCURRENCY, + OIDC_LOGIN_LIMIT_PER_SOURCE, + OIDC_LOGIN_WINDOW_MS, + OIDC_STATE_COOKIE, + parseCookies, + roleAllows, + safeReturnTo, + SESSION_COOKIE, +} from '../../../../core/domain'; + +import { projectHostedAuthStatus } from './HostedAuthStatusProjection'; +import { applyHostedCapabilityAdvertisements } from './HostedCapabilityAdvertisement'; +import { setHostedCredentialCookies as setCookies } from './HostedCredentialCookies'; +import { HostedEventStreamRequestFenceRegistry } from './HostedEventStreamRequestFence'; +import { + isHostedTeamWorkspaceAuthorized, + isHostedTeamWorkspaceEventAuthorized, +} from './HostedTeamWorkspaceEventAuthorization'; +import { + captureHostedTeamWorkspaceGrantFence, + type HostedRequestGrantFence, + isHostedRequestPermissionStillValid, + projectLiveHostedWorkspaceEvent, + resolveLiveHostedRequestContext, +} from './HostedTeamWorkspaceGrantFence'; + +import type { HostedAuthHttpControllerDependencies } from './HostedAuthHttpControllerDependencies'; +import type { TeamId } from '@shared/contracts/hosted'; + +export type { HostedAuthHttpControllerDependencies } from './HostedAuthHttpControllerDependencies'; +export type { HostedRequestGrantFence } from './HostedTeamWorkspaceGrantFence'; + +type RequestAuthContext = HostedAuthenticationContext; +export class HostedAuthHttpController { + private readonly requestContexts = new WeakMap(); + private readonly eventStreamRequestFences: HostedEventStreamRequestFenceRegistry; + private readonly admittedRequests = new WeakSet(); + private readonly oidcLoginAdmission = new Map(); + private readonly oidcBackchannelAdmission = new Map(); + private readonly oidcBackchannelGlobalAdmission: AdmissionWindow = { startedAt: 0, count: 0 }; + private readonly workspaceAccess: HostedWorkspaceAccessService; + private oidcBackchannelInFlight = 0; + constructor(private readonly dependencies: HostedAuthHttpControllerDependencies) { + this.workspaceAccess = new HostedWorkspaceAccessService( + dependencies.repository, + dependencies.restoreGeneration + ); + this.eventStreamRequestFences = new HostedEventStreamRequestFenceRegistry(this.workspaceAccess); + } + get allowedOrigin(): string { + return this.dependencies.publicOrigin; + } + register(application: unknown): void { + const app = application as HostedHttpApplication; + if (!app.hasContentTypeParser('application/x-www-form-urlencoded')) { + app.addContentTypeParser( + 'application/x-www-form-urlencoded', + { parseAs: 'string' }, + (_request, body, done) => { + done(null, Object.fromEntries(new URLSearchParams(String(body)))); + } + ); + } + app.addHook('preHandler', async (request, reply) => { + if (!this.dependencies.tryEnterPublicRequest()) { + await reply.code(503).send({ error: 'auth_mode_reset_requires_restart' }); + return; + } + this.admittedRequests.add(request); + reply.raw.once('close', () => { + this.leavePublicRequest(request); + }); + await this.authorize(request, reply); + }); + app.addHook('onSend', async (request, reply, payload) => { + const requestPath = request.url.split('?', 1)[0]; + if (requestPath?.startsWith('/api/auth/')) { + reply.header('cache-control', 'no-store, private'); + reply.header('pragma', 'no-cache'); + } + // Container readiness consumes only this boolean capability signal. It exposes no identity, + // grant, socket, artifact, or session data and stays absent unless the authenticated + // production owner lease is currently usable. + if ( + requestPath === HOSTED_AUTH_ROUTES.status && + this.dependencies.isLifecycleOwnerReady?.() === true + ) { + reply.header(HOSTED_AUTH_HEADERS.lifecycleOwnerReadiness, 'ready'); + } + const context = this.requestContexts.get(request); + if (!context) return payload; + applyHostedCapabilityAdvertisements( + request, + reply, + context.principal.role, + this.dependencies.isTaskBoardMutationRouteEnabled?.() === true, + this.dependencies.isTeamMessageSendRouteEnabled?.() === true + ); + reply.header('cache-control', 'no-store, private'); + reply.header('pragma', 'no-cache'); + if (typeof payload !== 'string') return payload; + if (request.url.split('?', 1)[0] === HOSTED_AUTH_ROUTES.logout) { + return payload; + } + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return payload; + } + try { + return JSON.stringify( + await this.workspaceAccess.projectPayload(context.principal.userId, parsed) + ); + } catch { + reply.code(503); + return JSON.stringify({ error: 'hosted_projection_unavailable' }); + } + }); + app.addHook('onResponse', async (request) => { + this.leavePublicRequest(request); + }); + this.registerAuthRoutes(app); + } + authenticatedPrincipalFor(request: object): HostedAuthenticatedPrincipal | null { + const context = this.requestContexts.get(request); + return context === undefined ? null : sanitizeHostedAuthenticatedPrincipal(context); + } + async isWorkspaceRegistered(workspaceId: string): Promise { + return this.dependencies.repository.isWorkspaceRegistered(workspaceId); + } + async projectWorkspaceId(request: unknown, runtimeWorkspaceId: string): Promise { + const context = this.requestContexts.get(request as object); + if (!context) return null; + try { + return await this.workspaceAccess.projectWorkspaceId( + context.principal.userId, + runtimeWorkspaceId + ); + } catch { + return null; + } + } + async projectPayload(request: unknown, payload: unknown): Promise { + const context = this.requestContexts.get(request as object); + if (!context) return null; + return this.workspaceAccess.projectPayload(context.principal.userId, payload); + } + async projectEvent(request: unknown, _channel: string, data: unknown): Promise { + return projectLiveHostedWorkspaceEvent({ + request: request as HostedHttpRequest, + data, + workspaceAccess: this.workspaceAccess, + liveRequestContext: (candidate) => this.liveRequestContext(candidate), + }); + } + async isEventStreamAuthorized(request: unknown): Promise { + const hostedRequest = request as HostedHttpRequest; + return this.eventStreamRequestFences.isCurrent( + hostedRequest, + await this.liveRequestContext(hostedRequest) + ); + } + async isHostedQueryAuthorized(request: unknown): Promise { + const context = await this.liveRequestContext(request as HostedHttpRequest); + return context !== null && roleAllows(context.principal.role, 'hosted.query'); + } + async isTeamWorkspaceAuthorized(request: unknown, teamId: TeamId): Promise { + return isHostedTeamWorkspaceAuthorized({ + request: request as HostedHttpRequest, + teamId, + workspaceAccess: this.workspaceAccess, + ...(this.dependencies.resolveTeamWorkspaceId === undefined + ? {} + : { resolveTeamWorkspaceId: this.dependencies.resolveTeamWorkspaceId }), + liveRequestContext: (candidate) => this.liveRequestContext(candidate), + }); + } + async isTeamWorkspaceEventAuthorized( + request: unknown, + teamId: TeamId, + runtimeWorkspaceId: string + ): Promise { + const hostedRequest = request as HostedHttpRequest; + return isHostedTeamWorkspaceEventAuthorized({ + request: hostedRequest, + teamId, + runtimeWorkspaceId, + workspaceAccess: this.workspaceAccess, + ...(this.dependencies.resolveTeamWorkspaceId === undefined + ? {} + : { resolveTeamWorkspaceId: this.dependencies.resolveTeamWorkspaceId }), + liveRequestContext: (candidate) => this.liveRequestContext(candidate), + }); + } + async isHostedTaskMutationAuthorized(request: unknown, teamId?: TeamId): Promise { + const hostedRequest = request as HostedHttpRequest; + const context = await this.liveRequestContext(hostedRequest); + return ( + (await this.requestPermissionStillValid(hostedRequest, context, 'hosted.command')) && + (teamId === undefined || (await this.isTeamWorkspaceAuthorized(hostedRequest, teamId))) + ); + } + async captureTeamWorkspaceGrantFence( + request: unknown, + teamId: TeamId, + permission: 'hosted.query' | 'hosted.command' + ): Promise { + const hostedRequest = request as HostedHttpRequest; + return captureHostedTeamWorkspaceGrantFence({ + request: hostedRequest, + teamId, + permission, + workspaceAccess: this.workspaceAccess, + resolveTeamWorkspaceId: this.dependencies.resolveTeamWorkspaceId, + liveRequestContext: (candidate) => this.liveRequestContext(candidate), + permissionStillValid: (candidate, context, required) => + this.requestPermissionStillValid(candidate, context, required), + }); + } + + private async requestPermissionStillValid( + request: HostedHttpRequest, + context: RequestAuthContext | null, + permission: 'hosted.query' | 'hosted.command' + ): Promise { + return isHostedRequestPermissionStillValid({ + request, + context, + permission, + trustedOrigin: this.hasTrustedOrigin(request), + verifyCsrf: (candidate, presented) => + this.dependencies.authentication.verifyCsrf(candidate, presented), + }); + } + private async liveRequestContext(request: HostedHttpRequest): Promise { + return resolveLiveHostedRequestContext({ + request, + initial: this.requestContexts.get(request) ?? null, + publicAccessActive: this.dependencies.isPublicAccessActive(), + authenticate: (input) => this.dependencies.authentication.authenticate(input), + }); + } + private registerAuthRoutes(app: HostedHttpApplication): void { + app.get(HOSTED_AUTH_ROUTES.status, async (request, reply) => { + const authenticated = await this.authenticate(request, reply, true); + if (reply.sent) return; + if (authenticated === null) return this.status(null, null); + return this.status(authenticated.principal, authenticated.csrfToken); + }); + app.post(HOSTED_AUTH_ROUTES.pair, async (request, reply) => { + if (this.dependencies.personal === null) { + return reply.code(404).send({ error: 'auth_mode_mismatch' }); + } + if (!this.hasTrustedOrigin(request)) { + return reply.code(403).send({ error: 'origin_invalid' }); + } + const pairingCode = bodyRecord(request.body).pairingCode; + try { + const result = await this.dependencies.personal.pair( + parseOpaqueAuthoritySecret(pairingCode) + ); + if (!result.ok) { + await this.auditPersonal(request, null, 'auth.personal.pair', 'denied', result.code); + return reply.code(401).send({ error: result.code }); + } + await this.auditPersonal( + request, + result.value.principal.userId, + 'auth.personal.pair', + 'success' + ); + setCookies(reply, result.value.sessionSecret, result.value.deviceSecret, this.dependencies); + return this.status(result.value.principal, result.value.csrfToken); + } catch (error) { + const storageUnavailable = + error instanceof Error && + (error.message === 'personal_identity_storage_unavailable' || + error.message === 'personal_authority_unavailable'); + await this.auditPersonal( + request, + null, + 'auth.personal.pair', + 'failure', + storageUnavailable ? 'identity_storage_unavailable' : 'invalid_request' + ); + return reply.code(storageUnavailable ? 503 : 401).send({ + error: storageUnavailable ? 'identity_storage_unavailable' : 'pairing_code_invalid', + }); + } + }); + app.get(HOSTED_AUTH_ROUTES.login, async (request, reply) => { + if (this.dependencies.oidc === null) { + return reply.code(404).send({ error: 'auth_mode_mismatch' }); + } + if (!this.admitOidcLogin(request.ip)) { + reply.header('retry-after', '60'); + return reply.code(429).send({ error: 'oidc_login_rate_limited' }); + } + const query = request.query as { returnTo?: unknown }; + const returnTo = safeReturnTo(query.returnTo, this.dependencies.publicOrigin); + try { + const begun = await this.dependencies.oidc.beginLogin(returnTo); + reply.headers({ + 'cache-control': 'no-store', + 'set-cookie': [ + cookie(OIDC_ATTEMPT_COOKIE, begun.attemptId, { + maxAge: 600, + secure: this.dependencies.secureCookies, + sameSite: 'Lax', + }), + cookie(OIDC_STATE_COOKIE, begun.state, { + maxAge: 600, + secure: this.dependencies.secureCookies, + sameSite: 'Lax', + }), + ], + }); + return reply.redirect(begun.redirectUrl); + } catch (error) { + return reply.code(503).send({ + error: + error instanceof Error && error.message === 'oidc_provider_unavailable' + ? 'oidc_provider_unavailable' + : 'oidc_login_unavailable', + }); + } + }); + app.get(HOSTED_AUTH_ROUTES.callback, async (request, reply) => { + if (this.dependencies.oidc === null) { + return reply.code(404).send({ error: 'auth_mode_mismatch' }); + } + const cookies = parseCookies(request.headers.cookie); + const state = cookies.get(OIDC_STATE_COOKIE); + const attempt = cookies.get(OIDC_ATTEMPT_COOKIE); + if (!state || !attempt) { + reply.header('set-cookie', [ + clearCookie(OIDC_ATTEMPT_COOKIE, this.dependencies.secureCookies), + clearCookie(OIDC_STATE_COOKIE, this.dependencies.secureCookies), + ]); + return reply.code(401).send({ error: 'oidc_state_missing' }); + } + try { + const callbackUrl = new URL(request.url, this.dependencies.publicOrigin); + const issued = await this.dependencies.oidc.completeLogin({ + callbackUrl, + expectedState: state, + attemptId: parseOidcLoginAttemptId(attempt), + sourceIp: request.ip, + }); + reply.headers({ + 'cache-control': 'no-store', + 'set-cookie': [ + cookie(SESSION_COOKIE, issued.sessionSecret, { + maxAge: this.dependencies.sessionMaxAgeSeconds, + secure: this.dependencies.secureCookies, + sameSite: 'Strict', + }), + clearCookie(OIDC_ATTEMPT_COOKIE, this.dependencies.secureCookies), + clearCookie(OIDC_STATE_COOKIE, this.dependencies.secureCookies), + ], + }); + return reply.redirect(issued.returnTo); + } catch (error) { + reply.header('set-cookie', [ + clearCookie(OIDC_ATTEMPT_COOKIE, this.dependencies.secureCookies), + clearCookie(OIDC_STATE_COOKIE, this.dependencies.secureCookies), + ]); + const code = + error instanceof Error && /^oidc_[a-z0-9_]+$/.test(error.message) + ? error.message + : 'oidc_callback_unavailable'; + const statusCode = code.endsWith('_unavailable') + ? 503 + : code === 'oidc_user_disabled' + ? 403 + : 401; + return reply.code(statusCode).send({ error: code }); + } + }); + app.post(HOSTED_AUTH_ROUTES.logout, async (request, reply) => { + const context = await this.requireContext(request, reply); + if (context === null) return; + const global = bodyRecord(request.body).global === true; + let redirectUrl: string | null = null; + let providerLogoutError: string | null = null; + if (this.dependencies.personal !== null) { + try { + await this.dependencies.authentication.logout({ + context, + global: false, + postLogoutRedirectUri: `${this.dependencies.publicOrigin}/`, + sourceIp: request.ip, + }); + } catch { + return reply.code(503).send({ error: 'personal_logout_unavailable' }); + } + await this.auditPersonal( + request, + context.principal.userId, + 'auth.personal.logout', + 'success' + ); + } else { + try { + redirectUrl = ( + await this.dependencies.authentication.logout({ + context, + global, + postLogoutRedirectUri: `${this.dependencies.publicOrigin}/`, + sourceIp: request.ip, + }) + ).redirectUrl; + } catch (error) { + if (global && error instanceof Error && error.message === 'oidc_provider_unavailable') { + providerLogoutError = 'oidc_provider_unavailable'; + } else { + // Preserve the cookie when revocation is unconfirmed; clearing it could leave a stolen copy active. + return reply.code(503).send({ error: 'oidc_logout_unavailable' }); + } + } + } + reply.header('set-cookie', clearCookie(SESSION_COOKIE, this.dependencies.secureCookies)); + return { ok: true, redirectUrl, providerLogoutError }; + }); + app.post(HOSTED_AUTH_ROUTES.forgetDevice, async (request, reply) => { + if (this.dependencies.personal === null) { + return reply.code(404).send({ error: 'auth_mode_mismatch' }); + } + const context = await this.requireContext(request, reply); + if (context === null) return; + try { + const result = await this.dependencies.personal.forgetDevice(context); + await this.auditPersonal( + request, + context.principal.userId, + 'auth.personal.forget-device', + result.ok ? 'success' : 'failure', + result.ok ? undefined : result.code + ); + if (!result.ok) { + return reply.code(result.code === 'session_invalid' ? 401 : 503).send({ + error: + result.code === 'session_invalid' + ? 'session_invalid' + : 'personal_forget_device_unavailable', + }); + } + reply.header('set-cookie', [ + clearCookie(SESSION_COOKIE, this.dependencies.secureCookies), + clearCookie(DEVICE_COOKIE, this.dependencies.secureCookies), + ]); + return { ok: true }; + } catch { + await this.auditPersonal( + request, + context.principal.userId, + 'auth.personal.forget-device', + 'failure', + 'authority_unavailable' + ); + return reply.code(503).send({ error: 'personal_forget_device_unavailable' }); + } + }); + app.post(HOSTED_AUTH_ROUTES.backchannelLogout, async (request, reply) => { + if (this.dependencies.oidc === null) { + return reply.code(404).send({ error: 'auth_mode_mismatch' }); + } + const token = bodyRecord(request.body).logout_token; + if (typeof token !== 'string') { + return reply.code(400).send({ error: 'logout_token_missing' }); + } + if (!this.admitOidcBackchannel(request.ip)) { + reply.header('retry-after', '1'); + return reply.code(429).send({ error: 'oidc_backchannel_logout_rate_limited' }); + } + try { + await this.dependencies.oidc.backchannelLogout(token); + return reply.code(204).send(); + } catch (error) { + if (error instanceof Error && error.message.endsWith('_unavailable')) { + return reply.code(503).send({ + error: + error.message === 'oidc_provider_unavailable' + ? 'oidc_provider_unavailable' + : 'oidc_backchannel_logout_unavailable', + }); + } + if (!(error instanceof Error && /^oidc_[a-z0-9_]+$/.test(error.message))) { + return reply.code(503).send({ error: 'oidc_backchannel_logout_unavailable' }); + } + return reply.code(400).send({ error: 'logout_token_invalid' }); + } finally { + this.oidcBackchannelInFlight -= 1; + } + }); + } + private leavePublicRequest(request: HostedHttpRequest): void { + if (this.admittedRequests.delete(request)) this.dependencies.leavePublicRequest(); + } + private async authorize(request: HostedHttpRequest, reply: HostedHttpReply): Promise { + const classify = this.dependencies.authorizationPolicy ?? classifyHostedHttpAuthorization; + const policy = classify(request.method, request.url); + if (policy.kind === 'public') return; + if (policy.kind === 'forbidden') { + await reply.code(404).send({ error: 'not_found' }); + return; + } + const context = await this.authenticate(request, reply, false); + if (context === null) { + if (reply.sent) return; + await reply.code(401).send({ error: 'authentication_required' }); + return; + } + if (!roleAllows(context.principal.role, policy.permission)) { + await this.auditDenied(request, context, policy.permission, 'permission_denied'); + await reply.code(403).send({ error: 'permission_denied' }); + return; + } + if (policy.csrfRequired) { + if (!this.hasTrustedOrigin(request)) { + await this.auditDenied(request, context, policy.permission, 'origin_invalid'); + await reply.code(403).send({ error: 'origin_invalid' }); + return; + } + const presented = request.headers[HOSTED_AUTH_HEADERS.csrf]; + let csrfValid = false; + try { + csrfValid = + typeof presented === 'string' && + (await this.dependencies.authentication.verifyCsrf(context, presented)); + } catch { + await reply.code(503).send({ error: 'identity_storage_unavailable' }); + return; + } + if (!csrfValid) { + await this.auditDenied(request, context, policy.permission, 'csrf_invalid'); + await reply.code(403).send({ error: 'csrf_invalid' }); + return; + } + } + if (policy.workspaceRequired) { + const parameters = request.params as Record; + const body = bodyRecord(request.body); + const publicWorkspaceId = + typeof parameters.projectId === 'string' + ? parameters.projectId + : typeof parameters.id === 'string' + ? parameters.id + : typeof body.projectId === 'string' + ? body.projectId + : null; + let grant = null; + if (publicWorkspaceId !== null) { + try { + grant = await this.workspaceAccess.resolvePublicGrant( + context.principal.userId, + publicWorkspaceId + ); + } catch { + await reply.code(503).send({ error: 'identity_storage_unavailable' }); + return; + } + } + if (grant === null) { + await this.auditDenied(request, context, policy.permission, 'workspace_denied'); + await reply.code(403).send({ error: 'workspace_access_denied' }); + return; + } + if (typeof parameters.projectId === 'string') { + parameters.projectId = grant.runtimeWorkspaceId; + } else if (typeof parameters.id === 'string') { + parameters.id = grant.runtimeWorkspaceId; + } else if (typeof body.projectId === 'string') { + body.projectId = grant.runtimeWorkspaceId; + } + } + if ( + policy.teamWorkspaceRequired && + !(await this.authorizeTeamWorkspace(request, reply, context, policy.permission)) + ) { + return; + } + } + private async authorizeTeamWorkspace( + request: HostedHttpRequest, + reply: HostedHttpReply, + context: RequestAuthContext, + permission: string + ): Promise { + let granted: boolean; + try { + granted = await this.workspaceAccess.hasTeamWorkspaceGrant( + context.principal.userId, + bodyRecord(request.body).teamId, + this.dependencies.resolveTeamWorkspaceId + ); + } catch (error) { + const code = + error instanceof Error && error.message === 'identity_storage_unavailable' + ? error.message + : 'workspace_attribution_unavailable'; + await reply.code(503).send({ error: code }); + return false; + } + if (!granted) { + await this.auditDenied(request, context, permission, 'workspace_denied'); + await reply.code(403).send({ error: 'workspace_access_denied' }); + return false; + } + return true; + } + private async auditDenied( + request: HostedHttpRequest, + context: RequestAuthContext, + permission: string, + reason: 'permission_denied' | 'origin_invalid' | 'csrf_invalid' | 'workspace_denied' + ): Promise { + await this.dependencies.authentication + .auditAuthorization({ + principal: context.principal, + sourceIp: request.ip, + reason, + method: request.method, + permission, + }) + .catch(() => undefined); + } + private async authenticate( + request: HostedHttpRequest, + reply: HostedHttpReply, + allowRenewal: boolean + ): Promise { + const existing = this.requestContexts.get(request); + if (existing) return existing; + const cookies = parseCookies(request.headers.cookie); + const sessionSecret = cookies.get(SESSION_COOKIE); + const deviceSecret = cookies.get(DEVICE_COOKIE); + try { + const result = await this.dependencies.authentication.authenticate({ + ...(sessionSecret === undefined ? {} : { sessionSecret }), + ...(deviceSecret === undefined ? {} : { deviceSecret }), + allowRenewal, + sourceIp: request.ip, + }); + if (!result.authenticated) { + if (this.dependencies.personal !== null && allowRenewal && deviceSecret) { + await this.auditPersonal(request, null, 'auth.personal.renew', 'failure', result.reason); + } + return null; + } + if (result.replacementDeviceSecret !== null) { + await this.auditPersonal( + request, + result.context.principal.userId, + 'auth.personal.renew', + 'success' + ); + setCookies( + reply, + result.context.sessionSecret, + result.replacementDeviceSecret, + this.dependencies + ); + } + const context = result.context; + this.requestContexts.set(request, context); + return context; + } catch (error) { + const unavailable = + error instanceof Error && + (error.message === 'personal_identity_storage_unavailable' || + error.message === 'personal_authority_unavailable' || + error.message === 'oidc_authentication_unavailable'); + if (unavailable) { + if (this.dependencies.personal !== null) { + await this.auditPersonal( + request, + null, + 'auth.personal.renew', + 'failure', + 'identity_storage_unavailable' + ); + } + await reply.code(503).send({ error: 'identity_storage_unavailable' }); + return null; + } + return null; + } + } + private async auditPersonal( + request: HostedHttpRequest, + userId: HostedPrincipal['userId'] | null, + action: + | 'auth.personal.pair' + | 'auth.personal.renew' + | 'auth.personal.logout' + | 'auth.personal.forget-device', + outcome: 'success' | 'denied' | 'failure', + reason?: string + ): Promise { + try { + await this.dependencies.personal?.auditPersonalAuthentication({ + userId, + action, + outcome, + sourceIp: request.ip, + reason, + }); + } catch { + // Never roll back a completed authority transition when its secondary audit append fails. + } + } + private async requireContext( + request: HostedHttpRequest, + reply: HostedHttpReply + ): Promise { + const context = this.requestContexts.get(request) ?? null; + if (context === null) await reply.code(401).send({ error: 'authentication_required' }); + return context; + } + private hasTrustedOrigin(request: HostedHttpRequest): boolean { + const origin = request.headers.origin; + const fetchSite = request.headers['sec-fetch-site']; + return ( + origin === this.dependencies.publicOrigin && + (fetchSite === undefined || fetchSite === 'same-origin' || fetchSite === 'same-site') + ); + } + private status(principal: HostedPrincipal | null, csrfToken: string | null) { + return projectHostedAuthStatus({ + mode: this.dependencies.mode, + principal, + csrfToken, + oidcProviderName: + this.dependencies.oidc === null ? null : this.dependencies.authentication.displayName, + runtimeIdentity: this.dependencies.runtimeIdentity, + }); + } + private admitOidcLogin(source: string): boolean { + return admitFixedWindow( + this.oidcLoginAdmission, + source, + Date.now(), + OIDC_LOGIN_LIMIT_PER_SOURCE + ); + } + private admitOidcBackchannel(source: string): boolean { + if (this.oidcBackchannelInFlight >= OIDC_BACKCHANNEL_MAX_CONCURRENCY) return false; + const now = Date.now(); + const global = this.oidcBackchannelGlobalAdmission; + if (now - global.startedAt >= OIDC_LOGIN_WINDOW_MS) { + global.startedAt = now; + global.count = 0; + } + if (global.count >= OIDC_BACKCHANNEL_GLOBAL_LIMIT) return false; + if ( + !admitFixedWindow( + this.oidcBackchannelAdmission, + source, + now, + OIDC_BACKCHANNEL_LIMIT_PER_SOURCE + ) + ) { + return false; + } + global.count += 1; + this.oidcBackchannelInFlight += 1; + return true; + } +} diff --git a/src/features/hosted-access/main/adapters/input/http/HostedAuthHttpControllerDependencies.ts b/src/features/hosted-access/main/adapters/input/http/HostedAuthHttpControllerDependencies.ts new file mode 100644 index 0000000000..33dd0c4272 --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedAuthHttpControllerDependencies.ts @@ -0,0 +1,32 @@ +import type { HostedAuthMode } from '../../../../contracts'; +import type { + HostedAuthenticationProvider, + HostedTeamWorkspaceAttribution, + OidcAuthenticationCapability, + PersonalAuthenticationCapability, +} from '../../../../core/application'; +import type { HostedHttpAuthorization } from '../../../../core/domain'; +import type { InternalStorageHostedAccessRepository } from '../../output/InternalStorageHostedAccessRepository'; +import type { TeamId } from '@shared/contracts/hosted'; + +export interface HostedAuthHttpControllerDependencies { + readonly mode: HostedAuthMode; + readonly publicOrigin: string; + readonly secureCookies: boolean; + readonly authentication: HostedAuthenticationProvider; + readonly personal: PersonalAuthenticationCapability | null; + readonly oidc: OidcAuthenticationCapability | null; + readonly repository: InternalStorageHostedAccessRepository; + readonly restoreGeneration: number; + readonly runtimeIdentity?: { readonly deploymentId: string; readonly bootId: string } | null; + readonly sessionMaxAgeSeconds: number; + readonly deviceMaxAgeSeconds: number; + readonly tryEnterPublicRequest: () => boolean; + readonly leavePublicRequest: () => void; + readonly isPublicAccessActive: () => boolean; + readonly isLifecycleOwnerReady?: () => boolean; + readonly isTaskBoardMutationRouteEnabled?: () => boolean; + readonly isTeamMessageSendRouteEnabled?: () => boolean; + readonly resolveTeamWorkspaceId?: (teamId: TeamId) => Promise; + readonly authorizationPolicy?: (method: string, url: string) => HostedHttpAuthorization; +} diff --git a/src/features/hosted-access/main/adapters/input/http/HostedAuthStatusProjection.ts b/src/features/hosted-access/main/adapters/input/http/HostedAuthStatusProjection.ts new file mode 100644 index 0000000000..32ad5bee5e --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedAuthStatusProjection.ts @@ -0,0 +1,19 @@ +import type { HostedAuthMode, HostedAuthStatus, HostedPrincipal } from '../../../../contracts'; + +export function projectHostedAuthStatus(options: { + readonly mode: HostedAuthMode; + readonly principal: HostedPrincipal | null; + readonly csrfToken: string | null; + readonly oidcProviderName: string | null; + readonly runtimeIdentity?: { readonly deploymentId: string; readonly bootId: string } | null; +}): HostedAuthStatus { + return Object.freeze({ + mode: options.mode, + authenticated: options.principal !== null, + principal: options.principal, + csrfToken: options.csrfToken, + oidcProviderName: options.oidcProviderName, + deploymentId: (options.principal && options.runtimeIdentity?.deploymentId) ?? null, + bootId: (options.principal && options.runtimeIdentity?.bootId) ?? null, + }); +} diff --git a/src/features/hosted-access/main/adapters/input/http/HostedCapabilityAdvertisement.ts b/src/features/hosted-access/main/adapters/input/http/HostedCapabilityAdvertisement.ts new file mode 100644 index 0000000000..285cc00b0e --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedCapabilityAdvertisement.ts @@ -0,0 +1,55 @@ +import { HOSTED_AUTH_HEADERS, type HostedRole } from '../../../../contracts'; +import { roleAllows } from '../../../../core/domain'; + +import type { HostedHttpReply, HostedHttpRequest } from '../../../../core/domain'; + +function successful(reply: HostedHttpReply): boolean { + const statusCode = Reflect.get(reply, 'statusCode'); + return Number.isSafeInteger(statusCode) && statusCode < 400; +} + +function advertisesTaskBoardMutations( + reply: HostedHttpReply, + role: HostedRole, + routeEnabled: boolean +): boolean { + try { + return routeEnabled && successful(reply) && roleAllows(role, 'hosted.command'); + } catch { + return false; + } +} + +function advertisesTeamMessageSend( + request: HostedHttpRequest, + reply: HostedHttpReply, + role: HostedRole, + routeEnabled: boolean +): boolean { + try { + return ( + routeEnabled && + request.method === 'POST' && + request.url.split('?', 1)[0] === '/api/hosted/v1/team-messages/page' && + successful(reply) && + roleAllows(role, 'hosted.command') + ); + } catch { + return false; + } +} + +export function applyHostedCapabilityAdvertisements( + request: HostedHttpRequest, + reply: HostedHttpReply, + role: HostedRole, + taskBoardMutationRouteEnabled: boolean, + teamMessageSendRouteEnabled: boolean +): void { + if (advertisesTaskBoardMutations(reply, role, taskBoardMutationRouteEnabled)) { + reply.header(HOSTED_AUTH_HEADERS.taskBoardMutationAdvertisement, 'enabled'); + } + if (advertisesTeamMessageSend(request, reply, role, teamMessageSendRouteEnabled)) { + reply.header(HOSTED_AUTH_HEADERS.teamMessageSendAdvertisement, 'enabled'); + } +} diff --git a/src/features/hosted-access/main/adapters/input/http/HostedCredentialCookies.ts b/src/features/hosted-access/main/adapters/input/http/HostedCredentialCookies.ts new file mode 100644 index 0000000000..0b30c916bf --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedCredentialCookies.ts @@ -0,0 +1,30 @@ +import { + cookie, + DEVICE_COOKIE, + type HostedHttpReply, + SESSION_COOKIE, +} from '../../../../core/domain'; + +export function setHostedCredentialCookies( + reply: HostedHttpReply, + sessionSecret: string, + deviceSecret: string, + options: { + readonly sessionMaxAgeSeconds: number; + readonly deviceMaxAgeSeconds: number; + readonly secureCookies: boolean; + } +): void { + reply.header('set-cookie', [ + cookie(SESSION_COOKIE, sessionSecret, { + maxAge: options.sessionMaxAgeSeconds, + secure: options.secureCookies, + sameSite: 'Strict', + }), + cookie(DEVICE_COOKIE, deviceSecret, { + maxAge: options.deviceMaxAgeSeconds, + secure: options.secureCookies, + sameSite: 'Strict', + }), + ]); +} diff --git a/src/features/hosted-access/main/adapters/input/http/HostedEventStreamRequestFence.ts b/src/features/hosted-access/main/adapters/input/http/HostedEventStreamRequestFence.ts new file mode 100644 index 0000000000..909445d56d --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedEventStreamRequestFence.ts @@ -0,0 +1,63 @@ +import type { + HostedAuthenticationContext, + HostedWorkspaceAccessService, + HostedWorkspaceGrantSetFence, +} from '../../../../core/application'; +import type { HostedHttpRequest } from '../../../../core/domain'; + +interface EventStreamRequestFence { + readonly authenticatedSessionId: NonNullable< + HostedAuthenticationContext['authenticatedSessionId'] + >; + readonly userId: HostedAuthenticationContext['principal']['userId']; + readonly role: HostedAuthenticationContext['principal']['role']; + readonly workspaceGrants: HostedWorkspaceGrantSetFence; +} + +export class HostedEventStreamRequestFenceRegistry { + private readonly fences = new WeakMap(); + + constructor(private readonly workspaceAccess: HostedWorkspaceAccessService) {} + + async isCurrent( + request: HostedHttpRequest, + context: HostedAuthenticationContext | null + ): Promise { + if (context === null || context.authenticatedSessionId === undefined) return false; + const existing = this.fences.get(request); + if (existing === undefined) return this.capture(request, context); + if ( + context.authenticatedSessionId !== existing.authenticatedSessionId || + context.principal.userId !== existing.userId || + context.principal.role !== existing.role + ) { + return false; + } + return this.workspaceAccess + .revalidateWorkspaceGrantSetFence(existing.workspaceGrants) + .catch(() => false); + } + + private async capture( + request: HostedHttpRequest, + context: HostedAuthenticationContext + ): Promise { + if (context.authenticatedSessionId === undefined) return false; + try { + this.fences.set( + request, + Object.freeze({ + authenticatedSessionId: context.authenticatedSessionId, + userId: context.principal.userId, + role: context.principal.role, + workspaceGrants: await this.workspaceAccess.captureWorkspaceGrantSetFence( + context.principal.userId + ), + }) + ); + return true; + } catch { + return false; + } + } +} diff --git a/src/features/hosted-access/main/adapters/input/http/HostedTeamWorkspaceEventAuthorization.ts b/src/features/hosted-access/main/adapters/input/http/HostedTeamWorkspaceEventAuthorization.ts new file mode 100644 index 0000000000..99d963415a --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedTeamWorkspaceEventAuthorization.ts @@ -0,0 +1,55 @@ +import { type HostedHttpRequest, roleAllows } from '../../../../core/domain'; + +import type { + HostedAuthenticationContext, + HostedTeamWorkspaceAttribution, + HostedWorkspaceAccessService, +} from '../../../../core/application'; +import type { TeamId } from '@shared/contracts/hosted'; + +export async function isHostedTeamWorkspaceEventAuthorized(options: { + readonly request: HostedHttpRequest; + readonly teamId: TeamId; + readonly runtimeWorkspaceId: string; + readonly workspaceAccess: HostedWorkspaceAccessService; + readonly resolveTeamWorkspaceId?: (teamId: TeamId) => Promise; + readonly liveRequestContext: ( + request: HostedHttpRequest + ) => Promise; +}): Promise { + const context = await options.liveRequestContext(options.request); + if (context === null || !roleAllows(context.principal.role, 'hosted.events')) return false; + try { + const fence = await options.workspaceAccess.captureTeamWorkspaceGrantFence( + context.principal.userId, + options.teamId, + options.resolveTeamWorkspaceId + ); + return ( + fence !== null && + fence.runtimeWorkspaceId === options.runtimeWorkspaceId && + (await options.workspaceAccess.revalidateTeamWorkspaceGrantFence( + fence, + options.resolveTeamWorkspaceId + )) + ); + } catch { + return false; + } +} + +export async function isHostedTeamWorkspaceAuthorized(options: { + readonly request: HostedHttpRequest; + readonly teamId: TeamId; + readonly workspaceAccess: HostedWorkspaceAccessService; + readonly resolveTeamWorkspaceId?: (teamId: TeamId) => Promise; + readonly liveRequestContext: ( + request: HostedHttpRequest + ) => Promise; +}): Promise { + const context = await options.liveRequestContext(options.request); + if (context === null) return false; + return options.workspaceAccess + .hasTeamWorkspaceGrant(context.principal.userId, options.teamId, options.resolveTeamWorkspaceId) + .catch(() => false); +} diff --git a/src/features/hosted-access/main/adapters/input/http/HostedTeamWorkspaceGrantFence.ts b/src/features/hosted-access/main/adapters/input/http/HostedTeamWorkspaceGrantFence.ts new file mode 100644 index 0000000000..7defdb6968 --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/http/HostedTeamWorkspaceGrantFence.ts @@ -0,0 +1,154 @@ +import { HOSTED_AUTH_HEADERS } from '../../../../contracts'; +import { bodyRecord, type HostedHttpRequest } from '../../../../core/domain'; +import { roleAllows } from '../../../../core/domain'; + +import type { + HostedAuthenticationContext, + HostedAuthenticationProvider, + HostedTeamWorkspaceAttribution, + HostedTeamWorkspaceGrantFence, + HostedWorkspaceAccessService, +} from '../../../../core/application'; +import type { TeamId } from '@shared/contracts/hosted'; + +export interface HostedRequestGrantFence { + readonly ownerEffectFence: Readonly<{ + readonly grantRevision: string; + readonly identityChecksum: string; + }>; + revalidate(): Promise; +} + +export async function isHostedRequestPermissionStillValid(options: { + readonly request: HostedHttpRequest; + readonly context: HostedAuthenticationContext | null; + readonly permission: 'hosted.query' | 'hosted.command'; + readonly trustedOrigin: boolean; + readonly verifyCsrf: ( + context: HostedAuthenticationContext, + presented: string + ) => Promise; +}): Promise { + const presented = options.request.headers[HOSTED_AUTH_HEADERS.csrf]; + return ( + options.context !== null && + options.trustedOrigin && + roleAllows(options.context.principal.role, options.permission) && + typeof presented === 'string' && + (await options.verifyCsrf(options.context, presented).catch(() => false)) + ); +} + +export async function resolveLiveHostedRequestContext(options: { + readonly request: HostedHttpRequest; + readonly initial: HostedAuthenticationContext | null; + readonly publicAccessActive: boolean; + readonly authenticate: HostedAuthenticationProvider['authenticate']; +}): Promise { + if (!options.publicAccessActive || options.initial === null) return null; + try { + const result = await options.authenticate({ + sessionSecret: options.initial.sessionSecret, + allowRenewal: false, + sourceIp: options.request.ip, + }); + return result.authenticated && + options.initial.authenticatedSessionId !== undefined && + result.context.authenticatedSessionId === options.initial.authenticatedSessionId && + result.context.principal.userId === options.initial.principal.userId + ? result.context + : null; + } catch { + return null; + } +} + +export async function projectLiveHostedWorkspaceEvent(options: { + readonly request: HostedHttpRequest; + readonly data: unknown; + readonly workspaceAccess: HostedWorkspaceAccessService; + readonly liveRequestContext: ( + request: HostedHttpRequest + ) => Promise; +}): Promise { + const context = await options.liveRequestContext(options.request); + if (context === null) return null; + try { + const source = bodyRecord(options.data); + const runtimeWorkspaceId = + typeof source.projectId === 'string' + ? source.projectId + : typeof source.workspaceId === 'string' + ? source.workspaceId + : null; + return runtimeWorkspaceId === null + ? null + : options.workspaceAccess.projectEvent( + context.principal.userId, + runtimeWorkspaceId, + options.data + ); + } catch { + return null; + } +} + +interface CaptureHostedTeamWorkspaceGrantFenceOptions { + readonly request: HostedHttpRequest; + readonly teamId: TeamId; + readonly permission: 'hosted.query' | 'hosted.command'; + readonly workspaceAccess: HostedWorkspaceAccessService; + readonly resolveTeamWorkspaceId?: (teamId: TeamId) => Promise; + readonly liveRequestContext: ( + request: HostedHttpRequest + ) => Promise; + readonly permissionStillValid: ( + request: HostedHttpRequest, + context: HostedAuthenticationContext | null, + permission: 'hosted.query' | 'hosted.command' + ) => Promise; +} + +/** Captures auth, session, role, attribution, and durable grant revisions as one live fence. */ +export async function captureHostedTeamWorkspaceGrantFence( + options: CaptureHostedTeamWorkspaceGrantFenceOptions +): Promise { + const context = await options.liveRequestContext(options.request); + if (!(await options.permissionStillValid(options.request, context, options.permission))) { + return null; + } + let workspaceFence: HostedTeamWorkspaceGrantFence | null; + try { + workspaceFence = await options.workspaceAccess.captureTeamWorkspaceGrantFence( + context!.principal.userId, + options.teamId, + options.resolveTeamWorkspaceId + ); + } catch { + return null; + } + if (workspaceFence === null) return null; + const sessionId = context!.authenticatedSessionId; + const userId = context!.principal.userId; + const role = context!.principal.role; + return Object.freeze({ + ownerEffectFence: Object.freeze({ + grantRevision: workspaceFence.grantRevision, + identityChecksum: workspaceFence.identityChecksum, + }), + revalidate: async (): Promise => { + const current = await options.liveRequestContext(options.request); + if ( + !(await options.permissionStillValid(options.request, current, options.permission)) || + current!.authenticatedSessionId !== sessionId || + current!.principal.userId !== userId || + current!.principal.role !== role + ) { + return false; + } + return options.workspaceAccess + .revalidateTeamWorkspaceGrantFence(workspaceFence, options.resolveTeamWorkspaceId) + .catch(() => false); + }, + }); +} diff --git a/src/features/hosted-access/main/adapters/input/local/HostedAuthLocalControlServer.ts b/src/features/hosted-access/main/adapters/input/local/HostedAuthLocalControlServer.ts new file mode 100644 index 0000000000..c6bfe100ec --- /dev/null +++ b/src/features/hosted-access/main/adapters/input/local/HostedAuthLocalControlServer.ts @@ -0,0 +1,221 @@ +import { HOSTED_ROLES, type HostedRole } from '../../../../contracts'; + +import type { + HostedAuthHostPlatform, + HostedAuthLocalControlTransport, + HostedAuthLocalControlTransportFactory, + HostedLocalAdministration, +} from '../../../../core/application'; + +const MAX_REQUEST_BYTES = 16 * 1024; +const REQUEST_TIMEOUT_MS = 5_000; + +interface LocalControlRequest { + readonly version: 1; + readonly command: string; + readonly arguments: Record; +} + +interface HostedAuthLocalControlServerDependencies { + readonly socketPath: string; + readonly administration: HostedLocalAdministration; + readonly platform: Pick; + readonly transportFactory: HostedAuthLocalControlTransportFactory; +} + +function record(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('hosted_local_control_request_invalid'); + } + return value as Record; +} + +function stringArgument( + argumentsValue: Record, + name: string, + maximum = 256 +): string { + const value = argumentsValue[name]; + if (typeof value !== 'string' || value.length === 0 || value.length > maximum) { + throw new TypeError('hosted_local_control_argument_invalid'); + } + return value; +} + +function parseRequest(body: string): LocalControlRequest { + const row = record(JSON.parse(body)); + if ( + row.version !== 1 || + typeof row.command !== 'string' || + Reflect.ownKeys(row).some( + (key) => typeof key !== 'string' || !['version', 'command', 'arguments'].includes(key) + ) + ) { + throw new TypeError('hosted_local_control_request_invalid'); + } + return Object.freeze({ + version: 1, + command: row.command, + arguments: record(row.arguments), + }); +} + +function assertArgumentKeys( + argumentsValue: Record, + expected: readonly string[] +): void { + if ( + Reflect.ownKeys(argumentsValue).some( + (key) => typeof key !== 'string' || !expected.includes(key) + ) || + expected.some((key) => !Object.hasOwn(argumentsValue, key)) + ) { + throw new TypeError('hosted_local_control_argument_invalid'); + } +} + +function errorCode(error: unknown): string { + if (error instanceof Error) { + if (error.message.startsWith('hosted_local_control_')) { + return error.message.slice('hosted_local_control_'.length); + } + if (error.message.startsWith('hosted-access-')) return 'argument_invalid'; + if (error instanceof SyntaxError || error instanceof TypeError) return 'request_invalid'; + } + return 'internal_error'; +} + +function response(ok: boolean, value: unknown): string { + return `${JSON.stringify(ok ? { ok: true, value } : { ok: false, code: value })}\n`; +} + +export async function executeHostedAuthLocalControlRequest( + administration: HostedLocalAdministration, + body: string +): Promise { + const request = parseRequest(body); + const args = request.arguments; + switch (request.command) { + case 'users.list': + assertArgumentKeys(args, []); + return administration.listUsers(); + case 'users.enable': + case 'users.disable': + assertArgumentKeys(args, ['userId']); + return { + changed: await administration.setUserStatus( + stringArgument(args, 'userId', 128), + request.command === 'users.enable' ? 'active' : 'disabled' + ), + }; + case 'roles.set': { + assertArgumentKeys(args, ['userId', 'role']); + const role = stringArgument(args, 'role'); + if (!HOSTED_ROLES.includes(role as HostedRole)) { + throw new TypeError('hosted_local_control_role_invalid'); + } + await administration.setLocalRole(stringArgument(args, 'userId', 128), role as HostedRole); + return { updated: true, effectiveAfter: 'reauthentication' }; + } + case 'roles.clear': + assertArgumentKeys(args, ['userId']); + return { + cleared: await administration.clearLocalRole(stringArgument(args, 'userId', 128)), + effectiveAfter: 'reauthentication', + }; + case 'workspaces.list': + assertArgumentKeys(args, []); + return administration.listWorkspaces(); + case 'workspaces.register': + assertArgumentKeys(args, ['workspaceId', 'displayName']); + return administration.registerWorkspace( + stringArgument(args, 'workspaceId'), + stringArgument(args, 'displayName') + ); + case 'workspaces.disable': + assertArgumentKeys(args, ['workspaceId']); + return { + disabled: await administration.disableWorkspace(stringArgument(args, 'workspaceId')), + }; + case 'workspaces.grant': + assertArgumentKeys(args, ['userId', 'workspaceId']); + return administration.grantWorkspace( + stringArgument(args, 'userId', 128), + stringArgument(args, 'workspaceId') + ); + case 'workspaces.revoke': + assertArgumentKeys(args, ['userId', 'workspaceId']); + return { + revoked: await administration.revokeWorkspaceGrant( + stringArgument(args, 'userId', 128), + stringArgument(args, 'workspaceId') + ), + }; + case 'personal.reset': { + assertArgumentKeys(args, ['resetGeneration']); + const resetGeneration = args.resetGeneration; + if (!Number.isSafeInteger(resetGeneration) || Number(resetGeneration) <= 0) { + throw new TypeError('hosted_local_control_reset_generation_invalid'); + } + return administration.resetPersonal(Number(resetGeneration)); + } + case 'auth-mode.reset': { + assertArgumentKeys(args, ['targetMode', 'resetGeneration']); + const targetMode = stringArgument(args, 'targetMode'); + if (targetMode !== 'personal' && targetMode !== 'oidc') { + throw new TypeError('hosted_local_control_auth_mode_invalid'); + } + const resetGeneration = args.resetGeneration; + if (!Number.isSafeInteger(resetGeneration) || Number(resetGeneration) <= 0) { + throw new TypeError('hosted_local_control_reset_generation_invalid'); + } + return administration.resetAuthMode(targetMode, Number(resetGeneration)); + } + default: + throw new TypeError('hosted_local_control_command_unknown'); + } +} + +/** + * Mode-0600 Unix-domain control transport. It never binds TCP and is not + * registered with Fastify, so browser traffic cannot reach local operations. + */ +export class HostedAuthLocalControlServer { + private transport: HostedAuthLocalControlTransport | null = null; + + constructor(private readonly dependencies: HostedAuthLocalControlServerDependencies) { + if ( + !dependencies.platform.isAbsolute(dependencies.socketPath) || + dependencies.platform.byteLength(dependencies.socketPath) > 100 + ) { + throw new TypeError('hosted_local_control_socket_path_invalid'); + } + } + + async start(): Promise { + if (this.transport !== null) return; + const transport = this.dependencies.transportFactory.create({ + socketPath: this.dependencies.socketPath, + maximumRequestBytes: MAX_REQUEST_BYTES, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + }); + await transport.start(async (requestBody) => { + try { + const value = await executeHostedAuthLocalControlRequest( + this.dependencies.administration, + requestBody + ); + return response(true, value); + } catch (error) { + return response(false, errorCode(error)); + } + }); + this.transport = transport; + } + + async close(): Promise { + const transport = this.transport; + this.transport = null; + await transport?.close(); + } +} diff --git a/src/features/hosted-access/main/adapters/output/InternalStorageHostedAccessRepository.ts b/src/features/hosted-access/main/adapters/output/InternalStorageHostedAccessRepository.ts new file mode 100644 index 0000000000..c603b5b6f3 --- /dev/null +++ b/src/features/hosted-access/main/adapters/output/InternalStorageHostedAccessRepository.ts @@ -0,0 +1,531 @@ +import { + type HostedAccessAuthorityPolicy, + type HostedAuthMode, + parseAuditEventId, + parseAuthKeyringId, + parseHostedSessionId, + parseHostedWorkspaceId, + parseOidcLoginAttemptId, + parseOperatorId, + parseUserId, +} from '../../../contracts'; +import { type HostedAccessAuthorityState, isRecoverableAuthorityState } from '../../../core/domain'; + +import type { + AuthorityRepositoryReadResult, + AuthorityRepositoryWriteResult, + ExternalIdentityRecord, + HostedAccessAuthorityRepositoryPort, + HostedAuditEvent, + HostedAuthConfiguration, + HostedAuthModeResetResult, + HostedIdentityRepositoryPort, + HostedLocalRoleAssignment, + HostedOperatorSessionRecord, + HostedPersonalOwnerRecord, + HostedRoleSnapshot, + HostedUserRecord, + HostedWorkspaceGrant, + HostedWorkspaceRegistration, + OidcLoginAttemptRecord, +} from '../../../core/application'; +import type { HostedAuthStorageGateway } from '@features/internal-storage/contracts'; + +function record(value: unknown, code: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(code); + return value as Record; +} + +function stringValue(value: unknown, code: string): string { + if (typeof value !== 'string') throw new Error(code); + return value; +} + +function numberValue(value: unknown, code: string): number { + if (!Number.isSafeInteger(value)) throw new Error(code); + return Number(value); +} + +function nullableString(value: unknown, code: string): string | null { + return value === null ? null : stringValue(value, code); +} + +function authConfiguration(value: unknown): HostedAuthConfiguration { + const row = record(value, 'hosted_auth_configuration_invalid'); + const mode = stringValue(row.authMode, 'hosted_auth_mode_invalid'); + if (mode !== 'personal' && mode !== 'oidc') throw new Error('hosted_auth_mode_invalid'); + const pendingPersonalKeyringId = + row.pendingPersonalKeyringId === null ? null : parseAuthKeyringId(row.pendingPersonalKeyringId); + return Object.freeze({ + mode, + configuredAt: numberValue(row.configuredAt, 'hosted_auth_configured_at_invalid'), + resetGeneration: numberValue(row.resetGeneration, 'hosted_auth_reset_generation_invalid'), + secretsRotatedGeneration: numberValue( + row.secretsRotatedGeneration, + 'hosted_auth_secrets_generation_invalid' + ), + pendingPersonalKeyringId, + }); +} + +function storageAuditEvent(event: HostedAuditEvent): Record { + parseAuditEventId(event.eventId); + return { + ...event, + details: undefined, + detailsJson: JSON.stringify(event.details), + }; +} + +function userRecord(value: unknown): HostedUserRecord { + const row = record(value, 'hosted_auth_user_row_invalid'); + const status = stringValue(row.status, 'hosted_auth_user_status_invalid'); + if (status !== 'active' && status !== 'disabled') { + throw new Error('hosted_auth_user_status_invalid'); + } + return Object.freeze({ + userId: parseUserId(row.userId), + displayName: stringValue(row.displayName, 'hosted_auth_user_name_invalid'), + status, + createdAt: numberValue(row.createdAt, 'hosted_auth_user_created_invalid'), + updatedAt: numberValue(row.updatedAt, 'hosted_auth_user_updated_invalid'), + }); +} + +function localRoleRecord(value: unknown): HostedLocalRoleAssignment { + const row = record(value, 'hosted_local_role_row_invalid'); + const role = stringValue(row.role, 'hosted_local_role_invalid'); + if (!['owner', 'admin', 'member', 'viewer'].includes(role)) { + throw new Error('hosted_local_role_invalid'); + } + if (row.assignedBy !== 'local-cli') throw new Error('hosted_local_role_source_invalid'); + return Object.freeze({ + userId: parseUserId(row.userId), + role: role as HostedLocalRoleAssignment['role'], + assignedAt: numberValue(row.assignedAt, 'hosted_local_role_assigned_invalid'), + assignedBy: 'local-cli', + }); +} + +function workspaceRecord(value: unknown): HostedWorkspaceRegistration { + const row = record(value, 'hosted_workspace_row_invalid'); + const status = stringValue(row.status, 'hosted_workspace_status_invalid'); + if (status !== 'active' && status !== 'disabled') { + throw new Error('hosted_workspace_status_invalid'); + } + return Object.freeze({ + runtimeWorkspaceId: stringValue(row.runtimeWorkspaceId, 'hosted_runtime_workspace_id_invalid'), + workspaceId: parseHostedWorkspaceId(row.workspaceId), + displayName: stringValue(row.displayName, 'hosted_workspace_name_invalid'), + status, + registeredAt: numberValue(row.registeredAt, 'hosted_workspace_registered_invalid'), + registeredBy: row.registeredBy === null ? null : parseUserId(row.registeredBy), + }); +} + +function workspaceGrantRecord(value: unknown): HostedWorkspaceGrant { + const row = record(value, 'hosted_workspace_grant_row_invalid'); + if (row.grantedBy !== 'local-cli') { + throw new Error('hosted_workspace_grant_source_invalid'); + } + return Object.freeze({ + userId: parseUserId(row.userId), + workspaceId: parseHostedWorkspaceId(row.workspaceId), + runtimeWorkspaceId: stringValue(row.runtimeWorkspaceId, 'hosted_runtime_workspace_id_invalid'), + displayName: stringValue(row.displayName, 'hosted_workspace_name_invalid'), + grantGeneration: numberValue(row.grantGeneration, 'hosted_workspace_grant_generation_invalid'), + grantRevision: stringValue(row.grantRevision, 'hosted_workspace_grant_revision_invalid'), + grantedAt: numberValue(row.grantedAt, 'hosted_workspace_granted_at_invalid'), + grantedBy: 'local-cli', + }); +} + +function loginAttemptRecord(value: unknown): OidcLoginAttemptRecord { + const row = record(value, 'oidc_login_attempt_row_invalid'); + return Object.freeze({ + attemptId: parseOidcLoginAttemptId(row.attemptId), + providerId: stringValue(row.providerId, 'oidc_provider_id_invalid'), + stateHash: stringValue(row.stateHash, 'oidc_state_hash_invalid'), + nonce: stringValue(row.nonce, 'oidc_nonce_invalid'), + pkceVerifierCiphertext: stringValue(row.pkceVerifierCiphertext, 'oidc_pkce_invalid'), + returnTo: stringValue(row.returnTo, 'oidc_return_to_invalid'), + createdAt: numberValue(row.createdAt, 'oidc_created_at_invalid'), + expiresAt: numberValue(row.expiresAt, 'oidc_expires_at_invalid'), + consumedAt: + row.consumedAt === null ? null : numberValue(row.consumedAt, 'oidc_consumed_at_invalid'), + }); +} + +function sessionRecord(value: unknown): HostedOperatorSessionRecord { + const row = record(value, 'hosted_session_row_invalid'); + const role = stringValue(row.role, 'hosted_session_role_invalid'); + if (!['owner', 'admin', 'member', 'viewer'].includes(role)) { + throw new Error('hosted_session_role_invalid'); + } + const source = stringValue(row.roleSource, 'hosted_session_role_source_invalid'); + if (!['personal-owner', 'oidc-claim', 'local-cli'].includes(source)) { + throw new Error('hosted_session_role_source_invalid'); + } + const status = stringValue(row.status, 'hosted_session_status_invalid'); + if (status !== 'active' && status !== 'revoked') { + throw new Error('hosted_session_status_invalid'); + } + const roleSnapshot: HostedRoleSnapshot = Object.freeze({ + role: role as HostedRoleSnapshot['role'], + source: source as HostedRoleSnapshot['source'], + capturedAt: numberValue(row.roleCapturedAt, 'hosted_session_role_captured_invalid'), + }); + return Object.freeze({ + sessionId: parseHostedSessionId(row.sessionId), + userId: parseUserId(row.userId), + secretHash: stringValue(row.secretHash, 'hosted_session_hash_invalid'), + authenticationMethod: 'oidc', + providerId: stringValue(row.providerId, 'hosted_session_provider_invalid'), + providerIssuer: stringValue(row.providerIssuer, 'hosted_session_issuer_invalid'), + providerSubject: stringValue(row.providerSubject, 'hosted_session_subject_invalid'), + providerSessionId: nullableString(row.providerSessionId, 'hosted_session_sid_invalid'), + roleSnapshot, + issuedAt: numberValue(row.issuedAt, 'hosted_session_issued_invalid'), + lastUsedAt: numberValue(row.lastUsedAt, 'hosted_session_used_invalid'), + idleExpiresAt: numberValue(row.idleExpiresAt, 'hosted_session_idle_invalid'), + absoluteExpiresAt: numberValue(row.absoluteExpiresAt, 'hosted_session_absolute_invalid'), + status, + revokedAt: + row.revokedAt === null ? null : numberValue(row.revokedAt, 'hosted_session_revoked_invalid'), + revocationReason: nullableString(row.revocationReason, 'hosted_session_reason_invalid'), + }); +} + +export class InternalStorageHostedAccessRepository + implements HostedAccessAuthorityRepositoryPort, HostedIdentityRepositoryPort +{ + constructor( + private readonly gateway: HostedAuthStorageGateway, + private readonly authorityPolicy: HostedAccessAuthorityPolicy + ) {} + + async claimAuthMode(mode: HostedAuthMode, configuredAt: number): Promise { + return ( + (await this.gateway.hostedAuthCall('configuration.claimMode', { + mode, + configuredAt, + })) === true + ); + } + + async readAuthConfiguration(): Promise { + const value = await this.gateway.hostedAuthCall('configuration.read', {}); + return value === null ? null : authConfiguration(value); + } + + async resetAuthMode(input: { + readonly currentMode: HostedAuthMode; + readonly targetMode: HostedAuthMode; + readonly resetGeneration: number; + readonly resetAt: number; + readonly expectedAuthorityRevision: number | null; + readonly nextAuthorityState: HostedAccessAuthorityState; + readonly pendingPersonalKeyringId: ReturnType; + readonly auditEvent: HostedAuditEvent; + }): Promise { + const value = await this.gateway.hostedAuthCall('configuration.resetMode', { + currentMode: input.currentMode, + targetMode: input.targetMode, + resetGeneration: input.resetGeneration, + resetAt: input.resetAt, + expectedAuthorityRevision: input.expectedAuthorityRevision, + nextAuthorityStateJson: JSON.stringify(input.nextAuthorityState), + pendingPersonalKeyringId: input.pendingPersonalKeyringId, + auditEvent: storageAuditEvent(input.auditEvent), + }); + if ( + value !== 'committed' && + value !== 'mode_mismatch' && + value !== 'generation_not_newer' && + value !== 'authority_conflict' + ) { + throw new Error('hosted_auth_mode_reset_result_invalid'); + } + return value; + } + + async markAuthSecretsRotated(input: { + readonly mode: HostedAuthMode; + readonly resetGeneration: number; + readonly pendingPersonalKeyringId: ReturnType; + }): Promise { + return (await this.gateway.hostedAuthCall('configuration.markSecretsRotated', input)) === true; + } + + async load(): Promise { + try { + const value = await this.gateway.hostedAuthCall('authority.load', {}); + if (value === null) return { status: 'empty', rollbackFenceRevision: null }; + const row = record(value, 'hosted_authority_row_invalid'); + const parsed: unknown = JSON.parse( + stringValue(row.stateJson, 'hosted_authority_state_json_invalid') + ); + const rollbackFenceRevision = numberValue( + row.rollbackFenceRevision, + 'hosted_authority_fence_invalid' + ); + if ( + !isRecoverableAuthorityState(parsed, this.authorityPolicy) || + parsed.revision !== numberValue(row.revision, 'hosted_authority_revision_invalid') || + parsed.revision !== rollbackFenceRevision + ) { + return { status: 'corrupt' }; + } + return { status: 'available', state: parsed, rollbackFenceRevision }; + } catch { + return { status: 'unavailable' }; + } + } + + async initialize(state: HostedAccessAuthorityState): Promise { + try { + const result = await this.gateway.hostedAuthCall('authority.initialize', { + stateJson: JSON.stringify(state), + revision: state.revision, + }); + return result === 'committed' ? { status: 'committed' } : { status: 'conflict' }; + } catch { + return { status: 'unavailable' }; + } + } + + async compareAndSwap(input: { + readonly expectedRevision: number; + readonly expectedRollbackFenceRevision: number; + readonly nextState: HostedAccessAuthorityState; + readonly nextRollbackFenceRevision: number; + }): Promise { + try { + const result = await this.gateway.hostedAuthCall('authority.compareAndSwap', { + expectedRevision: input.expectedRevision, + expectedRollbackFenceRevision: input.expectedRollbackFenceRevision, + stateJson: JSON.stringify(input.nextState), + nextRollbackFenceRevision: input.nextRollbackFenceRevision, + }); + return result === 'committed' ? { status: 'committed' } : { status: 'conflict' }; + } catch { + return { status: 'unavailable' }; + } + } + + async createOidcLoginAttempt( + attempt: OidcLoginAttemptRecord + ): Promise<'created' | 'conflict' | 'capacity'> { + const result = await this.gateway.hostedAuthCall('oidcAttempt.create', attempt); + return result === 'created' || result === 'capacity' ? result : 'conflict'; + } + + async consumeOidcLoginAttempt(input: { + readonly attemptId: OidcLoginAttemptRecord['attemptId']; + readonly providerId: string; + readonly stateHash: string; + readonly now: number; + }): Promise { + const value = await this.gateway.hostedAuthCall('oidcAttempt.consume', input); + return value === null ? null : loginAttemptRecord(value); + } + + async bindExternalIdentity(input: { + readonly identity: Omit; + readonly proposedUser: HostedUserRecord; + }): Promise<{ readonly user: HostedUserRecord; readonly identity: ExternalIdentityRecord }> { + const result = record( + await this.gateway.hostedAuthCall('identity.bind', input), + 'external_identity_bind_invalid' + ); + const identity = record(result.identity, 'external_identity_row_invalid'); + return Object.freeze({ + user: userRecord(result.user), + identity: Object.freeze({ + issuer: stringValue(identity.issuer, 'external_identity_issuer_invalid'), + subject: stringValue(identity.subject, 'external_identity_subject_invalid'), + userId: parseUserId(identity.userId), + providerId: stringValue(identity.providerId, 'external_identity_provider_invalid'), + createdAt: numberValue(identity.createdAt, 'external_identity_created_invalid'), + lastAuthenticatedAt: numberValue( + identity.lastAuthenticatedAt, + 'external_identity_authenticated_invalid' + ), + }), + }); + } + + async ensurePersonalOwner(input: { + readonly user: HostedUserRecord; + readonly operatorId: HostedPersonalOwnerRecord['operatorId']; + }): Promise { + const value = record( + await this.gateway.hostedAuthCall('personal.ensureOwner', input), + 'hosted_personal_owner_row_invalid' + ); + return Object.freeze({ + operatorId: parseOperatorId(value.operatorId), + user: userRecord(value.user), + }); + } + + async createSession(session: HostedOperatorSessionRecord): Promise { + await this.gateway.hostedAuthCall('session.create', { session }); + } + + async findSessionBySecretHash(secretHash: string): Promise { + const value = await this.gateway.hostedAuthCall('session.findByHash', { secretHash }); + return value === null ? null : sessionRecord(value); + } + + async touchSession(input: { + readonly sessionId: HostedOperatorSessionRecord['sessionId']; + readonly expectedLastUsedAt: number; + readonly lastUsedAt: number; + readonly idleExpiresAt: number; + }): Promise { + return (await this.gateway.hostedAuthCall('session.touch', input)) === true; + } + + async revokeSession(input: { + readonly sessionId: HostedOperatorSessionRecord['sessionId']; + readonly now: number; + readonly reason: string; + }): Promise { + await this.gateway.hostedAuthCall('session.revoke', input); + } + + async applyBackchannelLogout(input: { + readonly providerId: string; + readonly issuer: string; + readonly subject?: string; + readonly providerSessionId?: string; + readonly jti: string; + readonly expiresAt: number; + readonly consumedAt: number; + readonly reason: string; + }): Promise<{ readonly consumed: boolean; readonly revoked: number }> { + const value = record( + await this.gateway.hostedAuthCall('backchannel.apply', input), + 'oidc_backchannel_apply_invalid' + ); + if (typeof value.consumed !== 'boolean') throw new Error('oidc_backchannel_apply_invalid'); + return Object.freeze({ + consumed: value.consumed, + revoked: numberValue(value.revoked, 'oidc_backchannel_revoke_count_invalid'), + }); + } + + async getUser(userId: HostedUserRecord['userId']): Promise { + const value = await this.gateway.hostedAuthCall('user.get', { userId }); + return value === null ? null : userRecord(value); + } + + async listUsers(): Promise { + const value = await this.gateway.hostedAuthCall('user.list', {}); + if (!Array.isArray(value)) throw new Error('hosted_auth_user_list_invalid'); + return Object.freeze(value.map(userRecord)); + } + + async setUserStatus(input: { + readonly userId: HostedUserRecord['userId']; + readonly status: HostedUserRecord['status']; + readonly now: number; + }): Promise { + return (await this.gateway.hostedAuthCall('user.setStatus', input)) === true; + } + + async getLocalRoleAssignment( + userId: HostedUserRecord['userId'] + ): Promise { + const value = await this.gateway.hostedAuthCall('role.getLocal', { userId }); + return value === null ? null : localRoleRecord(value); + } + + async setLocalRoleAssignment(assignment: HostedLocalRoleAssignment): Promise { + await this.gateway.hostedAuthCall('role.setLocal', { assignment }); + } + + async clearLocalRoleAssignment(userId: HostedUserRecord['userId']): Promise { + return (await this.gateway.hostedAuthCall('role.clearLocal', { userId })) === true; + } + + async isWorkspaceRegistered(runtimeWorkspaceId: string): Promise { + return ( + (await this.gateway.hostedAuthCall('workspace.isRegistered', { + runtimeWorkspaceId, + })) === true + ); + } + + async listWorkspaces(): Promise { + const value = await this.gateway.hostedAuthCall('workspace.list', {}); + if (!Array.isArray(value)) throw new Error('hosted_auth_workspace_list_invalid'); + return Object.freeze(value.map(workspaceRecord)); + } + + async registerWorkspace(input: { + readonly runtimeWorkspaceId: string; + readonly workspaceId: HostedWorkspaceRegistration['workspaceId']; + readonly displayName: string; + readonly registeredAt: number; + readonly registeredBy: HostedUserRecord['userId'] | null; + }): Promise { + return workspaceRecord(await this.gateway.hostedAuthCall('workspace.register', input)); + } + + async disableWorkspace(runtimeWorkspaceId: string): Promise { + return ( + (await this.gateway.hostedAuthCall('workspace.disable', { runtimeWorkspaceId })) === true + ); + } + + async seedWorkspaces( + workspaces: readonly { + readonly runtimeWorkspaceId: string; + readonly workspaceId: HostedWorkspaceRegistration['workspaceId']; + }[], + now: number + ): Promise { + for (const workspace of workspaces) { + await this.gateway.hostedAuthCall('workspace.seed', { + runtimeWorkspaceId: workspace.runtimeWorkspaceId, + workspaceId: workspace.workspaceId, + displayName: workspace.runtimeWorkspaceId, + registeredAt: now, + }); + } + } + + async listWorkspaceGrants(input: { + readonly userId: HostedWorkspaceGrant['userId']; + readonly grantGeneration: number; + }): Promise { + const value = await this.gateway.hostedAuthCall('workspace.grant.list', input); + if (!Array.isArray(value)) throw new Error('hosted_workspace_grant_list_invalid'); + return Object.freeze(value.map(workspaceGrantRecord)); + } + + async grantWorkspace(input: { + readonly userId: HostedWorkspaceGrant['userId']; + readonly runtimeWorkspaceId: string; + readonly grantGeneration: number; + readonly grantedAt: number; + readonly grantedBy: 'local-cli'; + }): Promise { + return workspaceGrantRecord(await this.gateway.hostedAuthCall('workspace.grant.set', input)); + } + + async revokeWorkspaceGrant(input: { + readonly userId: HostedWorkspaceGrant['userId']; + readonly runtimeWorkspaceId: string; + }): Promise { + return (await this.gateway.hostedAuthCall('workspace.grant.revoke', input)) === true; + } + + async appendAudit(event: HostedAuditEvent): Promise { + await this.gateway.hostedAuthCall('audit.append', { + event: storageAuditEvent(event), + }); + } +} diff --git a/src/features/hosted-access/main/composition/createHostedAccessFeature.ts b/src/features/hosted-access/main/composition/createHostedAccessFeature.ts new file mode 100644 index 0000000000..93a963ec60 --- /dev/null +++ b/src/features/hosted-access/main/composition/createHostedAccessFeature.ts @@ -0,0 +1,796 @@ +import { + type AuthorityDeploymentId, + type HostedAccessAuthorityPolicy, + type HostedAuthMode, + parseAuthKeyringId, + parseAuthorityDeploymentId, + parseAuthorityKeyMaterial, +} from '../../contracts'; +import { + type AuthKeyringEnvelope, + HostedAccessAuthority, + type HostedAccessAuthorityDependencies, + type HostedAuthenticatedPrincipal, + type HostedAuthHostPlatform, + type HostedAuthLocalControlTransportFactory, + HostedIdentityService, + HostedLocalAdministration, + HostedOidcAuthenticationProvider, + HostedPersonalAuthenticationProvider, + type HostedTeamWorkspaceAttribution, + HostedWorkspaceAccessService, + type OidcAuthenticationCapability, + type OidcIdentityProvider, + type PairingDrainProofPort, + type PersonalAuthenticationCapability, +} from '../../core/application'; +import { + createInitialAuthorityState, + freezeAuthorityState, + type HostedAccessAuthorityState, + type HostedHttpAuthorization, +} from '../../core/domain'; +import { HostedAuthHttpController } from '../adapters/input/http/HostedAuthHttpController'; +import { HostedAuthLocalControlServer } from '../adapters/input/local/HostedAuthLocalControlServer'; +import { InternalStorageHostedAccessRepository } from '../adapters/output/InternalStorageHostedAccessRepository'; +import { + GenericOidcIdentityProvider, + type GenericOidcRoleMapping, + validateGenericOidcRoleMapping, +} from '../infrastructure/GenericOidcIdentityProvider'; +import { + NodeHostedIdentityCrypto, + prepareHostedAuthSecretPaths, + readProtectedHostedAuthSecret, +} from '../infrastructure/NodeHostedIdentityCrypto'; +import { + FileAuthKeyring, + FileHostedPairingDrainProof, + FilePairingChallengeDelivery, + NodePersonalAuthorityCrypto, +} from '../infrastructure/NodePersonalAuthorityAdapters'; + +import { createHostedAuthenticatedHttpFacade } from './createHostedAuthenticatedHttpFacade'; + +import type { + HostedAuditEvent, + HostedAuthConfiguration, + HostedAuthModeResetResult, +} from '../../core/application/identityPorts'; +import type { HostedAuthStorageGateway } from '@features/internal-storage/contracts'; +import type { RuntimeInstanceContext } from '@features/runtime-instance-context/contracts'; +import type { TeamId, WorkspaceId } from '@shared/contracts/hosted'; + +const MINUTE = 60_000; +const DAY = 24 * 60 * MINUTE; +const MINIMUM_OIDC_SESSION_IDLE_MS = MINUTE; +const MAXIMUM_OIDC_SESSION_IDLE_MS = 60 * MINUTE; +const MINIMUM_OIDC_SESSION_ABSOLUTE_MS = 5 * MINUTE; +const MAXIMUM_OIDC_SESSION_ABSOLUTE_MS = DAY; + +export async function authorizeHostedTeamConfigurationScope( + dependencies: Readonly<{ + authentication: Pick< + HostedAuthHttpController, + 'authenticatedPrincipalFor' | 'isHostedQueryAuthorized' | 'isHostedTaskMutationAuthorized' + >; + resolvePublicGrant: HostedWorkspaceAccessService['resolvePublicGrant']; + resolveTeamWorkspaceId?: (teamId: TeamId) => Promise; + }>, + request: object, + scope: Readonly<{ workspaceId: WorkspaceId; teamId?: TeamId }>, + mutation: boolean +): Promise<'authorized' | 'denied' | 'unavailable'> { + const admitted = mutation + ? await dependencies.authentication.isHostedTaskMutationAuthorized(request) + : await dependencies.authentication.isHostedQueryAuthorized(request); + const authenticated = dependencies.authentication.authenticatedPrincipalFor(request); + if (!admitted || authenticated === null) return 'denied'; + try { + const attribution = + scope.teamId === undefined + ? undefined + : await dependencies.resolveTeamWorkspaceId?.(scope.teamId); + if (scope.teamId !== undefined && attribution === undefined) return 'unavailable'; + if (attribution?.kind === 'unavailable') return 'unavailable'; + const grant = await dependencies.resolvePublicGrant( + authenticated.principal.userId, + scope.workspaceId + ); + if (grant === null) return 'denied'; + return attribution === undefined || + attribution.kind === 'not_found' || + attribution.runtimeWorkspaceId === grant.runtimeWorkspaceId + ? 'authorized' + : 'denied'; + } catch { + return 'unavailable'; + } +} + +export const HOSTED_PERSONAL_POLICY: HostedAccessAuthorityPolicy = Object.freeze({ + pairingChallengeTtlMs: 10 * MINUTE, + pairingMaxAttempts: 8, + deviceIdleTtlMs: 30 * DAY, + deviceAbsoluteTtlMs: 90 * DAY, + deviceRenewalTtlMs: 30 * DAY, + sessionIdleTtlMs: 15 * MINUTE, + sessionAbsoluteTtlMs: 60 * MINUTE, + sessionRenewalTtlMs: 15 * MINUTE, + predecessorGraceMs: 30_000, + predecessorMaxUses: 1, + retainedDeviceGenerations: 3, + compareAndSwapAttempts: 8, +}); + +export type HostedAccessEnvironment = Readonly>; + +export interface CreateHostedAccessFeatureDependencies { + readonly environment: HostedAccessEnvironment; + readonly storage: HostedAuthStorageGateway; + readonly dataDirectory: string; + readonly hostPlatform: HostedAuthHostPlatform; + readonly localControlTransportFactory: HostedAuthLocalControlTransportFactory; + readonly drainProof?: PairingDrainProofPort; + readonly noRuntimeMutationAtStartup?: true; + readonly runWithBrowserStreamsDrained: (operation: () => Promise) => Promise; + readonly authorizationPolicy?: (method: string, url: string) => HostedHttpAuthorization; + readonly isLifecycleOwnerReady?: () => boolean; + readonly isTaskBoardMutationRouteEnabled?: () => boolean; + readonly isTeamMessageSendRouteEnabled?: () => boolean; + readonly resolveTeamWorkspaceId?: (teamId: TeamId) => Promise; + /** Injected immutable process identity; auth never reads paths, PIDs, credentials, or tokens. */ + readonly runtimeInstance?: Pick | null; + readonly now?: () => number; + readonly fetch?: typeof globalThis.fetch; +} + +export interface HostedAuthHttpFacade { + readonly allowedOrigin: string; + register(app: unknown): void; + isWorkspaceRegistered(workspaceId: string): Promise; + projectWorkspaceId(request: unknown, runtimeWorkspaceId: string): Promise; + projectPayload(request: unknown, payload: unknown): Promise; + isEventStreamAuthorized(request: unknown): Promise; + projectEvent(request: unknown, channel: string, data: unknown): Promise; +} + +export interface HostedAuthenticatedHttpFacade extends HostedAuthHttpFacade { + authenticatedPrincipalFor(request: object): HostedAuthenticatedPrincipal | null; + resolveGrantedRuntimeWorkspaceId( + request: object, + publicWorkspaceId: string + ): Promise; + projectGrantedPublicWorkspaceId( + request: object, + runtimeWorkspaceId: string + ): Promise; + isHostedQueryAuthorized(request: unknown): Promise; + isHostedTaskMutationAuthorized(request: unknown, teamId: TeamId): Promise; + isTeamWorkspaceAuthorized(request: unknown, teamId: TeamId): Promise; + isTeamWorkspaceEventAuthorized( + request: unknown, + teamId: TeamId, + runtimeWorkspaceId: string + ): Promise; + captureTeamWorkspaceGrantFence( + request: unknown, + teamId: TeamId, + permission: 'hosted.query' | 'hosted.command' + ): Promise; + revalidate(): Promise; + }> | null>; + isTeamConfigurationScopeAuthorized( + request: unknown, + scope: Readonly<{ workspaceId: WorkspaceId; teamId?: TeamId }>, + mutation: boolean + ): Promise<'authorized' | 'denied' | 'unavailable'>; +} + +export interface HostedAuthLocalControlHandle { + close(): Promise; +} + +export interface HostedAccessFeature { + readonly deploymentId: AuthorityDeploymentId; + readonly restoreGeneration: number; + readonly mode: HostedAuthMode; + readonly http: HostedAuthenticatedHttpFacade; + readonly localAdministration: HostedLocalAdministration; + startLocalControl(socketPath: string): Promise; +} + +interface HostedPublicAccessGate { + tryEnter(): boolean; + leave(): void; + blockAndDrain(): Promise; + restore(): void; + isActive(): boolean; +} + +function createHostedPublicAccessGate(): HostedPublicAccessGate { + let active = true; + let admittedRequests = 0; + const drainedWaiters = new Set<() => void>(); + const resolveDrained = (): void => { + if (admittedRequests !== 0) return; + for (const resolve of drainedWaiters) resolve(); + drainedWaiters.clear(); + }; + return Object.freeze({ + tryEnter: () => { + if (!active) return false; + admittedRequests += 1; + return true; + }, + leave: () => { + if (admittedRequests <= 0) throw new Error('hosted_public_access_gate_unbalanced'); + admittedRequests -= 1; + resolveDrained(); + }, + blockAndDrain: async () => { + active = false; + if (admittedRequests === 0) return; + await new Promise((resolve) => drainedWaiters.add(resolve)); + }, + restore: () => { + active = true; + }, + isActive: () => active, + }); +} + +function resetAuthorityState( + current: HostedAccessAuthorityState | null, + binding: Parameters[0]['binding'], + keyringId: ReturnType, + resetGeneration: number +): HostedAccessAuthorityState { + const initial = createInitialAuthorityState({ binding, keyringId }); + return freezeAuthorityState({ + ...initial, + revision: current === null ? 0 : current.revision + 1, + consumedResetGeneration: resetGeneration, + operatorId: current?.operatorId ?? null, + }); +} + +function pathIsAtOrWithin( + candidate: string, + directory: string, + platform: HostedAuthHostPlatform +): boolean { + let current = platform.join(candidate); + const boundary = platform.join(directory); + for (;;) { + if (current === boundary) return true; + const parent = platform.dirname(current); + if (parent === current) return false; + current = parent; + } +} + +function required(environment: HostedAccessEnvironment, name: string): string { + const value = environment[name]?.trim(); + if (!value) throw new Error(`hosted_auth_config_missing:${name}`); + return value; +} + +function integer(environment: HostedAccessEnvironment, name: string, fallback: number): number { + const value = environment[name]; + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`hosted_auth_config_invalid:${name}`); + } + return parsed; +} + +function requiredNonNegativeInteger(environment: HostedAccessEnvironment, name: string): number { + const value = required(environment, name); + if (!/^(?:0|[1-9][0-9]*)$/.test(value)) { + throw new Error(`hosted_auth_config_invalid:${name}`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`hosted_auth_config_invalid:${name}`); + } + return parsed; +} + +function boundedInteger( + environment: HostedAccessEnvironment, + name: string, + fallback: number, + minimum: number, + maximum: number +): number { + const value = integer(environment, name, fallback); + if (value < minimum || value > maximum) { + throw new Error(`hosted_auth_config_invalid:${name}`); + } + return value; +} + +function oidcSessionPolicy(environment: HostedAccessEnvironment): { + readonly sessionIdleTtlMs: number; + readonly sessionAbsoluteTtlMs: number; +} { + const sessionIdleTtlMs = boundedInteger( + environment, + 'AUTH_SESSION_IDLE_MS', + 15 * MINUTE, + MINIMUM_OIDC_SESSION_IDLE_MS, + MAXIMUM_OIDC_SESSION_IDLE_MS + ); + const sessionAbsoluteTtlMs = boundedInteger( + environment, + 'AUTH_SESSION_ABSOLUTE_MS', + 8 * 60 * MINUTE, + MINIMUM_OIDC_SESSION_ABSOLUTE_MS, + MAXIMUM_OIDC_SESSION_ABSOLUTE_MS + ); + if (sessionIdleTtlMs > sessionAbsoluteTtlMs) { + throw new Error('hosted_auth_config_invalid:AUTH_SESSION_IDLE_MS'); + } + return Object.freeze({ sessionIdleTtlMs, sessionAbsoluteTtlMs }); +} + +function csv(value: string | undefined): readonly string[] { + return Object.freeze( + (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + ); +} + +function authMode(environment: HostedAccessEnvironment): HostedAuthMode { + const mode = required(environment, 'AUTH_MODE'); + if (mode !== 'personal' && mode !== 'oidc') { + throw new Error('hosted_auth_config_invalid:AUTH_MODE'); + } + return mode; +} + +function roleMapping(environment: HostedAccessEnvironment): GenericOidcRoleMapping { + const defaultRole = environment.OIDC_DEFAULT_ROLE ?? 'viewer'; + if (!['admin', 'member', 'viewer'].includes(defaultRole)) { + throw new Error('hosted_auth_config_invalid:OIDC_DEFAULT_ROLE'); + } + try { + return validateGenericOidcRoleMapping({ + claimPath: environment.OIDC_ROLE_CLAIM ?? 'realm_access.roles', + owner: csv(environment.OIDC_OWNER_ROLE_VALUES), + admin: csv(environment.OIDC_ADMIN_ROLE_VALUES), + member: csv(environment.OIDC_MEMBER_ROLE_VALUES), + viewer: csv(environment.OIDC_VIEWER_ROLE_VALUES), + defaultRole: defaultRole as 'admin' | 'member' | 'viewer', + }); + } catch (error) { + throw new Error( + `hosted_auth_config_invalid:${error instanceof Error ? error.message : 'OIDC_ROLE_MAPPING'}`, + { cause: error } + ); + } +} + +async function clientSecret( + environment: HostedAccessEnvironment, + platform: HostedAuthHostPlatform +): Promise { + if (environment.OIDC_CLIENT_SECRET !== undefined) { + throw new Error('hosted_auth_config_forbidden:OIDC_CLIENT_SECRET'); + } + if (environment.OIDC_CLIENT_SECRET_FILE) { + return readProtectedHostedAuthSecret(environment.OIDC_CLIENT_SECRET_FILE, platform); + } + return undefined; +} + +export async function createHostedAccessFeature( + dependencies: CreateHostedAccessFeatureDependencies +): Promise { + const { environment } = dependencies; + const mode = authMode(environment); + const now = dependencies.now ?? Date.now; + const oidcPolicy = mode === 'oidc' ? oidcSessionPolicy(environment) : null; + const oidcRoleMapping = mode === 'oidc' ? roleMapping(environment) : null; + if (mode === 'oidc' && environment.OIDC_CLIENT_SECRET !== undefined) { + throw new Error('hosted_auth_config_forbidden:OIDC_CLIENT_SECRET'); + } + const publicOrigin = required(environment, 'AUTH_PUBLIC_ORIGIN').replace(/\/$/, ''); + const parsedOrigin = new URL(publicOrigin); + const allowInsecure = + environment.NODE_ENV === 'test' && environment.AUTH_ALLOW_INSECURE_HTTP_FOR_TESTS === '1'; + if ( + (parsedOrigin.protocol !== 'https:' && !allowInsecure) || + parsedOrigin.origin !== publicOrigin || + parsedOrigin.username !== '' || + parsedOrigin.password !== '' || + parsedOrigin.pathname !== '/' || + parsedOrigin.search !== '' || + parsedOrigin.hash !== '' + ) { + throw new Error('hosted_auth_config_invalid:AUTH_PUBLIC_ORIGIN'); + } + if (environment.HOSTED_HTTPS_PORT !== undefined) { + const hostedHttpsPort = boundedInteger(environment, 'HOSTED_HTTPS_PORT', 443, 1, 65_535); + const publicOriginPort = + parsedOrigin.port === '' + ? parsedOrigin.protocol === 'https:' + ? 443 + : 80 + : Number(parsedOrigin.port); + if (publicOriginPort !== hostedHttpsPort) { + throw new Error('hosted_auth_config_invalid:HOSTED_HTTPS_PORT'); + } + } + + const binding = Object.freeze({ + deploymentId: parseAuthorityDeploymentId(required(environment, 'AUTH_DEPLOYMENT_ID')), + restoreGeneration: requiredNonNegativeInteger(environment, 'AUTH_RESTORE_GENERATION'), + }); + if ( + dependencies.runtimeInstance !== undefined && + dependencies.runtimeInstance !== null && + String(dependencies.runtimeInstance.deploymentId) !== String(binding.deploymentId) + ) { + throw new Error('hosted_auth_runtime_deployment_mismatch'); + } + const pairingCodePath = environment.PAIRING_CODE_FILE ?? '/run/agent-teams/pairing.json'; + const secretPaths = await prepareHostedAuthSecretPaths( + { + dataDirectory: dependencies.dataDirectory, + ...(environment.AUTH_IDENTITY_KEY_FILE === undefined + ? {} + : { identityKeyPath: environment.AUTH_IDENTITY_KEY_FILE }), + }, + dependencies.hostPlatform + ); + const personalKeyringPath = dependencies.hostPlatform.join( + environment.AUTH_KEYRING_FILE ?? secretPaths.personalKeyringPath + ); + if ( + !dependencies.hostPlatform.isAbsolute(personalKeyringPath) || + personalKeyringPath === dependencies.hostPlatform.join(secretPaths.identityKeyPath) || + pathIsAtOrWithin( + personalKeyringPath, + secretPaths.stagedKeyringDirectory, + dependencies.hostPlatform + ) + ) { + throw new Error('hosted_auth_config_invalid:AUTH_KEYRING_FILE'); + } + const identityCrypto = new NodeHostedIdentityCrypto( + secretPaths.identityKeyPath, + dependencies.hostPlatform + ); + const personalCrypto = new NodePersonalAuthorityCrypto(dependencies.hostPlatform); + const keyrings = new FileAuthKeyring( + personalKeyringPath, + secretPaths.stagedKeyringDirectory, + dependencies.hostPlatform + ); + const configuredDrainProof = + dependencies.drainProof ?? + new FileHostedPairingDrainProof( + environment.AUTH_DRAIN_EVIDENCE_FILE ?? '/run/agent-teams/drain-proof.json', + { noRuntimeMutationAtStartup: dependencies.noRuntimeMutationAtStartup === true, now }, + dependencies.hostPlatform + ); + const repository = new InternalStorageHostedAccessRepository( + dependencies.storage, + HOSTED_PERSONAL_POLICY + ); + if (!(await repository.claimAuthMode(mode, now()))) { + throw new Error('hosted_auth_mode_change_requires_host_reset'); + } + let authConfiguration = await repository.readAuthConfiguration(); + if (authConfiguration === null || authConfiguration.mode !== mode) { + throw new Error('hosted_auth_configuration_unavailable'); + } + if ( + authConfiguration.secretsRotatedGeneration > authConfiguration.resetGeneration || + (authConfiguration.secretsRotatedGeneration === authConfiguration.resetGeneration && + authConfiguration.pendingPersonalKeyringId !== null) || + (authConfiguration.secretsRotatedGeneration < authConfiguration.resetGeneration && + authConfiguration.pendingPersonalKeyringId === null) + ) { + throw new Error('hosted_auth_mode_reset_recovery_invalid'); + } + if (authConfiguration.secretsRotatedGeneration < authConfiguration.resetGeneration) { + const pendingPersonalKeyringId = authConfiguration.pendingPersonalKeyringId!; + const activated = await keyrings.activateStaged(pendingPersonalKeyringId); + if (activated.status !== 'activated' && activated.status !== 'already_applied') { + throw new Error('hosted_auth_mode_reset_recovery_failed'); + } + try { + await dependencies.hostPlatform.remove(pairingCodePath, { force: true }); + await dependencies.hostPlatform.remove(secretPaths.identityKeyPath, { force: true }); + await dependencies.hostPlatform.remove(secretPaths.stagedKeyringDirectory, { + force: true, + recursive: true, + }); + await dependencies.hostPlatform.mkdir(secretPaths.stagedKeyringDirectory, 0o700); + await identityCrypto.initialize(); + } catch { + throw new Error('hosted_auth_mode_reset_recovery_failed'); + } + if ( + !(await repository.markAuthSecretsRotated({ + mode, + resetGeneration: authConfiguration.resetGeneration, + pendingPersonalKeyringId, + })) + ) { + throw new Error('hosted_auth_mode_reset_recovery_conflict'); + } + authConfiguration = Object.freeze({ + ...authConfiguration, + secretsRotatedGeneration: authConfiguration.resetGeneration, + pendingPersonalKeyringId: null, + }); + } + + let oidcIdentityProvider: OidcIdentityProvider | null = null; + if (mode === 'oidc') { + oidcIdentityProvider = new GenericOidcIdentityProvider({ + id: environment.OIDC_PROVIDER_ID ?? 'oidc', + displayName: environment.OIDC_PROVIDER_NAME ?? 'Single sign-on', + issuer: required(environment, 'OIDC_ISSUER'), + clientId: required(environment, 'OIDC_CLIENT_ID'), + clientSecret: await clientSecret(environment, dependencies.hostPlatform), + redirectUri: `${publicOrigin}/api/auth/oidc/callback`, + scopes: csv(environment.OIDC_SCOPES).length + ? csv(environment.OIDC_SCOPES) + : ['openid', 'profile', 'email'], + roleMapping: oidcRoleMapping!, + crypto: dependencies.hostPlatform, + fetch: dependencies.fetch, + now, + allowInsecureHttpForTests: allowInsecure, + }); + } + + const identities = new HostedIdentityService({ + repository, + crypto: identityCrypto, + provider: oidcIdentityProvider, + now, + policy: { + oidcLoginTtlMs: 10 * MINUTE, + sessionIdleTtlMs: oidcPolicy?.sessionIdleTtlMs ?? 15 * MINUTE, + sessionAbsoluteTtlMs: oidcPolicy?.sessionAbsoluteTtlMs ?? 8 * 60 * MINUTE, + restoreGeneration: binding.restoreGeneration, + }, + }); + + let authority: HostedAccessAuthority | null = null; + if (mode === 'personal') { + if (dependencies.drainProof === undefined && !dependencies.noRuntimeMutationAtStartup) { + throw new Error('hosted_personal_drain_proof_required'); + } + const authorityDrainProof: PairingDrainProofPort = Object.freeze({ + confirmDrained: (input: Parameters[0]) => + input.purpose === 'initial_pairing' && + authConfiguration.resetGeneration > 0 && + input.resetGeneration === authConfiguration.resetGeneration + ? configuredDrainProof.confirmDrained({ + ...input, + purpose: 'auth_mode_reset', + targetAuthMode: 'personal', + }) + : configuredDrainProof.confirmDrained(input), + }); + const authorityDependencies: HostedAccessAuthorityDependencies = { + repository, + random: personalCrypto, + crypto: personalCrypto, + keyrings, + challengeDelivery: new FilePairingChallengeDelivery( + pairingCodePath, + dependencies.hostPlatform + ), + drainProof: authorityDrainProof, + clock: { now }, + policy: HOSTED_PERSONAL_POLICY, + }; + authority = new HostedAccessAuthority(authorityDependencies); + const initialized = await authority.initialize(binding); + if (!initialized.ok) throw new Error(`hosted_personal_initialize_failed:${initialized.code}`); + if (initialized.value.resetPending) { + const pendingAuthority = await repository.load(); + const pendingResetGeneration = + pendingAuthority.status === 'available' + ? pendingAuthority.state.resetIntent?.resetGeneration + : undefined; + if (pendingResetGeneration === undefined) { + throw new Error('hosted_personal_reset_recovery_unavailable'); + } + const recovered = await authority.consumeResetGeneration(binding, pendingResetGeneration); + if (!recovered.ok) { + throw new Error(`hosted_personal_reset_recovery_failed:${recovered.code}`); + } + } + const challenge = await authority.issueInitialChallenge(binding); + if (!challenge.ok && challenge.code !== 'pairing_already_established') { + throw new Error(`hosted_personal_challenge_failed:${challenge.code}`); + } + } + + let personal: PersonalAuthenticationCapability | null = null; + let oidc: OidcAuthenticationCapability | null = null; + if (mode === 'personal') { + if (authority === null) throw new Error('hosted_personal_capability_unavailable'); + personal = new HostedPersonalAuthenticationProvider(binding, authority, identities); + } else { + if (oidcIdentityProvider === null) throw new Error('hosted_oidc_capability_unavailable'); + oidc = new HostedOidcAuthenticationProvider(oidcIdentityProvider.displayName, identities); + } + const authentication = personal ?? oidc; + if (authentication === null) throw new Error('hosted_authentication_capability_unavailable'); + + await repository.seedWorkspaces( + await Promise.all( + csv(environment.HOSTED_WORKSPACE_IDS).map(async (runtimeWorkspaceId) => ({ + runtimeWorkspaceId, + workspaceId: await identities.createWorkspaceId(), + })) + ), + now() + ); + const publicAccessGate = createHostedPublicAccessGate(); + const performAuthModeReset = async (input: { + readonly targetMode: HostedAuthMode; + readonly resetGeneration: number; + readonly auditEvent: HostedAuditEvent; + }): Promise => { + const currentConfiguration: HostedAuthConfiguration | null = + await repository.readAuthConfiguration(); + if (currentConfiguration?.mode !== mode) return 'mode_mismatch' as const; + if (input.resetGeneration <= currentConfiguration.resetGeneration) { + return 'generation_not_newer' as const; + } + const loaded = await repository.load(); + if (loaded.status === 'corrupt' || loaded.status === 'unavailable') { + throw new Error('hosted_auth_authority_unavailable_for_mode_reset'); + } + const currentAuthority = loaded.status === 'available' ? loaded.state : null; + if ( + currentAuthority !== null && + input.resetGeneration <= currentAuthority.consumedResetGeneration + ) { + return 'generation_not_newer' as const; + } + const pendingPersonalKeyringId = parseAuthKeyringId( + await personalCrypto.randomId('auth-keyring') + ); + const envelope: AuthKeyringEnvelope = Object.freeze({ + format: 'hosted-access-keyring/v1', + keyringId: pendingPersonalKeyringId, + binding, + createdAt: now(), + hashKey: parseAuthorityKeyMaterial(await personalCrypto.randomSecret('hash-key', 32)), + csrfKey: parseAuthorityKeyMaterial(await personalCrypto.randomSecret('csrf-key', 32)), + }); + const staged = await keyrings.stageReplacement(envelope); + if (staged.status !== 'staged' && staged.status !== 'already_applied') { + throw new Error('hosted_auth_mode_reset_keyring_stage_failed'); + } + return repository.resetAuthMode({ + currentMode: mode, + targetMode: input.targetMode, + resetGeneration: input.resetGeneration, + resetAt: now(), + expectedAuthorityRevision: currentAuthority?.revision ?? null, + nextAuthorityState: resetAuthorityState( + currentAuthority, + binding, + pendingPersonalKeyringId, + input.resetGeneration + ), + pendingPersonalKeyringId, + auditEvent: input.auditEvent, + }); + }; + const localAdministration = new HostedLocalAdministration({ + mode, + binding, + authority, + identities, + repository, + drainProof: configuredDrainProof, + now, + runWithBrowserStreamsDrained: dependencies.runWithBrowserStreamsDrained, + blockPublicAccess: () => publicAccessGate.blockAndDrain(), + restorePublicAccess: () => publicAccessGate.restore(), + performAuthModeReset, + }); + + const workspaceAccess = new HostedWorkspaceAccessService(repository, binding.restoreGeneration); + const httpController = new HostedAuthHttpController({ + mode, + publicOrigin, + secureCookies: !allowInsecure, + authentication, + personal, + oidc, + repository, + restoreGeneration: binding.restoreGeneration, + runtimeIdentity: + dependencies.runtimeInstance === undefined || dependencies.runtimeInstance === null + ? null + : Object.freeze({ + deploymentId: dependencies.runtimeInstance.deploymentId, + bootId: dependencies.runtimeInstance.bootId, + }), + sessionMaxAgeSeconds: Math.floor( + (mode === 'personal' + ? HOSTED_PERSONAL_POLICY.sessionAbsoluteTtlMs + : oidcPolicy!.sessionAbsoluteTtlMs) / 1000 + ), + deviceMaxAgeSeconds: Math.floor(HOSTED_PERSONAL_POLICY.deviceAbsoluteTtlMs / 1000), + tryEnterPublicRequest: () => publicAccessGate.tryEnter(), + leavePublicRequest: () => publicAccessGate.leave(), + isPublicAccessActive: () => publicAccessGate.isActive(), + ...(dependencies.authorizationPolicy === undefined + ? {} + : { authorizationPolicy: dependencies.authorizationPolicy }), + ...(dependencies.isLifecycleOwnerReady === undefined + ? {} + : { isLifecycleOwnerReady: dependencies.isLifecycleOwnerReady }), + ...(dependencies.isTaskBoardMutationRouteEnabled === undefined + ? {} + : { isTaskBoardMutationRouteEnabled: dependencies.isTaskBoardMutationRouteEnabled }), + ...(dependencies.isTeamMessageSendRouteEnabled === undefined + ? {} + : { isTeamMessageSendRouteEnabled: dependencies.isTeamMessageSendRouteEnabled }), + ...(dependencies.resolveTeamWorkspaceId === undefined + ? {} + : { resolveTeamWorkspaceId: dependencies.resolveTeamWorkspaceId }), + }); + const http = createHostedAuthenticatedHttpFacade( + httpController, + workspaceAccess, + ( + request: unknown, + scope: Readonly<{ workspaceId: WorkspaceId; teamId?: TeamId }>, + mutation: boolean + ) => + authorizeHostedTeamConfigurationScope( + { + authentication: httpController, + resolvePublicGrant: workspaceAccess.resolvePublicGrant.bind(workspaceAccess), + ...(dependencies.resolveTeamWorkspaceId === undefined + ? {} + : { resolveTeamWorkspaceId: dependencies.resolveTeamWorkspaceId }), + }, + request as object, + scope, + mutation + ) + ); + + return Object.freeze({ + deploymentId: binding.deploymentId, + restoreGeneration: binding.restoreGeneration, + mode, + localAdministration, + http, + startLocalControl: async (socketPath: string) => { + const server = new HostedAuthLocalControlServer({ + socketPath, + administration: localAdministration, + platform: dependencies.hostPlatform, + transportFactory: dependencies.localControlTransportFactory, + }); + await server.start(); + return Object.freeze({ close: () => server.close() }); + }, + }); +} diff --git a/src/features/hosted-access/main/composition/createHostedAuthenticatedHttpFacade.ts b/src/features/hosted-access/main/composition/createHostedAuthenticatedHttpFacade.ts new file mode 100644 index 0000000000..e081fea3c7 --- /dev/null +++ b/src/features/hosted-access/main/composition/createHostedAuthenticatedHttpFacade.ts @@ -0,0 +1,65 @@ +import { HostedWorkspaceAccessService } from '../../core/application'; +import { HostedAuthHttpController } from '../adapters/input/http/HostedAuthHttpController'; + +import type { HostedAuthenticatedHttpFacade } from './createHostedAccessFeature'; +import type { TeamId, WorkspaceId } from '@shared/contracts/hosted'; + +export function createHostedAuthenticatedHttpFacade( + httpController: HostedAuthHttpController, + workspaceAccess: HostedWorkspaceAccessService, + authorizeTeamConfigurationScope: ( + request: object, + scope: Readonly<{ workspaceId: WorkspaceId; teamId?: TeamId }>, + mutation: boolean + ) => Promise<'authorized' | 'denied' | 'unavailable'> +): HostedAuthenticatedHttpFacade { + return Object.freeze({ + allowedOrigin: httpController.allowedOrigin, + register: (app: unknown) => httpController.register(app as never), + authenticatedPrincipalFor: (request: object) => + httpController.authenticatedPrincipalFor(request), + resolveGrantedRuntimeWorkspaceId: async (request: object, publicWorkspaceId: string) => { + const authenticated = httpController.authenticatedPrincipalFor(request); + if (authenticated === null) return null; + return ( + ( + await workspaceAccess.resolvePublicGrant( + authenticated.principal.userId, + publicWorkspaceId + ) + )?.runtimeWorkspaceId ?? null + ); + }, + projectGrantedPublicWorkspaceId: async (request: object, runtimeWorkspaceId: string) => { + const authenticated = httpController.authenticatedPrincipalFor(request); + if (authenticated === null) return null; + return workspaceAccess.projectWorkspaceId(authenticated.principal.userId, runtimeWorkspaceId); + }, + isWorkspaceRegistered: (workspaceId: string) => + httpController.isWorkspaceRegistered(workspaceId), + projectWorkspaceId: (request: unknown, runtimeWorkspaceId: string) => + httpController.projectWorkspaceId(request, runtimeWorkspaceId), + projectPayload: (request: unknown, payload: unknown) => + httpController.projectPayload(request, payload), + isHostedQueryAuthorized: (request: unknown) => httpController.isHostedQueryAuthorized(request), + isHostedTaskMutationAuthorized: (request: unknown, teamId: TeamId) => + httpController.isHostedTaskMutationAuthorized(request, teamId), + isTeamWorkspaceAuthorized: (request: unknown, teamId: TeamId) => + httpController.isTeamWorkspaceAuthorized(request, teamId), + isTeamWorkspaceEventAuthorized: ( + request: unknown, + teamId: TeamId, + runtimeWorkspaceId: string + ) => httpController.isTeamWorkspaceEventAuthorized(request, teamId, runtimeWorkspaceId), + captureTeamWorkspaceGrantFence: ( + request: unknown, + teamId: TeamId, + permission: 'hosted.query' | 'hosted.command' + ) => httpController.captureTeamWorkspaceGrantFence(request, teamId, permission), + isTeamConfigurationScopeAuthorized: authorizeTeamConfigurationScope, + isEventStreamAuthorized: (request: unknown) => + httpController.isEventStreamAuthorized(request as never), + projectEvent: (request: unknown, channel: string, data: unknown) => + httpController.projectEvent(request as never, channel, data), + }); +} diff --git a/src/features/hosted-access/main/index.ts b/src/features/hosted-access/main/index.ts new file mode 100644 index 0000000000..d85e561a5a --- /dev/null +++ b/src/features/hosted-access/main/index.ts @@ -0,0 +1,11 @@ +export type { + CreateHostedAccessFeatureDependencies, + HostedAccessEnvironment, + HostedAccessFeature, + HostedAuthHttpFacade, + HostedAuthLocalControlHandle, +} from './composition/createHostedAccessFeature'; +export { + createHostedAccessFeature, + HOSTED_PERSONAL_POLICY, +} from './composition/createHostedAccessFeature'; diff --git a/src/features/hosted-access/main/infrastructure/GenericOidcIdentityProvider.ts b/src/features/hosted-access/main/infrastructure/GenericOidcIdentityProvider.ts new file mode 100644 index 0000000000..45aeec4503 --- /dev/null +++ b/src/features/hosted-access/main/infrastructure/GenericOidcIdentityProvider.ts @@ -0,0 +1,756 @@ +import { isHostedRole } from '../../core/domain'; + +import type { HostedRole } from '../../contracts'; +import type { + HostedAuthHostPlatform, + IdentityProviderBackchannelLogout, + IdentityProviderBeginContext, + IdentityProviderBeginResult, + IdentityProviderClaims, + IdentityProviderCompleteContext, + IdentityProviderLogoutContext, + OidcIdentityProvider, +} from '../../core/application'; + +interface OidcMetadata { + readonly issuer: string; + readonly authorization_endpoint: string; + readonly token_endpoint: string; + readonly jwks_uri: string; + readonly end_session_endpoint?: string; + readonly token_endpoint_auth_methods_supported?: readonly string[]; +} + +interface JsonWebKey { + readonly kid?: string; + readonly kty: string; + readonly alg?: string; + readonly use?: string; + readonly [key: string]: unknown; +} + +interface JwtHeader { + readonly alg: string; + readonly kid?: string; + readonly typ?: string; + readonly crit?: unknown; + readonly b64?: unknown; +} + +interface JwtClaims { + readonly iss?: unknown; + readonly sub?: unknown; + readonly aud?: unknown; + readonly azp?: unknown; + readonly exp?: unknown; + readonly iat?: unknown; + readonly nbf?: unknown; + readonly nonce?: unknown; + readonly sid?: unknown; + readonly jti?: unknown; + readonly events?: unknown; + readonly name?: unknown; + readonly preferred_username?: unknown; + readonly email?: unknown; + readonly [key: string]: unknown; +} + +export interface GenericOidcRoleMapping { + readonly claimPath: string; + readonly owner: readonly string[]; + readonly admin: readonly string[]; + readonly member: readonly string[]; + readonly viewer: readonly string[]; + readonly defaultRole: Exclude; +} + +export interface GenericOidcIdentityProviderConfig { + readonly id: string; + readonly displayName: string; + readonly issuer: string; + readonly clientId: string; + readonly clientSecret?: string; + readonly redirectUri: string; + readonly scopes?: readonly string[]; + readonly roleMapping: GenericOidcRoleMapping; + readonly crypto: Pick< + HostedAuthHostPlatform, + 'base64UrlEncode' | 'randomBytes' | 'secureEqual' | 'sha256Base64Url' | 'verifyOidcSignature' + >; + readonly clockSkewSeconds?: number; + readonly metadataCacheMs?: number; + readonly fetch?: typeof globalThis.fetch; + readonly now?: () => number; + readonly allowInsecureHttpForTests?: boolean; +} + +const SIGNATURE_ALGORITHMS = new Set([ + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'ES256', + 'ES384', + 'ES512', + 'EdDSA', +]); +const MAXIMUM_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60; +const MAXIMUM_COMPACT_JWT_BYTES = 128 * 1024; +const MAXIMUM_OIDC_JSON_BYTES = 1024 * 1024; +const MAXIMUM_OIDC_SCOPES = 64; +const MAXIMUM_OIDC_SCOPE_LENGTH = 4_096; +const MAXIMUM_TOKEN_ENDPOINT_AUTH_METHODS = 32; +const MAXIMUM_TOKEN_ENDPOINT_AUTH_METHOD_LENGTH = 128; +const MAXIMUM_ROLE_CLAIM_PATH_LENGTH = 512; +const MAXIMUM_ROLE_CLAIM_SEGMENT_LENGTH = 128; +const MAXIMUM_ROLE_MAPPING_VALUES = 256; +const MAXIMUM_ROLE_MAPPING_VALUES_PER_ROLE = 128; +const MAXIMUM_ROLE_MAPPING_VALUE_LENGTH = 256; +// eslint-disable-next-line sonarjs/no-clear-text-protocols -- Registered OIDC event claim name; no network request. +const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout'; + +function formEncodeCredential(value: string): string { + return new URLSearchParams([['credential', value]]).toString().slice('credential='.length); +} + +function parseJsonObject(value: string, errorCode: string): Record { + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(errorCode); + } + return parsed as Record; + } catch { + throw new Error(errorCode); + } +} + +async function readJsonObject( + response: Response, + errorCode: string +): Promise> { + const declaredLength = Number(response.headers.get('content-length')); + if ( + (Number.isFinite(declaredLength) && declaredLength > MAXIMUM_OIDC_JSON_BYTES) || + response.body === null + ) { + throw new Error(errorCode); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + length += chunk.value.byteLength; + if (length > MAXIMUM_OIDC_JSON_BYTES) { + await reader.cancel().catch(() => undefined); + throw new Error(errorCode); + } + chunks.push(chunk.value); + } + } catch (error) { + if (error instanceof Error && error.message === errorCode) throw error; + throw new Error('oidc_provider_unavailable'); + } + return parseJsonObject( + Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + length + ).toString('utf8'), + errorCode + ); +} + +function decodeJwt(token: string): { + readonly signingInput: string; + readonly signature: Uint8Array; + readonly header: JwtHeader; + readonly claims: JwtClaims; +} { + if (Buffer.byteLength(token, 'utf8') > MAXIMUM_COMPACT_JWT_BYTES) { + throw new Error('oidc_token_malformed'); + } + const parts = token.split('.'); + if (parts.length !== 3) throw new Error('oidc_token_malformed'); + const [encodedHeader, encodedClaims, encodedSignature] = parts; + if (!encodedHeader || !encodedClaims || !encodedSignature) { + throw new Error('oidc_token_malformed'); + } + const header = parseJsonObject( + Buffer.from(encodedHeader, 'base64url').toString('utf8'), + 'oidc_token_header_invalid' + ); + const claims = parseJsonObject( + Buffer.from(encodedClaims, 'base64url').toString('utf8'), + 'oidc_token_claims_invalid' + ); + if (typeof header.alg !== 'string' || !SIGNATURE_ALGORITHMS.has(header.alg)) { + throw new Error('oidc_token_algorithm_invalid'); + } + if (header.kid !== undefined && typeof header.kid !== 'string') { + throw new Error('oidc_token_key_id_invalid'); + } + if (header.crit !== undefined || header.b64 !== undefined) { + // This implementation supports only the compact JWT profile used by OIDC. + // JWS extensions named by `crit` cannot be ignored, and RFC 7797 `b64` + // changes the signing-input semantics even when it is not listed correctly. + throw new Error('oidc_token_critical_header_unsupported'); + } + return { + signingInput: `${encodedHeader}.${encodedClaims}`, + signature: Buffer.from(encodedSignature, 'base64url'), + header: header as unknown as JwtHeader, + claims, + }; +} + +function claimStrings(claims: JwtClaims, path: string): readonly string[] { + let value: unknown = claims; + for (const segment of path.split('.')) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; + if (!Object.hasOwn(value, segment)) return []; + value = (value as Record)[segment]; + } + if (typeof value === 'string') return Object.freeze([value]); + if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { + return Object.freeze([...value]); + } + return []; +} + +function mappedRole(claims: JwtClaims, mapping: GenericOidcRoleMapping): HostedRole { + const values = new Set(claimStrings(claims, mapping.claimPath)); + for (const role of ['owner', 'admin', 'member', 'viewer'] as const) { + if (mapping[role].some((value) => values.has(value))) return role; + } + return mapping.defaultRole; +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +export function validateGenericOidcRoleMapping( + mapping: GenericOidcRoleMapping +): GenericOidcRoleMapping { + const runtimeDefaultRole: unknown = mapping.defaultRole; + if (!isHostedRole(runtimeDefaultRole) || runtimeDefaultRole === 'owner') { + throw new Error('oidc_default_role_invalid'); + } + if ( + typeof mapping.claimPath !== 'string' || + mapping.claimPath.length === 0 || + mapping.claimPath.length > MAXIMUM_ROLE_CLAIM_PATH_LENGTH || + hasControlCharacter(mapping.claimPath) + ) { + throw new Error('oidc_role_claim_path_invalid'); + } + const claimSegments = mapping.claimPath.split('.'); + if ( + claimSegments.some( + (segment) => segment.length === 0 || segment.length > MAXIMUM_ROLE_CLAIM_SEGMENT_LENGTH + ) + ) { + throw new Error('oidc_role_claim_path_invalid'); + } + + const roles = ['owner', 'admin', 'member', 'viewer'] as const; + const observedValues = new Map(); + let valueCount = 0; + const validated = Object.fromEntries( + roles.map((role) => { + const values: unknown = mapping[role]; + if (!Array.isArray(values) || values.length > MAXIMUM_ROLE_MAPPING_VALUES_PER_ROLE) { + throw new Error('oidc_role_mapping_invalid'); + } + const unique = new Set(); + for (const value of values) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAXIMUM_ROLE_MAPPING_VALUE_LENGTH || + hasControlCharacter(value) + ) { + throw new Error('oidc_role_mapping_invalid'); + } + const observedRole = observedValues.get(value); + if (observedRole !== undefined && observedRole !== role) { + throw new Error('oidc_role_mapping_ambiguous'); + } + observedValues.set(value, role); + unique.add(value); + } + valueCount += unique.size; + return [role, Object.freeze([...unique])] as const; + }) + ) as unknown as Pick; + if (valueCount > MAXIMUM_ROLE_MAPPING_VALUES) { + throw new Error('oidc_role_mapping_invalid'); + } + return Object.freeze({ + claimPath: mapping.claimPath, + ...validated, + defaultRole: runtimeDefaultRole, + }); +} + +function stringClaim(value: unknown, code: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 1024) { + throw new Error(code); + } + return value; +} + +function isOAuthScopeToken(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + return [...value].every((character) => { + const code = character.charCodeAt(0); + return code === 0x21 || (code >= 0x23 && code <= 0x5b) || (code >= 0x5d && code <= 0x7e); + }); +} + +function isCompatibleSigningKey(key: JsonWebKey, algorithm: string): boolean { + const expectedKeyType = + algorithm.startsWith('RS') || algorithm.startsWith('PS') + ? 'RSA' + : algorithm.startsWith('ES') + ? 'EC' + : 'OKP'; + return ( + key.kty === expectedKeyType && + (key.alg === undefined || key.alg === algorithm) && + (key.use === undefined || key.use === 'sig') + ); +} + +type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; + +function tokenEndpointAuthMethod( + metadata: OidcMetadata, + hasClientSecret: boolean +): TokenEndpointAuthMethod { + const advertised = metadata.token_endpoint_auth_methods_supported; + if (advertised === undefined) return hasClientSecret ? 'client_secret_basic' : 'none'; + if (hasClientSecret) { + if (advertised.includes('client_secret_basic')) return 'client_secret_basic'; + if (advertised.includes('client_secret_post')) return 'client_secret_post'; + } else if (advertised.includes('none')) { + return 'none'; + } + throw new Error('oidc_token_auth_method_unsupported'); +} + +export class GenericOidcIdentityProvider implements OidcIdentityProvider { + readonly id: string; + readonly displayName: string; + private readonly scopes: readonly string[]; + private readonly roleMapping: GenericOidcRoleMapping; + private metadataCache: { readonly value: OidcMetadata; readonly expiresAt: number } | null = null; + private jwksCache: { readonly value: readonly JsonWebKey[]; readonly expiresAt: number } | null = + null; + private lastUnknownKidRefreshAt = Number.NEGATIVE_INFINITY; + + constructor(private readonly config: GenericOidcIdentityProviderConfig) { + this.id = config.id; + this.displayName = config.displayName; + this.roleMapping = validateGenericOidcRoleMapping(config.roleMapping); + this.assertUrl(config.issuer, 'oidc_issuer_invalid', false); + this.assertUrl(config.redirectUri, 'oidc_redirect_uri_invalid', true); + const configuredScopes = config.scopes ?? ['openid', 'profile']; + if ( + configuredScopes.length > MAXIMUM_OIDC_SCOPES || + configuredScopes.some((scope) => !isOAuthScopeToken(scope)) + ) { + throw new Error('oidc_scopes_invalid'); + } + this.scopes = Object.freeze([...new Set(['openid', ...configuredScopes])]); + if (this.scopes.join(' ').length > MAXIMUM_OIDC_SCOPE_LENGTH) { + throw new Error('oidc_scopes_invalid'); + } + } + + async beginLogin(context: IdentityProviderBeginContext): Promise { + const metadata = await this.metadata(); + const state = this.config.crypto.base64UrlEncode(this.config.crypto.randomBytes(32)); + const nonce = this.config.crypto.base64UrlEncode(this.config.crypto.randomBytes(32)); + const pkceVerifier = this.config.crypto.base64UrlEncode(this.config.crypto.randomBytes(64)); + const authorization = new URL(metadata.authorization_endpoint); + authorization.searchParams.set('client_id', this.config.clientId); + authorization.searchParams.set('redirect_uri', this.config.redirectUri); + authorization.searchParams.set('response_type', 'code'); + authorization.searchParams.set('scope', this.scopes.join(' ')); + authorization.searchParams.set('state', state); + authorization.searchParams.set('nonce', nonce); + authorization.searchParams.set( + 'code_challenge', + this.config.crypto.sha256Base64Url(pkceVerifier) + ); + authorization.searchParams.set('code_challenge_method', 'S256'); + return Object.freeze({ + redirectUrl: authorization.toString(), + attemptId: context.attemptId, + state, + nonce, + pkceVerifier, + }); + } + + async completeLogin(context: IdentityProviderCompleteContext): Promise { + const expectedCallback = new URL(this.config.redirectUri); + if ( + context.callbackUrl.origin !== expectedCallback.origin || + context.callbackUrl.pathname !== expectedCallback.pathname || + context.callbackUrl.hash !== '' + ) { + throw new Error('oidc_callback_url_invalid'); + } + const callbackStates = context.callbackUrl.searchParams.getAll('state'); + const codes = context.callbackUrl.searchParams.getAll('code'); + const errors = context.callbackUrl.searchParams.getAll('error'); + if ( + callbackStates.length !== 1 || + codes.length > 1 || + errors.length > 1 || + (codes.length > 0 && errors.length > 0) + ) { + throw new Error('oidc_callback_parameters_invalid'); + } + const callbackState = callbackStates[0]; + const error = errors[0]; + if (!this.config.crypto.secureEqual(callbackState, context.expectedState)) { + throw new Error('oidc_state_mismatch'); + } + if (error) throw new Error('oidc_provider_error'); + const code = codes[0]; + if (!code) throw new Error('oidc_authorization_code_missing'); + + const metadata = await this.metadata(); + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.config.redirectUri, + client_id: this.config.clientId, + code_verifier: context.pkceVerifier, + }); + const headers: Record = { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }; + const authenticationMethod = tokenEndpointAuthMethod( + metadata, + Boolean(this.config.clientSecret) + ); + if (authenticationMethod === 'client_secret_basic') { + headers.authorization = `Basic ${Buffer.from( + `${formEncodeCredential(this.config.clientId)}:${formEncodeCredential( + this.config.clientSecret! + )}` + ).toString('base64')}`; + body.delete('client_id'); + } else if (authenticationMethod === 'client_secret_post') { + body.set('client_secret', this.config.clientSecret!); + } + const response = await this.fetch(metadata.token_endpoint, { + method: 'POST', + headers, + body, + redirect: 'error', + }); + if ( + response.status === 408 || + response.status === 425 || + response.status === 429 || + response.status >= 500 + ) { + throw new Error('oidc_provider_unavailable'); + } + if (!response.ok) throw new Error('oidc_token_exchange_rejected'); + const tokenResponse = await readJsonObject(response, 'oidc_token_response_invalid'); + if (typeof tokenResponse.id_token !== 'string' || tokenResponse.id_token.length === 0) { + throw new Error('oidc_id_token_missing'); + } + const idToken = tokenResponse.id_token; + const claims = await this.verifyJwt(idToken, context.nonce); + const issuer = stringClaim(claims.iss, 'oidc_issuer_missing'); + const subject = stringClaim(claims.sub, 'oidc_subject_missing'); + const displayName = [claims.name, claims.preferred_username, claims.email, subject].find( + (value): value is string => typeof value === 'string' && value.length > 0 + )!; + return Object.freeze({ + issuer, + subject, + displayName: displayName.slice(0, 256), + role: mappedRole(claims, this.roleMapping), + providerSessionId: typeof claims.sid === 'string' ? claims.sid : null, + }); + } + + async logout( + context: IdentityProviderLogoutContext + ): Promise<{ readonly redirectUrl: string | null }> { + const endpoint = (await this.metadata()).end_session_endpoint; + if (!endpoint) return Object.freeze({ redirectUrl: null }); + const url = new URL(endpoint); + url.searchParams.set('client_id', this.config.clientId); + url.searchParams.set('post_logout_redirect_uri', context.postLogoutRedirectUri); + return Object.freeze({ redirectUrl: url.toString() }); + } + + async verifyBackchannelLogout(token: string): Promise { + const claims = await this.verifyJwt(token, null); + if ( + typeof claims.events !== 'object' || + claims.events === null || + Array.isArray(claims.events) || + !Object.hasOwn(claims.events, BACKCHANNEL_LOGOUT_EVENT) || + claims.nonce !== undefined + ) { + throw new Error('oidc_backchannel_events_invalid'); + } + const logoutEvent = (claims.events as Record)[BACKCHANNEL_LOGOUT_EVENT]; + if ( + typeof logoutEvent !== 'object' || + logoutEvent === null || + Array.isArray(logoutEvent) || + Reflect.ownKeys(logoutEvent).length !== 0 + ) { + throw new Error('oidc_backchannel_events_invalid'); + } + const issuer = stringClaim(claims.iss, 'oidc_issuer_missing'); + const subject = + claims.sub === undefined + ? undefined + : stringClaim(claims.sub, 'oidc_backchannel_subject_invalid'); + const providerSessionId = + claims.sid === undefined + ? undefined + : stringClaim(claims.sid, 'oidc_backchannel_session_invalid'); + if (!subject && !providerSessionId) throw new Error('oidc_backchannel_subject_missing'); + const nowSeconds = Math.floor((this.config.now?.() ?? Date.now()) / 1000); + const skew = this.config.clockSkewSeconds ?? 60; + if ( + typeof claims.iat !== 'number' || + !Number.isSafeInteger(claims.iat) || + claims.iat < nowSeconds - 5 * 60 - skew + ) { + throw new Error('oidc_backchannel_iat_invalid'); + } + return Object.freeze({ + issuer, + subject, + providerSessionId, + jti: stringClaim(claims.jti, 'oidc_backchannel_jti_missing'), + // Retain replay state through the same validation leeway that can admit + // a just-expired token. + expiresAt: (Number(claims.exp) + skew) * 1000, + }); + } + + private async verifyJwt(token: string, expectedNonce: string | null): Promise { + const decoded = decodeJwt(token); + const verificationNow = this.config.now?.() ?? Date.now(); + const usedCachedJwks = this.jwksCache !== null && verificationNow < this.jwksCache.expiresAt; + let keys = await this.jwks(); + let candidates = keys.filter( + (key) => + (decoded.header.kid === undefined || key.kid === decoded.header.kid) && + isCompatibleSigningKey(key, decoded.header.alg) + ); + if (decoded.header.kid === undefined && candidates.length > 1) { + throw new Error('oidc_token_key_id_ambiguous'); + } + if ( + candidates.length === 0 && + decoded.header.kid !== undefined && + usedCachedJwks && + verificationNow - this.lastUnknownKidRefreshAt >= 60_000 + ) { + this.lastUnknownKidRefreshAt = verificationNow; + this.jwksCache = null; + keys = await this.jwks(); + candidates = keys.filter( + (key) => key.kid === decoded.header.kid && isCompatibleSigningKey(key, decoded.header.alg) + ); + } + const verified = candidates.some((key) => { + try { + return this.config.crypto.verifyOidcSignature({ + algorithm: decoded.header.alg, + jwk: key, + signingInput: decoded.signingInput, + signature: decoded.signature, + }); + } catch { + return false; + } + }); + if (!verified) throw new Error('oidc_token_signature_invalid'); + + const metadata = await this.metadata(); + const claims = decoded.claims; + if (claims.iss !== metadata.issuer || claims.iss !== this.config.issuer) { + throw new Error('oidc_token_issuer_invalid'); + } + const audiences = + typeof claims.aud === 'string' + ? [claims.aud] + : Array.isArray(claims.aud) && claims.aud.every((value) => typeof value === 'string') + ? claims.aud + : []; + if ( + !audiences.includes(this.config.clientId) || + (claims.azp !== undefined && claims.azp !== this.config.clientId) || + (audiences.length > 1 && claims.azp !== this.config.clientId) + ) { + throw new Error('oidc_token_audience_invalid'); + } + const nowSeconds = Math.floor((this.config.now?.() ?? Date.now()) / 1000); + const skew = this.config.clockSkewSeconds ?? 60; + if ( + typeof claims.exp !== 'number' || + !Number.isSafeInteger(claims.exp) || + claims.exp <= nowSeconds - skew || + claims.exp > nowSeconds + MAXIMUM_TOKEN_LIFETIME_SECONDS + skew || + typeof claims.iat !== 'number' || + !Number.isSafeInteger(claims.iat) || + claims.iat > nowSeconds + skew || + claims.iat >= claims.exp || + claims.exp - claims.iat > MAXIMUM_TOKEN_LIFETIME_SECONDS || + (claims.nbf !== undefined && + (typeof claims.nbf !== 'number' || + !Number.isSafeInteger(claims.nbf) || + claims.nbf > nowSeconds + skew)) + ) { + throw new Error('oidc_token_expiry_invalid'); + } + if ( + expectedNonce !== null && + (typeof claims.nonce !== 'string' || + !this.config.crypto.secureEqual(claims.nonce, expectedNonce)) + ) { + throw new Error('oidc_token_nonce_invalid'); + } + return claims; + } + + private async metadata(): Promise { + const now = this.config.now?.() ?? Date.now(); + if (this.metadataCache && now < this.metadataCache.expiresAt) { + return this.metadataCache.value; + } + const issuer = this.config.issuer.replace(/\/$/, ''); + const response = await this.fetch(`${issuer}/.well-known/openid-configuration`, { + headers: { accept: 'application/json' }, + redirect: 'error', + }); + if (!response.ok) throw new Error('oidc_provider_unavailable'); + const value = (await readJsonObject( + response, + 'oidc_metadata_invalid' + )) as unknown as OidcMetadata; + if (value.issuer !== this.config.issuer) throw new Error('oidc_metadata_issuer_mismatch'); + for (const [url, error] of [ + [value.authorization_endpoint, 'oidc_authorization_endpoint_invalid'], + [value.token_endpoint, 'oidc_token_endpoint_invalid'], + [value.jwks_uri, 'oidc_jwks_uri_invalid'], + ] as const) { + this.assertUrl(url, error, true); + } + if (value.end_session_endpoint) { + this.assertUrl(value.end_session_endpoint, 'oidc_logout_endpoint_invalid', true); + } + const authenticationMethods: unknown = value.token_endpoint_auth_methods_supported; + if ( + authenticationMethods !== undefined && + (!Array.isArray(authenticationMethods) || + authenticationMethods.length === 0 || + authenticationMethods.length > MAXIMUM_TOKEN_ENDPOINT_AUTH_METHODS || + authenticationMethods.some( + (method) => + typeof method !== 'string' || + method.length === 0 || + method.length > MAXIMUM_TOKEN_ENDPOINT_AUTH_METHOD_LENGTH || + hasControlCharacter(method) + )) + ) { + throw new Error('oidc_metadata_invalid'); + } + this.metadataCache = { + value: Object.freeze({ + ...value, + ...(authenticationMethods === undefined + ? {} + : { + token_endpoint_auth_methods_supported: Object.freeze([ + ...new Set(authenticationMethods as string[]), + ]), + }), + }), + expiresAt: now + (this.config.metadataCacheMs ?? 5 * 60_000), + }; + return this.metadataCache.value; + } + + private async jwks(): Promise { + const now = this.config.now?.() ?? Date.now(); + if (this.jwksCache && now < this.jwksCache.expiresAt) return this.jwksCache.value; + const response = await this.fetch((await this.metadata()).jwks_uri, { + headers: { accept: 'application/json' }, + redirect: 'error', + }); + if (!response.ok) throw new Error('oidc_provider_unavailable'); + const value = await readJsonObject(response, 'oidc_jwks_invalid'); + if (!Array.isArray(value.keys) || value.keys.length === 0 || value.keys.length > 128) { + throw new Error('oidc_jwks_invalid'); + } + const keys = value.keys.filter( + (key): key is JsonWebKey => + typeof key === 'object' && + key !== null && + typeof (key as { kty?: unknown }).kty === 'string' + ); + if (keys.length === 0) throw new Error('oidc_jwks_invalid'); + this.jwksCache = { + value: Object.freeze(keys), + expiresAt: now + (this.config.metadataCacheMs ?? 5 * 60_000), + }; + return this.jwksCache.value; + } + + private async fetch(input: string, init: RequestInit): Promise { + try { + return await (this.config.fetch ?? globalThis.fetch)(input, { + ...init, + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new Error('oidc_provider_unavailable'); + } + } + + private assertUrl(value: string, code: string, allowQuery: boolean): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(code); + } + if ( + url.username || + url.password || + url.hash || + (!allowQuery && url.search) || + (url.protocol !== 'https:' && + !(this.config.allowInsecureHttpForTests && url.protocol === 'http:')) + ) { + throw new Error(code); + } + } +} diff --git a/src/features/hosted-access/main/infrastructure/NodeHostedIdentityCrypto.ts b/src/features/hosted-access/main/infrastructure/NodeHostedIdentityCrypto.ts new file mode 100644 index 0000000000..9ca42a9e33 --- /dev/null +++ b/src/features/hosted-access/main/infrastructure/NodeHostedIdentityCrypto.ts @@ -0,0 +1,404 @@ +import { + type HostedSessionId, + parseAuditEventId, + parseHostedSessionId, + parseHostedWorkspaceId, + parseOidcLoginAttemptId, + parseUserId, +} from '../../contracts'; + +import type { + HostedAuthHostPlatform, + HostedAuthPathStat, + HostedIdentityCryptoPort, +} from '../../core/application'; + +const KEY_BYTES = 32; + +export interface HostedAuthSecretPaths { + readonly identityKeyPath: string; + readonly personalKeyringPath: string; + readonly stagedKeyringDirectory: string; +} + +const DOCKER_SERVICE_SECRET_PATH = /^\/run\/secrets\/[A-Za-z0-9._-]+$/; +const MAXIMUM_OIDC_CLIENT_SECRET_BYTES = 8_192; +const MAXIMUM_IDENTITY_KEY_BYTES = 256; + +function assertOwnedPath( + stat: HostedAuthPathStat, + platform: HostedAuthHostPlatform, + kind: 'directory' | 'file', + errorCode: string +): void { + const expectedKind = kind === 'directory' ? stat.isDirectory() : stat.isFile(); + if ( + !expectedKind || + stat.isSymbolicLink() || + (platform.uid !== undefined && stat.uid !== platform.uid) + ) { + throw new Error(errorCode); + } +} + +function isDockerServiceSecretPath(path: string, platform: HostedAuthHostPlatform): boolean { + const normalizedPath = platform.join(path); + return normalizedPath === path && DOCKER_SERVICE_SECRET_PATH.test(normalizedPath); +} + +function sameFileIdentity(left: HostedAuthPathStat, right: HostedAuthPathStat): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function assertPrivateOwnedDirectory( + stat: HostedAuthPathStat, + platform: HostedAuthHostPlatform, + errorCode: string +): void { + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (stat.mode & 0o077) !== 0 || + (platform.uid !== undefined && stat.uid !== platform.uid) + ) { + throw new Error(errorCode); + } +} + +function validSecretOwner( + stat: HostedAuthPathStat, + platform: HostedAuthHostPlatform, + _dockerServicePath: boolean +): boolean { + if (platform.uid === undefined) return true; + return stat.uid === platform.uid; +} + +function assertSafeSecretDirectory( + stat: HostedAuthPathStat, + platform: HostedAuthHostPlatform, + _dockerServicePath: boolean, + errorCode: string +): void { + const writableByOthers = (stat.mode & 0o022) !== 0; + const protectedStickyRoot = stat.uid === 0 && (stat.mode & 0o1000) !== 0 && stat.isDirectory(); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (writableByOthers && !protectedStickyRoot) || + (platform.uid !== undefined && stat.uid !== platform.uid && stat.uid !== 0) + ) { + throw new Error(errorCode); + } +} + +function assertSafeSecretFile( + stat: HostedAuthPathStat, + platform: HostedAuthHostPlatform, + dockerServicePath: boolean, + errorCode: string +): void { + if ( + !stat.isFile() || + stat.isSocket() || + stat.isSymbolicLink() || + (stat.mode & 0o077) !== 0 || + !validSecretOwner(stat, platform, dockerServicePath) + ) { + throw new Error(errorCode); + } +} + +async function snapshotSecretParentChain( + path: string, + platform: HostedAuthHostPlatform, + dockerServicePath: boolean, + errorCode: string +): Promise { + const parents: { path: string; stat: HostedAuthPathStat }[] = []; + let current = platform.dirname(path); + for (;;) { + const stat = await platform.lstat(current); + assertSafeSecretDirectory(stat, platform, dockerServicePath, errorCode); + parents.push({ path: current, stat }); + const parent = platform.dirname(current); + if (parent === current) return parents; + current = parent; + } +} + +export async function prepareHostedAuthSecretPaths( + input: { + readonly dataDirectory: string; + readonly identityKeyPath?: string; + }, + platform: HostedAuthHostPlatform +): Promise { + if (!platform.isAbsolute(input.dataDirectory)) { + throw new Error('hosted_auth_data_directory_invalid'); + } + const secretsDirectory = platform.join(input.dataDirectory, 'hosted-auth-secrets'); + await platform.mkdir(secretsDirectory, 0o700); + const secretsStat = await platform.lstat(secretsDirectory); + assertOwnedPath(secretsStat, platform, 'directory', 'hosted_auth_secret_directory_invalid'); + await platform.chmod(secretsDirectory, 0o700); + const identityKeyPath = input.identityKeyPath ?? platform.join(secretsDirectory, 'identity.key'); + if (!platform.isAbsolute(identityKeyPath) || platform.join(identityKeyPath) !== identityKeyPath) { + throw new Error('hosted_auth_identity_key_path_invalid'); + } + const identityKeyDirectory = platform.dirname(identityKeyPath); + await platform.mkdir(identityKeyDirectory, 0o700); + const identityDirectoryStat = await platform.lstat(identityKeyDirectory); + assertOwnedPath( + identityDirectoryStat, + platform, + 'directory', + 'hosted_auth_identity_key_directory_invalid' + ); + await platform.chmod(identityKeyDirectory, 0o700); + return Object.freeze({ + identityKeyPath, + personalKeyringPath: platform.join(secretsDirectory, 'personal-keyring.json'), + stagedKeyringDirectory: platform.join(secretsDirectory, 'staged'), + }); +} + +export async function readProtectedHostedAuthSecret( + path: string, + platform: HostedAuthHostPlatform +): Promise { + const errorCode = 'hosted_auth_config_invalid:OIDC_CLIENT_SECRET_FILE'; + if (!platform.isAbsolute(path) || platform.join(path) !== path) throw new Error(errorCode); + const dockerServicePath = isDockerServiceSecretPath(path, platform); + const parentChain = await snapshotSecretParentChain(path, platform, dockerServicePath, errorCode); + const before = await platform.lstat(path); + assertSafeSecretFile(before, platform, dockerServicePath, errorCode); + const handle = await platform.openReadOnlyNoFollow(path).catch(() => { + throw new Error(errorCode); + }); + try { + const opened = await handle.stat(); + assertSafeSecretFile(opened, platform, dockerServicePath, errorCode); + if (!sameFileIdentity(before, opened)) throw new Error(errorCode); + const after = await platform.lstat(path); + assertSafeSecretFile(after, platform, dockerServicePath, errorCode); + if (!sameFileIdentity(opened, after)) throw new Error(errorCode); + for (const parent of parentChain) { + const observed = await platform.lstat(parent.path); + assertSafeSecretDirectory(observed, platform, dockerServicePath, errorCode); + if (!sameFileIdentity(parent.stat, observed)) throw new Error(errorCode); + } + const value = (await handle.readTextBounded(MAXIMUM_OIDC_CLIENT_SECRET_BYTES)).trim(); + if (value.length === 0 || platform.byteLength(value) > MAXIMUM_OIDC_CLIENT_SECRET_BYTES) { + throw new Error(errorCode); + } + return value; + } catch (error) { + throw error instanceof Error && error.message === errorCode ? error : new Error(errorCode); + } finally { + await handle.close().catch(() => undefined); + } +} + +/** + * Reads deployment-owned auth material through a no-follow descriptor and + * proves that neither the file nor its private parent changed around open. + */ +export async function readProtectedOwnedHostedAuthFile( + path: string, + platform: HostedAuthHostPlatform, + maximumBytes: number, + errorCode: string +): Promise { + if ( + !platform.isAbsolute(path) || + platform.join(path) !== path || + !Number.isSafeInteger(maximumBytes) || + maximumBytes <= 0 + ) { + throw new Error(errorCode); + } + const parentPath = platform.dirname(path); + const parentBefore = await platform.lstat(parentPath); + assertPrivateOwnedDirectory(parentBefore, platform, errorCode); + const before = await platform.lstat(path); + assertOwnedPath(before, platform, 'file', errorCode); + if ((before.mode & 0o077) !== 0) throw new Error(errorCode); + const handle = await platform.openReadOnlyNoFollow(path).catch(() => { + throw new Error(errorCode); + }); + try { + const opened = await handle.stat(); + assertOwnedPath(opened, platform, 'file', errorCode); + if ((opened.mode & 0o077) !== 0 || !sameFileIdentity(before, opened)) { + throw new Error(errorCode); + } + const after = await platform.lstat(path); + assertOwnedPath(after, platform, 'file', errorCode); + if ((after.mode & 0o077) !== 0 || !sameFileIdentity(opened, after)) { + throw new Error(errorCode); + } + const parentAfter = await platform.lstat(parentPath); + assertPrivateOwnedDirectory(parentAfter, platform, errorCode); + if (!sameFileIdentity(parentBefore, parentAfter)) throw new Error(errorCode); + return await handle.readTextBounded(maximumBytes); + } catch (error) { + throw error instanceof Error && error.message === errorCode ? error : new Error(errorCode); + } finally { + await handle.close().catch(() => undefined); + } +} + +function identifierSuffix(platform: HostedAuthHostPlatform): string { + // A base64url value may begin with "-" or "_", while hosted identifiers + // deliberately require an alphanumeric first character after the prefix. + return `x${platform.base64UrlEncode(platform.randomBytes(18))}`; +} + +function decodeKey(value: string, platform: HostedAuthHostPlatform): Uint8Array { + const decoded = platform.base64UrlDecode(value.trim()); + if (decoded.length !== KEY_BYTES) throw new Error('hosted_auth_key_invalid'); + return decoded; +} + +function loginEncryptionKey(masterKey: Uint8Array, platform: HostedAuthHostPlatform): Uint8Array { + return platform.hkdfSha256( + masterKey, + new TextEncoder().encode('agent-teams-hosted-auth-v1'), + 'oidc-login-encryption', + KEY_BYTES + ); +} + +async function readKey(path: string, platform: HostedAuthHostPlatform): Promise { + return decodeKey( + await readProtectedOwnedHostedAuthFile( + path, + platform, + MAXIMUM_IDENTITY_KEY_BYTES, + 'hosted_auth_key_permissions_invalid' + ), + platform + ); +} + +async function loadOrCreateKey( + path: string, + platform: HostedAuthHostPlatform +): Promise { + try { + return await readKey(path, platform); + } catch (error) { + if ((error as { readonly code?: string }).code !== 'ENOENT') throw error; + const key = platform.randomBytes(KEY_BYTES); + try { + await platform.writeTextDurable(path, platform.base64UrlEncode(key), { + exclusive: true, + mode: 0o600, + }); + return await readKey(path, platform); + } catch (writeError) { + if ((writeError as { readonly code?: string }).code !== 'EEXIST') throw writeError; + return readKey(path, platform); + } + } +} + +export class NodeHostedIdentityCrypto implements HostedIdentityCryptoPort { + private keyPromise: Promise | null = null; + + constructor( + private readonly keyPath: string, + private readonly platform: HostedAuthHostPlatform + ) {} + + async initialize(): Promise { + await this.key(); + } + + async randomId( + kind: 'user' | 'session' | 'oidc-attempt' | 'audit-event' | 'workspace' + ): Promise { + const suffix = identifierSuffix(this.platform); + switch (kind) { + case 'user': + return parseUserId(`usr_${suffix}`); + case 'session': + return parseHostedSessionId(`hss_${suffix}`); + case 'oidc-attempt': + return parseOidcLoginAttemptId(`ola_${suffix}`); + case 'audit-event': + return parseAuditEventId(`aud_${suffix}`); + case 'workspace': + return parseHostedWorkspaceId( + `workspace_${Array.from(this.platform.randomBytes(16), (byte) => + byte.toString(16).padStart(2, '0') + ).join('')}` + ); + } + } + + async randomSecret(_kind: 'session' | 'csrf'): Promise { + return this.platform.base64UrlEncode(this.platform.randomBytes(32)); + } + + async hashSecret( + purpose: 'session' | 'oidc-state' | 'source-ip', + secret: string + ): Promise { + const key = await this.key(); + return `hmac-sha256:${this.platform.hmacSha256( + key, + [`hosted-auth:${purpose}\0`, secret], + 'hex' + )}`; + } + + async deriveCsrf(sessionId: HostedSessionId, sessionSecret: string): Promise { + const key = await this.key(); + return this.platform.hmacSha256( + key, + ['hosted-auth:csrf\0', sessionId, '\0', sessionSecret], + 'base64url' + ); + } + + async encryptLoginSecret(secret: string): Promise { + const key = loginEncryptionKey(await this.key(), this.platform); + const nonce = this.platform.randomBytes(12); + const encrypted = this.platform.encryptAes256Gcm({ + key, + nonce, + aad: 'hosted-auth:oidc-login-secret:v1', + plaintext: secret, + }); + return `v1.${this.platform.base64UrlEncode(nonce)}.${this.platform.base64UrlEncode( + encrypted.ciphertext + )}.${this.platform.base64UrlEncode(encrypted.tag)}`; + } + + async decryptLoginSecret(value: string): Promise { + const parts = value.split('.'); + if (parts.length !== 4) throw new Error('oidc_login_secret_corrupt'); + const [version, encodedNonce, encodedCiphertext, encodedTag] = parts; + if (version !== 'v1' || !encodedNonce || !encodedCiphertext || !encodedTag) { + throw new Error('oidc_login_secret_corrupt'); + } + return this.platform.decryptAes256Gcm({ + key: loginEncryptionKey(await this.key(), this.platform), + nonce: this.platform.base64UrlDecode(encodedNonce), + aad: 'hosted-auth:oidc-login-secret:v1', + ciphertext: this.platform.base64UrlDecode(encodedCiphertext), + tag: this.platform.base64UrlDecode(encodedTag), + }); + } + + async secureEqual(left: string, right: string): Promise { + return this.platform.secureEqual(left, right); + } + + private key(): Promise { + this.keyPromise ??= loadOrCreateKey(this.keyPath, this.platform); + return this.keyPromise; + } +} diff --git a/src/features/hosted-access/main/infrastructure/NodePersonalAuthorityAdapters.ts b/src/features/hosted-access/main/infrastructure/NodePersonalAuthorityAdapters.ts new file mode 100644 index 0000000000..9d51c05ebe --- /dev/null +++ b/src/features/hosted-access/main/infrastructure/NodePersonalAuthorityAdapters.ts @@ -0,0 +1,565 @@ +import { + type AuthorityBinding, + type PairingChallengeId, + parseAuthKeyringId, + parseAuthorityDeploymentId, + parseAuthorityKeyMaterial, + parseCsrfToken, + parseDeviceFamilyId, + parseDeviceGrantId, + parseKeyedSecretHash, + parseOpaqueAuthoritySecret, + parseOperatorId, + parseOperatorSessionId, + parsePairingChallengeId, +} from '../../contracts'; + +import { readProtectedOwnedHostedAuthFile } from './NodeHostedIdentityCrypto'; + +import type { + AuthKeyringEnvelope, + AuthKeyringPort, + AuthKeyringReadResult, + AuthKeyringWriteResult, + AuthorityRandomIdKind, + AuthorityRandomSecretKind, + HostedAccessCryptoPort, + HostedAccessRandomPort, + HostedAuthHostPlatform, + PairingChallengeDeliveryPort, + PairingChallengeDeliveryStatus, + PairingChallengeDeliveryWriteResult, + PairingDrainProofPort, + PairingDrainPurpose, + PairingDrainResult, +} from '../../core/application'; + +const MAXIMUM_KEYRING_BYTES = 16 * 1024; +const MAXIMUM_PAIRING_DELIVERY_BYTES = 4 * 1024; +const MAXIMUM_DRAIN_EVIDENCE_BYTES = 4 * 1024; + +function secret(platform: HostedAuthHostPlatform): ReturnType { + return parseOpaqueAuthoritySecret(platform.base64UrlEncode(platform.randomBytes(32))); +} + +function identifierSuffix(platform: HostedAuthHostPlatform): string { + // Keep the parser's alphanumeric first-suffix invariant independently of + // the first random base64url character ("-" and "_" are both possible). + return `x${platform.base64UrlEncode(platform.randomBytes(18))}`; +} + +const ID_PREFIX: Record = { + 'auth-keyring': 'akr', + 'device-family': 'dfm', + 'device-grant': 'dgr', + operator: 'opr', + 'pairing-challenge': 'pch', + session: 'ops', +}; + +export class NodePersonalAuthorityCrypto implements HostedAccessRandomPort, HostedAccessCryptoPort { + constructor(private readonly platform: HostedAuthHostPlatform) {} + + async randomId(kind: AuthorityRandomIdKind): Promise { + const value = `${ID_PREFIX[kind]}_${identifierSuffix(this.platform)}`; + switch (kind) { + case 'auth-keyring': + return parseAuthKeyringId(value); + case 'device-family': + return parseDeviceFamilyId(value); + case 'device-grant': + return parseDeviceGrantId(value); + case 'operator': + return parseOperatorId(value); + case 'pairing-challenge': + return parsePairingChallengeId(value); + case 'session': + return parseOperatorSessionId(value); + } + } + + async randomSecret( + _kind: AuthorityRandomSecretKind, + _byteLength: 32 + ): Promise> { + return secret(this.platform); + } + + async keyedHash(input: { + readonly key: ReturnType; + readonly purpose: 'device-grant' | 'operator-session' | 'pairing-challenge'; + readonly secret: ReturnType; + }) { + return parseKeyedSecretHash( + `hmac-sha256:${this.platform.hmacSha256( + this.platform.base64UrlDecode(input.key), + [`hosted-personal:${input.purpose}\0`, input.secret], + 'hex' + )}` + ); + } + + async deriveSecret(input: { + readonly key: ReturnType; + readonly purpose: + | 'pairing-device-grant' + | 'pairing-session' + | 'renewed-device-grant' + | 'renewed-session'; + readonly sourceSecret: ReturnType; + readonly context: string; + }) { + const derived = this.platform.hkdfSha256( + new TextEncoder().encode(input.sourceSecret), + this.platform.base64UrlDecode(input.key), + `hosted-personal:${input.purpose}\0${input.context}`, + 32 + ); + return parseOpaqueAuthoritySecret(this.platform.base64UrlEncode(derived)); + } + + async deriveCsrf(input: { + readonly key: ReturnType; + readonly sessionId: string; + readonly sessionSecret: ReturnType; + }) { + return parseCsrfToken( + this.platform.hmacSha256( + this.platform.base64UrlDecode(input.key), + ['hosted-personal:csrf\0', input.sessionId, '\0', input.sessionSecret], + 'base64url' + ) + ); + } + + async secureEqual(left: string, right: string): Promise { + return this.platform.secureEqual(left, right); + } +} + +function assertPrivateSecretDirectory( + stat: Awaited>, + platform: HostedAuthHostPlatform +): void { + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (stat.mode & 0o077) !== 0 || + (platform.uid !== undefined && stat.uid !== platform.uid) + ) { + throw new Error('hosted_auth_secret_directory_invalid'); + } +} + +async function writeSecretFile( + path: string, + body: string, + exclusive: boolean, + platform: HostedAuthHostPlatform +): Promise { + const directory = platform.dirname(path); + await platform.mkdir(directory, 0o700); + const directoryBefore = await platform.lstat(directory); + assertPrivateSecretDirectory(directoryBefore, platform); + await platform.chmod(directory, 0o700); + const directoryAfter = await platform.lstat(directory); + assertPrivateSecretDirectory(directoryAfter, platform); + if (directoryBefore.dev !== directoryAfter.dev || directoryBefore.ino !== directoryAfter.ino) { + throw new Error('hosted_auth_secret_directory_invalid'); + } + await platform.writeTextDurable(path, body, { exclusive, mode: 0o600 }); + await platform.chmod(path, 0o600); +} + +function parseKeyring(value: unknown): AuthKeyringEnvelope { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('hosted_auth_keyring_corrupt'); + } + const row = value as Record; + if ( + row.format !== 'hosted-access-keyring/v1' || + typeof row.createdAt !== 'number' || + !Number.isSafeInteger(row.createdAt) || + typeof row.binding !== 'object' || + row.binding === null + ) { + throw new Error('hosted_auth_keyring_corrupt'); + } + const binding = row.binding as Record; + if (!Number.isSafeInteger(binding.restoreGeneration)) { + throw new Error('hosted_auth_keyring_corrupt'); + } + return Object.freeze({ + format: 'hosted-access-keyring/v1', + keyringId: parseAuthKeyringId(row.keyringId), + binding: Object.freeze({ + deploymentId: parseAuthorityDeploymentId(binding.deploymentId), + restoreGeneration: Number(binding.restoreGeneration), + }), + createdAt: row.createdAt, + hashKey: parseAuthorityKeyMaterial(row.hashKey), + csrfKey: parseAuthorityKeyMaterial(row.csrfKey), + }); +} + +function keyringsEqual(left: AuthKeyringEnvelope, right: AuthKeyringEnvelope): boolean { + return ( + left.format === right.format && + left.keyringId === right.keyringId && + left.binding.deploymentId === right.binding.deploymentId && + left.binding.restoreGeneration === right.binding.restoreGeneration && + left.createdAt === right.createdAt && + left.hashKey === right.hashKey && + left.csrfKey === right.csrfKey + ); +} + +export class FileAuthKeyring implements AuthKeyringPort { + constructor( + private readonly activePath: string, + private readonly stageDirectory: string, + private readonly platform: HostedAuthHostPlatform + ) {} + + async loadActive(): Promise { + return this.read(this.activePath); + } + + async createInitial(envelope: AuthKeyringEnvelope): Promise { + try { + await writeSecretFile(this.activePath, JSON.stringify(envelope), true, this.platform); + return { status: 'created' }; + } catch (error) { + if ((error as { readonly code?: string }).code !== 'EEXIST') { + return { status: 'unavailable' }; + } + const existing = await this.loadActive(); + return existing.status === 'available' && keyringsEqual(existing.envelope, envelope) + ? { status: 'already_applied' } + : { status: 'conflict' }; + } + } + + loadStaged(keyringId: AuthKeyringEnvelope['keyringId']): Promise { + return this.read(this.stagePath(keyringId)); + } + + async stageReplacement(envelope: AuthKeyringEnvelope): Promise { + const path = this.stagePath(envelope.keyringId); + try { + await writeSecretFile(path, JSON.stringify(envelope), true, this.platform); + return { status: 'staged' }; + } catch (error) { + if ((error as { readonly code?: string }).code !== 'EEXIST') { + return { status: 'unavailable' }; + } + const existing = await this.read(path); + return existing.status === 'available' && keyringsEqual(existing.envelope, envelope) + ? { status: 'already_applied' } + : { status: 'conflict' }; + } + } + + async activateStaged( + keyringId: AuthKeyringEnvelope['keyringId'] + ): Promise { + const stagedPath = this.stagePath(keyringId); + try { + const staged = await this.read(stagedPath); + if (staged.status === 'missing') { + const active = await this.loadActive(); + return active.status === 'available' && active.envelope.keyringId === keyringId + ? { status: 'already_applied' } + : { status: 'unavailable' }; + } + if (staged.status !== 'available') return { status: 'unavailable' }; + const temporary = `${this.activePath}.activate-${this.platform.pid}`; + try { + await writeSecretFile(temporary, JSON.stringify(staged.envelope), true, this.platform); + } catch (error) { + if ((error as { readonly code?: string }).code !== 'EEXIST') { + return { status: 'unavailable' }; + } + const recoverable = await this.read(temporary); + if ( + recoverable.status !== 'available' || + !keyringsEqual(recoverable.envelope, staged.envelope) + ) { + return { status: 'conflict' }; + } + } + await this.platform.rename(temporary, this.activePath); + await this.platform.remove(stagedPath, { force: true }); + return { status: 'activated' }; + } catch { + return { status: 'unavailable' }; + } + } + + private async read(path: string): Promise { + try { + return { + status: 'available', + envelope: parseKeyring( + JSON.parse( + await readProtectedOwnedHostedAuthFile( + path, + this.platform, + MAXIMUM_KEYRING_BYTES, + 'hosted_auth_keyring_permissions_invalid' + ) + ) + ), + }; + } catch (error) { + if ((error as { readonly code?: string }).code === 'ENOENT') { + return { status: 'missing' }; + } + if ( + error instanceof SyntaxError || + (error instanceof Error && error.message.includes('corrupt')) + ) { + return { status: 'corrupt' }; + } + return { status: 'unavailable' }; + } + } + + private stagePath(keyringId: AuthKeyringEnvelope['keyringId']): string { + return `${this.stageDirectory}/${keyringId}.json`; + } +} + +interface PairingDeliveryDocument { + readonly challengeId: string; + readonly pairingCode: string; + readonly expiresAt: number; +} + +function parsePairingDelivery(value: unknown): PairingDeliveryDocument { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('pairing_delivery_corrupt'); + } + const row = value as Record; + if ( + Reflect.ownKeys(row).length !== 3 || + Reflect.ownKeys(row).some( + (key) => typeof key !== 'string' || !['challengeId', 'pairingCode', 'expiresAt'].includes(key) + ) + ) { + throw new Error('pairing_delivery_corrupt'); + } + if (!Number.isSafeInteger(row.expiresAt) || Number(row.expiresAt) < 0) { + throw new Error('pairing_delivery_corrupt'); + } + return Object.freeze({ + challengeId: parsePairingChallengeId(row.challengeId), + pairingCode: parseOpaqueAuthoritySecret(row.pairingCode), + expiresAt: Number(row.expiresAt), + }); +} + +export class FilePairingChallengeDelivery implements PairingChallengeDeliveryPort { + constructor( + private readonly path: string, + private readonly platform: HostedAuthHostPlatform + ) {} + + async status(challengeId: PairingChallengeId): Promise { + try { + const document = await this.read(); + return { status: document.challengeId === challengeId ? 'present' : 'missing' }; + } catch (error) { + if ((error as { readonly code?: string }).code === 'ENOENT') return { status: 'missing' }; + return { status: 'unavailable' }; + } + } + + async publish(input: { + readonly challengeId: PairingChallengeId; + readonly secret: ReturnType; + readonly expiresAt: number; + }): Promise { + try { + await writeSecretFile( + this.path, + JSON.stringify({ + challengeId: input.challengeId, + pairingCode: input.secret, + expiresAt: input.expiresAt, + }), + true, + this.platform + ); + return { status: 'published' }; + } catch (error) { + if ((error as { readonly code?: string }).code !== 'EEXIST') { + return { status: 'unavailable' }; + } + try { + const existing = await this.read(); + return existing.challengeId === input.challengeId && + existing.pairingCode === input.secret && + existing.expiresAt === input.expiresAt + ? { status: 'already_published' } + : { status: 'conflict' }; + } catch { + return { status: 'unavailable' }; + } + } + } + + async remove(challengeId: PairingChallengeId): Promise { + try { + const existing = await this.read(); + if (existing.challengeId !== challengeId) return { status: 'conflict' }; + await this.platform.remove(this.path); + return { status: 'removed' }; + } catch (error) { + return (error as { readonly code?: string }).code === 'ENOENT' + ? { status: 'already_missing' } + : { status: 'unavailable' }; + } + } + + private async read(): Promise { + return parsePairingDelivery( + JSON.parse( + await readProtectedOwnedHostedAuthFile( + this.path, + this.platform, + MAXIMUM_PAIRING_DELIVERY_BYTES, + 'pairing_delivery_unavailable' + ) + ) + ); + } +} + +interface RuntimeDrainEvidenceDocument { + readonly format: 'agent-teams-runtime-drain/v1'; + readonly deploymentId: string; + readonly restoreGeneration: number; + readonly purpose: PairingDrainPurpose; + readonly targetAuthMode?: 'personal' | 'oidc'; + readonly resetGeneration: number; + readonly outcome: 'drained'; + readonly evidenceRef: string; + readonly observedAt: number; + readonly expiresAt: number; +} + +function parseRuntimeDrainEvidence(value: unknown): RuntimeDrainEvidenceDocument { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('hosted_auth_drain_evidence_invalid'); + } + const row = value as Record; + const baseKeys = [ + 'format', + 'deploymentId', + 'restoreGeneration', + 'purpose', + 'resetGeneration', + 'outcome', + 'evidenceRef', + 'observedAt', + 'expiresAt', + ]; + if ( + row.format !== 'agent-teams-runtime-drain/v1' || + row.outcome !== 'drained' || + (row.purpose !== 'initial_pairing' && + row.purpose !== 'host_reset' && + row.purpose !== 'auth_mode_reset') + ) { + throw new Error('hosted_auth_drain_evidence_invalid'); + } + const expectedKeys = + row.purpose === 'auth_mode_reset' ? [...baseKeys, 'targetAuthMode'] : baseKeys; + if ( + Reflect.ownKeys(row).length !== expectedKeys.length || + Reflect.ownKeys(row).some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + throw new Error('hosted_auth_drain_evidence_invalid'); + } + if ( + (row.purpose === 'auth_mode_reset' && + row.targetAuthMode !== 'personal' && + row.targetAuthMode !== 'oidc') || + (row.purpose !== 'auth_mode_reset' && row.targetAuthMode !== undefined) || + typeof row.evidenceRef !== 'string' || + !/^[A-Za-z0-9:._/-]{1,256}$/.test(row.evidenceRef) || + !Number.isSafeInteger(row.restoreGeneration) || + !Number.isSafeInteger(row.resetGeneration) || + !Number.isSafeInteger(row.observedAt) || + !Number.isSafeInteger(row.expiresAt) + ) { + throw new Error('hosted_auth_drain_evidence_invalid'); + } + parseAuthorityDeploymentId(row.deploymentId); + return row as unknown as RuntimeDrainEvidenceDocument; +} + +/** + * Reads the AR-owned, operator-only drain proof for destructive personal reset. + * Initial pairing can use startup evidence only when this composition has no + * runtime mutation capability at all. + */ +export class FileHostedPairingDrainProof implements PairingDrainProofPort { + constructor( + private readonly path: string, + private readonly options: { + readonly noRuntimeMutationAtStartup: boolean; + readonly now?: () => number; + }, + private readonly platform: HostedAuthHostPlatform + ) {} + + async confirmDrained(input: { + readonly binding: AuthorityBinding; + readonly purpose: PairingDrainPurpose; + readonly resetGeneration: number; + readonly targetAuthMode?: 'personal' | 'oidc'; + }): Promise { + if (input.purpose === 'initial_pairing' && this.options.noRuntimeMutationAtStartup) { + return { + status: 'drained', + evidenceRef: `hosted-auth:initial-pairing:${input.resetGeneration}:no-runtime-admission`, + }; + } + try { + const document = parseRuntimeDrainEvidence( + JSON.parse( + await readProtectedOwnedHostedAuthFile( + this.path, + this.platform, + MAXIMUM_DRAIN_EVIDENCE_BYTES, + 'hosted_auth_drain_evidence_invalid' + ) + ) + ); + const now = (this.options.now ?? Date.now)(); + if ( + document.deploymentId !== input.binding.deploymentId || + document.restoreGeneration !== input.binding.restoreGeneration || + document.purpose !== input.purpose || + document.targetAuthMode !== input.targetAuthMode || + document.resetGeneration !== input.resetGeneration || + document.observedAt > now + 30_000 || + document.expiresAt <= now || + document.expiresAt > document.observedAt + 15 * 60_000 + ) { + return { status: 'unclassified' }; + } + return { status: 'drained', evidenceRef: document.evidenceRef }; + } catch (error) { + return (error as { readonly code?: string }).code === 'ENOENT' + ? { status: 'unavailable' } + : { status: 'unclassified' }; + } + } +} + +export function createInitialKeyringMaterial(platform: HostedAuthHostPlatform) { + return parseAuthorityKeyMaterial(platform.base64UrlEncode(platform.randomBytes(32))); +} diff --git a/src/features/hosted-access/renderer/HostedAuthGate.tsx b/src/features/hosted-access/renderer/HostedAuthGate.tsx new file mode 100644 index 0000000000..af120edc70 --- /dev/null +++ b/src/features/hosted-access/renderer/HostedAuthGate.tsx @@ -0,0 +1,290 @@ +import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; + +import { HOSTED_AUTH_HEADERS, HOSTED_AUTH_ROUTES, type HostedAuthStatus } from '../contracts'; + +import { setHostedCsrfToken } from './csrfMemory'; + +interface HostedAuthGateProps { + readonly children: ReactNode; + readonly onAuthenticated?: (auth: HostedAuthStatus) => void; +} + +type GateState = + | { readonly status: 'loading' } + | { readonly status: 'anonymous'; readonly auth: HostedAuthStatus; readonly error: string | null } + | { readonly status: 'authenticated'; readonly auth: HostedAuthStatus } + | { readonly status: 'unavailable'; readonly error: string }; + +interface LogoutResponse { + readonly ok: boolean; + readonly redirectUrl?: string | null; + readonly providerLogoutError?: string | null; +} + +async function readJson(response: Response): Promise { + const value = (await response.json()) as T & { readonly error?: string }; + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + return value; +} + +export const HostedAuthGate = ({ children, onAuthenticated }: HostedAuthGateProps) => { + const [state, setState] = useState({ status: 'loading' }); + const [pairingCode, setPairingCode] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [accountError, setAccountError] = useState(null); + + const acceptAuthenticated = useCallback( + (auth: HostedAuthStatus) => { + setHostedCsrfToken(auth.csrfToken); + onAuthenticated?.(auth); + setState({ status: 'authenticated', auth }); + }, + [onAuthenticated] + ); + + const load = useCallback(async () => { + try { + const response = await fetch(HOSTED_AUTH_ROUTES.status, { + credentials: 'include', + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + const auth = await readJson(response); + if (auth.authenticated) { + acceptAuthenticated(auth); + } else { + setHostedCsrfToken(null); + setState({ status: 'anonymous', auth, error: null }); + } + } catch (error) { + setHostedCsrfToken(null); + setState({ + status: 'unavailable', + error: error instanceof Error ? error.message : 'Authentication is unavailable.', + }); + } + }, [acceptAuthenticated]); + + useEffect(() => { + void load(); + return () => setHostedCsrfToken(null); + }, [load]); + + const pair = async (event: FormEvent) => { + event.preventDefault(); + if (state.status !== 'anonymous' || state.auth.mode !== 'personal') return; + setSubmitting(true); + try { + const response = await fetch(HOSTED_AUTH_ROUTES.pair, { + method: 'POST', + credentials: 'include', + headers: { + 'content-type': 'application/json', + [HOSTED_AUTH_HEADERS.csrf]: '', + }, + body: JSON.stringify({ pairingCode }), + }); + const auth = await readJson(response); + setPairingCode(''); + acceptAuthenticated(auth); + } catch (error) { + const message = error instanceof Error ? error.message : 'Pairing failed.'; + setPairingCode(''); + if (message === 'identity_storage_unavailable') { + setHostedCsrfToken(null); + setState({ status: 'unavailable', error: message }); + } else { + setState({ + status: 'anonymous', + auth: state.auth, + error: message, + }); + } + } finally { + setSubmitting(false); + } + }; + + const endSession = async (action: 'local' | 'global' | 'forget-device') => { + if (state.status !== 'authenticated' || state.auth.csrfToken === null) return; + setSubmitting(true); + setAccountError(null); + try { + const response = await fetch( + action === 'forget-device' ? HOSTED_AUTH_ROUTES.forgetDevice : HOSTED_AUTH_ROUTES.logout, + { + method: 'POST', + credentials: 'include', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + [HOSTED_AUTH_HEADERS.csrf]: state.auth.csrfToken, + }, + body: JSON.stringify({ global: action === 'global' }), + } + ); + const result = await readJson(response); + setHostedCsrfToken(null); + if (result.providerLogoutError) { + setState({ + status: 'anonymous', + auth: { + ...state.auth, + authenticated: false, + principal: null, + csrfToken: null, + }, + error: result.providerLogoutError, + }); + setSubmitting(false); + return; + } + if (result.redirectUrl) { + window.location.assign(result.redirectUrl); + } else { + window.location.reload(); + } + } catch (error) { + setAccountError(error instanceof Error ? error.message : 'Sign out failed.'); + setSubmitting(false); + } + }; + + if (state.status === 'authenticated') { + return ( + <> + {children} + + + ); + } + + return ( +
+
+

+ Agent Teams hosted +

+

Sign in to this deployment

+ + {state.status === 'loading' && ( +

Checking your session…

+ )} + + {state.status === 'unavailable' && ( + <> +

+ Authentication is unavailable: {state.error} +

+ + + )} + + {state.status === 'anonymous' && state.auth.mode === 'oidc' && ( + <> +

+ Continue with {state.auth.oidcProviderName ?? 'your identity provider'}. If it is + offline, Agent Teams will not fall back to personal pairing. +

+ {state.error && ( +

+ {state.error} +

+ )} + + + )} + + {state.status === 'anonymous' && state.auth.mode === 'personal' && ( +
void pair(event)}> +

+ Retrieve the one-time pairing code from the local Docker host. The code expires after + ten minutes and is never stored by this browser. +

+ + setPairingCode(event.target.value)} + required + minLength={32} + disabled={submitting} + /> + {state.error && ( +

+ {state.error} +

+ )} + +
+ )} +
+
+ ); +}; diff --git a/src/features/hosted-access/renderer/csrfMemory.ts b/src/features/hosted-access/renderer/csrfMemory.ts new file mode 100644 index 0000000000..5bcbb1c500 --- /dev/null +++ b/src/features/hosted-access/renderer/csrfMemory.ts @@ -0,0 +1,18 @@ +import { HOSTED_AUTH_HEADERS } from '../contracts'; + +let csrfToken: string | null = null; + +export function setHostedCsrfToken(value: string | null): void { + csrfToken = value; +} + +export function getHostedCsrfToken(): string | null { + return csrfToken; +} + +export function getHostedMutationHeaders(): Record { + return { + 'Content-Type': 'application/json', + ...(csrfToken ? { [HOSTED_AUTH_HEADERS.csrf]: csrfToken } : {}), + }; +} diff --git a/src/features/hosted-access/renderer/index.ts b/src/features/hosted-access/renderer/index.ts new file mode 100644 index 0000000000..74525a8b67 --- /dev/null +++ b/src/features/hosted-access/renderer/index.ts @@ -0,0 +1,2 @@ +export { getHostedCsrfToken, getHostedMutationHeaders, setHostedCsrfToken } from './csrfMemory'; +export { HostedAuthGate } from './HostedAuthGate'; diff --git a/src/features/hosted-lifecycle-hardening/hosted-lifecycle-coordinator.ts b/src/features/hosted-lifecycle-hardening/hosted-lifecycle-coordinator.ts new file mode 100644 index 0000000000..30e9d60d2a --- /dev/null +++ b/src/features/hosted-lifecycle-hardening/hosted-lifecycle-coordinator.ts @@ -0,0 +1,243 @@ +import { type LifecycleState, LifecycleStateMachine } from './lifecycle-state-machine'; +import { + deadlineFrom, + type OperationBudget, + runWithinBudget, + validateBudgetMs, +} from './operation-budget'; + +import type { + AuditFlushPort, + ConnectionDrainingPort, + DurableStateFlushPort, + LifecycleCancellation, + LifecycleOperationContext, + MonotonicClock, + OwnedRuntimeReleasePort, + ReadinessPublicationPort, + RouteAdmissionPort, +} from './ports'; + +export type LifecycleOperation = + | 'close_route_admission' + | 'publish_not_ready' + | 'drain_http_sse' + | 'flush_durable_state' + | 'flush_audit' + | 'release_owned_runtime'; + +export interface LifecycleFailure { + readonly operation: LifecycleOperation; + readonly error: unknown; +} + +interface LifecycleStopResultBase { + readonly finalState: 'stopped'; + readonly startedAtMs: number; + readonly completedAtMs: number; + readonly deadlineMs: number; + readonly failures: readonly LifecycleFailure[]; +} + +export type LifecycleStopResult = + | (LifecycleStopResultBase & { readonly kind: 'stopped' }) + | (LifecycleStopResultBase & { readonly kind: 'failed' }) + | (LifecycleStopResultBase & { + readonly kind: 'deadline_exceeded'; + readonly interruptedOperation: LifecycleOperation; + }) + | (LifecycleStopResultBase & { + readonly kind: 'cancelled'; + readonly interruptedOperation: LifecycleOperation; + }); + +export interface HostedLifecyclePorts { + readonly routeAdmission: RouteAdmissionPort; + readonly readiness: ReadinessPublicationPort; + readonly connections: ConnectionDrainingPort; + readonly durableState: DurableStateFlushPort; + readonly audit: AuditFlushPort; + readonly ownedRuntime: OwnedRuntimeReleasePort; + readonly clock: MonotonicClock; + readonly cancellation: LifecycleCancellation; +} + +export interface HostedLifecycleCoordinatorOptions { + readonly shutdownBudgetMs: number; +} + +interface Stage { + readonly state: Exclude; + readonly operations: readonly { + readonly name: LifecycleOperation; + readonly run: (context: LifecycleOperationContext) => Promise; + }[]; +} + +/** Coordinates shutdown; it never supervises or executes provider processes. */ +export class HostedLifecycleCoordinator { + private readonly machine = new LifecycleStateMachine(); + private stopResult?: Promise; + + constructor( + private readonly ports: HostedLifecyclePorts, + private readonly options: HostedLifecycleCoordinatorOptions + ) { + validateBudgetMs(options.shutdownBudgetMs); + } + + get state(): LifecycleState { + return this.machine.state; + } + + get stateHistory(): readonly LifecycleState[] { + return this.machine.history; + } + + /** The first request owns the budget; repeated signals receive the same promise. */ + requestStop(): Promise { + if (!this.stopResult) { + this.stopResult = this.stopOnce(); + } + return this.stopResult; + } + + private async stopOnce(): Promise { + const startedAtMs = this.ports.clock.nowMs(); + const deadlineMs = deadlineFrom(this.ports.clock, this.options.shutdownBudgetMs, startedAtMs); + const budget: OperationBudget = { + clock: this.ports.clock, + cancellation: this.ports.cancellation, + deadlineMs, + }; + const context: LifecycleOperationContext = { + cancellation: this.ports.cancellation, + deadlineMs, + }; + const failures: LifecycleFailure[] = []; + + const finishFailed = (): LifecycleStopResult => { + this.machine.stopAfterTerminalOutcome(); + return { + kind: 'failed', + finalState: 'stopped', + startedAtMs, + completedAtMs: this.ports.clock.nowMs(), + deadlineMs, + failures, + }; + }; + const finishInterrupted = ( + kind: 'deadline_exceeded' | 'cancelled', + interruptedOperation: LifecycleOperation + ): LifecycleStopResult => { + this.machine.stopAfterTerminalOutcome(); + return { + kind, + finalState: 'stopped', + startedAtMs, + completedAtMs: this.ports.clock.nowMs(), + deadlineMs, + failures, + interruptedOperation, + }; + }; + + const closeAdmission = await runWithinBudget( + budget, + () => this.ports.routeAdmission.closeAdmission(context), + { + // Admission closure is the fail-closed action and is always initiated. + startWhenInterrupted: true, + } + ); + if (closeAdmission.kind === 'deadline_exceeded' || closeAdmission.kind === 'cancelled') { + return finishInterrupted(closeAdmission.kind, 'close_route_admission'); + } + if (closeAdmission.kind === 'failed') { + failures.push({ operation: 'close_route_admission', error: closeAdmission.error }); + + // Publishing not-ready is still safe after closure fails, but no work that + // assumes admission is closed may begin. + const readiness = await runWithinBudget(budget, () => + this.ports.readiness.publishNotReady(context) + ); + if (readiness.kind === 'failed') { + failures.push({ operation: 'publish_not_ready', error: readiness.error }); + } else if (readiness.kind === 'deadline_exceeded' || readiness.kind === 'cancelled') { + return finishInterrupted(readiness.kind, 'publish_not_ready'); + } + return finishFailed(); + } + + this.machine.transition('admission_closed'); + + const readiness = await runWithinBudget(budget, () => + this.ports.readiness.publishNotReady(context) + ); + if (readiness.kind === 'failed') { + failures.push({ operation: 'publish_not_ready', error: readiness.error }); + } else if (readiness.kind === 'deadline_exceeded' || readiness.kind === 'cancelled') { + return finishInterrupted(readiness.kind, 'publish_not_ready'); + } + + const drainConnections = await runWithinBudget(budget, () => + this.ports.connections.drainHttpAndSse(context) + ); + if (drainConnections.kind === 'failed') { + failures.push({ operation: 'drain_http_sse', error: drainConnections.error }); + return finishFailed(); + } + if (drainConnections.kind === 'deadline_exceeded' || drainConnections.kind === 'cancelled') { + return finishInterrupted(drainConnections.kind, 'drain_http_sse'); + } + + this.machine.transition('draining_http_sse'); + + const stages: readonly Stage[] = [ + { + state: 'flushing_state_audit', + operations: [ + { + name: 'flush_durable_state', + run: (value) => this.ports.durableState.flushDurableState(value), + }, + { name: 'flush_audit', run: (value) => this.ports.audit.flushAudit(value) }, + ], + }, + { + state: 'releasing_owned_runtime', + operations: [ + { + name: 'release_owned_runtime', + run: (value) => this.ports.ownedRuntime.releaseOwnedRuntime(value), + }, + ], + }, + ]; + + for (const stage of stages) { + this.machine.transition(stage.state); + for (const operation of stage.operations) { + const result = await runWithinBudget(budget, () => operation.run(context)); + if (result.kind === 'failed') { + failures.push({ operation: operation.name, error: result.error }); + continue; + } + if (result.kind === 'deadline_exceeded' || result.kind === 'cancelled') { + return finishInterrupted(result.kind, operation.name); + } + } + } + + this.machine.transition('stopped'); + return { + kind: failures.length === 0 ? 'stopped' : 'failed', + finalState: 'stopped', + startedAtMs, + completedAtMs: this.ports.clock.nowMs(), + deadlineMs, + failures, + }; + } +} diff --git a/src/features/hosted-lifecycle-hardening/index.ts b/src/features/hosted-lifecycle-hardening/index.ts new file mode 100644 index 0000000000..0fb20d277c --- /dev/null +++ b/src/features/hosted-lifecycle-hardening/index.ts @@ -0,0 +1,38 @@ +export { + HostedLifecycleCoordinator, + type HostedLifecycleCoordinatorOptions, + type HostedLifecyclePorts, + type LifecycleFailure, + type LifecycleOperation, + type LifecycleStopResult, +} from './hosted-lifecycle-coordinator'; +export { + LIFECYCLE_STATES, + type LifecycleState, + LifecycleStateMachine, +} from './lifecycle-state-machine'; +export type { + AdmissionAttempt, + AdmissionMutationAcknowledgement, + AuditFlushPort, + ConnectionDrainingPort, + DurableStateFlushPort, + LifecycleCancellation, + LifecycleOperationContext, + MonotonicClock, + OwnedRuntimeReleasePort, + ReadinessPublicationPort, + ReplacementAdmissionOperationContext, + ReplacementReadinessPublicationPort, + ReplacementRouteAdmissionPort, + RouteAdmissionPort, +} from './ports'; +export { + AdmissionAcknowledgementError, + type ReplacementAdmissionCleanupFailure, + type ReplacementAdmissionCleanupOperation, + ReplacementAdmissionGate, + type ReplacementAdmissionGateOptions, + type ReplacementAdmissionPorts, + type ReplacementAdmissionResult, +} from './replacement-admission'; diff --git a/src/features/hosted-lifecycle-hardening/lifecycle-state-machine.ts b/src/features/hosted-lifecycle-hardening/lifecycle-state-machine.ts new file mode 100644 index 0000000000..d734998c41 --- /dev/null +++ b/src/features/hosted-lifecycle-hardening/lifecycle-state-machine.ts @@ -0,0 +1,49 @@ +export const LIFECYCLE_STATES = [ + 'accepting', + 'admission_closed', + 'draining_http_sse', + 'flushing_state_audit', + 'releasing_owned_runtime', + 'stopped', +] as const; + +export type LifecycleState = (typeof LIFECYCLE_STATES)[number]; + +const NEXT_STATE: Readonly>> = { + accepting: 'admission_closed', + admission_closed: 'draining_http_sse', + draining_http_sse: 'flushing_state_audit', + flushing_state_audit: 'releasing_owned_runtime', + releasing_owned_runtime: 'stopped', +}; + +/** Pure, forward-only state model for the hosted shutdown sequence. */ +export class LifecycleStateMachine { + private currentState: LifecycleState = 'accepting'; + private readonly visitedStates: LifecycleState[] = ['accepting']; + + get state(): LifecycleState { + return this.currentState; + } + + get history(): readonly LifecycleState[] { + return [...this.visitedStates]; + } + + transition(next: LifecycleState): void { + if (NEXT_STATE[this.currentState] !== next) { + throw new Error(`Invalid lifecycle transition: ${this.currentState} -> ${next}`); + } + + this.currentState = next; + this.visitedStates.push(next); + } + + /** Ends coordination without claiming that an unproven phase was reached. */ + stopAfterTerminalOutcome(): void { + if (this.currentState !== 'stopped') { + this.currentState = 'stopped'; + this.visitedStates.push('stopped'); + } + } +} diff --git a/src/features/hosted-lifecycle-hardening/operation-budget.ts b/src/features/hosted-lifecycle-hardening/operation-budget.ts new file mode 100644 index 0000000000..1d9134ed2b --- /dev/null +++ b/src/features/hosted-lifecycle-hardening/operation-budget.ts @@ -0,0 +1,68 @@ +import type { LifecycleCancellation, MonotonicClock } from './ports'; + +export type BudgetedOperationResult = + | { readonly kind: 'completed' } + | { readonly kind: 'failed'; readonly error: unknown } + | { readonly kind: 'deadline_exceeded' } + | { readonly kind: 'cancelled' }; + +export interface OperationBudget { + readonly clock: MonotonicClock; + readonly cancellation: LifecycleCancellation; + readonly deadlineMs: number; +} + +export function validateBudgetMs(budgetMs: number): void { + if (!Number.isFinite(budgetMs) || budgetMs < 0) { + throw new RangeError('Lifecycle budget must be a finite, non-negative number'); + } +} + +export function deadlineFrom( + clock: MonotonicClock, + budgetMs: number, + startedAtMs = clock.nowMs() +): number { + validateBudgetMs(budgetMs); + if (!Number.isFinite(startedAtMs)) { + throw new RangeError('Monotonic clock must return a finite number'); + } + const deadlineMs = startedAtMs + budgetMs; + if (!Number.isFinite(deadlineMs)) { + throw new RangeError('Lifecycle deadline must be finite'); + } + return deadlineMs; +} + +export async function runWithinBudget( + budget: OperationBudget, + operation: () => Promise, + options: { readonly startWhenInterrupted?: boolean } = {} +): Promise { + const startExecution = async (): Promise => { + try { + await operation(); + return { kind: 'completed' }; + } catch (error: unknown) { + return { kind: 'failed', error }; + } + }; + + const earlyExecution = options.startWhenInterrupted ? startExecution() : undefined; + if (budget.clock.nowMs() >= budget.deadlineMs) { + return { kind: 'deadline_exceeded' }; + } + if (budget.cancellation.isCancellationRequested()) { + return { kind: 'cancelled' }; + } + + const execution = earlyExecution ?? startExecution(); + const cancellation = budget.cancellation + .whenCancellationRequested() + .then(() => ({ kind: 'cancelled' })); + const deadline = budget.clock + .whenMsReached(budget.deadlineMs) + .then(() => ({ kind: 'deadline_exceeded' })); + + return Promise.race([execution, deadline, cancellation]); +} diff --git a/src/features/hosted-lifecycle-hardening/ports.ts b/src/features/hosted-lifecycle-hardening/ports.ts new file mode 100644 index 0000000000..50d4755299 --- /dev/null +++ b/src/features/hosted-lifecycle-hardening/ports.ts @@ -0,0 +1,73 @@ +/** A cancellation source supplied by the composition root. */ +export interface LifecycleCancellation { + isCancellationRequested(): boolean; + whenCancellationRequested(): Promise; +} + +/** Monotonic time and deadline scheduling supplied by the composition root. */ +export interface MonotonicClock { + nowMs(): number; + whenMsReached(monotonicTimeMs: number): Promise; +} + +export interface LifecycleOperationContext { + readonly deadlineMs: number; + readonly cancellation: LifecycleCancellation; +} + +export interface RouteAdmissionPort { + closeAdmission(context: LifecycleOperationContext): Promise; +} + +export interface ReadinessPublicationPort { + publishNotReady(context: LifecycleOperationContext): Promise; +} + +/** + * A single replacement-admission generation. Implementations must check + * `isCurrent()` immediately before making an externally visible mutation. + */ +export interface AdmissionAttempt { + readonly generation: number; + isCurrent(): boolean; +} + +export interface ReplacementAdmissionOperationContext extends LifecycleOperationContext { + readonly attempt: AdmissionAttempt; +} + +export interface AdmissionMutationAcknowledgement { + readonly generation: number; + readonly disposition: 'applied' | 'duplicate' | 'stale'; +} + +export interface ReplacementRouteAdmissionPort extends RouteAdmissionPort { + /** A stale attempt must be acknowledged without opening admission. */ + openAdmission( + context: ReplacementAdmissionOperationContext + ): Promise; +} + +export interface ReplacementReadinessPublicationPort extends ReadinessPublicationPort { + /** A stale attempt must be acknowledged without publishing readiness. */ + publishReady( + context: ReplacementAdmissionOperationContext + ): Promise; +} + +export interface ConnectionDrainingPort { + drainHttpAndSse(context: LifecycleOperationContext): Promise; +} + +export interface DurableStateFlushPort { + flushDurableState(context: LifecycleOperationContext): Promise; +} + +export interface AuditFlushPort { + flushAudit(context: LifecycleOperationContext): Promise; +} + +/** Releases only runtime resources whose ownership was transferred to this host. */ +export interface OwnedRuntimeReleasePort { + releaseOwnedRuntime(context: LifecycleOperationContext): Promise; +} diff --git a/src/features/hosted-lifecycle-hardening/replacement-admission.ts b/src/features/hosted-lifecycle-hardening/replacement-admission.ts new file mode 100644 index 0000000000..e988653ed3 --- /dev/null +++ b/src/features/hosted-lifecycle-hardening/replacement-admission.ts @@ -0,0 +1,239 @@ +import { + type BudgetedOperationResult, + deadlineFrom, + type OperationBudget, + runWithinBudget, + validateBudgetMs, +} from './operation-budget'; + +import type { LifecycleStopResult } from './hosted-lifecycle-coordinator'; +import type { + AdmissionAttempt, + AdmissionMutationAcknowledgement, + LifecycleCancellation, + LifecycleOperationContext, + MonotonicClock, + ReplacementAdmissionOperationContext, + ReplacementReadinessPublicationPort, + ReplacementRouteAdmissionPort, +} from './ports'; + +export type ReplacementAdmissionCleanupOperation = 'close_route_admission' | 'publish_not_ready'; + +export interface ReplacementAdmissionCleanupFailure { + readonly operation: ReplacementAdmissionCleanupOperation; + readonly kind: 'failed' | 'deadline_exceeded' | 'cancelled'; + readonly error?: unknown; +} + +export type ReplacementAdmissionResult = + | { readonly kind: 'admitted'; readonly predecessor: LifecycleStopResult } + | { readonly kind: 'held_closed'; readonly predecessor: LifecycleStopResult } + | { + readonly kind: 'failed' | 'deadline_exceeded' | 'cancelled'; + readonly predecessor: LifecycleStopResult; + readonly error?: unknown; + readonly cleanupFailures: readonly ReplacementAdmissionCleanupFailure[]; + }; + +export interface ReplacementAdmissionPorts { + readonly routeAdmission: ReplacementRouteAdmissionPort; + readonly readiness: ReplacementReadinessPublicationPort; + readonly clock: MonotonicClock; + readonly cancellation: LifecycleCancellation; +} + +export interface ReplacementAdmissionGateOptions { + readonly admissionBudgetMs: number; + readonly cleanupBudgetMs: number; +} + +type AdmissionFailure = + | { readonly kind: 'failed'; readonly error: unknown } + | { readonly kind: 'deadline_exceeded' | 'cancelled' }; + +const CLEANUP_CANCELLATION: LifecycleCancellation = { + isCancellationRequested: () => false, + whenCancellationRequested: () => new Promise(() => undefined), +}; + +class MutableAdmissionAttempt implements AdmissionAttempt { + private active = true; + + constructor( + readonly generation: number, + private readonly clock: MonotonicClock, + private readonly context: LifecycleOperationContext + ) {} + + isCurrent(): boolean { + return ( + this.active && + this.clock.nowMs() < this.context.deadlineMs && + !this.context.cancellation.isCancellationRequested() + ); + } + + invalidate(): void { + this.active = false; + } +} + +export class AdmissionAcknowledgementError extends Error { + constructor( + readonly expectedGeneration: number, + readonly acknowledgement: AdmissionMutationAcknowledgement | undefined + ) { + const received = acknowledgement + ? `${acknowledgement.generation}/${acknowledgement.disposition}` + : 'missing'; + super( + `Invalid admission acknowledgement: expected ${expectedGeneration}, received ${received}` + ); + this.name = 'AdmissionAcknowledgementError'; + } +} + +/** + * Opens a replacement only after its predecessor stops cleanly. Failure keeps, + * or restores, both admission and readiness to their closed state. + */ +export class ReplacementAdmissionGate { + private admissionResult?: Promise; + private nextGeneration = 1; + + constructor( + private readonly ports: ReplacementAdmissionPorts, + private readonly options: ReplacementAdmissionGateOptions + ) { + validateBudgetMs(options.admissionBudgetMs); + validateBudgetMs(options.cleanupBudgetMs); + } + + admitAfter(predecessorStop: Promise): Promise { + if (!this.admissionResult) { + this.admissionResult = this.admitOnce(predecessorStop); + } + return this.admissionResult; + } + + private async admitOnce( + predecessorStop: Promise + ): Promise { + const predecessor = await predecessorStop; + if (predecessor.kind !== 'stopped') { + return { kind: 'held_closed', predecessor }; + } + + const deadlineMs = deadlineFrom(this.ports.clock, this.options.admissionBudgetMs); + const budget: OperationBudget = { + clock: this.ports.clock, + cancellation: this.ports.cancellation, + deadlineMs, + }; + const context: LifecycleOperationContext = { + cancellation: this.ports.cancellation, + deadlineMs, + }; + const attempt = new MutableAdmissionAttempt(this.nextGeneration, this.ports.clock, context); + this.nextGeneration += 1; + const operationContext: ReplacementAdmissionOperationContext = { ...context, attempt }; + + const readiness = await this.runAcknowledged(budget, attempt, () => + this.ports.readiness.publishReady(operationContext) + ); + if (readiness) { + return this.failClosed(attempt, readiness, predecessor); + } + + const admission = await this.runAcknowledged(budget, attempt, () => + this.ports.routeAdmission.openAdmission(operationContext) + ); + if (admission) { + return this.failClosed(attempt, admission, predecessor); + } + + return { kind: 'admitted', predecessor }; + } + + private async runAcknowledged( + budget: OperationBudget, + attempt: AdmissionAttempt, + operation: () => Promise + ): Promise { + let acknowledgement: AdmissionMutationAcknowledgement | undefined; + const result = await runWithinBudget(budget, async () => { + acknowledgement = await operation(); + }); + + if (result.kind !== 'completed') { + return result; + } + if ( + acknowledgement?.generation !== attempt.generation || + acknowledgement.disposition === 'stale' + ) { + return { + kind: 'failed', + error: new AdmissionAcknowledgementError(attempt.generation, acknowledgement), + }; + } + return undefined; + } + + private async failClosed( + attempt: MutableAdmissionAttempt, + failure: AdmissionFailure, + predecessor: LifecycleStopResult + ): Promise { + attempt.invalidate(); + const cleanupFailures = await this.restoreClosedState(); + return failure.kind === 'failed' + ? { kind: 'failed', predecessor, error: failure.error, cleanupFailures } + : { kind: failure.kind, predecessor, cleanupFailures }; + } + + private async restoreClosedState(): Promise { + const deadlineMs = deadlineFrom(this.ports.clock, this.options.cleanupBudgetMs); + const budget: OperationBudget = { + clock: this.ports.clock, + cancellation: CLEANUP_CANCELLATION, + deadlineMs, + }; + const context: LifecycleOperationContext = { + cancellation: CLEANUP_CANCELLATION, + deadlineMs, + }; + const operations: readonly { + readonly name: ReplacementAdmissionCleanupOperation; + readonly run: () => Promise; + }[] = [ + { + name: 'close_route_admission', + run: () => this.ports.routeAdmission.closeAdmission(context), + }, + { name: 'publish_not_ready', run: () => this.ports.readiness.publishNotReady(context) }, + ]; + + const results = await Promise.all( + operations.map(async ({ name, run }) => ({ + name, + result: await runWithinBudget(budget, run, { startWhenInterrupted: true }), + })) + ); + + return results.flatMap(({ name, result }) => this.toCleanupFailure(name, result)); + } + + private toCleanupFailure( + operation: ReplacementAdmissionCleanupOperation, + result: BudgetedOperationResult + ): readonly ReplacementAdmissionCleanupFailure[] { + if (result.kind === 'completed') return []; + return [ + result.kind === 'failed' + ? { operation, kind: 'failed', error: result.error } + : { operation, kind: result.kind }, + ]; + } +} diff --git a/src/features/hosted-opencode-runtime/core/domain/hostedOpenCodeRuntimeLock.test.ts b/src/features/hosted-opencode-runtime/core/domain/hostedOpenCodeRuntimeLock.test.ts new file mode 100644 index 0000000000..a7a04df2e4 --- /dev/null +++ b/src/features/hosted-opencode-runtime/core/domain/hostedOpenCodeRuntimeLock.test.ts @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { parseHostedOpenCodeRuntimeLock } from './hostedOpenCodeRuntimeLock'; + +describe('hosted OpenCode candidate lock', () => { + it('accepts the externally materialized release lock and exact Linux x64 artifact', async () => { + const raw = await readFile(resolve(process.cwd(), 'opencode-hosted-runtime.lock.json'), 'utf8'); + const lock = parseHostedOpenCodeRuntimeLock(JSON.parse(raw)); + + expect(lock.productionEligible).toBe(false); + expect(lock.source.repository).toBe(lock.releaseRepository); + expect(lock.platforms['linux-x64']).toEqual({ + status: 'available', + file: 'opencode-linux-x64.tar.gz', + archiveKind: 'tar.gz', + binaryName: 'opencode', + archiveSha256: '86bb966110001cd3bb5b90b33cbb413f03f207c9a5e4a23241a8a23038464923', + binarySha256: '7858adb4fdf140d7a3bc0a982e559418482333feb9b3d75389d25a0828a8a32d', + assetUrl: + 'https://github.com/777genius/opencode-anomaly/releases/download/v1.18.4-agentteams.1/opencode-linux-x64.tar.gz', + }); + expect(lock.platforms['win32-arm64']).toEqual({ + status: 'unavailable', + reason: 'artifact_digests_pending', + }); + }); +}); diff --git a/src/features/hosted-opencode-runtime/core/domain/hostedOpenCodeRuntimeLock.ts b/src/features/hosted-opencode-runtime/core/domain/hostedOpenCodeRuntimeLock.ts new file mode 100644 index 0000000000..90c9e5f042 --- /dev/null +++ b/src/features/hosted-opencode-runtime/core/domain/hostedOpenCodeRuntimeLock.ts @@ -0,0 +1,209 @@ +export const HOSTED_OPENCODE_RUNTIME_LOCK_SCHEMA_VERSION = 2 as const; +export const HOSTED_OPENCODE_RUNTIME_PLATFORM_KEYS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +] as const; + +export type HostedOpenCodeRuntimePlatformKey = + (typeof HOSTED_OPENCODE_RUNTIME_PLATFORM_KEYS)[number]; + +export const HOSTED_OPENCODE_CURRENT_MANIFEST_SCHEMA_VERSION = 2 as const; + +export interface HostedOpenCodeCurrentManifestV2 { + readonly schemaVersion: 2; + readonly runtime: 'opencode'; + readonly version: string; + readonly tag: string; + readonly platform: HostedOpenCodeRuntimePlatformKey; + readonly binaryPath: string; + readonly assetUrl: string; + readonly archiveSha256: string; + readonly binarySha256: string; + readonly sourceCommit: string; + readonly installedAt: string; +} + +export interface HostedOpenCodeRuntimeAvailableArtifact { + readonly status: 'available'; + readonly file: string; + readonly archiveKind: 'tar.gz' | 'zip'; + readonly binaryName: 'opencode' | 'opencode.exe'; + readonly archiveSha256: string; + readonly binarySha256: string; + readonly assetUrl: string; +} + +export interface HostedOpenCodeRuntimeUnavailableArtifact { + readonly status: 'unavailable'; + readonly reason: 'artifact_digests_pending'; +} + +export type HostedOpenCodeRuntimeArtifact = + | HostedOpenCodeRuntimeAvailableArtifact + | HostedOpenCodeRuntimeUnavailableArtifact; + +export interface HostedOpenCodeRuntimeLockV2 { + readonly schemaVersion: 2; + readonly runtime: 'opencode'; + readonly version: string; + readonly tag: string; + readonly productionEligible: false; + readonly source: { + readonly repository: string; + readonly baseCommit: string; + readonly commit: string; + readonly reviewedPatchSha256: string; + }; + readonly releaseRepository: string; + readonly platforms: Readonly< + Record + >; +} + +const SHA256 = /^[0-9a-f]{64}$/u; +const COMMIT = /^[0-9a-f]{40}$/u; +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+-agentteams\.[0-9]+$/u; + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function exactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).toSorted(); + return ( + actual.length === keys.length && actual.every((key, index) => key === keys.toSorted()[index]) + ); +} + +function parseArtifact( + value: unknown, + platform: HostedOpenCodeRuntimePlatformKey, + releaseRepository: string, + tag: string +): HostedOpenCodeRuntimeArtifact { + const artifact = record(value); + if (!artifact) throw new Error(`hosted_opencode_lock_artifact_invalid:${platform}`); + if (artifact.status === 'unavailable') { + if ( + !exactKeys(artifact, ['status', 'reason']) || + artifact.reason !== 'artifact_digests_pending' + ) { + throw new Error(`hosted_opencode_lock_unavailable_invalid:${platform}`); + } + return { status: 'unavailable', reason: 'artifact_digests_pending' }; + } + if ( + artifact.status !== 'available' || + !exactKeys(artifact, [ + 'status', + 'file', + 'archiveKind', + 'binaryName', + 'archiveSha256', + 'binarySha256', + 'assetUrl', + ]) || + typeof artifact.file !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._-]*\.(?:tar\.gz|zip)$/u.test(artifact.file) || + (artifact.archiveKind !== 'tar.gz' && artifact.archiveKind !== 'zip') || + !artifact.file.endsWith(artifact.archiveKind === 'tar.gz' ? '.tar.gz' : '.zip') || + artifact.binaryName !== (platform.startsWith('win32-') ? 'opencode.exe' : 'opencode') || + typeof artifact.archiveSha256 !== 'string' || + !SHA256.test(artifact.archiveSha256) || + typeof artifact.binarySha256 !== 'string' || + !SHA256.test(artifact.binarySha256) + ) { + throw new Error(`hosted_opencode_lock_available_invalid:${platform}`); + } + const expectedUrl = `https://github.com/${releaseRepository}/releases/download/${tag}/${artifact.file}`; + if (artifact.assetUrl !== expectedUrl || artifact.assetUrl.includes('/releases/latest')) { + throw new Error(`hosted_opencode_lock_asset_url_invalid:${platform}`); + } + return artifact as unknown as HostedOpenCodeRuntimeAvailableArtifact; +} + +export function parseHostedOpenCodeRuntimeLock(value: unknown): HostedOpenCodeRuntimeLockV2 { + const lock = record(value); + if ( + !lock || + !exactKeys(lock, [ + 'schemaVersion', + 'runtime', + 'version', + 'tag', + 'productionEligible', + 'source', + 'releaseRepository', + 'platforms', + ]) || + lock.schemaVersion !== HOSTED_OPENCODE_RUNTIME_LOCK_SCHEMA_VERSION || + lock.runtime !== 'opencode' || + typeof lock.version !== 'string' || + !VERSION.test(lock.version) || + lock.tag !== `v${lock.version}` || + lock.productionEligible !== false || + typeof lock.releaseRepository !== 'string' || + !REPOSITORY.test(lock.releaseRepository) + ) { + throw new Error('hosted_opencode_lock_invalid'); + } + const source = record(lock.source); + if ( + !source || + !exactKeys(source, ['repository', 'baseCommit', 'commit', 'reviewedPatchSha256']) || + typeof source.repository !== 'string' || + !REPOSITORY.test(source.repository) || + source.repository !== lock.releaseRepository || + typeof source.baseCommit !== 'string' || + !COMMIT.test(source.baseCommit) || + typeof source.commit !== 'string' || + !COMMIT.test(source.commit) || + typeof source.reviewedPatchSha256 !== 'string' || + !SHA256.test(source.reviewedPatchSha256) + ) { + throw new Error('hosted_opencode_lock_source_invalid'); + } + const platforms = record(lock.platforms); + if (!platforms || !exactKeys(platforms, HOSTED_OPENCODE_RUNTIME_PLATFORM_KEYS)) { + throw new Error('hosted_opencode_lock_platforms_invalid'); + } + const parsedPlatforms = Object.fromEntries( + HOSTED_OPENCODE_RUNTIME_PLATFORM_KEYS.map((platform) => [ + platform, + parseArtifact( + platforms[platform], + platform, + lock.releaseRepository as string, + lock.tag as string + ), + ]) + ) as unknown as HostedOpenCodeRuntimeLockV2['platforms']; + return { + schemaVersion: 2, + runtime: 'opencode', + version: lock.version, + tag: lock.tag, + productionEligible: false, + source: source as unknown as HostedOpenCodeRuntimeLockV2['source'], + releaseRepository: lock.releaseRepository, + platforms: parsedPlatforms, + }; +} + +export function hostedOpenCodeRuntimePlatformKey( + platform: string, + arch: string +): HostedOpenCodeRuntimePlatformKey { + const key = `${platform}-${arch}`; + if (!HOSTED_OPENCODE_RUNTIME_PLATFORM_KEYS.includes(key as HostedOpenCodeRuntimePlatformKey)) { + throw new Error(`hosted_opencode_platform_unsupported:${key}`); + } + return key as HostedOpenCodeRuntimePlatformKey; +} diff --git a/src/features/hosted-opencode-runtime/index.ts b/src/features/hosted-opencode-runtime/index.ts new file mode 100644 index 0000000000..47c0e70591 --- /dev/null +++ b/src/features/hosted-opencode-runtime/index.ts @@ -0,0 +1,12 @@ +export { + HOSTED_OPENCODE_CURRENT_MANIFEST_SCHEMA_VERSION, + HOSTED_OPENCODE_RUNTIME_LOCK_SCHEMA_VERSION, + HOSTED_OPENCODE_RUNTIME_PLATFORM_KEYS, + type HostedOpenCodeCurrentManifestV2, + type HostedOpenCodeRuntimeArtifact, + type HostedOpenCodeRuntimeAvailableArtifact, + type HostedOpenCodeRuntimeLockV2, + type HostedOpenCodeRuntimePlatformKey, + hostedOpenCodeRuntimePlatformKey, + parseHostedOpenCodeRuntimeLock, +} from './core/domain/hostedOpenCodeRuntimeLock'; diff --git a/src/features/hosted-opencode-runtime/main/composition/hostedOpenCodeRuntimeComposition.ts b/src/features/hosted-opencode-runtime/main/composition/hostedOpenCodeRuntimeComposition.ts new file mode 100644 index 0000000000..704ae4660e --- /dev/null +++ b/src/features/hosted-opencode-runtime/main/composition/hostedOpenCodeRuntimeComposition.ts @@ -0,0 +1,45 @@ +import type { HostedOpenCodeCurrentManifestV2 } from '@features/hosted-opencode-runtime'; + +export interface HostedOpenCodeRuntimeInstallRequest { + readonly runtimeRoot: string; + readonly lock: unknown; + readonly platform?: NodeJS.Platform; + readonly arch?: string; +} + +export interface HostedOpenCodeRuntimeInstallerPort { + install(input: HostedOpenCodeRuntimeInstallRequest): Promise; + resolveBinary(input: HostedOpenCodeRuntimeInstallRequest): Promise; +} + +export interface HostedOpenCodeRuntimeComposition { + install(): Promise; + resolveBinary(): Promise; +} + +export interface HostedOpenCodeRuntimeCompositionInput { + readonly runtimeRoot: string; + readonly loadLock: () => Promise; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly installer: HostedOpenCodeRuntimeInstallerPort; +} + +export function createHostedOpenCodeRuntimeComposition( + input: HostedOpenCodeRuntimeCompositionInput +): HostedOpenCodeRuntimeComposition { + const common = async () => ({ + runtimeRoot: input.runtimeRoot, + lock: await input.loadLock(), + platform: input.platform, + arch: input.arch, + }); + return Object.freeze({ + async install() { + return input.installer.install(await common()); + }, + async resolveBinary() { + return input.installer.resolveBinary(await common()); + }, + }); +} diff --git a/src/features/hosted-opencode-runtime/main/index.ts b/src/features/hosted-opencode-runtime/main/index.ts new file mode 100644 index 0000000000..2d4bc82ae1 --- /dev/null +++ b/src/features/hosted-opencode-runtime/main/index.ts @@ -0,0 +1,7 @@ +export { + createHostedOpenCodeRuntimeComposition, + type HostedOpenCodeRuntimeComposition, + type HostedOpenCodeRuntimeCompositionInput, + type HostedOpenCodeRuntimeInstallerPort, + type HostedOpenCodeRuntimeInstallRequest, +} from './composition/hostedOpenCodeRuntimeComposition'; diff --git a/src/features/hosted-operations/contracts/budgets.ts b/src/features/hosted-operations/contracts/budgets.ts new file mode 100644 index 0000000000..fd0abc7d90 --- /dev/null +++ b/src/features/hosted-operations/contracts/budgets.ts @@ -0,0 +1,65 @@ +import { snapshotExactDataRecord } from './exactDataSnapshot'; + +export interface RetentionBudget { + readonly maxEntries: number; + readonly maxAgeMs: number; + readonly maxTotalBytes: number; +} + +export interface ReferenceLoadBudget { + readonly maxReferences: number; + readonly maxBytesPerReference: number; + readonly maxTotalBytes: number; + readonly maxConcurrentLoads: number; +} + +function assertNonNegativeSafeInteger(value: unknown): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError('hosted-operations-budget-invalid'); + } +} + +export function createRetentionBudget(value: RetentionBudget): RetentionBudget { + const snapshot = snapshotExactDataRecord( + value, + ['maxEntries', 'maxAgeMs', 'maxTotalBytes'], + 'hosted-operations-budget-invalid', + { rejectProxy: true } + ); + const maxEntries = snapshot.maxEntries; + const maxAgeMs = snapshot.maxAgeMs; + const maxTotalBytes = snapshot.maxTotalBytes; + assertNonNegativeSafeInteger(maxEntries); + assertNonNegativeSafeInteger(maxAgeMs); + assertNonNegativeSafeInteger(maxTotalBytes); + return Object.freeze({ maxEntries, maxAgeMs, maxTotalBytes }); +} + +export function createReferenceLoadBudget(value: ReferenceLoadBudget): ReferenceLoadBudget { + const snapshot = snapshotExactDataRecord( + value, + ['maxReferences', 'maxBytesPerReference', 'maxTotalBytes', 'maxConcurrentLoads'], + 'hosted-operations-budget-invalid', + { rejectProxy: true } + ); + const maxReferences = snapshot.maxReferences; + const maxBytesPerReference = snapshot.maxBytesPerReference; + const maxTotalBytes = snapshot.maxTotalBytes; + const maxConcurrentLoads = snapshot.maxConcurrentLoads; + assertNonNegativeSafeInteger(maxReferences); + assertNonNegativeSafeInteger(maxBytesPerReference); + assertNonNegativeSafeInteger(maxTotalBytes); + if ( + typeof maxConcurrentLoads !== 'number' || + !Number.isSafeInteger(maxConcurrentLoads) || + maxConcurrentLoads < 1 + ) { + throw new TypeError('hosted-operations-budget-invalid'); + } + return Object.freeze({ + maxReferences, + maxBytesPerReference, + maxTotalBytes, + maxConcurrentLoads, + }); +} diff --git a/src/features/hosted-operations/contracts/correlation.ts b/src/features/hosted-operations/contracts/correlation.ts new file mode 100644 index 0000000000..21a1c73734 --- /dev/null +++ b/src/features/hosted-operations/contracts/correlation.ts @@ -0,0 +1,82 @@ +import { parseRunId, parseTeamId, type RunId, type TeamId } from '@shared/contracts/hosted'; + +import { snapshotExactDataRecord } from './exactDataSnapshot'; +import { + type DiagnosticId, + type OperationCorrelationId, + parseDiagnosticId, + parseOperationCorrelationId, + parseSseConnectionId, + type SseConnectionId, +} from './identifiers'; + +export interface OperationCorrelationContext { + readonly requestId: OperationCorrelationId; + readonly sseConnectionId?: SseConnectionId; + readonly runId?: RunId; + readonly teamId?: TeamId; + readonly diagnosticId?: DiagnosticId; +} + +export type OperationCorrelationExtension = Partial>; + +const CORRELATION_EXTENSION_KEYS = Object.freeze([ + 'sseConnectionId', + 'runId', + 'teamId', + 'diagnosticId', +] as const); + +export function createOperationCorrelationContext(value: unknown): OperationCorrelationContext { + const input = snapshotExactDataRecord( + value, + ['requestId'], + 'hosted-operations-correlation-invalid', + { optionalKeys: CORRELATION_EXTENSION_KEYS } + ); + + try { + const requestId = parseOperationCorrelationId(input.requestId); + const sseConnectionId = + input.sseConnectionId === undefined ? undefined : parseSseConnectionId(input.sseConnectionId); + const runId = input.runId === undefined ? undefined : parseRunId(input.runId); + const teamId = input.teamId === undefined ? undefined : parseTeamId(input.teamId); + const diagnosticId = + input.diagnosticId === undefined ? undefined : parseDiagnosticId(input.diagnosticId); + + return Object.freeze({ + requestId, + ...(sseConnectionId === undefined ? {} : { sseConnectionId }), + ...(runId === undefined ? {} : { runId }), + ...(teamId === undefined ? {} : { teamId }), + ...(diagnosticId === undefined ? {} : { diagnosticId }), + }); + } catch { + throw new TypeError('hosted-operations-correlation-invalid'); + } +} + +export function propagateOperationCorrelation( + parent: OperationCorrelationContext, + extension: OperationCorrelationExtension +): OperationCorrelationContext { + const trustedParent = createOperationCorrelationContext(parent); + const extensionRecord = snapshotExactDataRecord( + extension, + [], + 'hosted-operations-correlation-extension-invalid', + { optionalKeys: CORRELATION_EXTENSION_KEYS } + ); + const merged: Record = { ...trustedParent }; + + for (const key of CORRELATION_EXTENSION_KEYS) { + const current = trustedParent[key as keyof OperationCorrelationContext]; + const next = extensionRecord[key]; + if (current !== undefined && next !== undefined && current !== next) { + throw new TypeError('hosted-operations-correlation-conflict'); + } + if (next !== undefined) merged[key] = next; + } + + return createOperationCorrelationContext(merged); +} diff --git a/src/features/hosted-operations/contracts/events.ts b/src/features/hosted-operations/contracts/events.ts new file mode 100644 index 0000000000..93f954e786 --- /dev/null +++ b/src/features/hosted-operations/contracts/events.ts @@ -0,0 +1,94 @@ +import type { OperationCorrelationContext } from './correlation'; + +export const OPERATION_EVENT_KINDS = Object.freeze([ + 'http_request', + 'sse_connection', + 'run_operation', + 'team_operation', + 'reference_load', + 'retention', +] as const); + +export type OperationEventKind = (typeof OPERATION_EVENT_KINDS)[number]; + +export const OPERATION_OUTCOMES = Object.freeze([ + 'started', + 'succeeded', + 'failed', + 'cancelled', + 'rejected', +] as const); + +export type OperationOutcome = (typeof OPERATION_OUTCOMES)[number]; + +export const SAFE_OPERATION_ATTRIBUTE_KEYS = Object.freeze([ + 'component', + 'operation', + 'reason', + 'state', +] as const); + +export type SafeOperationAttributeKey = (typeof SAFE_OPERATION_ATTRIBUTE_KEYS)[number]; + +export const SAFE_OPERATION_ATTRIBUTE_VALUES = Object.freeze({ + component: Object.freeze([ + 'http_server', + 'operations_kernel', + 'reference_loader', + 'retention_policy', + 'run_controller', + 'sse_stream', + 'team_controller', + ] as const), + operation: Object.freeze([ + 'connect', + 'disconnect', + 'launch', + 'load', + 'prune', + 'publish', + 'replay', + 'request', + 'snapshot', + 'start', + 'stop', + ] as const), + reason: Object.freeze([ + 'budget_exceeded', + 'cancelled', + 'deadline_exceeded', + 'invalid_input', + 'source_failed', + 'unavailable', + ] as const), + state: Object.freeze(['active', 'closed', 'degraded', 'idle', 'ready'] as const), +}); + +export const REDACTED_OPERATION_ATTRIBUTE_VALUE = 'redacted' as const; + +export interface SafeOperationAttributes { + readonly component?: + | (typeof SAFE_OPERATION_ATTRIBUTE_VALUES.component)[number] + | typeof REDACTED_OPERATION_ATTRIBUTE_VALUE; + readonly operation?: + | (typeof SAFE_OPERATION_ATTRIBUTE_VALUES.operation)[number] + | typeof REDACTED_OPERATION_ATTRIBUTE_VALUE; + readonly reason?: + | (typeof SAFE_OPERATION_ATTRIBUTE_VALUES.reason)[number] + | typeof REDACTED_OPERATION_ATTRIBUTE_VALUE; + readonly state?: + | (typeof SAFE_OPERATION_ATTRIBUTE_VALUES.state)[number] + | typeof REDACTED_OPERATION_ATTRIBUTE_VALUE; +} + +export const OPERATIONS_EVENT_SCHEMA_VERSION = 1 as const; + +export interface SafeOperationsEvent { + readonly schemaVersion: typeof OPERATIONS_EVENT_SCHEMA_VERSION; + readonly kind: OperationEventKind; + readonly outcome: OperationOutcome; + /** Monotonic process-relative time, never a wall-clock or user-provided timestamp. */ + readonly occurredAtMonotonicMs: number; + readonly correlation: OperationCorrelationContext; + readonly attributes: SafeOperationAttributes; +} diff --git a/src/features/hosted-operations/contracts/exactDataSnapshot.ts b/src/features/hosted-operations/contracts/exactDataSnapshot.ts new file mode 100644 index 0000000000..0eade0bb1f --- /dev/null +++ b/src/features/hosted-operations/contracts/exactDataSnapshot.ts @@ -0,0 +1,82 @@ +export interface ExactDataSnapshotOptions { + readonly optionalKeys?: readonly string[]; + readonly rejectProxy?: boolean; +} + +function invalid(errorCode: string): TypeError { + return new TypeError(errorCode); +} + +/** + * Copies each exact own data-property value once. Accessor getters are never invoked, and extra or + * missing keys are rejected; callers validate and use only the returned frozen record. + */ +export function snapshotExactDataRecord( + value: unknown, + requiredKeys: readonly string[], + errorCode: string, + options: ExactDataSnapshotOptions = {} +): Readonly> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalid(errorCode); + } + + const optionalKeys = options.optionalKeys ?? []; + const allowedKeys = new Set([...requiredKeys, ...optionalKeys]); + + try { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw invalid(errorCode); + + const snapshot: Record = {}; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowedKeys.has(key)) throw invalid(errorCode); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) throw invalid(errorCode); + snapshot[key] = descriptor.value; + } + if (requiredKeys.some((key) => !Object.prototype.hasOwnProperty.call(snapshot, key))) { + throw invalid(errorCode); + } + + if (options.rejectProxy) { + // Budgets contain primitives only. A structured-clone probe therefore rejects Proxy input + // without changing which descriptor values become the authoritative snapshot. + structuredClone(value); + } + + return Object.freeze(snapshot); + } catch { + throw invalid(errorCode); + } +} + +/** Copies a dense array through exact own data descriptors, never through index accessors. */ +export function snapshotDenseDataArray(value: unknown, errorCode: string): readonly unknown[] { + if (!Array.isArray(value)) throw invalid(errorCode); + + try { + const descriptors = new Map(); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) throw invalid(errorCode); + descriptors.set(key, descriptor); + } + + const length = descriptors.get('length')?.value; + if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) { + throw invalid(errorCode); + } + if (descriptors.size !== length + 1) throw invalid(errorCode); + + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors.get(String(index)); + if (!descriptor) throw invalid(errorCode); + snapshot.push(descriptor.value); + } + return Object.freeze(snapshot); + } catch { + throw invalid(errorCode); + } +} diff --git a/src/features/hosted-operations/contracts/hostedDiagnostics.ts b/src/features/hosted-operations/contracts/hostedDiagnostics.ts new file mode 100644 index 0000000000..e67f96837c --- /dev/null +++ b/src/features/hosted-operations/contracts/hostedDiagnostics.ts @@ -0,0 +1,370 @@ +import { + createSafeAppError, + HOSTED_SCHEMA_VERSION, + type SafeAppError, +} from '@shared/contracts/hosted'; + +import { createReferenceLoadBudget, type ReferenceLoadBudget } from './budgets'; +import { createOperationCorrelationContext, type OperationCorrelationContext } from './correlation'; +import { + OPERATION_EVENT_KINDS, + OPERATION_OUTCOMES, + type OperationEventKind, + type OperationOutcome, + REDACTED_OPERATION_ATTRIBUTE_VALUE, + SAFE_OPERATION_ATTRIBUTE_KEYS, + SAFE_OPERATION_ATTRIBUTE_VALUES, + type SafeOperationAttributeKey, + type SafeOperationAttributes, +} from './events'; +import { snapshotExactDataRecord } from './exactDataSnapshot'; +import { + type DiagnosticId, + type OperationalReferenceId, + parseDiagnosticId, + parseOperationalReferenceId, +} from './identifiers'; + +export const HOSTED_DIAGNOSTICS_SCHEMA_VERSION = HOSTED_SCHEMA_VERSION; +export const HOSTED_DIAGNOSTICS_QUERY_ROUTE = '/api/hosted/v1/operations/diagnostics' as const; + +export const HOSTED_DIAGNOSTICS_MAX_REFERENCES = 32; +export const HOSTED_DIAGNOSTICS_MAX_BYTES_PER_REFERENCE = 64 * 1_024; +export const HOSTED_DIAGNOSTICS_MAX_TOTAL_BYTES = 512 * 1_024; +export const HOSTED_DIAGNOSTICS_MAX_CONCURRENT_LOADS = 4; + +/** Fixed host policy. A browser request has no field capable of replacing or widening it. */ +export const HOSTED_DIAGNOSTICS_REFERENCE_BUDGET: ReferenceLoadBudget = createReferenceLoadBudget({ + maxReferences: HOSTED_DIAGNOSTICS_MAX_REFERENCES, + maxBytesPerReference: HOSTED_DIAGNOSTICS_MAX_BYTES_PER_REFERENCE, + maxTotalBytes: HOSTED_DIAGNOSTICS_MAX_TOTAL_BYTES, + maxConcurrentLoads: HOSTED_DIAGNOSTICS_MAX_CONCURRENT_LOADS, +}); + +export const HOSTED_DIAGNOSTICS_ERROR_REASONS = Object.freeze([ + 'request_invalid', + 'request_cancelled', + 'reference_budget_exceeded', + 'diagnostics_unavailable', + 'response_invalid', + 'transport_unavailable', +] as const); + +export type HostedDiagnosticsErrorReason = (typeof HOSTED_DIAGNOSTICS_ERROR_REASONS)[number]; + +/** Browser input. Principal, scope, deadline, cancellation, and budgets are host-owned. */ +export interface HostedDiagnosticsRequest { + readonly schemaVersion: typeof HOSTED_DIAGNOSTICS_SCHEMA_VERSION; + readonly referenceIds: readonly OperationalReferenceId[]; +} + +/** + * A deliberately narrow structured projection. There is no free-form text, path, command, + * environment, header, credential, token, or provider-output field. + */ +export interface HostedDiagnosticItem { + readonly referenceId: OperationalReferenceId; + readonly kind: OperationEventKind; + readonly outcome: OperationOutcome; + readonly occurredAtMonotonicMs: number; + readonly attributes: SafeOperationAttributes; + readonly byteLength: number; +} + +export interface HostedDiagnosticsSuccess { + readonly schemaVersion: typeof HOSTED_DIAGNOSTICS_SCHEMA_VERSION; + readonly kind: 'success'; + readonly correlation: OperationCorrelationContext & { + readonly diagnosticId: DiagnosticId; + readonly runId?: never; + readonly sseConnectionId?: never; + readonly teamId?: never; + }; + readonly items: readonly HostedDiagnosticItem[]; + readonly totalBytes: number; +} + +export interface HostedDiagnosticsFailure { + readonly schemaVersion: typeof HOSTED_DIAGNOSTICS_SCHEMA_VERSION; + readonly kind: 'error'; + readonly error: SafeAppError & { readonly reason: HostedDiagnosticsErrorReason }; + readonly retryable: boolean; +} + +export type HostedDiagnosticsResponse = HostedDiagnosticsSuccess | HostedDiagnosticsFailure; + +export type HostedDiagnosticsParseResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false }; + +const REQUEST_KEYS = Object.freeze(['schemaVersion', 'referenceIds'] as const); +const SUCCESS_KEYS = Object.freeze([ + 'schemaVersion', + 'kind', + 'correlation', + 'items', + 'totalBytes', +] as const); +const FAILURE_KEYS = Object.freeze(['schemaVersion', 'kind', 'error', 'retryable'] as const); +const ITEM_KEYS = Object.freeze([ + 'referenceId', + 'kind', + 'outcome', + 'occurredAtMonotonicMs', + 'attributes', + 'byteLength', +] as const); + +const PARSE_FAILURE: HostedDiagnosticsParseResult = Object.freeze({ ok: false }); + +function success(value: T): HostedDiagnosticsParseResult { + return Object.freeze({ ok: true, value }); +} + +function snapshotBoundedDenseArray(value: unknown, maximumLength: number): readonly unknown[] { + if (!Array.isArray(value)) throw new TypeError('hosted-diagnostics-array-invalid'); + + try { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = lengthDescriptor?.value; + if ( + typeof length !== 'number' || + !Number.isSafeInteger(length) || + length < 0 || + length > maximumLength + ) { + throw new TypeError('hosted-diagnostics-array-invalid'); + } + + const keys = Reflect.ownKeys(value); + if (keys.length !== length + 1) throw new TypeError('hosted-diagnostics-array-invalid'); + + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !('value' in descriptor)) { + throw new TypeError('hosted-diagnostics-array-invalid'); + } + snapshot.push(descriptor.value); + } + return Object.freeze(snapshot); + } catch { + throw new TypeError('hosted-diagnostics-array-invalid'); + } +} + +export function parseHostedDiagnosticsRequest( + value: unknown +): HostedDiagnosticsParseResult { + try { + const input = snapshotExactDataRecord( + value, + REQUEST_KEYS, + 'hosted-diagnostics-request-invalid' + ); + if (input.schemaVersion !== HOSTED_DIAGNOSTICS_SCHEMA_VERSION) return PARSE_FAILURE; + + const referenceIds = snapshotBoundedDenseArray( + input.referenceIds, + HOSTED_DIAGNOSTICS_MAX_REFERENCES + ).map(parseOperationalReferenceId); + if (new Set(referenceIds).size !== referenceIds.length) return PARSE_FAILURE; + + return success( + Object.freeze({ + schemaVersion: HOSTED_DIAGNOSTICS_SCHEMA_VERSION, + referenceIds: Object.freeze(referenceIds), + }) + ); + } catch { + return PARSE_FAILURE; + } +} + +function parseSafeAttributes(value: unknown): SafeOperationAttributes { + const input = snapshotExactDataRecord(value, [], 'hosted-diagnostics-attributes-invalid', { + optionalKeys: SAFE_OPERATION_ATTRIBUTE_KEYS, + }); + const attributes: Partial> = {}; + + for (const key of SAFE_OPERATION_ATTRIBUTE_KEYS) { + if (!Object.hasOwn(input, key)) continue; + const candidate = input[key]; + if ( + typeof candidate !== 'string' || + (candidate !== REDACTED_OPERATION_ATTRIBUTE_VALUE && + !SAFE_OPERATION_ATTRIBUTE_VALUES[key].some((allowed) => allowed === candidate)) + ) { + throw new TypeError('hosted-diagnostics-attributes-invalid'); + } + attributes[key] = candidate; + } + + return Object.freeze(attributes) as SafeOperationAttributes; +} + +function parseItem(value: unknown): HostedDiagnosticItem { + const input = snapshotExactDataRecord(value, ITEM_KEYS, 'hosted-diagnostics-item-invalid'); + const kind = input.kind; + const outcome = input.outcome; + const occurredAtMonotonicMs = input.occurredAtMonotonicMs; + const byteLength = input.byteLength; + + if ( + !OPERATION_EVENT_KINDS.includes(kind as OperationEventKind) || + !OPERATION_OUTCOMES.includes(outcome as OperationOutcome) || + !Number.isSafeInteger(occurredAtMonotonicMs) || + (occurredAtMonotonicMs as number) < 0 || + !Number.isSafeInteger(byteLength) || + (byteLength as number) < 0 || + (byteLength as number) > HOSTED_DIAGNOSTICS_MAX_BYTES_PER_REFERENCE + ) { + throw new TypeError('hosted-diagnostics-item-invalid'); + } + + return Object.freeze({ + referenceId: parseOperationalReferenceId(input.referenceId), + kind: kind as OperationEventKind, + outcome: outcome as OperationOutcome, + occurredAtMonotonicMs: occurredAtMonotonicMs as number, + attributes: parseSafeAttributes(input.attributes), + byteLength: byteLength as number, + }); +} + +function parseCorrelation(value: unknown): HostedDiagnosticsSuccess['correlation'] { + const input = snapshotExactDataRecord( + value, + ['requestId', 'diagnosticId'], + 'hosted-diagnostics-correlation-invalid' + ); + const correlation = createOperationCorrelationContext(input); + if (correlation.diagnosticId === undefined) { + throw new TypeError('hosted-diagnostics-correlation-invalid'); + } + return correlation as HostedDiagnosticsSuccess['correlation']; +} + +function parseSuccessResponse(input: Readonly>): HostedDiagnosticsSuccess { + const items = snapshotBoundedDenseArray(input.items, HOSTED_DIAGNOSTICS_MAX_REFERENCES).map( + parseItem + ); + if (new Set(items.map(({ referenceId }) => referenceId)).size !== items.length) { + throw new TypeError('hosted-diagnostics-response-invalid'); + } + + let totalBytes = 0; + for (const item of items) { + if (item.byteLength > HOSTED_DIAGNOSTICS_MAX_TOTAL_BYTES - totalBytes) { + throw new TypeError('hosted-diagnostics-response-invalid'); + } + totalBytes += item.byteLength; + } + if (input.totalBytes !== totalBytes) { + throw new TypeError('hosted-diagnostics-response-invalid'); + } + + return Object.freeze({ + schemaVersion: HOSTED_DIAGNOSTICS_SCHEMA_VERSION, + kind: 'success', + correlation: parseCorrelation(input.correlation), + items: Object.freeze(items), + totalBytes, + }); +} + +function errorPropertiesFor(reason: HostedDiagnosticsErrorReason): { + readonly code: SafeAppError['code']; + readonly retryable: boolean; +} { + switch (reason) { + case 'request_invalid': + case 'reference_budget_exceeded': + return { code: 'invalid_request', retryable: false }; + case 'request_cancelled': + return { code: 'cancelled', retryable: false }; + case 'diagnostics_unavailable': + case 'transport_unavailable': + return { code: 'unavailable', retryable: true }; + case 'response_invalid': + return { code: 'internal', retryable: false }; + } +} + +export function createHostedDiagnosticsFailure( + reason: HostedDiagnosticsErrorReason, + diagnosticId?: DiagnosticId +): HostedDiagnosticsFailure { + if (!HOSTED_DIAGNOSTICS_ERROR_REASONS.includes(reason)) { + throw new TypeError('hosted-diagnostics-error-reason-invalid'); + } + const properties = errorPropertiesFor(reason); + const parsedDiagnosticId = + diagnosticId === undefined ? undefined : parseDiagnosticId(diagnosticId); + const error = createSafeAppError({ + code: properties.code, + reason, + ...(parsedDiagnosticId === undefined ? {} : { diagnosticId: parsedDiagnosticId }), + }); + return Object.freeze({ + schemaVersion: HOSTED_DIAGNOSTICS_SCHEMA_VERSION, + kind: 'error', + error: error as HostedDiagnosticsFailure['error'], + retryable: properties.retryable, + }); +} + +function parseFailureResponse(input: Readonly>): HostedDiagnosticsFailure { + const errorInput = snapshotExactDataRecord( + input.error, + ['code', 'reason'], + 'hosted-diagnostics-error-invalid', + { optionalKeys: ['diagnosticId'] } + ); + const reason = errorInput.reason; + if ( + typeof reason !== 'string' || + !HOSTED_DIAGNOSTICS_ERROR_REASONS.includes(reason as HostedDiagnosticsErrorReason) + ) { + throw new TypeError('hosted-diagnostics-error-invalid'); + } + const parsed = createHostedDiagnosticsFailure( + reason as HostedDiagnosticsErrorReason, + errorInput.diagnosticId === undefined ? undefined : parseDiagnosticId(errorInput.diagnosticId) + ); + if (input.retryable !== parsed.retryable || errorInput.code !== parsed.error.code) { + throw new TypeError('hosted-diagnostics-error-invalid'); + } + return parsed; +} + +export function parseHostedDiagnosticsResponse( + value: unknown +): HostedDiagnosticsParseResult { + try { + const discriminant = snapshotExactDataRecord( + value, + ['schemaVersion', 'kind'], + 'hosted-diagnostics-response-invalid', + { optionalKeys: ['correlation', 'items', 'totalBytes', 'error', 'retryable'] } + ); + if (discriminant.schemaVersion !== HOSTED_DIAGNOSTICS_SCHEMA_VERSION) return PARSE_FAILURE; + if (discriminant.kind === 'success') { + return success( + parseSuccessResponse( + snapshotExactDataRecord(value, SUCCESS_KEYS, 'hosted-diagnostics-response-invalid') + ) + ); + } + if (discriminant.kind === 'error') { + return success( + parseFailureResponse( + snapshotExactDataRecord(value, FAILURE_KEYS, 'hosted-diagnostics-response-invalid') + ) + ); + } + return PARSE_FAILURE; + } catch { + return PARSE_FAILURE; + } +} diff --git a/src/features/hosted-operations/contracts/identifiers.ts b/src/features/hosted-operations/contracts/identifiers.ts new file mode 100644 index 0000000000..27366d731e --- /dev/null +++ b/src/features/hosted-operations/contracts/identifiers.ts @@ -0,0 +1,45 @@ +declare const operationsIdentifierBrand: unique symbol; + +export type DiagnosticId = string & { + readonly [operationsIdentifierBrand]: 'DiagnosticId'; +}; +export type SseConnectionId = string & { + readonly [operationsIdentifierBrand]: 'SseConnectionId'; +}; +export type OperationalReferenceId = string & { + readonly [operationsIdentifierBrand]: 'OperationalReferenceId'; +}; +export type OperationCorrelationId = string & { + readonly [operationsIdentifierBrand]: 'OperationCorrelationId'; +}; + +const OPAQUE_IDENTIFIER_PAYLOAD = /^[0-9a-f]{32}$/; + +function parseOpaqueIdentifier( + value: unknown, + prefix: 'diagnostic' | 'reference' | 'request' | 'stream' +): T { + const expectedLength = prefix.length + 1 + 32; + if ( + typeof value !== 'string' || + value.length !== expectedLength || + !value.startsWith(`${prefix}_`) || + !OPAQUE_IDENTIFIER_PAYLOAD.test(value.slice(prefix.length + 1)) + ) { + throw new TypeError('hosted-operations-opaque-identifier-invalid'); + } + return value as T; +} + +export const parseDiagnosticId = (value: unknown): DiagnosticId => + parseOpaqueIdentifier(value, 'diagnostic'); + +export const parseSseConnectionId = (value: unknown): SseConnectionId => + parseOpaqueIdentifier(value, 'stream'); + +export const parseOperationalReferenceId = (value: unknown): OperationalReferenceId => + parseOpaqueIdentifier(value, 'reference'); + +/** Parses the server-minted fixed-shape identifier shared across one operation context. */ +export const parseOperationCorrelationId = (value: unknown): OperationCorrelationId => + parseOpaqueIdentifier(value, 'request'); diff --git a/src/features/hosted-operations/contracts/index.ts b/src/features/hosted-operations/contracts/index.ts new file mode 100644 index 0000000000..efef4317ba --- /dev/null +++ b/src/features/hosted-operations/contracts/index.ts @@ -0,0 +1,6 @@ +export * from './budgets'; +export * from './correlation'; +export * from './events'; +export * from './hostedDiagnostics'; +export * from './identifiers'; +export * from './metrics'; diff --git a/src/features/hosted-operations/contracts/metrics.ts b/src/features/hosted-operations/contracts/metrics.ts new file mode 100644 index 0000000000..ba34ec18d4 --- /dev/null +++ b/src/features/hosted-operations/contracts/metrics.ts @@ -0,0 +1,33 @@ +import type { OperationOutcome } from './events'; + +export const OPERATION_AREAS = Object.freeze([ + 'http', + 'sse', + 'run', + 'team', + 'reference', + 'retention', +] as const); + +export type OperationArea = (typeof OPERATION_AREAS)[number]; + +export const OPERATIONS_EVENT_COUNT_METRIC = 'operation_events_total' as const; + +export interface OperationsMetricLabels { + readonly area: OperationArea; + readonly outcome: OperationOutcome; +} + +export interface OperationsMetricSeries { + readonly name: typeof OPERATIONS_EVENT_COUNT_METRIC; + readonly labels: OperationsMetricLabels; + readonly value: number; +} + +export interface OperationsMetricSnapshot { + readonly capturedAtMonotonicMs: number; + readonly maxSeries: number; + readonly series: readonly OperationsMetricSeries[]; + /** Observations dropped because accepting their label set would exceed maxSeries. */ + readonly discardedObservations: number; +} diff --git a/src/features/hosted-operations/core/application/BoundedMetricAccumulator.ts b/src/features/hosted-operations/core/application/BoundedMetricAccumulator.ts new file mode 100644 index 0000000000..0851d01a34 --- /dev/null +++ b/src/features/hosted-operations/core/application/BoundedMetricAccumulator.ts @@ -0,0 +1,94 @@ +import { + type OperationEventKind, + type OperationOutcome, + OPERATIONS_EVENT_COUNT_METRIC, + type OperationsMetricSeries, + type OperationsMetricSnapshot, +} from '../../contracts'; +import { snapshotExactDataRecord } from '../../contracts/exactDataSnapshot'; +import { + assertOperationEventKind, + assertOperationOutcome, + operationAreaForEventKind, +} from '../domain'; + +interface MutableSeries { + readonly kind: OperationEventKind; + readonly outcome: OperationOutcome; + value: number; +} + +export interface BoundedMetricAccumulatorOptions { + readonly maxSeries: number; +} + +/** Instance-scoped accumulator with fixed labels and a hard cap on accepted label sets. */ +export class BoundedMetricAccumulator { + readonly #maxSeries: number; + readonly #series = new Map(); + #discardedObservations = 0; + + constructor(options: BoundedMetricAccumulatorOptions) { + const snapshot = snapshotExactDataRecord( + options, + ['maxSeries'], + 'hosted-operations-metric-limit-invalid', + { rejectProxy: true } + ); + const maxSeries = snapshot.maxSeries; + if (typeof maxSeries !== 'number' || !Number.isSafeInteger(maxSeries) || maxSeries < 0) { + throw new TypeError('hosted-operations-metric-limit-invalid'); + } + this.#maxSeries = maxSeries; + } + + increment(kind: OperationEventKind, outcome: OperationOutcome, amount = 1): void { + assertOperationEventKind(kind); + assertOperationOutcome(outcome); + if (!Number.isSafeInteger(amount) || amount < 1) { + throw new TypeError('hosted-operations-metric-amount-invalid'); + } + + const area = operationAreaForEventKind(kind); + const seriesKey = `${area}\u0000${outcome}`; + const existing = this.#series.get(seriesKey); + if (existing) { + existing.value = Math.min(Number.MAX_SAFE_INTEGER, existing.value + amount); + return; + } + if (this.#series.size >= this.#maxSeries) { + this.#discardedObservations = Math.min( + Number.MAX_SAFE_INTEGER, + this.#discardedObservations + amount + ); + return; + } + this.#series.set(seriesKey, { kind, outcome, value: amount }); + } + + snapshot(capturedAtMonotonicMs: number): OperationsMetricSnapshot { + if (!Number.isSafeInteger(capturedAtMonotonicMs) || capturedAtMonotonicMs < 0) { + throw new TypeError('hosted-operations-metric-time-invalid'); + } + + const series: OperationsMetricSeries[] = [...this.#series.entries()] + .sort(([left], [right]) => (left === right ? 0 : left < right ? -1 : 1)) + .map(([, item]) => + Object.freeze({ + name: OPERATIONS_EVENT_COUNT_METRIC, + labels: Object.freeze({ + area: operationAreaForEventKind(item.kind), + outcome: item.outcome, + }), + value: item.value, + }) + ); + + return Object.freeze({ + capturedAtMonotonicMs, + maxSeries: this.#maxSeries, + series: Object.freeze(series), + discardedObservations: this.#discardedObservations, + }); + } +} diff --git a/src/features/hosted-operations/core/application/BoundedReferenceLoader.ts b/src/features/hosted-operations/core/application/BoundedReferenceLoader.ts new file mode 100644 index 0000000000..35ac66b29b --- /dev/null +++ b/src/features/hosted-operations/core/application/BoundedReferenceLoader.ts @@ -0,0 +1,185 @@ +import { + createReferenceLoadBudget, + type OperationalReferenceId, + parseOperationalReferenceId, + type ReferenceLoadBudget, +} from '../../contracts'; +import { snapshotDenseDataArray, snapshotExactDataRecord } from '../../contracts/exactDataSnapshot'; + +import { ReferenceLoadError } from './errors'; + +import type { OperationalReferenceSourcePort, ReferenceSourceResult } from './ports'; + +export interface LoadedOperationalReference extends ReferenceSourceResult { + readonly referenceId: OperationalReferenceId; +} + +export interface BoundedReferenceLoadResult { + readonly references: readonly LoadedOperationalReference[]; + readonly totalBytes: number; +} + +export interface LoadOperationalReferencesInput { + readonly referenceIds: readonly OperationalReferenceId[]; + readonly budget: ReferenceLoadBudget; + readonly signal: AbortSignal; +} + +function cancelled(): ReferenceLoadError { + return new ReferenceLoadError('reference_load_cancelled'); +} + +function materializeSourceResult(value: ReferenceSourceResult): ReferenceSourceResult { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ReferenceLoadError('reference_source_result_invalid'); + } + + try { + const byteLength = Object.getOwnPropertyDescriptor(value, 'byteLength'); + const resultValue = Object.getOwnPropertyDescriptor(value, 'value'); + if ( + !byteLength || + !('value' in byteLength) || + !Number.isSafeInteger(byteLength.value) || + (byteLength.value as number) < 0 || + !resultValue || + !('value' in resultValue) + ) { + throw new ReferenceLoadError('reference_source_result_invalid'); + } + return Object.freeze({ + value: resultValue.value as T, + byteLength: byteLength.value as number, + }); + } catch (error) { + throw error instanceof ReferenceLoadError + ? error + : new ReferenceLoadError('reference_source_result_invalid'); + } +} + +/** Loads only budgeted references and never exposes reference values to event or metric ports. */ +export class BoundedReferenceLoader { + constructor(private readonly source: OperationalReferenceSourcePort) {} + + async load(input: LoadOperationalReferencesInput): Promise> { + const inputSnapshot = snapshotExactDataRecord( + input, + ['referenceIds', 'budget', 'signal'], + 'hosted-operations-reference-load-input-invalid' + ); + const signal = inputSnapshot.signal; + if (!(signal instanceof AbortSignal)) { + throw new TypeError('hosted-operations-reference-load-input-invalid'); + } + const budget = createReferenceLoadBudget(inputSnapshot.budget as ReferenceLoadBudget); + const referenceIds = snapshotDenseDataArray( + inputSnapshot.referenceIds, + 'hosted-operations-reference-load-input-invalid' + ).map(parseOperationalReferenceId); + if (referenceIds.length > budget.maxReferences) { + throw new ReferenceLoadError( + 'reference_count_exceeded', + budget.maxReferences, + referenceIds.length + ); + } + + if (signal.aborted) throw cancelled(); + if (referenceIds.length === 0) { + return Object.freeze({ references: Object.freeze([]), totalBytes: 0 }); + } + + const controller = new AbortController(); + let terminalError: ReferenceLoadError | undefined; + let rejectCancellation: ((error: ReferenceLoadError) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const stop = (error: ReferenceLoadError): ReferenceLoadError => { + terminalError ??= error; + controller.abort(); + return terminalError; + }; + const handleCancellation = (): void => { + const error = stop(cancelled()); + rejectCancellation?.(error); + }; + signal.addEventListener('abort', handleCancellation, { once: true }); + if (signal.aborted) handleCancellation(); + + let nextIndex = 0; + let totalBytes = 0; + const results: LoadedOperationalReference[] = new Array(referenceIds.length); + + const worker = async (): Promise => { + while (nextIndex < referenceIds.length) { + if (terminalError) throw terminalError; + const index = nextIndex; + nextIndex += 1; + const referenceId = referenceIds[index]; + + let sourceResult: ReferenceSourceResult; + try { + sourceResult = await this.source.load(referenceId, { signal: controller.signal }); + } catch { + if (terminalError) throw stop(terminalError); + if (signal.aborted) throw stop(cancelled()); + throw stop(new ReferenceLoadError('reference_source_failed')); + } + + if (terminalError) throw stop(terminalError); + let loaded: ReferenceSourceResult; + try { + loaded = materializeSourceResult(sourceResult); + } catch (error) { + throw stop( + error instanceof ReferenceLoadError + ? error + : new ReferenceLoadError('reference_source_result_invalid') + ); + } + if (loaded.byteLength > budget.maxBytesPerReference) { + throw stop( + new ReferenceLoadError( + 'reference_item_bytes_exceeded', + budget.maxBytesPerReference, + loaded.byteLength + ) + ); + } + if (loaded.byteLength > budget.maxTotalBytes - totalBytes) { + throw stop( + new ReferenceLoadError( + 'reference_total_bytes_exceeded', + budget.maxTotalBytes, + loaded.byteLength > Number.MAX_SAFE_INTEGER - totalBytes + ? Number.MAX_SAFE_INTEGER + : totalBytes + loaded.byteLength + ) + ); + } + + totalBytes += loaded.byteLength; + results[index] = Object.freeze({ + referenceId, + value: loaded.value, + byteLength: loaded.byteLength, + }); + } + }; + + const workerCount = Math.min(budget.maxConcurrentLoads, referenceIds.length); + const workers = Array.from({ length: workerCount }, worker); + try { + await Promise.race([Promise.all(workers), cancellation]); + } finally { + signal.removeEventListener('abort', handleCancellation); + } + + return Object.freeze({ + references: Object.freeze(results), + totalBytes, + }); + } +} diff --git a/src/features/hosted-operations/core/application/DiagnosticContextService.ts b/src/features/hosted-operations/core/application/DiagnosticContextService.ts new file mode 100644 index 0000000000..806ae39335 --- /dev/null +++ b/src/features/hosted-operations/core/application/DiagnosticContextService.ts @@ -0,0 +1,21 @@ +import { + createOperationCorrelationContext, + type OperationCorrelationContext, + parseDiagnosticId, + propagateOperationCorrelation, +} from '../../contracts'; + +import type { DiagnosticIdGeneratorPort } from './ports'; + +/** Adds one opaque diagnostic ID and preserves it on every downstream propagation. */ +export class DiagnosticContextService { + constructor(private readonly generator: DiagnosticIdGeneratorPort) {} + + ensureDiagnosticId(correlation: OperationCorrelationContext): OperationCorrelationContext { + const normalized = createOperationCorrelationContext(correlation); + if (normalized.diagnosticId !== undefined) return normalized; + + const diagnosticId = parseDiagnosticId(this.generator.generateDiagnosticId()); + return propagateOperationCorrelation(normalized, { diagnosticId }); + } +} diff --git a/src/features/hosted-operations/core/application/GetBoundedHostedDiagnostics.ts b/src/features/hosted-operations/core/application/GetBoundedHostedDiagnostics.ts new file mode 100644 index 0000000000..ebf18228ca --- /dev/null +++ b/src/features/hosted-operations/core/application/GetBoundedHostedDiagnostics.ts @@ -0,0 +1,263 @@ +import { createQueryContext, type QueryContext } from '@shared/contracts/hosted'; + +import { + createHostedDiagnosticsFailure, + createOperationCorrelationContext, + HOSTED_DIAGNOSTICS_REFERENCE_BUDGET, + HOSTED_DIAGNOSTICS_SCHEMA_VERSION, + type HostedDiagnosticItem, + type HostedDiagnosticsResponse, + type HostedDiagnosticsSuccess, + OPERATION_EVENT_KINDS, + OPERATION_OUTCOMES, + type OperationEventKind, + type OperationOutcome, + parseHostedDiagnosticsRequest, + parseOperationCorrelationId, +} from '../../contracts'; +import { snapshotExactDataRecord } from '../../contracts/exactDataSnapshot'; +import { redactOperationAttributes } from '../domain'; + +import { BoundedReferenceLoader } from './BoundedReferenceLoader'; +import { DiagnosticContextService } from './DiagnosticContextService'; +import { ReferenceLoadError } from './errors'; + +import type { + HostedDiagnosticsCorrelationIdPort, + HostedDiagnosticsDeadlineSchedulerPort, + HostedDiagnosticsSourcePort, + HostedDiagnosticsSourceRecord, +} from './ports/HostedDiagnosticsPorts'; + +interface DeadlineScope { + readonly signal: AbortSignal; + readonly deadlineElapsed: () => boolean; + readonly dispose: () => void; +} + +function validNowMs(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function createDeadlineScope( + context: QueryContext, + scheduler: HostedDiagnosticsDeadlineSchedulerPort +): DeadlineScope | null { + const startedAtMs = scheduler.nowMs(); + if (!validNowMs(startedAtMs)) { + throw new TypeError('hosted-diagnostics-deadline-clock-invalid'); + } + if (context.signal.aborted || startedAtMs >= context.deadlineAtMs) return null; + + const controller = new AbortController(); + let active = true; + let deadlineElapsed = false; + const abortFromParent = (): void => controller.abort(); + context.signal.addEventListener('abort', abortFromParent, { once: true }); + if (context.signal.aborted) abortFromParent(); + + let cancelDeadline: (() => void) | undefined; + try { + cancelDeadline = scheduler.schedule(context.deadlineAtMs - startedAtMs, () => { + if (!active) return; + deadlineElapsed = true; + controller.abort(); + }); + if (typeof cancelDeadline !== 'function') { + throw new TypeError('hosted-diagnostics-deadline-scheduler-invalid'); + } + } catch (error) { + active = false; + context.signal.removeEventListener('abort', abortFromParent); + controller.abort(); + throw error; + } + + return Object.freeze({ + signal: controller.signal, + deadlineElapsed: () => { + if (deadlineElapsed) return true; + const nowMs = scheduler.nowMs(); + if (!validNowMs(nowMs)) { + throw new TypeError('hosted-diagnostics-deadline-clock-invalid'); + } + if (nowMs < context.deadlineAtMs) return false; + deadlineElapsed = true; + controller.abort(); + return true; + }, + dispose: () => { + if (!active) return; + active = false; + try { + cancelDeadline?.(); + } finally { + context.signal.removeEventListener('abort', abortFromParent); + } + }, + }); +} + +function contextWithSignal(context: QueryContext, signal: AbortSignal): QueryContext { + return createQueryContext({ + actorId: context.actorId, + sessionId: context.sessionId, + deploymentId: context.deploymentId, + bootId: context.bootId, + requestId: context.requestId, + authorizedScope: context.authorizedScope, + deadlineAtMs: context.deadlineAtMs, + signal, + }); +} + +function requestCancelled(context: QueryContext, scope: DeadlineScope): boolean { + return context.signal.aborted || scope.signal.aborted || scope.deadlineElapsed(); +} + +function parseSourceRecord( + value: HostedDiagnosticsSourceRecord, + referenceId: HostedDiagnosticItem['referenceId'], + byteLength: number +): HostedDiagnosticItem { + const input = snapshotExactDataRecord( + value, + ['kind', 'outcome', 'occurredAtMonotonicMs', 'attributes'], + 'hosted-diagnostics-source-record-invalid' + ); + const kind = input.kind; + const outcome = input.outcome; + const occurredAtMonotonicMs = input.occurredAtMonotonicMs; + if ( + !OPERATION_EVENT_KINDS.includes(kind as OperationEventKind) || + !OPERATION_OUTCOMES.includes(outcome as OperationOutcome) || + !Number.isSafeInteger(occurredAtMonotonicMs) || + (occurredAtMonotonicMs as number) < 0 + ) { + throw new TypeError('hosted-diagnostics-source-record-invalid'); + } + + return Object.freeze({ + referenceId, + kind: kind as OperationEventKind, + outcome: outcome as OperationOutcome, + occurredAtMonotonicMs: occurredAtMonotonicMs as number, + attributes: redactOperationAttributes(input.attributes), + byteLength, + }); +} + +function referenceFailure( + error: ReferenceLoadError, + diagnosticId?: HostedDiagnosticsSuccess['correlation']['diagnosticId'] +): HostedDiagnosticsResponse { + switch (error.code) { + case 'reference_count_exceeded': + case 'reference_item_bytes_exceeded': + case 'reference_total_bytes_exceeded': + return createHostedDiagnosticsFailure('reference_budget_exceeded', diagnosticId); + case 'reference_load_cancelled': + return createHostedDiagnosticsFailure('request_cancelled', diagnosticId); + case 'reference_source_failed': + case 'reference_source_result_invalid': + return createHostedDiagnosticsFailure('diagnostics_unavailable', diagnosticId); + } +} + +/** Reads only opaque references through fixed host budgets and returns a redacted browser DTO. */ +export class GetBoundedHostedDiagnostics { + constructor( + private readonly source: HostedDiagnosticsSourcePort, + private readonly diagnostics: DiagnosticContextService, + private readonly correlationIds: HostedDiagnosticsCorrelationIdPort, + private readonly deadlineScheduler: HostedDiagnosticsDeadlineSchedulerPort + ) {} + + async execute(requestValue: unknown, context: QueryContext): Promise { + let queryContext: QueryContext; + let correlation: HostedDiagnosticsSuccess['correlation']; + try { + queryContext = createQueryContext(context); + let requestId; + try { + requestId = parseOperationCorrelationId(queryContext.requestId); + } catch { + requestId = parseOperationCorrelationId( + this.correlationIds.resolveCorrelationId(queryContext.requestId) + ); + } + const diagnosed = this.diagnostics.ensureDiagnosticId( + createOperationCorrelationContext({ + requestId, + }) + ); + if (diagnosed.diagnosticId === undefined) throw new TypeError(); + correlation = diagnosed as HostedDiagnosticsSuccess['correlation']; + } catch { + return createHostedDiagnosticsFailure('diagnostics_unavailable'); + } + + let deadlineScope: DeadlineScope | null; + try { + deadlineScope = createDeadlineScope(queryContext, this.deadlineScheduler); + } catch { + return createHostedDiagnosticsFailure('diagnostics_unavailable', correlation.diagnosticId); + } + if (deadlineScope === null) { + return createHostedDiagnosticsFailure('request_cancelled', correlation.diagnosticId); + } + + try { + const request = parseHostedDiagnosticsRequest(requestValue); + if (!request.ok) { + return createHostedDiagnosticsFailure('request_invalid', correlation.diagnosticId); + } + + const loader = new BoundedReferenceLoader({ + load: (referenceId, { signal }) => + this.source.load(referenceId, contextWithSignal(queryContext, signal)), + }); + + const loaded = await loader.load({ + referenceIds: request.value.referenceIds, + budget: HOSTED_DIAGNOSTICS_REFERENCE_BUDGET, + signal: deadlineScope.signal, + }); + if (requestCancelled(queryContext, deadlineScope)) { + return createHostedDiagnosticsFailure('request_cancelled', correlation.diagnosticId); + } + + const items = loaded.references.map(({ referenceId, value, byteLength }) => + parseSourceRecord(value, referenceId, byteLength) + ); + if (requestCancelled(queryContext, deadlineScope)) { + return createHostedDiagnosticsFailure('request_cancelled', correlation.diagnosticId); + } + return Object.freeze({ + schemaVersion: HOSTED_DIAGNOSTICS_SCHEMA_VERSION, + kind: 'success', + correlation, + items: Object.freeze(items), + totalBytes: loaded.totalBytes, + }); + } catch (error) { + try { + if (requestCancelled(queryContext, deadlineScope)) { + return createHostedDiagnosticsFailure('request_cancelled', correlation.diagnosticId); + } + } catch { + return createHostedDiagnosticsFailure('diagnostics_unavailable', correlation.diagnosticId); + } + if (error instanceof ReferenceLoadError) { + return referenceFailure(error, correlation.diagnosticId); + } + return createHostedDiagnosticsFailure('diagnostics_unavailable', correlation.diagnosticId); + } finally { + try { + deadlineScope.dispose(); + } catch { + // A server scheduler cleanup failure must not replace the bounded safe response. + } + } + } +} diff --git a/src/features/hosted-operations/core/application/OperationsTelemetryService.ts b/src/features/hosted-operations/core/application/OperationsTelemetryService.ts new file mode 100644 index 0000000000..e7fe51ad55 --- /dev/null +++ b/src/features/hosted-operations/core/application/OperationsTelemetryService.ts @@ -0,0 +1,96 @@ +import { + createOperationCorrelationContext, + type OperationCorrelationContext, + type OperationEventKind, + type OperationOutcome, + OPERATIONS_EVENT_SCHEMA_VERSION, + type OperationsMetricSnapshot, + type SafeOperationsEvent, +} from '../../contracts'; +import { snapshotExactDataRecord } from '../../contracts/exactDataSnapshot'; +import { + assertCorrelationSupportsEvent, + assertOperationEventKind, + assertOperationOutcome, + redactOperationAttributes, +} from '../domain'; + +import { BoundedMetricAccumulator } from './BoundedMetricAccumulator'; +import { DiagnosticContextService } from './DiagnosticContextService'; +import { OperationsRecordingCancelledError } from './errors'; + +import type { MonotonicClockPort, OperationsEventSinkPort } from './ports'; + +export interface RecordOperationsEventInput { + readonly kind: OperationEventKind; + readonly outcome: OperationOutcome; + readonly correlation: OperationCorrelationContext; + readonly attributes?: unknown; + readonly signal: AbortSignal; +} + +export interface OperationsTelemetryServiceDeps { + readonly clock: MonotonicClockPort; + readonly eventSink: OperationsEventSinkPort; + readonly diagnostics: DiagnosticContextService; + readonly metrics: BoundedMetricAccumulator; +} + +function assertMonotonicTime(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError('hosted-operations-monotonic-time-invalid'); + } +} + +function needsDiagnosticId(outcome: OperationOutcome): boolean { + return outcome === 'failed' || outcome === 'rejected'; +} + +export class OperationsTelemetryService { + constructor(private readonly deps: OperationsTelemetryServiceDeps) {} + + async record(input: RecordOperationsEventInput): Promise { + const inputSnapshot = snapshotExactDataRecord( + input, + ['kind', 'outcome', 'correlation', 'signal'], + 'hosted-operations-event-input-invalid', + { optionalKeys: ['attributes'] } + ); + const kind = inputSnapshot.kind; + const outcome = inputSnapshot.outcome; + const signal = inputSnapshot.signal; + assertOperationEventKind(kind); + assertOperationOutcome(outcome); + if (!(signal instanceof AbortSignal)) { + throw new TypeError('hosted-operations-signal-invalid'); + } + if (signal.aborted) throw new OperationsRecordingCancelledError(); + + const initialCorrelation = createOperationCorrelationContext(inputSnapshot.correlation); + assertCorrelationSupportsEvent(kind, initialCorrelation); + const diagnosedCorrelation = needsDiagnosticId(outcome) + ? this.deps.diagnostics.ensureDiagnosticId(initialCorrelation) + : initialCorrelation; + const correlation = createOperationCorrelationContext(diagnosedCorrelation); + const occurredAtMonotonicMs = this.deps.clock.nowMs(); + assertMonotonicTime(occurredAtMonotonicMs); + + const event: SafeOperationsEvent = Object.freeze({ + schemaVersion: OPERATIONS_EVENT_SCHEMA_VERSION, + kind, + outcome, + occurredAtMonotonicMs, + correlation, + attributes: redactOperationAttributes(inputSnapshot.attributes), + }); + + if (signal.aborted) throw new OperationsRecordingCancelledError(); + await this.deps.eventSink.write(event, { signal }); + this.deps.metrics.increment(kind, outcome); + return event; + } + + metricSnapshot(): OperationsMetricSnapshot { + return this.deps.metrics.snapshot(this.deps.clock.nowMs()); + } +} diff --git a/src/features/hosted-operations/core/application/errors.ts b/src/features/hosted-operations/core/application/errors.ts new file mode 100644 index 0000000000..6c64ccec45 --- /dev/null +++ b/src/features/hosted-operations/core/application/errors.ts @@ -0,0 +1,28 @@ +export type ReferenceLoadErrorCode = + | 'reference_count_exceeded' + | 'reference_item_bytes_exceeded' + | 'reference_total_bytes_exceeded' + | 'reference_load_cancelled' + | 'reference_source_failed' + | 'reference_source_result_invalid'; + +export class ReferenceLoadError extends Error { + readonly name = 'ReferenceLoadError'; + + constructor( + readonly code: ReferenceLoadErrorCode, + readonly limit?: number, + readonly observed?: number + ) { + super(code); + } +} + +export class OperationsRecordingCancelledError extends Error { + readonly name = 'OperationsRecordingCancelledError'; + readonly code = 'operation_recording_cancelled' as const; + + constructor() { + super('operation_recording_cancelled'); + } +} diff --git a/src/features/hosted-operations/core/application/index.ts b/src/features/hosted-operations/core/application/index.ts new file mode 100644 index 0000000000..294c9d69b5 --- /dev/null +++ b/src/features/hosted-operations/core/application/index.ts @@ -0,0 +1,6 @@ +export * from './BoundedMetricAccumulator'; +export * from './BoundedReferenceLoader'; +export * from './DiagnosticContextService'; +export * from './errors'; +export * from './OperationsTelemetryService'; +export type * from './ports'; diff --git a/src/features/hosted-operations/core/application/ports.ts b/src/features/hosted-operations/core/application/ports.ts new file mode 100644 index 0000000000..4ec297cf6d --- /dev/null +++ b/src/features/hosted-operations/core/application/ports.ts @@ -0,0 +1,25 @@ +import type { DiagnosticId, OperationalReferenceId, SafeOperationsEvent } from '../../contracts'; + +export interface MonotonicClockPort { + nowMs(): number; +} + +export interface DiagnosticIdGeneratorPort { + generateDiagnosticId(): DiagnosticId; +} + +export interface OperationsEventSinkPort { + write(event: SafeOperationsEvent, context: { readonly signal: AbortSignal }): Promise; +} + +export interface ReferenceSourceResult { + readonly value: T; + readonly byteLength: number; +} + +export interface OperationalReferenceSourcePort { + load( + referenceId: OperationalReferenceId, + context: { readonly signal: AbortSignal } + ): Promise>; +} diff --git a/src/features/hosted-operations/core/application/ports/HostedDiagnosticsPorts.ts b/src/features/hosted-operations/core/application/ports/HostedDiagnosticsPorts.ts new file mode 100644 index 0000000000..15aa2157cb --- /dev/null +++ b/src/features/hosted-operations/core/application/ports/HostedDiagnosticsPorts.ts @@ -0,0 +1,39 @@ +import type { + OperationalReferenceId, + OperationCorrelationId, + OperationEventKind, + OperationOutcome, +} from '../../../contracts'; +import type { QueryContext, RequestId } from '@shared/contracts/hosted'; + +/** Resolves a strict operations ID without narrowing the public QueryContext request-ID grammar. */ +export interface HostedDiagnosticsCorrelationIdPort { + resolveCorrelationId(requestId: RequestId): OperationCorrelationId; +} + +/** Server-owned wall-clock scheduling used to enforce the authenticated query deadline. */ +export interface HostedDiagnosticsDeadlineSchedulerPort { + nowMs(): number; + schedule(delayMs: number, onDeadline: () => void): () => void; +} + +export interface HostedDiagnosticsSourceRecord { + readonly kind: OperationEventKind; + readonly outcome: OperationOutcome; + readonly occurredAtMonotonicMs: number; + /** Untrusted operational metadata; the use case applies the fixed hosted-operations allowlist. */ + readonly attributes: unknown; +} + +export interface HostedDiagnosticsSourceResult { + readonly value: HostedDiagnosticsSourceRecord; + readonly byteLength: number; +} + +/** Every reference read is scoped to host-authenticated identity, authority, deadline, and abort. */ +export interface HostedDiagnosticsSourcePort { + load( + referenceId: OperationalReferenceId, + context: QueryContext + ): Promise; +} diff --git a/src/features/hosted-operations/core/domain/index.ts b/src/features/hosted-operations/core/domain/index.ts new file mode 100644 index 0000000000..90a4db94cd --- /dev/null +++ b/src/features/hosted-operations/core/domain/index.ts @@ -0,0 +1,3 @@ +export * from './operationPolicy'; +export * from './redaction'; +export * from './retentionPolicy'; diff --git a/src/features/hosted-operations/core/domain/operationPolicy.ts b/src/features/hosted-operations/core/domain/operationPolicy.ts new file mode 100644 index 0000000000..894441d5a7 --- /dev/null +++ b/src/features/hosted-operations/core/domain/operationPolicy.ts @@ -0,0 +1,48 @@ +import { + OPERATION_EVENT_KINDS, + OPERATION_OUTCOMES, + type OperationArea, + type OperationCorrelationContext, + type OperationEventKind, + type OperationOutcome, +} from '../../contracts'; + +const AREA_BY_EVENT_KIND: Readonly> = Object.freeze({ + http_request: 'http', + sse_connection: 'sse', + run_operation: 'run', + team_operation: 'team', + reference_load: 'reference', + retention: 'retention', +}); + +export function assertOperationEventKind(value: unknown): asserts value is OperationEventKind { + if (!OPERATION_EVENT_KINDS.includes(value as OperationEventKind)) { + throw new TypeError('hosted-operations-event-kind-invalid'); + } +} + +export function assertOperationOutcome(value: unknown): asserts value is OperationOutcome { + if (!OPERATION_OUTCOMES.includes(value as OperationOutcome)) { + throw new TypeError('hosted-operations-outcome-invalid'); + } +} + +export function operationAreaForEventKind(kind: OperationEventKind): OperationArea { + assertOperationEventKind(kind); + return AREA_BY_EVENT_KIND[kind]; +} + +export function assertCorrelationSupportsEvent( + kind: OperationEventKind, + correlation: OperationCorrelationContext +): void { + const missingRequiredScope = + (kind === 'sse_connection' && correlation.sseConnectionId === undefined) || + (kind === 'run_operation' && correlation.runId === undefined) || + (kind === 'team_operation' && correlation.teamId === undefined); + + if (missingRequiredScope) { + throw new TypeError('hosted-operations-correlation-scope-missing'); + } +} diff --git a/src/features/hosted-operations/core/domain/redaction.ts b/src/features/hosted-operations/core/domain/redaction.ts new file mode 100644 index 0000000000..12bec45f3b --- /dev/null +++ b/src/features/hosted-operations/core/domain/redaction.ts @@ -0,0 +1,55 @@ +import { + REDACTED_OPERATION_ATTRIBUTE_VALUE, + SAFE_OPERATION_ATTRIBUTE_KEYS, + SAFE_OPERATION_ATTRIBUTE_VALUES, + type SafeOperationAttributeKey, + type SafeOperationAttributes, +} from '../../contracts'; + +export const REDACTED_OPERATION_ATTRIBUTE = REDACTED_OPERATION_ATTRIBUTE_VALUE; + +function isSafeAttributeKey(value: string): value is SafeOperationAttributeKey { + return SAFE_OPERATION_ATTRIBUTE_KEYS.includes(value as SafeOperationAttributeKey); +} + +function isSafeAttributeValue(key: SafeOperationAttributeKey, value: unknown): value is string { + return ( + typeof value === 'string' && + SAFE_OPERATION_ATTRIBUTE_VALUES[key].some((candidate) => candidate === value) + ); +} + +/** + * Keeps only a fixed operational allowlist. Values that are not short machine tokens are replaced + * wholesale, so prompts, message bodies, filesystem paths, credentials, and error text cannot be + * recovered from a structured event. + */ +export function redactOperationAttributes(value: unknown): SafeOperationAttributes { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return Object.freeze({}); + } + + const redacted: Partial> = {}; + let keys: readonly PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + return Object.freeze({}); + } + + for (const key of keys) { + if (typeof key !== 'string' || !isSafeAttributeKey(key)) continue; + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) continue; + const candidate = descriptor.value; + redacted[key] = isSafeAttributeValue(key, candidate) + ? candidate + : REDACTED_OPERATION_ATTRIBUTE; + } catch { + // Proxy/accessor failures are untrusted detail, so they are omitted rather than surfaced. + } + } + + return Object.freeze(redacted) as SafeOperationAttributes; +} diff --git a/src/features/hosted-operations/core/domain/retentionPolicy.ts b/src/features/hosted-operations/core/domain/retentionPolicy.ts new file mode 100644 index 0000000000..b9d2bda50b --- /dev/null +++ b/src/features/hosted-operations/core/domain/retentionPolicy.ts @@ -0,0 +1,128 @@ +import { createRetentionBudget, type RetentionBudget } from '../../contracts'; +import { snapshotDenseDataArray, snapshotExactDataRecord } from '../../contracts/exactDataSnapshot'; + +export interface RetentionCandidate { + readonly retentionKey: string; + readonly recordedAtMonotonicMs: number; + readonly byteLength: number; + readonly value: T; +} + +export type RetentionEvictionReason = 'age' | 'entry_count' | 'total_bytes'; + +export interface RetentionEviction { + readonly entry: RetentionCandidate; + readonly reason: RetentionEvictionReason; +} + +export interface RetentionDecision { + readonly retained: readonly RetentionCandidate[]; + readonly evicted: readonly RetentionEviction[]; + readonly retainedBytes: number; +} + +function materializeCandidate(value: unknown, nowMs: number): RetentionCandidate { + const candidate = snapshotExactDataRecord( + value, + ['retentionKey', 'recordedAtMonotonicMs', 'byteLength', 'value'], + 'hosted-operations-retention-candidate-invalid' + ); + const retentionKey = candidate.retentionKey; + const recordedAtMonotonicMs = candidate.recordedAtMonotonicMs; + const byteLength = candidate.byteLength; + if ( + typeof retentionKey !== 'string' || + retentionKey.length === 0 || + retentionKey.length > 256 || + typeof recordedAtMonotonicMs !== 'number' || + !Number.isSafeInteger(recordedAtMonotonicMs) || + recordedAtMonotonicMs < 0 || + recordedAtMonotonicMs > nowMs || + typeof byteLength !== 'number' || + !Number.isSafeInteger(byteLength) || + byteLength < 0 + ) { + throw new TypeError('hosted-operations-retention-candidate-invalid'); + } + + return Object.freeze({ + retentionKey, + recordedAtMonotonicMs, + byteLength, + value: candidate.value as T, + }); +} + +function oldestFirst(left: RetentionCandidate, right: RetentionCandidate): number { + const timeOrder = left.recordedAtMonotonicMs - right.recordedAtMonotonicMs; + if (timeOrder !== 0) return timeOrder; + if (left.retentionKey === right.retentionKey) return 0; + return left.retentionKey < right.retentionKey ? -1 : 1; +} + +/** Applies age, count, then byte limits with a stable oldest-first eviction order. */ +export function applyRetentionBudget(input: { + readonly entries: readonly RetentionCandidate[]; + readonly budget: RetentionBudget; + readonly nowMonotonicMs: number; +}): RetentionDecision { + const inputSnapshot = snapshotExactDataRecord( + input, + ['entries', 'budget', 'nowMonotonicMs'], + 'hosted-operations-retention-input-invalid' + ); + const nowMs = inputSnapshot.nowMonotonicMs; + if (typeof nowMs !== 'number' || !Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError('hosted-operations-retention-input-invalid'); + } + const budget = createRetentionBudget(inputSnapshot.budget as RetentionBudget); + const entries = snapshotDenseDataArray( + inputSnapshot.entries, + 'hosted-operations-retention-input-invalid' + ).map((candidate) => materializeCandidate(candidate, nowMs)); + + const keys = new Set(); + for (const candidate of entries) { + if (keys.has(candidate.retentionKey)) { + throw new TypeError('hosted-operations-retention-key-duplicate'); + } + keys.add(candidate.retentionKey); + } + + const ordered = [...entries].sort(oldestFirst); + const retained: RetentionCandidate[] = []; + const evicted: RetentionEviction[] = []; + let retainedBytes = 0; + + for (const entry of ordered) { + if (nowMs - entry.recordedAtMonotonicMs > budget.maxAgeMs) { + evicted.push(Object.freeze({ entry, reason: 'age' })); + continue; + } + if (entry.byteLength > Number.MAX_SAFE_INTEGER - retainedBytes) { + throw new TypeError('hosted-operations-retention-size-overflow'); + } + retained.push(entry); + retainedBytes += entry.byteLength; + } + + while (retained.length > budget.maxEntries) { + const entry = retained.shift(); + if (!entry) break; + retainedBytes -= entry.byteLength; + evicted.push(Object.freeze({ entry, reason: 'entry_count' })); + } + + while (retainedBytes > budget.maxTotalBytes) { + const entry = retained.shift(); + if (!entry) break; + retainedBytes -= entry.byteLength; + evicted.push(Object.freeze({ entry, reason: 'total_bytes' })); + } + + return Object.freeze({ + retained: Object.freeze(retained), + evicted: Object.freeze(evicted), + retainedBytes, + }); +} diff --git a/src/features/hosted-operations/index.ts b/src/features/hosted-operations/index.ts new file mode 100644 index 0000000000..a6c74fad00 --- /dev/null +++ b/src/features/hosted-operations/index.ts @@ -0,0 +1,3 @@ +export * from './contracts'; +export * from './core/application'; +export * from './core/domain'; diff --git a/src/features/hosted-operations/main/adapters/input/http/hostedDiagnosticsRoutes.ts b/src/features/hosted-operations/main/adapters/input/http/hostedDiagnosticsRoutes.ts new file mode 100644 index 0000000000..2c999358cd --- /dev/null +++ b/src/features/hosted-operations/main/adapters/input/http/hostedDiagnosticsRoutes.ts @@ -0,0 +1,23 @@ +import { HOSTED_DIAGNOSTICS_QUERY_ROUTE } from '../../../../contracts'; + +import type { RouteDescriptor } from '@main/composition/hosted/routing'; + +const READINESS = Object.freeze(['serve', 'auth', 'read'] as const); + +export const HOSTED_DIAGNOSTICS_ROUTE_DESCRIPTORS = Object.freeze([ + Object.freeze({ + id: 'hosted-operations.diagnostics.v1', + method: 'POST', + path: HOSTED_DIAGNOSTICS_QUERY_ROUTE, + owner: 'hosted-operations', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session', + readiness: READINESS, + requestSchemaId: 'hosted-operations.diagnostics.request.v1', + responseSchemaId: 'hosted-operations.diagnostics.response.v1', + handlerId: 'hosted-operations.diagnostics.handler.v1', + clientId: 'hosted-operations.diagnostics.client.v1', + semanticTestId: 'hosted-operations.diagnostics.semantic.v1', + testOnly: false, + } satisfies RouteDescriptor), +] satisfies readonly RouteDescriptor[]); diff --git a/src/features/hosted-operations/main/adapters/input/http/registerHostedDiagnosticsHttp.ts b/src/features/hosted-operations/main/adapters/input/http/registerHostedDiagnosticsHttp.ts new file mode 100644 index 0000000000..3d730f45be --- /dev/null +++ b/src/features/hosted-operations/main/adapters/input/http/registerHostedDiagnosticsHttp.ts @@ -0,0 +1,118 @@ +import { + createHostedDiagnosticsFailure, + type HostedDiagnosticsResponse, + parseHostedDiagnosticsResponse, +} from '../../../../contracts'; + +import { HOSTED_DIAGNOSTICS_ROUTE_DESCRIPTORS } from './hostedDiagnosticsRoutes'; + +import type { + HostedRouteAdmission, + HostedRouteContribution, +} from '@main/composition/hosted/application'; +import type { RouteDescriptor } from '@main/composition/hosted/routing'; +import type { QueryContext } from '@shared/contracts/hosted'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export interface HostedDiagnosticsHttpFacade { + getDiagnostics(request: unknown, context: QueryContext): Promise; +} + +export type HostedDiagnosticsContextFactory = ( + descriptor: RouteDescriptor, + request: FastifyRequest, + signal: AbortSignal +) => QueryContext | Promise; + +const REQUEST_ABORTED = Object.freeze({ kind: 'hosted-diagnostics-request-aborted' }); + +async function withRequestSignal( + request: FastifyRequest, + reply: FastifyReply, + operation: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController(); + let rejectCancellation: ((reason: typeof REQUEST_ABORTED) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const abort = (): void => { + if (controller.signal.aborted) return; + controller.abort(); + rejectCancellation?.(REQUEST_ABORTED); + }; + + request.raw.once('aborted', abort); + request.raw.socket.once('close', abort); + reply.raw.once('close', abort); + if (request.raw.aborted || request.raw.socket.destroyed || reply.raw.destroyed) abort(); + + try { + return await Promise.race([operation(controller.signal), cancellation]); + } finally { + request.raw.removeListener('aborted', abort); + request.raw.socket.removeListener('close', abort); + reply.raw.removeListener('close', abort); + } +} + +function safeResponse(value: unknown): HostedDiagnosticsResponse { + const parsed = parseHostedDiagnosticsResponse(value); + return parsed.ok ? parsed.value : createHostedDiagnosticsFailure('response_invalid'); +} + +function statusFor(response: HostedDiagnosticsResponse): number { + if (response.kind === 'success') return 200; + switch (response.error.reason) { + case 'request_invalid': + return 400; + case 'reference_budget_exceeded': + return 413; + case 'request_cancelled': + case 'diagnostics_unavailable': + case 'transport_unavailable': + return 503; + case 'response_invalid': + return 500; + } +} + +function sendResponse(reply: FastifyReply, value: unknown): FastifyReply { + const response = safeResponse(value); + return reply.status(statusFor(response)).send(response); +} + +export function registerHostedDiagnosticsHttp( + app: FastifyInstance, + contribution: HostedRouteContribution, + routeAdmission: HostedRouteAdmission, + createContext: HostedDiagnosticsContextFactory +): void { + const descriptor = contribution.routes[0]; + if ( + contribution.id !== 'hosted-operations.diagnostics.hosted.v1' || + contribution.routes.length !== 1 || + descriptor !== HOSTED_DIAGNOSTICS_ROUTE_DESCRIPTORS[0] + ) { + throw new TypeError('hosted-diagnostics-route-contribution-invalid'); + } + const facade = contribution.facade; + app.post<{ Body: unknown }>(descriptor.path, async (request, reply) => { + void reply.header('Cache-Control', 'no-store'); + try { + return await withRequestSignal(request, reply, async (signal) => { + const invocation = await routeAdmission.invoke(descriptor.id, async () => { + const context = await createContext(descriptor, request, signal); + if (signal.aborted || context.signal !== signal) return null; + return facade.getDiagnostics(request.body, context); + }); + if (!invocation.admitted || invocation.value === null) { + return sendResponse(reply, createHostedDiagnosticsFailure('diagnostics_unavailable')); + } + return sendResponse(reply, invocation.value); + }); + } catch { + return sendResponse(reply, createHostedDiagnosticsFailure('diagnostics_unavailable')); + } + }); +} diff --git a/src/features/hosted-operations/main/adapters/output/BoundedHostedDiagnosticsReferenceStore.ts b/src/features/hosted-operations/main/adapters/output/BoundedHostedDiagnosticsReferenceStore.ts new file mode 100644 index 0000000000..d74dd7ea87 --- /dev/null +++ b/src/features/hosted-operations/main/adapters/output/BoundedHostedDiagnosticsReferenceStore.ts @@ -0,0 +1,272 @@ +import { createQueryContext, type QueryContext } from '@shared/contracts/hosted'; + +import { + HOSTED_DIAGNOSTICS_MAX_BYTES_PER_REFERENCE, + OPERATION_EVENT_KINDS, + OPERATION_OUTCOMES, + parseOperationalReferenceId, +} from '../../../contracts'; +import { snapshotExactDataRecord } from '../../../contracts/exactDataSnapshot'; +import { applyRetentionBudget, redactOperationAttributes } from '../../../core/domain'; + +import type { + OperationalReferenceId, + OperationEventKind, + OperationOutcome, + RetentionBudget, +} from '../../../contracts'; +import type { + HostedDiagnosticsSourcePort, + HostedDiagnosticsSourceRecord, + HostedDiagnosticsSourceResult, +} from '../../../core/application/ports/HostedDiagnosticsPorts'; + +const QUERY_CONTEXT_KEYS = Object.freeze([ + 'actorId', + 'sessionId', + 'deploymentId', + 'bootId', + 'requestId', + 'authorizedScope', + 'deadlineAtMs', + 'signal', +] as const); +const SOURCE_RECORD_KEYS = Object.freeze([ + 'kind', + 'outcome', + 'occurredAtMonotonicMs', + 'attributes', +] as const); +const MAX_REFERENCE_ID_ATTEMPTS = 8; + +interface HostedDiagnosticsReferenceStorePlatform { + nowEpochMs(): number; + nowMonotonicMs(): number; +} + +interface StoredAuthority { + readonly actorId: QueryContext['actorId']; + readonly sessionId: QueryContext['sessionId']; + readonly deploymentId: QueryContext['deploymentId']; + readonly authorizedScope: QueryContext['authorizedScope']; +} + +interface StoredDiagnostic { + readonly authority: StoredAuthority; + readonly byteLength: number; + readonly recordedAtMonotonicMs: number; + readonly referenceId: OperationalReferenceId; + readonly value: HostedDiagnosticsSourceRecord; +} + +export interface BoundedHostedDiagnosticsReferenceStoreDependencies { + readonly generateReferenceId: () => OperationalReferenceId; + readonly platform: HostedDiagnosticsReferenceStorePlatform; + readonly retentionBudget: RetentionBudget; +} + +function unavailable(): Error { + return new Error('hosted-diagnostics-unavailable'); +} + +function isValidTime(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function snapshotContext(value: unknown): QueryContext { + try { + return createQueryContext( + snapshotExactDataRecord(value, QUERY_CONTEXT_KEYS, 'hosted-diagnostics-context-invalid') + ); + } catch { + throw unavailable(); + } +} + +function snapshotSafeRecord(value: unknown): HostedDiagnosticsSourceRecord { + try { + const input = snapshotExactDataRecord( + value, + SOURCE_RECORD_KEYS, + 'hosted-diagnostics-record-invalid' + ); + const kind = input.kind; + const outcome = input.outcome; + const occurredAtMonotonicMs = input.occurredAtMonotonicMs; + if ( + typeof kind !== 'string' || + !OPERATION_EVENT_KINDS.includes(kind as OperationEventKind) || + typeof outcome !== 'string' || + !OPERATION_OUTCOMES.includes(outcome as OperationOutcome) || + !isValidTime(occurredAtMonotonicMs) + ) { + throw unavailable(); + } + + return Object.freeze({ + kind: kind as OperationEventKind, + outcome: outcome as OperationOutcome, + occurredAtMonotonicMs, + attributes: redactOperationAttributes(input.attributes), + }); + } catch { + throw unavailable(); + } +} + +function byteLengthOf(value: HostedDiagnosticsSourceRecord): number { + try { + const byteLength = new TextEncoder().encode(JSON.stringify(value)).byteLength; + if ( + !Number.isSafeInteger(byteLength) || + byteLength < 0 || + byteLength > HOSTED_DIAGNOSTICS_MAX_BYTES_PER_REFERENCE + ) { + throw unavailable(); + } + return byteLength; + } catch { + throw unavailable(); + } +} + +function authorityFrom(context: QueryContext): StoredAuthority { + return Object.freeze({ + actorId: context.actorId, + sessionId: context.sessionId, + deploymentId: context.deploymentId, + authorizedScope: context.authorizedScope, + }); +} + +function hasSameAuthority(authority: StoredAuthority, context: QueryContext): boolean { + return ( + authority.actorId === context.actorId && + authority.sessionId === context.sessionId && + authority.deploymentId === context.deploymentId && + authority.authorizedScope === context.authorizedScope + ); +} + +/** Process-local storage for already-redacted diagnostic projections and server-computed sizes. */ +export class BoundedHostedDiagnosticsReferenceStore implements HostedDiagnosticsSourcePort { + private closed = false; + private readonly records = new Map(); + + constructor(private readonly dependencies: BoundedHostedDiagnosticsReferenceStoreDependencies) {} + + private currentEpochMs(): number { + try { + const nowMs = this.dependencies.platform.nowEpochMs(); + if (!isValidTime(nowMs)) throw unavailable(); + return nowMs; + } catch { + throw unavailable(); + } + } + + private currentMonotonicMs(): number { + try { + const nowMs = this.dependencies.platform.nowMonotonicMs(); + if (!isValidTime(nowMs)) throw unavailable(); + return nowMs; + } catch { + throw unavailable(); + } + } + + private assertAvailable(context: QueryContext): void { + if (this.closed || context.signal.aborted || this.currentEpochMs() >= context.deadlineAtMs) { + throw unavailable(); + } + } + + private applyRetention(nowMonotonicMs: number): void { + try { + const decision = applyRetentionBudget({ + entries: [...this.records.values()].map((entry) => ({ + retentionKey: entry.referenceId, + recordedAtMonotonicMs: entry.recordedAtMonotonicMs, + byteLength: entry.byteLength, + value: entry, + })), + budget: this.dependencies.retentionBudget, + nowMonotonicMs, + }); + for (const eviction of decision.evicted) { + this.records.delete(eviction.entry.value.referenceId); + } + } catch { + throw unavailable(); + } + } + + private nextReferenceId(): OperationalReferenceId { + for (let attempt = 0; attempt < MAX_REFERENCE_ID_ATTEMPTS; attempt += 1) { + try { + const referenceId = parseOperationalReferenceId(this.dependencies.generateReferenceId()); + if (!this.records.has(referenceId)) return referenceId; + } catch { + throw unavailable(); + } + } + throw unavailable(); + } + + record(value: HostedDiagnosticsSourceRecord, contextValue: QueryContext): OperationalReferenceId { + const context = snapshotContext(contextValue); + this.assertAvailable(context); + const safeValue = snapshotSafeRecord(value); + const byteLength = byteLengthOf(safeValue); + const recordedAtMonotonicMs = this.currentMonotonicMs(); + this.applyRetention(recordedAtMonotonicMs); + const referenceId = this.nextReferenceId(); + const stored: StoredDiagnostic = Object.freeze({ + authority: authorityFrom(context), + byteLength, + recordedAtMonotonicMs, + referenceId, + value: safeValue, + }); + this.records.set(referenceId, stored); + + try { + this.applyRetention(recordedAtMonotonicMs); + this.assertAvailable(context); + if (this.records.get(referenceId) !== stored) throw unavailable(); + return referenceId; + } catch { + this.records.delete(referenceId); + throw unavailable(); + } + } + + async load( + referenceIdValue: OperationalReferenceId, + contextValue: QueryContext + ): Promise { + let referenceId: OperationalReferenceId; + try { + referenceId = parseOperationalReferenceId(referenceIdValue); + } catch { + throw unavailable(); + } + const context = snapshotContext(contextValue); + this.assertAvailable(context); + this.applyRetention(this.currentMonotonicMs()); + const stored = this.records.get(referenceId); + if (!stored || !hasSameAuthority(stored.authority, context)) throw unavailable(); + this.assertAvailable(context); + + return Object.freeze({ + value: stored.value, + byteLength: stored.byteLength, + }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.records.clear(); + } +} diff --git a/src/features/hosted-operations/main/composition/createHostedDiagnosticsAdapters.ts b/src/features/hosted-operations/main/composition/createHostedDiagnosticsAdapters.ts new file mode 100644 index 0000000000..2e3b1bc595 --- /dev/null +++ b/src/features/hosted-operations/main/composition/createHostedDiagnosticsAdapters.ts @@ -0,0 +1,141 @@ +import { + createRetentionBudget, + parseDiagnosticId, + parseOperationalReferenceId, + parseOperationCorrelationId, +} from '../../contracts'; +import { snapshotExactDataRecord } from '../../contracts/exactDataSnapshot'; +import { BoundedHostedDiagnosticsReferenceStore } from '../adapters/output/BoundedHostedDiagnosticsReferenceStore'; +import { createNodeHostedDiagnosticsPlatform } from '../infrastructure/NodeHostedDiagnosticsPlatform'; + +import type { OperationalReferenceId, RetentionBudget } from '../../contracts'; +import type { DiagnosticIdGeneratorPort } from '../../core/application/ports'; +import type { + HostedDiagnosticsCorrelationIdPort, + HostedDiagnosticsDeadlineSchedulerPort, + HostedDiagnosticsSourcePort, + HostedDiagnosticsSourceRecord, +} from '../../core/application/ports/HostedDiagnosticsPorts'; +import type { QueryContext } from '@shared/contracts/hosted'; + +const OPAQUE_IDENTIFIER_BYTES = 16; + +export const HOSTED_DIAGNOSTICS_RETENTION_BUDGET: RetentionBudget = createRetentionBudget({ + maxEntries: 256, + maxAgeMs: 10 * 60 * 1_000, + maxTotalBytes: 512 * 1_024, +}); + +/** The only public mutation port for process-local hosted diagnostic references. */ +export interface HostedDiagnosticsRecorderPort { + record(value: HostedDiagnosticsSourceRecord, context: QueryContext): OperationalReferenceId; +} + +export interface CreateHostedDiagnosticsAdaptersOptions { + readonly retentionBudget?: RetentionBudget; +} + +export interface HostedDiagnosticsAdapters { + readonly source: HostedDiagnosticsSourcePort; + readonly recorder: HostedDiagnosticsRecorderPort; + readonly diagnosticIds: DiagnosticIdGeneratorPort; + readonly correlationIds: HostedDiagnosticsCorrelationIdPort; + readonly deadlineScheduler: HostedDiagnosticsDeadlineSchedulerPort; + close(): void; +} + +function createOpaqueIdentifier( + platform: ReturnType, + prefix: 'diagnostic' | 'reference' | 'request' +): string { + try { + const bytes = platform.randomBytes(OPAQUE_IDENTIFIER_BYTES); + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== OPAQUE_IDENTIFIER_BYTES) { + throw new TypeError('hosted-diagnostics-random-invalid'); + } + return `${prefix}_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}`; + } catch { + throw new TypeError('hosted-diagnostics-random-invalid'); + } +} + +function parseRetentionBudget(value: unknown): RetentionBudget { + return createRetentionBudget(value as RetentionBudget); +} + +function createDeadlineScheduler( + platform: ReturnType +): HostedDiagnosticsDeadlineSchedulerPort { + return Object.freeze({ + nowMs(): number { + const nowMs = platform.nowEpochMs(); + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError('hosted-diagnostics-deadline-clock-invalid'); + } + return nowMs; + }, + schedule(delayMs: number, onDeadline: () => void): () => void { + if (!Number.isSafeInteger(delayMs) || delayMs < 0 || typeof onDeadline !== 'function') { + throw new TypeError('hosted-diagnostics-deadline-scheduler-invalid'); + } + let active = true; + let cancelPlatformTimer: (() => void) | undefined; + try { + cancelPlatformTimer = platform.schedule(delayMs, () => { + if (!active) return; + active = false; + onDeadline(); + }); + } catch { + active = false; + throw new TypeError('hosted-diagnostics-deadline-scheduler-invalid'); + } + if (typeof cancelPlatformTimer !== 'function') { + active = false; + throw new TypeError('hosted-diagnostics-deadline-scheduler-invalid'); + } + return () => { + if (!active) return; + active = false; + cancelPlatformTimer(); + }; + }, + }); +} + +/** Creates one bounded registry whose references exist only for this adapter-set lifetime. */ +export function createHostedDiagnosticsAdapters( + options: CreateHostedDiagnosticsAdaptersOptions = {} +): HostedDiagnosticsAdapters { + const input = snapshotExactDataRecord( + options, + [], + 'hosted-diagnostics-adapters-options-invalid', + { optionalKeys: ['retentionBudget'] } + ); + const retentionBudget = + input.retentionBudget === undefined + ? HOSTED_DIAGNOSTICS_RETENTION_BUDGET + : parseRetentionBudget(input.retentionBudget); + const platform = createNodeHostedDiagnosticsPlatform(); + const store = new BoundedHostedDiagnosticsReferenceStore({ + platform, + retentionBudget, + generateReferenceId: () => + parseOperationalReferenceId(createOpaqueIdentifier(platform, 'reference')), + }); + + return Object.freeze({ + source: store, + recorder: store, + diagnosticIds: Object.freeze({ + generateDiagnosticId: () => parseDiagnosticId(createOpaqueIdentifier(platform, 'diagnostic')), + }), + correlationIds: Object.freeze({ + resolveCorrelationId: () => + parseOperationCorrelationId(createOpaqueIdentifier(platform, 'request')), + }), + deadlineScheduler: createDeadlineScheduler(platform), + close: () => store.close(), + }); +} diff --git a/src/features/hosted-operations/main/composition/createHostedDiagnosticsFeature.ts b/src/features/hosted-operations/main/composition/createHostedDiagnosticsFeature.ts new file mode 100644 index 0000000000..27f91f5f96 --- /dev/null +++ b/src/features/hosted-operations/main/composition/createHostedDiagnosticsFeature.ts @@ -0,0 +1,48 @@ +import { DiagnosticContextService } from '../../core/application/DiagnosticContextService'; +import { GetBoundedHostedDiagnostics } from '../../core/application/GetBoundedHostedDiagnostics'; +import { HOSTED_DIAGNOSTICS_ROUTE_DESCRIPTORS } from '../adapters/input/http/hostedDiagnosticsRoutes'; + +import type { DiagnosticIdGeneratorPort } from '../../core/application/ports'; +import type { + HostedDiagnosticsCorrelationIdPort, + HostedDiagnosticsDeadlineSchedulerPort, + HostedDiagnosticsSourcePort, +} from '../../core/application/ports/HostedDiagnosticsPorts'; +import type { HostedDiagnosticsHttpFacade } from '../adapters/input/http/registerHostedDiagnosticsHttp'; +import type { HostedRouteContribution } from '@main/composition/hosted/application'; + +export interface HostedDiagnosticsFeature extends HostedDiagnosticsHttpFacade { + readonly routes: typeof HOSTED_DIAGNOSTICS_ROUTE_DESCRIPTORS; +} + +export interface CreateHostedDiagnosticsFeatureDependencies { + readonly source: HostedDiagnosticsSourcePort; + readonly diagnosticIds: DiagnosticIdGeneratorPort; + readonly correlationIds: HostedDiagnosticsCorrelationIdPort; + readonly deadlineScheduler: HostedDiagnosticsDeadlineSchedulerPort; +} + +export function createHostedDiagnosticsFeature( + dependencies: CreateHostedDiagnosticsFeatureDependencies +): HostedDiagnosticsFeature { + const useCase = new GetBoundedHostedDiagnostics( + dependencies.source, + new DiagnosticContextService(dependencies.diagnosticIds), + dependencies.correlationIds, + dependencies.deadlineScheduler + ); + return Object.freeze({ + routes: HOSTED_DIAGNOSTICS_ROUTE_DESCRIPTORS, + getDiagnostics: useCase.execute.bind(useCase), + }); +} + +export function createHostedDiagnosticsRouteContribution( + feature: HostedDiagnosticsFeature +): HostedRouteContribution { + return Object.freeze({ + id: 'hosted-operations.diagnostics.hosted.v1', + facade: feature, + routes: feature.routes, + }); +} diff --git a/src/features/hosted-operations/main/hosted.ts b/src/features/hosted-operations/main/hosted.ts new file mode 100644 index 0000000000..5aadbd1c18 --- /dev/null +++ b/src/features/hosted-operations/main/hosted.ts @@ -0,0 +1,26 @@ +export type { + HostedDiagnosticsCorrelationIdPort, + HostedDiagnosticsDeadlineSchedulerPort, + HostedDiagnosticsSourcePort, + HostedDiagnosticsSourceRecord, + HostedDiagnosticsSourceResult, +} from '../core/application/ports/HostedDiagnosticsPorts'; +export { HOSTED_DIAGNOSTICS_ROUTE_DESCRIPTORS } from './adapters/input/http/hostedDiagnosticsRoutes'; +export { + type HostedDiagnosticsContextFactory, + type HostedDiagnosticsHttpFacade, + registerHostedDiagnosticsHttp, +} from './adapters/input/http/registerHostedDiagnosticsHttp'; +export { + createHostedDiagnosticsAdapters, + type CreateHostedDiagnosticsAdaptersOptions, + HOSTED_DIAGNOSTICS_RETENTION_BUDGET, + type HostedDiagnosticsAdapters, + type HostedDiagnosticsRecorderPort, +} from './composition/createHostedDiagnosticsAdapters'; +export { + createHostedDiagnosticsFeature, + type CreateHostedDiagnosticsFeatureDependencies, + createHostedDiagnosticsRouteContribution, + type HostedDiagnosticsFeature, +} from './composition/createHostedDiagnosticsFeature'; diff --git a/src/features/hosted-operations/main/infrastructure/NodeHostedDiagnosticsPlatform.ts b/src/features/hosted-operations/main/infrastructure/NodeHostedDiagnosticsPlatform.ts new file mode 100644 index 0000000000..e3853f0045 --- /dev/null +++ b/src/features/hosted-operations/main/infrastructure/NodeHostedDiagnosticsPlatform.ts @@ -0,0 +1,57 @@ +import { randomBytes } from 'node:crypto'; + +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +export interface NodeHostedDiagnosticsPlatform { + nowEpochMs(): number; + nowMonotonicMs(): number; + randomBytes(size: number): Uint8Array; + schedule(delayMs: number, callback: () => void): () => void; +} + +function schedule(delayMs: number, callback: () => void): () => void { + if (!Number.isSafeInteger(delayMs) || delayMs < 0 || typeof callback !== 'function') { + throw new TypeError('hosted-diagnostics-timer-invalid'); + } + + let active = true; + let remainingMs = delayMs; + let handle: ReturnType | undefined; + const arm = (): void => { + const sliceMs = Math.min(remainingMs, MAX_TIMER_DELAY_MS); + handle = setTimeout(() => { + handle = undefined; + if (!active) return; + remainingMs -= sliceMs; + if (remainingMs > 0) { + arm(); + return; + } + active = false; + callback(); + }, sliceMs); + handle.unref(); + }; + arm(); + + return () => { + if (!active) return; + active = false; + if (handle !== undefined) clearTimeout(handle); + handle = undefined; + }; +} + +export function createNodeHostedDiagnosticsPlatform(): NodeHostedDiagnosticsPlatform { + return Object.freeze({ + nowEpochMs: () => Date.now(), + nowMonotonicMs: () => Math.floor(performance.now()), + randomBytes(size: number): Uint8Array { + if (!Number.isSafeInteger(size) || size < 1) { + throw new TypeError('hosted-diagnostics-random-size-invalid'); + } + return randomBytes(size); + }, + schedule, + }); +} diff --git a/src/features/hosted-operations/renderer/composition/createHostedDiagnosticsTransport.ts b/src/features/hosted-operations/renderer/composition/createHostedDiagnosticsTransport.ts new file mode 100644 index 0000000000..77f04eec62 --- /dev/null +++ b/src/features/hosted-operations/renderer/composition/createHostedDiagnosticsTransport.ts @@ -0,0 +1,71 @@ +import { + createHostedDiagnosticsFailure, + HOSTED_DIAGNOSTICS_QUERY_ROUTE, + type HostedDiagnosticsRequest, + parseHostedDiagnosticsRequest, + parseHostedDiagnosticsResponse, +} from '../../contracts'; + +import type { + HostedDiagnosticsJsonClientPort, + HostedDiagnosticsTransportPort, +} from '../ports/HostedDiagnosticsTransportPorts'; + +const CANCELLED = Symbol('hosted-diagnostics-transport-cancelled'); + +async function raceCancellation( + operation: Promise, + signal: AbortSignal +): Promise { + let cancel: (() => void) | undefined; + const cancellation = new Promise((resolve) => { + cancel = () => resolve(CANCELLED); + signal.addEventListener('abort', cancel, { once: true }); + }); + if (signal.aborted) cancel?.(); + try { + return await Promise.race([operation, cancellation]); + } finally { + if (cancel) signal.removeEventListener('abort', cancel); + } +} + +/** Creates a browser transport without reading a global fetch, token, cookie, or header source. */ +export function createHostedDiagnosticsTransport( + client: HostedDiagnosticsJsonClientPort +): HostedDiagnosticsTransportPort { + return Object.freeze({ + async getDiagnostics(request: HostedDiagnosticsRequest, suppliedSignal?: AbortSignal) { + const parsedRequest = parseHostedDiagnosticsRequest(request); + if (!parsedRequest.ok) return createHostedDiagnosticsFailure('request_invalid'); + if (suppliedSignal !== undefined && !(suppliedSignal instanceof AbortSignal)) { + return createHostedDiagnosticsFailure('request_invalid'); + } + + const signal = suppliedSignal ?? new AbortController().signal; + if (signal.aborted) return createHostedDiagnosticsFailure('request_cancelled'); + + try { + const response = await raceCancellation( + client.post( + HOSTED_DIAGNOSTICS_QUERY_ROUTE, + parsedRequest.value, + Object.freeze({ signal }) + ), + signal + ); + if (response === CANCELLED || signal.aborted) { + return createHostedDiagnosticsFailure('request_cancelled'); + } + const parsedResponse = parseHostedDiagnosticsResponse(response); + return parsedResponse.ok + ? parsedResponse.value + : createHostedDiagnosticsFailure('response_invalid'); + } catch { + return createHostedDiagnosticsFailure( + signal.aborted ? 'request_cancelled' : 'transport_unavailable' + ); + } + }, + }); +} diff --git a/src/features/hosted-operations/renderer/index.ts b/src/features/hosted-operations/renderer/index.ts new file mode 100644 index 0000000000..be9b3de592 --- /dev/null +++ b/src/features/hosted-operations/renderer/index.ts @@ -0,0 +1,9 @@ +export { createHostedDiagnosticsTransport } from './composition/createHostedDiagnosticsTransport'; +export type { + HostedDiagnosticsJsonClientPort, + HostedDiagnosticsTransportPort, +} from './ports/HostedDiagnosticsTransportPorts'; +export { + HostedDiagnosticsPanel, + type HostedDiagnosticsPanelProps, +} from './ui/HostedDiagnosticsPanel'; diff --git a/src/features/hosted-operations/renderer/ports/HostedDiagnosticsTransportPorts.ts b/src/features/hosted-operations/renderer/ports/HostedDiagnosticsTransportPorts.ts new file mode 100644 index 0000000000..8cf3e5a01e --- /dev/null +++ b/src/features/hosted-operations/renderer/ports/HostedDiagnosticsTransportPorts.ts @@ -0,0 +1,20 @@ +import type { + HOSTED_DIAGNOSTICS_QUERY_ROUTE, + HostedDiagnosticsRequest, + HostedDiagnosticsResponse, +} from '../../contracts'; + +export interface HostedDiagnosticsJsonClientPort { + post( + path: typeof HOSTED_DIAGNOSTICS_QUERY_ROUTE, + request: HostedDiagnosticsRequest, + context: { readonly signal: AbortSignal } + ): Promise; +} + +export interface HostedDiagnosticsTransportPort { + getDiagnostics( + request: HostedDiagnosticsRequest, + signal?: AbortSignal + ): Promise; +} diff --git a/src/features/hosted-operations/renderer/ui/HostedDiagnosticsPanel.tsx b/src/features/hosted-operations/renderer/ui/HostedDiagnosticsPanel.tsx new file mode 100644 index 0000000000..eda7cf406a --- /dev/null +++ b/src/features/hosted-operations/renderer/ui/HostedDiagnosticsPanel.tsx @@ -0,0 +1,124 @@ +import { useLayoutEffect, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Loader2, RefreshCw, Stethoscope } from 'lucide-react'; + +import { + HOSTED_DIAGNOSTICS_SCHEMA_VERSION, + type HostedDiagnosticsResponse, + type OperationalReferenceId, +} from '../../contracts'; + +import type { HostedDiagnosticsTransportPort } from '../ports/HostedDiagnosticsTransportPorts'; + +export interface HostedDiagnosticsPanelProps { + /** Immutable caller-owned identity for the current principal/workspace scope. */ + readonly bindingKey: string; + readonly referenceIds: readonly OperationalReferenceId[]; + readonly transport: HostedDiagnosticsTransportPort; + readonly heading?: string; +} + +interface DiagnosticsState { + readonly bindingKey: string; + readonly loading: boolean; + readonly response: HostedDiagnosticsResponse | null; +} + +export const HostedDiagnosticsPanel = ({ + bindingKey, + referenceIds, + transport, + heading = 'Hosted diagnostics', +}: HostedDiagnosticsPanelProps): React.JSX.Element => { + const [reloadSequence, setReloadSequence] = useState(0); + const [state, setState] = useState(() => ({ + bindingKey, + loading: true, + response: null, + })); + // A prop rebind renders before effects. Never expose content retained for the previous authority. + const visibleState = state.bindingKey === bindingKey ? state : null; + + useLayoutEffect(() => { + const controller = new AbortController(); + setState({ bindingKey, loading: true, response: null }); + void transport + .getDiagnostics( + Object.freeze({ + schemaVersion: HOSTED_DIAGNOSTICS_SCHEMA_VERSION, + referenceIds: Object.freeze([...referenceIds]), + }), + controller.signal + ) + .then((response) => { + if (!controller.signal.aborted) setState({ bindingKey, loading: false, response }); + }) + .catch(() => { + if (!controller.signal.aborted) setState({ bindingKey, loading: false, response: null }); + }); + return () => controller.abort(); + }, [bindingKey, referenceIds, reloadSequence, transport]); + + const response = visibleState?.response ?? null; + return ( +
+
+
+
+ +
+ + {visibleState === null || visibleState.loading ? ( +

+ Loading diagnostics… +

+ ) : null} + {visibleState !== null && !visibleState.loading && response === null ? ( +

+ Diagnostics are temporarily unavailable. +

+ ) : null} + {response?.kind === 'error' ? ( +

+ Diagnostics are temporarily unavailable ({response.error.reason}). +

+ ) : null} + {response?.kind === 'success' ? ( + response.items.length === 0 ? ( +

No diagnostics found.

+ ) : ( +
    + {response.items.map((item) => ( +
  • +

    + {item.kind}: {item.outcome} +

    +

    {item.referenceId}

    +
  • + ))} +
+ ) + ) : null} +
+ ); +}; diff --git a/src/features/hosted-query-context/core/application/AuthenticatedHostedQueryContextFactory.ts b/src/features/hosted-query-context/core/application/AuthenticatedHostedQueryContextFactory.ts new file mode 100644 index 0000000000..2466264c2a --- /dev/null +++ b/src/features/hosted-query-context/core/application/AuthenticatedHostedQueryContextFactory.ts @@ -0,0 +1,300 @@ +import { + HOSTED_PERMISSIONS, + type HostedPermission, + type HostedPrincipal, + parseHostedSessionId, + parseOperatorSessionId, + parseUserId, +} from '@features/hosted-access/contracts'; +import { createQueryContext } from '@shared/contracts/hosted'; + +import type { + AuthenticatedHostedQueryContextFactoryDependencies, + AuthenticatedHostedQueryContextFactoryPort, + AuthenticatedHostedQueryContextFailure, + AuthenticatedHostedQueryContextFailureCode, + AuthenticatedHostedQueryContextResult, + AuthenticatedHostedQueryContextSuccess, + AuthenticatedHostedSessionId, +} from './ports'; + +const HOSTED_PERMISSION_SET = new Set(HOSTED_PERMISSIONS); + +const FAILURES: Readonly< + Record +> = Object.freeze( + Object.fromEntries( + [ + 'request_invalid', + 'request_cancelled', + 'signal_invalid', + 'signal_rebinding', + 'authentication_required', + 'authentication_invalid', + 'authenticated_session_required', + 'principal_session_required', + 'principal_session_mismatch', + 'permission_denied', + 'context_unavailable', + ].map((code) => [code, Object.freeze({ kind: 'failure', code })]) + ) as Record +); + +function failure( + code: AuthenticatedHostedQueryContextFailureCode +): AuthenticatedHostedQueryContextFailure { + return FAILURES[code]; +} + +function isRequestKey(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function'; +} + +function isServerSignal(value: unknown): value is AbortSignal { + return value instanceof AbortSignal; +} + +function isAuthenticationMethod(value: unknown): value is HostedPrincipal['authenticationMethod'] { + return value === 'desktop-local-owner' || value === 'oidc' || value === 'personal'; +} + +interface AuthenticationEvidenceSnapshot { + readonly principal: Readonly<{ + userId: unknown; + authenticationMethod: unknown; + permissions: readonly unknown[]; + sessionId: unknown; + }>; + readonly authenticatedSessionId: unknown; +} + +interface ValidatedAuthenticationSnapshot { + readonly principal: Readonly< + Pick + >; + readonly authenticatedSessionId: AuthenticatedHostedSessionId; +} + +type AuthenticationRead = + | { readonly ok: true; readonly value: ValidatedAuthenticationSnapshot } + | { readonly ok: false; readonly failure: AuthenticatedHostedQueryContextFailure }; + +function invalidAuthentication( + code: AuthenticatedHostedQueryContextFailureCode +): AuthenticationRead { + return Object.freeze({ ok: false, failure: failure(code) }); +} + +function snapshotAuthentication( + value: unknown +): AuthenticationRead | AuthenticationEvidenceSnapshot { + if (value === null) { + return invalidAuthentication('authentication_required'); + } + if (typeof value !== 'object') { + return invalidAuthentication('authentication_invalid'); + } + + try { + const principal = Reflect.get(value, 'principal') as unknown; + const authenticatedSessionId = Reflect.get(value, 'authenticatedSessionId') as unknown; + if (typeof principal !== 'object' || principal === null) { + return invalidAuthentication('authentication_invalid'); + } + + const userId = Reflect.get(principal, 'userId') as unknown; + const authenticationMethod = Reflect.get(principal, 'authenticationMethod') as unknown; + const permissionEvidence = Reflect.get(principal, 'permissions') as unknown; + const sessionId = Reflect.get(principal, 'sessionId') as unknown; + if (!Array.isArray(permissionEvidence)) { + return invalidAuthentication('authentication_invalid'); + } + const permissionCount = permissionEvidence.length; + if (permissionCount > HOSTED_PERMISSIONS.length) { + return invalidAuthentication('authentication_invalid'); + } + const permissions: unknown[] = []; + for (let index = 0; index < permissionCount; index += 1) { + permissions.push(permissionEvidence[index]); + } + + return Object.freeze({ + principal: Object.freeze({ + userId, + authenticationMethod, + permissions: Object.freeze(permissions), + sessionId, + }), + authenticatedSessionId, + }); + } catch { + return invalidAuthentication('authentication_invalid'); + } +} + +function readAuthentication(value: unknown): AuthenticationRead { + const evidence = snapshotAuthentication(value); + if ('ok' in evidence) return evidence; + + const { authenticationMethod, permissions: permissionEvidence } = evidence.principal; + if (!isAuthenticationMethod(authenticationMethod)) { + return invalidAuthentication('authentication_invalid'); + } + + let userId: HostedPrincipal['userId']; + let principalSessionId: HostedPrincipal['sessionId']; + let permissions: readonly HostedPermission[]; + try { + userId = parseUserId(evidence.principal.userId); + principalSessionId = + evidence.principal.sessionId === null + ? null + : parseHostedSessionId(evidence.principal.sessionId); + permissions = Object.freeze( + permissionEvidence.map((permission) => { + if (!HOSTED_PERMISSION_SET.has(permission as HostedPermission)) { + throw new TypeError('hosted-query-context-permission-invalid'); + } + return permission as HostedPermission; + }) + ); + } catch { + return invalidAuthentication('authentication_invalid'); + } + + let authenticatedSessionId: AuthenticatedHostedSessionId; + try { + authenticatedSessionId = + authenticationMethod === 'personal' + ? parseOperatorSessionId(evidence.authenticatedSessionId) + : parseHostedSessionId(evidence.authenticatedSessionId); + } catch { + return invalidAuthentication('authenticated_session_required'); + } + + if (principalSessionId === null && authenticationMethod !== 'personal') { + return invalidAuthentication('principal_session_required'); + } + if (principalSessionId !== null && principalSessionId !== authenticatedSessionId) { + return invalidAuthentication('principal_session_mismatch'); + } + + return Object.freeze({ + ok: true, + value: Object.freeze({ + principal: Object.freeze({ + userId, + authenticationMethod, + permissions, + sessionId: principalSessionId, + }), + authenticatedSessionId, + }), + }); +} + +export class AuthenticatedHostedQueryContextFactory implements AuthenticatedHostedQueryContextFactoryPort { + private readonly successes = new WeakMap(); + private readonly authentication: AuthenticatedHostedQueryContextFactoryDependencies['authentication']; + private readonly identity: AuthenticatedHostedQueryContextFactoryDependencies['identity']; + private readonly clock: AuthenticatedHostedQueryContextFactoryDependencies['clock']; + private readonly authorizedScope: AuthenticatedHostedQueryContextFactoryDependencies['policy']['authorizedScope']; + private readonly requiredPermission: AuthenticatedHostedQueryContextFactoryDependencies['policy']['requiredPermission']; + private readonly timeoutMs: number; + private readonly deploymentId: AuthenticatedHostedQueryContextFactoryDependencies['runtimeInstance']['deploymentId']; + private readonly bootId: AuthenticatedHostedQueryContextFactoryDependencies['runtimeInstance']['bootId']; + + constructor(dependencies: AuthenticatedHostedQueryContextFactoryDependencies) { + const policy = dependencies.policy; + const timeoutMs = policy.timeoutMs; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new TypeError('authenticated-hosted-query-context-policy-invalid'); + } + const requiredPermission = policy.requiredPermission; + if (!HOSTED_PERMISSION_SET.has(requiredPermission)) { + throw new TypeError('authenticated-hosted-query-context-policy-invalid'); + } + const runtimeInstance = dependencies.runtimeInstance; + this.authentication = dependencies.authentication; + this.identity = dependencies.identity; + this.clock = dependencies.clock; + this.authorizedScope = policy.authorizedScope; + this.requiredPermission = requiredPermission; + this.timeoutMs = timeoutMs; + this.deploymentId = runtimeInstance.deploymentId; + this.bootId = runtimeInstance.bootId; + } + + create(request: object, signal: AbortSignal): AuthenticatedHostedQueryContextResult { + if (!isRequestKey(request)) return failure('request_invalid'); + if (!isServerSignal(signal)) return failure('signal_invalid'); + if (signal.aborted) return failure('request_cancelled'); + + const cached = this.successes.get(request); + if (cached !== undefined) { + return cached.context.signal === signal ? cached : failure('signal_rebinding'); + } + + let authentication: ReturnType< + AuthenticatedHostedQueryContextFactoryDependencies['authentication']['authenticatedPrincipalFor'] + >; + try { + authentication = this.authentication.authenticatedPrincipalFor(request); + } catch { + return signal.aborted ? failure('request_cancelled') : failure('context_unavailable'); + } + if (signal.aborted) return failure('request_cancelled'); + + let read; + try { + read = readAuthentication(authentication); + } catch { + return signal.aborted ? failure('request_cancelled') : failure('authentication_invalid'); + } + if (signal.aborted) return failure('request_cancelled'); + if (!read.ok) return read.failure; + + const { principal, authenticatedSessionId } = read.value; + let permitted: boolean; + try { + permitted = principal.permissions.includes(this.requiredPermission); + } catch { + return signal.aborted ? failure('request_cancelled') : failure('authentication_invalid'); + } + if (signal.aborted) return failure('request_cancelled'); + if (!permitted) { + return failure('permission_denied'); + } + + try { + const nowMs = this.clock.nowMs(); + if (signal.aborted) return failure('request_cancelled'); + const deadlineAtMs = nowMs + this.timeoutMs; + if (!Number.isSafeInteger(nowMs) || nowMs < 0 || !Number.isSafeInteger(deadlineAtMs)) { + return failure('context_unavailable'); + } + const actorId = this.identity.projectActorId(principal.userId); + if (signal.aborted) return failure('request_cancelled'); + const sessionId = this.identity.projectSessionId(authenticatedSessionId); + if (signal.aborted) return failure('request_cancelled'); + const requestId = this.identity.createRequestId(); + if (signal.aborted) return failure('request_cancelled'); + const context = createQueryContext({ + actorId, + sessionId, + deploymentId: this.deploymentId, + bootId: this.bootId, + requestId, + authorizedScope: this.authorizedScope, + deadlineAtMs, + signal, + }); + if (signal.aborted) return failure('request_cancelled'); + const success = Object.freeze({ kind: 'success', context } as const); + this.successes.set(request, success); + return success; + } catch { + return signal.aborted ? failure('request_cancelled') : failure('context_unavailable'); + } + } +} diff --git a/src/features/hosted-query-context/core/application/ports.ts b/src/features/hosted-query-context/core/application/ports.ts new file mode 100644 index 0000000000..44e89a20fa --- /dev/null +++ b/src/features/hosted-query-context/core/application/ports.ts @@ -0,0 +1,84 @@ +import type { + HostedPermission, + HostedPrincipal, + HostedSessionId, + OperatorSessionId, +} from '@features/hosted-access/contracts'; +import type { RuntimeInstanceContext } from '@features/runtime-instance-context/contracts'; +import type { + ActorId, + AuthorizedScope, + QueryContext, + RequestId, + SessionId, +} from '@shared/contracts/hosted'; + +export type AuthenticatedHostedSessionId = HostedSessionId | OperatorSessionId; + +/** + * Authentication evidence is supplied by the server authentication boundary. + * The request body, query, params, headers, and cookies are deliberately absent. + */ +export interface AuthenticatedHostedPrincipal { + readonly principal: HostedPrincipal; + readonly authenticatedSessionId: AuthenticatedHostedSessionId; +} + +export interface AuthenticatedHostedPrincipalSourcePort { + authenticatedPrincipalFor(request: object): AuthenticatedHostedPrincipal | null; +} + +export interface HostedQueryContextIdentityPort { + projectActorId(userId: HostedPrincipal['userId']): ActorId; + projectSessionId(authenticatedSessionId: AuthenticatedHostedSessionId): SessionId; + createRequestId(): RequestId; +} + +export interface HostedQueryContextClockPort { + nowMs(): number; +} + +export interface AuthenticatedHostedQueryContextPolicy { + readonly authorizedScope: AuthorizedScope; + readonly requiredPermission: HostedPermission; + readonly timeoutMs: number; +} + +export interface AuthenticatedHostedQueryContextFactoryDependencies { + readonly authentication: AuthenticatedHostedPrincipalSourcePort; + readonly identity: HostedQueryContextIdentityPort; + readonly runtimeInstance: RuntimeInstanceContext; + readonly clock: HostedQueryContextClockPort; + readonly policy: AuthenticatedHostedQueryContextPolicy; +} + +export type AuthenticatedHostedQueryContextFailureCode = + | 'request_invalid' + | 'request_cancelled' + | 'signal_invalid' + | 'signal_rebinding' + | 'authentication_required' + | 'authentication_invalid' + | 'authenticated_session_required' + | 'principal_session_required' + | 'principal_session_mismatch' + | 'permission_denied' + | 'context_unavailable'; + +export interface AuthenticatedHostedQueryContextFailure { + readonly kind: 'failure'; + readonly code: AuthenticatedHostedQueryContextFailureCode; +} + +export interface AuthenticatedHostedQueryContextSuccess { + readonly kind: 'success'; + readonly context: QueryContext; +} + +export type AuthenticatedHostedQueryContextResult = + | AuthenticatedHostedQueryContextSuccess + | AuthenticatedHostedQueryContextFailure; + +export interface AuthenticatedHostedQueryContextFactoryPort { + create(request: object, signal: AbortSignal): AuthenticatedHostedQueryContextResult; +} diff --git a/src/features/hosted-query-context/main/composition/createAuthenticatedHostedQueryContextFactory.ts b/src/features/hosted-query-context/main/composition/createAuthenticatedHostedQueryContextFactory.ts new file mode 100644 index 0000000000..464b95e51f --- /dev/null +++ b/src/features/hosted-query-context/main/composition/createAuthenticatedHostedQueryContextFactory.ts @@ -0,0 +1,39 @@ +import { createRuntimeInstanceContext } from '@features/runtime-instance-context'; +import { parseAuthorizedScope } from '@shared/contracts/hosted'; + +import { AuthenticatedHostedQueryContextFactory } from '../../core/application/AuthenticatedHostedQueryContextFactory'; +import { NodeHostedQueryContextIdentity } from '../infrastructure/NodeHostedQueryContextIdentity'; + +import type { + AuthenticatedHostedPrincipalSourcePort, + AuthenticatedHostedQueryContextFactoryPort, + HostedQueryContextClockPort, +} from '../../core/application/ports'; +import type { RuntimeInstanceContext } from '@features/runtime-instance-context/contracts'; + +const AUTHENTICATED_HOSTED_QUERY_CONTEXT_POLICY = Object.freeze({ + authorizedScope: parseAuthorizedScope('scope_authenticated-hosted-query'), + requiredPermission: 'hosted.query' as const, + timeoutMs: 10_000, +}); + +const SERVER_CLOCK: HostedQueryContextClockPort = Object.freeze({ nowMs: Date.now }); + +export interface CreateAuthenticatedHostedQueryContextFactoryDependencies { + readonly authentication: AuthenticatedHostedPrincipalSourcePort; + readonly runtimeInstance: RuntimeInstanceContext; + readonly clock?: HostedQueryContextClockPort; +} + +export function createAuthenticatedHostedQueryContextFactory( + dependencies: CreateAuthenticatedHostedQueryContextFactoryDependencies +): AuthenticatedHostedQueryContextFactoryPort { + const runtimeInstance = createRuntimeInstanceContext(dependencies.runtimeInstance); + return new AuthenticatedHostedQueryContextFactory({ + authentication: dependencies.authentication, + identity: new NodeHostedQueryContextIdentity(), + runtimeInstance, + clock: dependencies.clock ?? SERVER_CLOCK, + policy: AUTHENTICATED_HOSTED_QUERY_CONTEXT_POLICY, + }); +} diff --git a/src/features/hosted-query-context/main/hosted.ts b/src/features/hosted-query-context/main/hosted.ts new file mode 100644 index 0000000000..77e1bd9d2c --- /dev/null +++ b/src/features/hosted-query-context/main/hosted.ts @@ -0,0 +1,4 @@ +export { + createAuthenticatedHostedQueryContextFactory, + type CreateAuthenticatedHostedQueryContextFactoryDependencies, +} from './composition/createAuthenticatedHostedQueryContextFactory'; diff --git a/src/features/hosted-query-context/main/index.ts b/src/features/hosted-query-context/main/index.ts new file mode 100644 index 0000000000..e1cd9e21ef --- /dev/null +++ b/src/features/hosted-query-context/main/index.ts @@ -0,0 +1,10 @@ +export type { + AuthenticatedHostedPrincipal, + AuthenticatedHostedPrincipalSourcePort, + AuthenticatedHostedQueryContextFactoryPort, + AuthenticatedHostedQueryContextFailure, + AuthenticatedHostedQueryContextFailureCode, + AuthenticatedHostedQueryContextResult, + AuthenticatedHostedQueryContextSuccess, + AuthenticatedHostedSessionId, +} from '../core/application/ports'; diff --git a/src/features/hosted-query-context/main/infrastructure/NodeHostedQueryContextIdentity.ts b/src/features/hosted-query-context/main/infrastructure/NodeHostedQueryContextIdentity.ts new file mode 100644 index 0000000000..802509b948 --- /dev/null +++ b/src/features/hosted-query-context/main/infrastructure/NodeHostedQueryContextIdentity.ts @@ -0,0 +1,68 @@ +import { Buffer } from 'node:buffer'; +import { createHash, randomBytes as nodeRandomBytes } from 'node:crypto'; + +import { parseActorId, parseRequestId, parseSessionId } from '@shared/contracts/hosted'; + +import type { + AuthenticatedHostedSessionId, + HostedQueryContextIdentityPort, +} from '../../core/application/ports'; +import type { HostedPrincipal } from '@features/hosted-access/contracts'; + +const REQUEST_ID_BYTES = 16; +const FRAME_LENGTH_BYTES = 4; +const ACTOR_PROJECTION_DOMAIN = 'agent-teams/hosted-query-context/actor/v1'; +const SESSION_PROJECTION_DOMAIN = 'agent-teams/hosted-query-context/session/v1'; + +export interface NodeHostedQueryContextIdentityDependencies { + readonly randomBytes?: (size: number) => Uint8Array; +} + +function framedProjectionInput(domain: string, opaqueId: string): Uint8Array { + const domainBytes = Buffer.from(domain, 'utf8'); + const opaqueIdBytes = Buffer.from(opaqueId, 'utf8'); + const frame = Buffer.allocUnsafe( + FRAME_LENGTH_BYTES + domainBytes.byteLength + FRAME_LENGTH_BYTES + opaqueIdBytes.byteLength + ); + let offset = 0; + frame.writeUInt32BE(domainBytes.byteLength, offset); + offset += FRAME_LENGTH_BYTES; + domainBytes.copy(frame, offset); + offset += domainBytes.byteLength; + frame.writeUInt32BE(opaqueIdBytes.byteLength, offset); + offset += FRAME_LENGTH_BYTES; + opaqueIdBytes.copy(frame, offset); + return frame; +} + +function project(domain: string, opaqueId: string): string { + return createHash('sha256').update(framedProjectionInput(domain, opaqueId)).digest('hex'); +} + +function hexadecimal(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export class NodeHostedQueryContextIdentity implements HostedQueryContextIdentityPort { + private readonly randomBytes: (size: number) => Uint8Array; + + constructor(dependencies: NodeHostedQueryContextIdentityDependencies = {}) { + this.randomBytes = dependencies.randomBytes ?? nodeRandomBytes; + } + + projectActorId(userId: HostedPrincipal['userId']) { + return parseActorId(`actor_${project(ACTOR_PROJECTION_DOMAIN, userId)}`); + } + + projectSessionId(authenticatedSessionId: AuthenticatedHostedSessionId) { + return parseSessionId(`session_${project(SESSION_PROJECTION_DOMAIN, authenticatedSessionId)}`); + } + + createRequestId() { + const bytes = this.randomBytes(REQUEST_ID_BYTES); + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== REQUEST_ID_BYTES) { + throw new TypeError('hosted-query-context-request-id-randomness-invalid'); + } + return parseRequestId(`request_${hexadecimal(bytes)}`); + } +} diff --git a/src/features/hosted-readiness/contracts/hostedReadiness.ts b/src/features/hosted-readiness/contracts/hostedReadiness.ts new file mode 100644 index 0000000000..a32cd9e17d --- /dev/null +++ b/src/features/hosted-readiness/contracts/hostedReadiness.ts @@ -0,0 +1,512 @@ +import { + type BootId, + type DeploymentId, + parseBootId, + parseDeploymentId, +} from '@shared/contracts/hosted'; + +export const HOSTED_READINESS_ROUTE = '/api/hosted/v1/meta/readiness' as const; +export const HOSTED_READINESS_SCHEMA_VERSION = 1 as const; +export const MAX_HOSTED_READINESS_RESPONSE_BYTES = 64 * 1024; +export const MAX_HOSTED_READINESS_FACETS = 32; +export const MAX_HOSTED_READINESS_ACTIONS = 128; +export const MAX_HOSTED_READINESS_REASONS = 8; + +export const HOSTED_READINESS_DIMENSIONS = Object.freeze([ + 'live', + 'serve', + 'auth', + 'read', + 'mutation', + 'runtime-control', + 'machine-ingress', + 'recovery-point', +] as const); +export type HostedReadinessDimension = (typeof HOSTED_READINESS_DIMENSIONS)[number]; + +export const HOSTED_READINESS_REASON_CODES = Object.freeze([ + 'startup_in_progress', + 'dependency_unavailable', + 'serve_unavailable', + 'authentication_required', + 'authentication_unavailable', + 'read_unavailable', + 'mutation_unavailable', + 'runtime_control_unavailable', + 'machine_ingress_unavailable', + 'recovery_point_unavailable', + 'read_only', + 'provider_unavailable', + 'provider_auth_required', + 'workspace_unavailable', + 'workspace_read_only', + 'recovery_required', + 'stale_generation', + 'stale_revision', + 'resource_busy', + 'policy_denied', + 'temporarily_unavailable', + 'not_offered', + 'not_implemented', + 'request_cancelled', + 'deadline_exceeded', + 'source_unavailable', +] as const); +export type HostedReadinessReasonCode = (typeof HOSTED_READINESS_REASON_CODES)[number]; + +export type HostedReadinessStatus = 'ready' | 'not_ready'; +export type HostedFacetAvailability = 'available' | 'temporarily_unavailable' | 'not_offered'; +export type HostedActionImplementation = 'implemented' | 'not_implemented'; + +export interface HostedReadinessDimensionProjection { + readonly dimension: HostedReadinessDimension; + readonly status: HostedReadinessStatus; + readonly reasons: readonly HostedReadinessReasonCode[]; +} + +export interface HostedTerminalProjection { + readonly dimension: 'terminal'; + readonly status: 'not_offered'; + readonly reasons: readonly []; +} + +export interface HostedReadinessFacetProjection { + readonly facetId: string; + readonly availability: HostedFacetAvailability; + readonly requiredReadiness: readonly HostedReadinessDimension[]; + readonly reasons: readonly HostedReadinessReasonCode[]; +} + +export interface HostedReadinessActionProjection { + readonly actionId: string; + readonly facetId: string; + readonly implementation: HostedActionImplementation; + readonly availability: HostedFacetAvailability; + readonly requiredReadiness: readonly HostedReadinessDimension[]; + readonly reasons: readonly HostedReadinessReasonCode[]; +} + +export interface HostedReadinessProjection { + readonly schemaVersion: typeof HOSTED_READINESS_SCHEMA_VERSION; + readonly kind: 'success'; + readonly deploymentId: DeploymentId; + readonly bootId: BootId; + readonly revision: number; + readonly requiredReadiness: readonly HostedReadinessDimension[]; + readonly dimensions: readonly HostedReadinessDimensionProjection[]; + readonly terminal: HostedTerminalProjection; + readonly facets: readonly HostedReadinessFacetProjection[]; + readonly actions: readonly HostedReadinessActionProjection[]; +} + +export type HostedReadinessFailureReason = + | 'request_cancelled' + | 'deadline_exceeded' + | 'readiness_unavailable' + | 'response_invalid'; + +export interface HostedReadinessFailure { + readonly schemaVersion: typeof HOSTED_READINESS_SCHEMA_VERSION; + readonly kind: 'failure'; + readonly reason: HostedReadinessFailureReason; +} + +export type HostedReadinessResponse = HostedReadinessProjection | HostedReadinessFailure; + +export type HostedReadinessProjectionParseResult = + | { readonly ok: true; readonly value: HostedReadinessProjection } + | { readonly ok: false; readonly error: 'hosted_readiness_projection_invalid' }; + +const PROJECTION_KEYS = Object.freeze([ + 'schemaVersion', + 'kind', + 'deploymentId', + 'bootId', + 'revision', + 'requiredReadiness', + 'dimensions', + 'terminal', + 'facets', + 'actions', +] as const); +const DIMENSION_KEYS = Object.freeze(['dimension', 'status', 'reasons'] as const); +const FACET_KEYS = Object.freeze([ + 'facetId', + 'availability', + 'requiredReadiness', + 'reasons', +] as const); +const ACTION_KEYS = Object.freeze([ + 'actionId', + 'facetId', + 'implementation', + 'availability', + 'requiredReadiness', + 'reasons', +] as const); +const FAILURE_KEYS = Object.freeze(['schemaVersion', 'kind', 'reason'] as const); +const DIMENSION_SET = new Set(HOSTED_READINESS_DIMENSIONS); +const REASON_SET = new Set(HOSTED_READINESS_REASON_CODES); +const FACET_AVAILABILITY_SET = new Set([ + 'available', + 'temporarily_unavailable', + 'not_offered', +]); +const IMPLEMENTATION_SET = new Set(['implemented', 'not_implemented']); +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; +const MAX_CAPABILITY_ID_LENGTH = 96; + +function invalid(): never { + throw new TypeError('hosted_readiness_projection_invalid'); +} + +function snapshotExactRecord( + value: unknown, + expectedKeys: readonly string[] +): Readonly> { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + try { + if (Object.getPrototypeOf(value) !== Object.prototype) invalid(); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length || + keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + invalid(); + } + const snapshot: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) invalid(); + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot); + } catch { + return invalid(); + } +} + +function snapshotDenseArray(value: unknown, maximumLength: number): readonly unknown[] { + if (!Array.isArray(value)) invalid(); + try { + if (Object.getPrototypeOf(value) !== Array.prototype) invalid(); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = lengthDescriptor && 'value' in lengthDescriptor ? lengthDescriptor.value : -1; + if ( + !Number.isSafeInteger(length) || + (length as number) < 0 || + (length as number) > maximumLength + ) { + invalid(); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== (length as number) + 1) invalid(); + const values: unknown[] = []; + for (let index = 0; index < (length as number); index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !('value' in descriptor)) invalid(); + values.push(descriptor.value); + } + return Object.freeze(values); + } catch { + return invalid(); + } +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function parseCapabilityId(value: unknown): string { + if ( + typeof value !== 'string' || + value.length > MAX_CAPABILITY_ID_LENGTH || + !ID_PATTERN.test(value) + ) { + invalid(); + } + return value; +} + +function parseDimensions(value: unknown): readonly HostedReadinessDimension[] { + const values = snapshotDenseArray(value, HOSTED_READINESS_DIMENSIONS.length); + const unique = new Set(); + for (const item of values) { + if (typeof item !== 'string' || !DIMENSION_SET.has(item)) invalid(); + unique.add(item as HostedReadinessDimension); + } + return Object.freeze(HOSTED_READINESS_DIMENSIONS.filter((dimension) => unique.has(dimension))); +} + +function parseReasons(value: unknown): readonly HostedReadinessReasonCode[] { + const values = snapshotDenseArray(value, MAX_HOSTED_READINESS_REASONS); + const unique = new Set(); + for (const item of values) { + if (typeof item !== 'string' || !REASON_SET.has(item)) invalid(); + unique.add(item as HostedReadinessReasonCode); + } + return Object.freeze([...unique].sort(compareText)); +} + +function assertAvailabilityReasons( + availability: HostedFacetAvailability, + reasons: readonly HostedReadinessReasonCode[] +): void { + if (availability === 'available' && reasons.length !== 0) invalid(); + if (availability === 'temporarily_unavailable') { + if ( + reasons.length === 0 || + reasons.includes('not_offered') || + reasons.includes('not_implemented') + ) { + invalid(); + } + } + if (availability === 'not_offered' && (reasons.length !== 1 || reasons[0] !== 'not_offered')) { + invalid(); + } +} + +function parseDimensionProjection(value: unknown): HostedReadinessDimensionProjection { + const record = snapshotExactRecord(value, DIMENSION_KEYS); + if (typeof record.dimension !== 'string' || !DIMENSION_SET.has(record.dimension)) invalid(); + if (record.status !== 'ready' && record.status !== 'not_ready') invalid(); + const reasons = parseReasons(record.reasons); + if ((record.status === 'ready') !== (reasons.length === 0)) invalid(); + return Object.freeze({ + dimension: record.dimension as HostedReadinessDimension, + status: record.status, + reasons, + }); +} + +function parseAllDimensionProjections( + value: unknown +): readonly HostedReadinessDimensionProjection[] { + const values = snapshotDenseArray(value, HOSTED_READINESS_DIMENSIONS.length); + if (values.length !== HOSTED_READINESS_DIMENSIONS.length) invalid(); + const byDimension = new Map(); + for (const item of values) { + const dimension = parseDimensionProjection(item); + if (byDimension.has(dimension.dimension)) invalid(); + byDimension.set(dimension.dimension, dimension); + } + if (byDimension.size !== HOSTED_READINESS_DIMENSIONS.length) invalid(); + return Object.freeze(HOSTED_READINESS_DIMENSIONS.map((dimension) => byDimension.get(dimension)!)); +} + +function parseTerminal(value: unknown): HostedTerminalProjection { + const record = snapshotExactRecord(value, DIMENSION_KEYS); + if (record.dimension !== 'terminal' || record.status !== 'not_offered') invalid(); + const reasons = snapshotDenseArray(record.reasons, 0); + if (reasons.length !== 0) invalid(); + return Object.freeze({ + dimension: 'terminal', + status: 'not_offered', + reasons: Object.freeze([] as const), + }); +} + +function parseFacet(value: unknown): HostedReadinessFacetProjection { + const record = snapshotExactRecord(value, FACET_KEYS); + const facetId = parseCapabilityId(record.facetId); + if (typeof record.availability !== 'string' || !FACET_AVAILABILITY_SET.has(record.availability)) { + invalid(); + } + const availability = record.availability as HostedFacetAvailability; + const requiredReadiness = parseDimensions(record.requiredReadiness); + const reasons = parseReasons(record.reasons); + assertAvailabilityReasons(availability, reasons); + return Object.freeze({ facetId, availability, requiredReadiness, reasons }); +} + +function parseFacets(value: unknown): readonly HostedReadinessFacetProjection[] { + const values = snapshotDenseArray(value, MAX_HOSTED_READINESS_FACETS); + const byId = new Map(); + for (const item of values) { + const facet = parseFacet(item); + if (byId.has(facet.facetId)) invalid(); + byId.set(facet.facetId, facet); + } + return Object.freeze( + [...byId.values()].sort((left, right) => compareText(left.facetId, right.facetId)) + ); +} + +function parseAction( + value: unknown, + availableFacetIds: ReadonlySet +): HostedReadinessActionProjection { + const record = snapshotExactRecord(value, ACTION_KEYS); + const actionId = parseCapabilityId(record.actionId); + const facetId = parseCapabilityId(record.facetId); + if (!availableFacetIds.has(facetId)) invalid(); + if (typeof record.implementation !== 'string' || !IMPLEMENTATION_SET.has(record.implementation)) { + invalid(); + } + if (typeof record.availability !== 'string' || !FACET_AVAILABILITY_SET.has(record.availability)) { + invalid(); + } + const implementation = record.implementation as HostedActionImplementation; + const availability = record.availability as HostedFacetAvailability; + const requiredReadiness = parseDimensions(record.requiredReadiness); + const reasons = parseReasons(record.reasons); + if (implementation === 'not_implemented') { + if ( + availability !== 'not_offered' || + reasons.length !== 1 || + reasons[0] !== 'not_implemented' + ) { + invalid(); + } + } else { + assertAvailabilityReasons(availability, reasons); + } + return Object.freeze({ + actionId, + facetId, + implementation, + availability, + requiredReadiness, + reasons, + }); +} + +function parseActions( + value: unknown, + facets: readonly HostedReadinessFacetProjection[] +): readonly HostedReadinessActionProjection[] { + const values = snapshotDenseArray(value, MAX_HOSTED_READINESS_ACTIONS); + const facetIds = new Set(facets.map((facet) => facet.facetId)); + const byId = new Map(); + for (const item of values) { + const action = parseAction(item, facetIds); + if (byId.has(action.actionId)) invalid(); + byId.set(action.actionId, action); + } + return Object.freeze( + [...byId.values()].sort((left, right) => compareText(left.actionId, right.actionId)) + ); +} + +function readinessAllowsAvailable( + requiredReadiness: readonly HostedReadinessDimension[], + dimensionsById: ReadonlyMap +): boolean { + return requiredReadiness.every((dimension) => dimensionsById.get(dimension)?.status === 'ready'); +} + +function availabilityDoesNotExceed( + availability: HostedFacetAvailability, + ownerAvailability: HostedFacetAvailability +): boolean { + if (ownerAvailability === 'available') return true; + if (ownerAvailability === 'temporarily_unavailable') return availability !== 'available'; + return availability === 'not_offered'; +} + +function assertReadinessLattice( + dimensions: readonly HostedReadinessDimensionProjection[], + facets: readonly HostedReadinessFacetProjection[], + actions: readonly HostedReadinessActionProjection[] +): void { + const dimensionsById = new Map(dimensions.map((dimension) => [dimension.dimension, dimension])); + const facetsById = new Map(facets.map((facet) => [facet.facetId, facet])); + + for (const facet of facets) { + if ( + facet.availability === 'available' && + !readinessAllowsAvailable(facet.requiredReadiness, dimensionsById) + ) { + invalid(); + } + } + + for (const action of actions) { + const owningFacet = facetsById.get(action.facetId); + if (!owningFacet || !availabilityDoesNotExceed(action.availability, owningFacet.availability)) { + invalid(); + } + if ( + action.availability === 'available' && + !readinessAllowsAvailable(action.requiredReadiness, dimensionsById) + ) { + invalid(); + } + } +} + +function parseProjection(value: unknown): HostedReadinessProjection { + const record = snapshotExactRecord(value, PROJECTION_KEYS); + if (record.schemaVersion !== HOSTED_READINESS_SCHEMA_VERSION || record.kind !== 'success') + invalid(); + if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 0) invalid(); + const requiredReadiness = parseDimensions(record.requiredReadiness); + const dimensions = parseAllDimensionProjections(record.dimensions); + const terminal = parseTerminal(record.terminal); + const facets = parseFacets(record.facets); + const actions = parseActions(record.actions, facets); + assertReadinessLattice(dimensions, facets, actions); + const projection = Object.freeze({ + schemaVersion: HOSTED_READINESS_SCHEMA_VERSION, + kind: 'success' as const, + deploymentId: parseDeploymentId(record.deploymentId), + bootId: parseBootId(record.bootId), + revision: record.revision as number, + requiredReadiness, + dimensions, + terminal, + facets, + actions, + }); + if ( + new TextEncoder().encode(JSON.stringify(projection)).byteLength > + MAX_HOSTED_READINESS_RESPONSE_BYTES + ) { + invalid(); + } + return projection; +} + +export function parseHostedReadinessProjection( + value: unknown +): HostedReadinessProjectionParseResult { + try { + return Object.freeze({ ok: true, value: parseProjection(value) }); + } catch { + return Object.freeze({ ok: false, error: 'hosted_readiness_projection_invalid' }); + } +} + +export function createHostedReadinessFailure( + reason: HostedReadinessFailureReason +): HostedReadinessFailure { + return Object.freeze({ + schemaVersion: HOSTED_READINESS_SCHEMA_VERSION, + kind: 'failure', + reason, + }); +} + +export function parseHostedReadinessResponse(value: unknown): HostedReadinessResponse | null { + const projection = parseHostedReadinessProjection(value); + if (projection.ok) return projection.value; + try { + const record = snapshotExactRecord(value, FAILURE_KEYS); + if ( + record.schemaVersion !== HOSTED_READINESS_SCHEMA_VERSION || + record.kind !== 'failure' || + ![ + 'request_cancelled', + 'deadline_exceeded', + 'readiness_unavailable', + 'response_invalid', + ].includes(record.reason as string) + ) { + return null; + } + return createHostedReadinessFailure(record.reason as HostedReadinessFailureReason); + } catch { + return null; + } +} diff --git a/src/features/hosted-readiness/contracts/index.ts b/src/features/hosted-readiness/contracts/index.ts new file mode 100644 index 0000000000..ab8a054274 --- /dev/null +++ b/src/features/hosted-readiness/contracts/index.ts @@ -0,0 +1 @@ +export * from './hostedReadiness'; diff --git a/src/features/hosted-readiness/core/application/GetHostedReadinessProjection.ts b/src/features/hosted-readiness/core/application/GetHostedReadinessProjection.ts new file mode 100644 index 0000000000..614cf6c71c --- /dev/null +++ b/src/features/hosted-readiness/core/application/GetHostedReadinessProjection.ts @@ -0,0 +1,154 @@ +import { + compareHostedReadinessFreshness, + HostedReadinessProjectionPolicyError, + normalizeHostedReadinessProjection, +} from '../domain/HostedReadinessProjectionPolicy'; + +import type { HostedReadinessProjection } from '../../contracts'; +import type { + HostedReadinessProjectionClockPort, + HostedReadinessProjectionDeadlinePort, + HostedReadinessProjectionExecutionContext, + HostedReadinessProjectionSourcePort, +} from './ports/HostedReadinessProjectionPorts'; + +export type HostedReadinessProjectionExecutionErrorCode = + | 'request_cancelled' + | 'deadline_exceeded' + | 'source_unavailable' + | 'source_invalid' + | 'source_fence_mismatch' + | 'stale_revision' + | 'revision_conflict'; + +export class HostedReadinessProjectionExecutionError extends Error { + constructor(readonly code: HostedReadinessProjectionExecutionErrorCode) { + super(`hosted-readiness-projection-${code}`); + this.name = 'HostedReadinessProjectionExecutionError'; + } +} + +const SYSTEM_CLOCK: HostedReadinessProjectionClockPort = Object.freeze({ nowMs: Date.now }); + +function assertContext( + context: HostedReadinessProjectionExecutionContext +): HostedReadinessProjectionExecutionContext { + if ( + !context || + typeof context !== 'object' || + !Number.isSafeInteger(context.deadlineAtMs) || + context.deadlineAtMs < 0 || + !(context.signal instanceof AbortSignal) + ) { + throw new HostedReadinessProjectionExecutionError('source_invalid'); + } + return context; +} + +export class GetHostedReadinessProjection { + private lastPublished: HostedReadinessProjection | undefined; + + constructor( + private readonly source: HostedReadinessProjectionSourcePort, + private readonly deadline: HostedReadinessProjectionDeadlinePort, + private readonly clock: HostedReadinessProjectionClockPort = SYSTEM_CLOCK + ) { + if (!source || typeof source.readProjection !== 'function') { + throw new TypeError('hosted-readiness-projection-source-invalid'); + } + } + + async execute( + contextValue: HostedReadinessProjectionExecutionContext + ): Promise { + const context = assertContext(contextValue); + if (context.signal.aborted) { + throw new HostedReadinessProjectionExecutionError('request_cancelled'); + } + + const remainingMs = context.deadlineAtMs - this.clock.nowMs(); + if (!Number.isFinite(remainingMs) || remainingMs <= 0) { + throw new HostedReadinessProjectionExecutionError('deadline_exceeded'); + } + + const controller = new AbortController(); + let cancellationCode: 'request_cancelled' | 'deadline_exceeded' = 'request_cancelled'; + let rejectCancellation: ((error: HostedReadinessProjectionExecutionError) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const cancel = (code: 'request_cancelled' | 'deadline_exceeded'): void => { + if (controller.signal.aborted) return; + cancellationCode = code; + controller.abort(); + rejectCancellation?.(new HostedReadinessProjectionExecutionError(code)); + }; + const abortFromCaller = (): void => cancel('request_cancelled'); + context.signal.addEventListener('abort', abortFromCaller, { once: true }); + + let cancelDeadline: (() => void) | undefined; + try { + cancelDeadline = this.deadline.schedule(remainingMs, () => cancel('deadline_exceeded')); + if (typeof cancelDeadline !== 'function') { + throw new HostedReadinessProjectionExecutionError('source_invalid'); + } + if (controller.signal.aborted) { + throw new HostedReadinessProjectionExecutionError(cancellationCode); + } + + const sourceResult = Promise.resolve().then(() => + this.source.readProjection( + Object.freeze({ + deploymentId: context.deploymentId, + bootId: context.bootId, + deadlineAtMs: context.deadlineAtMs, + signal: controller.signal, + }) + ) + ); + let raw: unknown; + try { + raw = await Promise.race([sourceResult, cancellation]); + } catch (error) { + if (error instanceof HostedReadinessProjectionExecutionError) throw error; + throw new HostedReadinessProjectionExecutionError('source_unavailable'); + } + if (controller.signal.aborted) { + throw new HostedReadinessProjectionExecutionError(cancellationCode); + } + + let projection: HostedReadinessProjection; + try { + projection = normalizeHostedReadinessProjection(raw); + } catch (error) { + if (error instanceof HostedReadinessProjectionPolicyError) { + throw new HostedReadinessProjectionExecutionError('source_invalid'); + } + throw error; + } + if ( + projection.deploymentId !== context.deploymentId || + projection.bootId !== context.bootId + ) { + throw new HostedReadinessProjectionExecutionError('source_fence_mismatch'); + } + + const freshness = compareHostedReadinessFreshness(this.lastPublished, projection); + if (freshness !== 'accept') { + throw new HostedReadinessProjectionExecutionError( + freshness === 'revision_conflict' ? 'revision_conflict' : 'stale_revision' + ); + } + this.lastPublished = projection; + return projection; + } finally { + context.signal.removeEventListener('abort', abortFromCaller); + try { + cancelDeadline?.(); + } catch { + // The projection has already been safely classified. + } + if (!controller.signal.aborted) controller.abort(); + } + } +} diff --git a/src/features/hosted-readiness/core/application/ports/HostedReadinessProjectionPorts.ts b/src/features/hosted-readiness/core/application/ports/HostedReadinessProjectionPorts.ts new file mode 100644 index 0000000000..ea29de9296 --- /dev/null +++ b/src/features/hosted-readiness/core/application/ports/HostedReadinessProjectionPorts.ts @@ -0,0 +1,28 @@ +import type { BootId, DeploymentId } from '@shared/contracts/hosted'; + +export interface HostedReadinessProjectionSourceRequest { + readonly deploymentId: DeploymentId; + readonly bootId: BootId; + readonly deadlineAtMs: number; + readonly signal: AbortSignal; +} + +/** The production adapter is deliberately injected by the later serial composition gate. */ +export interface HostedReadinessProjectionSourcePort { + readProjection(request: HostedReadinessProjectionSourceRequest): unknown | Promise; +} + +export interface HostedReadinessProjectionClockPort { + nowMs(): number; +} + +export interface HostedReadinessProjectionDeadlinePort { + schedule(delayMs: number, onDeadline: () => void): () => void; +} + +export interface HostedReadinessProjectionExecutionContext { + readonly deploymentId: DeploymentId; + readonly bootId: BootId; + readonly deadlineAtMs: number; + readonly signal: AbortSignal; +} diff --git a/src/features/hosted-readiness/core/domain/HostedReadinessProjectionPolicy.ts b/src/features/hosted-readiness/core/domain/HostedReadinessProjectionPolicy.ts new file mode 100644 index 0000000000..84f726cd2e --- /dev/null +++ b/src/features/hosted-readiness/core/domain/HostedReadinessProjectionPolicy.ts @@ -0,0 +1,69 @@ +import { type HostedReadinessProjection, parseHostedReadinessProjection } from '../../contracts'; + +export type HostedReadinessBannerState = 'ready' | 'degraded' | 'not_offered'; + +export type HostedReadinessFreshnessDecision = + | 'accept' + | 'stale_deployment' + | 'stale_boot' + | 'stale_revision' + | 'revision_conflict'; + +export class HostedReadinessProjectionPolicyError extends Error { + readonly code: 'source_invalid'; + + constructor() { + super('hosted-readiness-projection-source-invalid'); + this.name = 'HostedReadinessProjectionPolicyError'; + this.code = 'source_invalid'; + } +} + +/** Reduces hostile source data to the exact browser-safe readiness contract. */ +export function normalizeHostedReadinessProjection(value: unknown): HostedReadinessProjection { + const parsed = parseHostedReadinessProjection(value); + if (!parsed.ok) throw new HostedReadinessProjectionPolicyError(); + return parsed.value; +} + +export function compareHostedReadinessFreshness( + previous: HostedReadinessProjection | undefined, + incoming: HostedReadinessProjection +): HostedReadinessFreshnessDecision { + if (!previous) return 'accept'; + if (incoming.deploymentId !== previous.deploymentId) return 'stale_deployment'; + if (incoming.bootId !== previous.bootId) return 'stale_boot'; + if (incoming.revision < previous.revision) return 'stale_revision'; + if ( + incoming.revision === previous.revision && + JSON.stringify(incoming) !== JSON.stringify(previous) + ) { + return 'revision_conflict'; + } + return 'accept'; +} + +export function deriveHostedReadinessBannerState( + projection: HostedReadinessProjection +): HostedReadinessBannerState { + const dimensions = new Map( + projection.dimensions.map((dimension) => [dimension.dimension, dimension.status]) + ); + if (projection.requiredReadiness.some((dimension) => dimensions.get(dimension) !== 'ready')) { + return 'degraded'; + } + + const offeredFacets = projection.facets.filter((facet) => facet.availability !== 'not_offered'); + const implementedActions = projection.actions.filter( + (action) => action.implementation === 'implemented' + ); + if (offeredFacets.length === 0 && implementedActions.length === 0) return 'not_offered'; + + if ( + offeredFacets.some((facet) => facet.availability === 'temporarily_unavailable') || + implementedActions.some((action) => action.availability === 'temporarily_unavailable') + ) { + return 'degraded'; + } + return 'ready'; +} diff --git a/src/features/hosted-readiness/main/adapters/input/http/hostedReadinessRoutes.ts b/src/features/hosted-readiness/main/adapters/input/http/hostedReadinessRoutes.ts new file mode 100644 index 0000000000..860eabff17 --- /dev/null +++ b/src/features/hosted-readiness/main/adapters/input/http/hostedReadinessRoutes.ts @@ -0,0 +1,23 @@ +import { HOSTED_READINESS_ROUTE } from '../../../../contracts'; + +import type { RouteDescriptor } from '@main/composition/hosted/routing'; + +const STATIC_READINESS = Object.freeze(['serve', 'auth'] as const); + +export const HOSTED_READINESS_ROUTE_DESCRIPTORS = Object.freeze([ + Object.freeze({ + id: 'hosted-readiness.projection.v1', + method: 'GET', + path: HOSTED_READINESS_ROUTE, + owner: 'hosted-readiness', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session', + readiness: STATIC_READINESS, + requestSchemaId: 'hosted-readiness.projection.request.v1', + responseSchemaId: 'hosted-readiness.projection.response.v1', + handlerId: 'hosted-readiness.projection.handler.v1', + clientId: 'hosted-readiness.projection.client.v1', + semanticTestId: 'hosted-readiness.projection.semantic.v1', + testOnly: false, + } satisfies RouteDescriptor), +] satisfies readonly RouteDescriptor[]); diff --git a/src/features/hosted-readiness/main/adapters/input/http/registerHostedReadinessHttp.ts b/src/features/hosted-readiness/main/adapters/input/http/registerHostedReadinessHttp.ts new file mode 100644 index 0000000000..584e7c9486 --- /dev/null +++ b/src/features/hosted-readiness/main/adapters/input/http/registerHostedReadinessHttp.ts @@ -0,0 +1,83 @@ +import { + createHostedReadinessFailure, + HOSTED_READINESS_ROUTE, + type HostedReadinessFailureReason, + type HostedReadinessProjection, + parseHostedReadinessProjection, +} from '../../../../contracts'; +import { HostedReadinessProjectionExecutionError } from '../../../../core/application/GetHostedReadinessProjection'; + +import type { QueryContext } from '@shared/contracts/hosted'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export interface HostedReadinessHttpFacade { + getReadiness(context: QueryContext): Promise; +} + +export type HostedReadinessContextFactory = ( + request: FastifyRequest, + signal: AbortSignal +) => QueryContext | Promise; + +const REQUEST_ABORTED = Object.freeze({ kind: 'hosted-readiness-request-aborted' }); + +async function withRequestSignal( + request: FastifyRequest, + reply: FastifyReply, + operation: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController(); + let rejectCancellation: ((reason: typeof REQUEST_ABORTED) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const abort = (): void => { + if (controller.signal.aborted) return; + controller.abort(); + rejectCancellation?.(REQUEST_ABORTED); + }; + request.raw.once('aborted', abort); + request.raw.socket.once('close', abort); + reply.raw.once('close', abort); + if (request.raw.aborted || request.raw.socket.destroyed || reply.raw.destroyed) abort(); + + try { + return await Promise.race([operation(controller.signal), cancellation]); + } finally { + request.raw.removeListener('aborted', abort); + request.raw.socket.removeListener('close', abort); + reply.raw.removeListener('close', abort); + } +} + +function failureReason(error: unknown): HostedReadinessFailureReason { + if (error === REQUEST_ABORTED) return 'request_cancelled'; + if (error instanceof HostedReadinessProjectionExecutionError) { + if (error.code === 'request_cancelled') return 'request_cancelled'; + if (error.code === 'deadline_exceeded') return 'deadline_exceeded'; + } + return 'readiness_unavailable'; +} + +export function registerHostedReadinessHttp( + app: FastifyInstance, + facade: HostedReadinessHttpFacade, + createContext: HostedReadinessContextFactory +): void { + app.get(HOSTED_READINESS_ROUTE, async (request, reply) => { + void reply.header('Cache-Control', 'no-store'); + try { + return await withRequestSignal(request, reply, async (signal) => { + const context = await createContext(request, signal); + const projection = await facade.getReadiness(context); + const parsed = parseHostedReadinessProjection(projection); + if (!parsed.ok) { + return reply.status(500).send(createHostedReadinessFailure('response_invalid')); + } + return reply.status(200).send(parsed.value); + }); + } catch (error) { + return reply.status(503).send(createHostedReadinessFailure(failureReason(error))); + } + }); +} diff --git a/src/features/hosted-readiness/main/composition/createHostedReadinessFeature.ts b/src/features/hosted-readiness/main/composition/createHostedReadinessFeature.ts new file mode 100644 index 0000000000..764f586e62 --- /dev/null +++ b/src/features/hosted-readiness/main/composition/createHostedReadinessFeature.ts @@ -0,0 +1,58 @@ +import { GetHostedReadinessProjection } from '../../core/application/GetHostedReadinessProjection'; +import { HOSTED_READINESS_ROUTE_DESCRIPTORS } from '../adapters/input/http/hostedReadinessRoutes'; + +import type { + HostedReadinessProjectionClockPort, + HostedReadinessProjectionDeadlinePort, + HostedReadinessProjectionSourcePort, +} from '../../core/application/ports/HostedReadinessProjectionPorts'; +import type { HostedReadinessHttpFacade } from '../adapters/input/http/registerHostedReadinessHttp'; +import type { HostedRouteContribution } from '@main/composition/hosted/application'; +import type { QueryContext } from '@shared/contracts/hosted'; + +const SYSTEM_DEADLINE: HostedReadinessProjectionDeadlinePort = Object.freeze({ + schedule(delayMs: number, onDeadline: () => void) { + const timer = setTimeout(onDeadline, delayMs); + return () => clearTimeout(timer); + }, +}); + +export interface HostedReadinessFeature extends HostedReadinessHttpFacade { + readonly routes: typeof HOSTED_READINESS_ROUTE_DESCRIPTORS; +} + +export interface CreateHostedReadinessFeatureDependencies { + readonly source: HostedReadinessProjectionSourcePort; + readonly clock?: HostedReadinessProjectionClockPort; + readonly deadline?: HostedReadinessProjectionDeadlinePort; +} + +export function createHostedReadinessFeature( + dependencies: CreateHostedReadinessFeatureDependencies +): HostedReadinessFeature { + const useCase = new GetHostedReadinessProjection( + dependencies.source, + dependencies.deadline === undefined ? SYSTEM_DEADLINE : dependencies.deadline, + dependencies.clock + ); + return Object.freeze({ + routes: HOSTED_READINESS_ROUTE_DESCRIPTORS, + getReadiness: (context: QueryContext) => + useCase.execute({ + deploymentId: context.deploymentId, + bootId: context.bootId, + deadlineAtMs: context.deadlineAtMs, + signal: context.signal, + }), + }); +} + +export function createHostedReadinessRouteContribution( + feature: HostedReadinessFeature +): HostedRouteContribution { + return Object.freeze({ + id: 'hosted-readiness.projection.hosted.v1', + facade: feature, + routes: feature.routes, + }); +} diff --git a/src/features/hosted-readiness/main/hosted.ts b/src/features/hosted-readiness/main/hosted.ts new file mode 100644 index 0000000000..08436154f8 --- /dev/null +++ b/src/features/hosted-readiness/main/hosted.ts @@ -0,0 +1,4 @@ +export type * from '../core/application/ports/HostedReadinessProjectionPorts'; +export * from './adapters/input/http/hostedReadinessRoutes'; +export * from './adapters/input/http/registerHostedReadinessHttp'; +export * from './composition/createHostedReadinessFeature'; diff --git a/src/features/hosted-readiness/renderer/composition/createHostedReadinessTransport.ts b/src/features/hosted-readiness/renderer/composition/createHostedReadinessTransport.ts new file mode 100644 index 0000000000..19aaf1f2d5 --- /dev/null +++ b/src/features/hosted-readiness/renderer/composition/createHostedReadinessTransport.ts @@ -0,0 +1,102 @@ +import { parseBootId, parseDeploymentId } from '@shared/contracts/hosted'; + +import { HOSTED_READINESS_ROUTE, parseHostedReadinessProjection } from '../../contracts'; +import { compareHostedReadinessFreshness } from '../../core/domain/HostedReadinessProjectionPolicy'; +import { + type CreateHostedReadinessTransportDependencies, + type HostedReadinessRendererTransport, + HostedReadinessTransportError, +} from '../ports/HostedReadinessRendererPorts'; + +export const HOSTED_READINESS_TRANSPORT_TIMEOUT_MS = 10_000; +const MAX_TIMEOUT_MS = 30_000; +const HEADERS = Object.freeze({ Accept: 'application/json' as const }); + +function validateTimeout(value: number | undefined): number { + const timeout = value ?? HOSTED_READINESS_TRANSPORT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > MAX_TIMEOUT_MS) { + throw new TypeError('hosted-readiness-transport-timeout-invalid'); + } + return timeout; +} + +/** Browser-only authenticated transport with an immutable deployment/boot fence. */ +export function createHostedReadinessTransport( + dependencies: CreateHostedReadinessTransportDependencies +): HostedReadinessRendererTransport { + if (!dependencies || typeof dependencies.fetch !== 'function') { + throw new TypeError('hosted-readiness-transport-dependencies-invalid'); + } + const expectedDeploymentId = parseDeploymentId(dependencies.expectedDeploymentId); + const expectedBootId = parseBootId(dependencies.expectedBootId); + const timeoutMs = validateTimeout(dependencies.timeoutMs); + let lastProjection: Awaited> | undefined; + + return Object.freeze({ + async load(signal?: AbortSignal) { + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new HostedReadinessTransportError('transport_unavailable'); + } + if (signal?.aborted) throw new HostedReadinessTransportError('request_cancelled'); + + const controller = new AbortController(); + let cancellationCode: 'request_cancelled' | 'deadline_exceeded' = 'deadline_exceeded'; + let rejectCancellation: ((error: HostedReadinessTransportError) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const cancel = (code: 'request_cancelled' | 'deadline_exceeded'): void => { + if (controller.signal.aborted) return; + cancellationCode = code; + controller.abort(); + rejectCancellation?.(new HostedReadinessTransportError(code)); + }; + const abortFromCaller = (): void => cancel('request_cancelled'); + signal?.addEventListener('abort', abortFromCaller, { once: true }); + const timer = setTimeout(() => cancel('deadline_exceeded'), timeoutMs); + + try { + const operation = Promise.resolve().then(async () => { + const response = await dependencies.fetch(HOSTED_READINESS_ROUTE, { + method: 'GET', + credentials: 'include', + cache: 'no-store', + headers: HEADERS, + signal: controller.signal, + }); + if (controller.signal.aborted) { + throw new HostedReadinessTransportError(cancellationCode); + } + if (!response || response.status !== 200 || typeof response.json !== 'function') { + throw new HostedReadinessTransportError('transport_unavailable'); + } + const parsed = parseHostedReadinessProjection(await response.json()); + if (controller.signal.aborted) { + throw new HostedReadinessTransportError(cancellationCode); + } + if (!parsed.ok) throw new HostedReadinessTransportError('response_invalid'); + if (parsed.value.deploymentId !== expectedDeploymentId) { + throw new HostedReadinessTransportError('stale_deployment'); + } + if (parsed.value.bootId !== expectedBootId) { + throw new HostedReadinessTransportError('stale_boot'); + } + const freshness = compareHostedReadinessFreshness(lastProjection, parsed.value); + if (freshness !== 'accept') { + throw new HostedReadinessTransportError(freshness); + } + lastProjection = parsed.value; + return parsed.value; + }); + return await Promise.race([operation, cancellation]); + } catch (error) { + if (error instanceof HostedReadinessTransportError) throw error; + throw new HostedReadinessTransportError('transport_unavailable'); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abortFromCaller); + if (!controller.signal.aborted) controller.abort(); + } + }, + }); +} diff --git a/src/features/hosted-readiness/renderer/index.ts b/src/features/hosted-readiness/renderer/index.ts new file mode 100644 index 0000000000..630591820b --- /dev/null +++ b/src/features/hosted-readiness/renderer/index.ts @@ -0,0 +1,13 @@ +export { + createHostedReadinessTransport, + HOSTED_READINESS_TRANSPORT_TIMEOUT_MS, +} from './composition/createHostedReadinessTransport'; +export { + type CreateHostedReadinessTransportDependencies, + type HostedReadinessFetchPort, + type HostedReadinessHttpResponse, + type HostedReadinessRendererTransport, + HostedReadinessTransportError, + type HostedReadinessTransportErrorCode, +} from './ports/HostedReadinessRendererPorts'; +export { HostedReadinessBanner, type HostedReadinessBannerProps } from './ui/HostedReadinessBanner'; diff --git a/src/features/hosted-readiness/renderer/ports/HostedReadinessRendererPorts.ts b/src/features/hosted-readiness/renderer/ports/HostedReadinessRendererPorts.ts new file mode 100644 index 0000000000..fe5402fe31 --- /dev/null +++ b/src/features/hosted-readiness/renderer/ports/HostedReadinessRendererPorts.ts @@ -0,0 +1,46 @@ +import type { HostedReadinessProjection } from '../../contracts'; +import type { BootId, DeploymentId } from '@shared/contracts/hosted'; + +export interface HostedReadinessHttpResponse { + readonly status: number; + json(): Promise; +} + +export type HostedReadinessFetchPort = ( + input: string, + init: Readonly<{ + method: 'GET'; + credentials: 'include'; + cache: 'no-store'; + headers: Readonly<{ Accept: 'application/json' }>; + signal: AbortSignal; + }> +) => Promise; + +export interface HostedReadinessRendererTransport { + load(signal?: AbortSignal): Promise; +} + +export interface CreateHostedReadinessTransportDependencies { + readonly fetch: HostedReadinessFetchPort; + readonly expectedDeploymentId: DeploymentId; + readonly expectedBootId: BootId; + readonly timeoutMs?: number; +} + +export type HostedReadinessTransportErrorCode = + | 'request_cancelled' + | 'deadline_exceeded' + | 'transport_unavailable' + | 'response_invalid' + | 'stale_deployment' + | 'stale_boot' + | 'stale_revision' + | 'revision_conflict'; + +export class HostedReadinessTransportError extends Error { + constructor(readonly code: HostedReadinessTransportErrorCode) { + super(`hosted-readiness-transport-${code}`); + this.name = 'HostedReadinessTransportError'; + } +} diff --git a/src/features/hosted-readiness/renderer/ui/HostedReadinessBanner.tsx b/src/features/hosted-readiness/renderer/ui/HostedReadinessBanner.tsx new file mode 100644 index 0000000000..4a4ab77b84 --- /dev/null +++ b/src/features/hosted-readiness/renderer/ui/HostedReadinessBanner.tsx @@ -0,0 +1,40 @@ +import { deriveHostedReadinessBannerState } from '../../core/domain/HostedReadinessProjectionPolicy'; + +import type { HostedReadinessProjection } from '../../contracts'; + +export interface HostedReadinessBannerProps { + readonly projection: HostedReadinessProjection; +} + +const COPY = Object.freeze({ + ready: Object.freeze({ + heading: 'Hosted features are ready', + description: 'The hosted features offered by this deployment are available.', + }), + degraded: Object.freeze({ + heading: 'Hosted features are degraded', + description: 'Some hosted features are temporarily unavailable. Try again after they recover.', + }), + not_offered: Object.freeze({ + heading: 'Hosted features are not offered', + description: 'This deployment does not offer hosted product features.', + }), +}); + +export const HostedReadinessBanner = ({ projection }: HostedReadinessBannerProps) => { + const state = deriveHostedReadinessBannerState(projection); + const copy = COPY[state]; + return ( +
+

{copy.heading}

+

{copy.description}

+
+ ); +}; diff --git a/src/features/hosted-state-compatibility/contracts/hostedStateCompatibilityContracts.ts b/src/features/hosted-state-compatibility/contracts/hostedStateCompatibilityContracts.ts new file mode 100644 index 0000000000..c1ac4961f3 --- /dev/null +++ b/src/features/hosted-state-compatibility/contracts/hostedStateCompatibilityContracts.ts @@ -0,0 +1,206 @@ +import type { + BackupVerificationPlan, + ImmutableBackupVerification, + Sha256Digest, + StateCompatibilityManifestRef, +} from '@features/coordination-backup/contracts'; +import type { CoordinationSnapshotMetadata } from '@features/coordination-events/contracts'; + +export const HOSTED_STATE_COMPATIBILITY_MANIFEST_FORMAT = + 'hosted-state-compatibility-manifest/v1' as const; +export const HOSTED_STATE_COMPATIBILITY_MANIFEST_SCHEMA_VERSION = 1 as const; +export const HOSTED_STATE_HEADER_FORMAT = 'hosted-state-header/v1' as const; +export const HOSTED_STATE_HEADER_SCHEMA_VERSION = 1 as const; +export const HOSTED_STATE_MIGRATION_JOURNAL_FORMAT = 'hosted-state-migration-journal/v1' as const; +export const HOSTED_STATE_MIGRATION_JOURNAL_SCHEMA_VERSION = 1 as const; +export const HOSTED_STATE_RESTORE_SET_FORMAT = 'hosted-state-restore-set/v1' as const; +export const HOSTED_STATE_RESTORE_SET_SCHEMA_VERSION = 1 as const; + +export const HOSTED_STATE_MIGRATION_PHASES = Object.freeze([ + 'prepared', + 'applying', + 'verifying', +] as const); + +export type HostedStateMigrationPhase = (typeof HOSTED_STATE_MIGRATION_PHASES)[number]; +export type HostedStateMigrationBackupRequirement = 'none' | 'verified_offline_archive'; + +export interface HostedStateMigrationDescriptor { + readonly migrationId: string; + readonly fromVersion: number; + readonly toVersion: number; + readonly sha256: Sha256Digest; + readonly backupRequirement: HostedStateMigrationBackupRequirement; +} + +/** Immutable compatibility contract shipped with one built application artifact. */ +export interface BuiltArtifactStateManifest { + readonly format: typeof HOSTED_STATE_COMPATIBILITY_MANIFEST_FORMAT; + readonly schemaVersion: typeof HOSTED_STATE_COMPATIBILITY_MANIFEST_SCHEMA_VERSION; + readonly manifestId: string; + readonly artifactVersion: string; + readonly hostedStateSchemaVersion: number; + readonly minimumReadableHostedStateVersion: number; + readonly orderedMigrations: readonly HostedStateMigrationDescriptor[]; +} + +export interface BuiltArtifactStateManifestEnvelope { + readonly manifest: BuiltArtifactStateManifest; + /** Hash evidence is produced by an artifact integrity adapter, never by the pure core. */ + readonly ref: StateCompatibilityManifestRef; +} + +/** Small header read without mutating the hosted state store. */ +export interface HostedStateHeader { + readonly format: typeof HOSTED_STATE_HEADER_FORMAT; + readonly schemaVersion: typeof HOSTED_STATE_HEADER_SCHEMA_VERSION; + readonly deploymentId: string; + readonly hostedStateSchemaVersion: number; +} + +/** Durable, idempotent forward-migration recovery record. */ +export interface HostedStateMigrationJournal { + readonly format: typeof HOSTED_STATE_MIGRATION_JOURNAL_FORMAT; + readonly schemaVersion: typeof HOSTED_STATE_MIGRATION_JOURNAL_SCHEMA_VERSION; + readonly deploymentId: string; + readonly migrationId: string; + readonly fromVersion: number; + readonly toVersion: number; + readonly migrationSha256: Sha256Digest; + readonly phase: HostedStateMigrationPhase; +} + +export type HostedStateCompatibilityRefusalReason = + | 'artifact_manifest_invalid' + | 'artifact_manifest_integrity_failed' + | 'state_header_invalid' + | 'future_state_version' + | 'state_version_too_old' + | 'migration_path_unavailable' + | 'migration_journal_invalid' + | 'migration_journal_mismatch'; + +export type HostedStateAdmission = + | { + readonly status: 'read_write'; + readonly hostedStateSchemaVersion: number; + } + | { + readonly status: 'migration_required'; + readonly fromVersion: number; + readonly toVersion: number; + readonly orderedMigrations: readonly HostedStateMigrationDescriptor[]; + readonly backupRequired: boolean; + } + | { + readonly status: 'migration_recovery_required'; + readonly recovery: 'resume_idempotently' | 'verify_before_commit'; + readonly migration: HostedStateMigrationDescriptor; + readonly journalPhase: HostedStateMigrationPhase; + } + | { + readonly status: 'refused'; + readonly reason: HostedStateCompatibilityRefusalReason; + }; + +export interface ArchiveEntryChecksum { + readonly entryId: string; + readonly byteLength: number; + readonly mode: number; + readonly sha256: Sha256Digest; +} + +export interface RestoreSetIdentity { + readonly format: typeof HOSTED_STATE_RESTORE_SET_FORMAT; + readonly schemaVersion: typeof HOSTED_STATE_RESTORE_SET_SCHEMA_VERSION; + readonly deploymentId: string; + readonly backupRunId: string; + readonly manifestHash: Sha256Digest; + readonly fenceGeneration: number; + readonly stateCompatibilityManifest: StateCompatibilityManifestRef; + readonly snapshot: { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly replayCursor: string; + }; +} + +export type RestoreArchiveRefusalReason = + | 'archive_incomplete' + | 'archive_integrity_failed' + | 'archive_checksum_mismatch' + | 'archive_entry_set_mismatch' + | 'sqlite_integrity_failed' + | 'restore_set_identity_mismatch' + | 'snapshot_topology_mismatch'; + +export interface RestoreArchiveEvidence { + readonly publication: 'committed' | 'partial'; + readonly immutableVerification: ImmutableBackupVerification; + readonly expectedRestoreSet: RestoreSetIdentity; + readonly observedRestoreSet: RestoreSetIdentity; + /** Snapshot identity recorded by the checksum-bound backup manifest. */ + readonly manifestSnapshot: RestoreSetIdentity['snapshot']; + readonly expectedChecksums: readonly ArchiveEntryChecksum[]; + readonly observedChecksums: readonly ArchiveEntryChecksum[]; + readonly sqliteIntegrity: 'ok' | 'failed'; +} + +export type RestoreArchiveInspection = + | { readonly status: 'verified'; readonly restoreSet: RestoreSetIdentity } + | { + readonly status: 'invalid'; + readonly reasons: readonly RestoreArchiveRefusalReason[]; + }; + +export type OfflineRestoreRefusalReason = + | RestoreArchiveRefusalReason + | HostedStateCompatibilityRefusalReason + | 'controller_not_stopped' + | 'source_offline_not_attested' + | 'restore_mode_unsupported' + | 'target_not_empty' + | 'target_unavailable' + | 'source_migration_interrupted'; + +export interface OfflineRestoreAdmissionInput { + readonly mode: 'replace_deployment' | 'fork_deployment'; + readonly controllerState: 'stopped' | 'running' | 'unknown'; + readonly sourceOfflineAttested: boolean; + readonly targetState: 'empty' | 'non_empty' | 'unavailable'; + readonly archive: RestoreArchiveEvidence; + readonly stateAdmission: HostedStateAdmission; +} + +export type OfflineRestoreAdmission = + | { + readonly status: 'admitted'; + readonly restoreSet: RestoreSetIdentity; + readonly postRestore: { + readonly preserveLogicalIdentities: true; + readonly rotateBootId: true; + readonly rotateEventEpoch: true; + readonly revokeBrowserAuthority: true; + readonly revokeRuntimeAuthority: true; + readonly establishFreshMountBindings: true; + }; + } + | { + readonly status: 'refused'; + readonly reasons: readonly OfflineRestoreRefusalReason[]; + }; + +/** Archive metadata read through the existing public backup and snapshot contracts. */ +export interface OfflineArchiveReadModel { + readonly verificationPlan: BackupVerificationPlan; + readonly stateHeader: HostedStateHeader; + readonly migrationJournal: HostedStateMigrationJournal | null; + readonly snapshotMetadata: CoordinationSnapshotMetadata; +} + +export interface OfflineRestoreRequest { + readonly archiveRef: string; + readonly mode: 'replace_deployment' | 'fork_deployment'; + readonly sourceOfflineAttested: boolean; + readonly expectedRestoreSet: RestoreSetIdentity; +} diff --git a/src/features/hosted-state-compatibility/contracts/index.ts b/src/features/hosted-state-compatibility/contracts/index.ts new file mode 100644 index 0000000000..898b728396 --- /dev/null +++ b/src/features/hosted-state-compatibility/contracts/index.ts @@ -0,0 +1 @@ +export * from './hostedStateCompatibilityContracts'; diff --git a/src/features/hosted-state-compatibility/core/application/AdmitOfflineRestore.ts b/src/features/hosted-state-compatibility/core/application/AdmitOfflineRestore.ts new file mode 100644 index 0000000000..0e4e4dfe15 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/application/AdmitOfflineRestore.ts @@ -0,0 +1,169 @@ +import { + type ArchiveEntryChecksum, + HOSTED_STATE_RESTORE_SET_FORMAT, + HOSTED_STATE_RESTORE_SET_SCHEMA_VERSION, + type OfflineArchiveReadModel, + type OfflineRestoreAdmission, + type OfflineRestoreRequest, + type RestoreArchiveEvidence, + type RestoreSetIdentity, +} from '../../contracts'; +import { evaluateHostedStateAdmission, evaluateOfflineRestoreAdmission } from '../domain'; + +import type { + BuiltArtifactStateManifestIntegrityProbePort, + BuiltArtifactStateManifestReaderPort, + OfflineArchiveIntegrityProbePort, + OfflineArchiveReaderPort, + OfflineControllerStateProbePort, + OfflineRestoreTargetProbePort, +} from './ports'; +import type { + BackupManifest, + ImmutableBackupVerification, + MeasuredBackupEntry, +} from '@features/coordination-backup/contracts'; +import type { CoordinationSnapshotMetadata } from '@features/coordination-events/contracts'; + +export interface AdmitOfflineRestoreDependencies { + readonly artifactManifestReader: BuiltArtifactStateManifestReaderPort; + readonly artifactIntegrityProbe: BuiltArtifactStateManifestIntegrityProbePort; + readonly archiveReader: OfflineArchiveReaderPort; + readonly archiveIntegrityProbe: OfflineArchiveIntegrityProbePort; + readonly controllerStateProbe: OfflineControllerStateProbePort; + readonly targetProbe: OfflineRestoreTargetProbePort; +} + +export class AdmitOfflineRestore { + constructor(private readonly dependencies: AdmitOfflineRestoreDependencies) {} + + async execute(request: OfflineRestoreRequest): Promise { + const [artifact, archive, controllerState, targetState] = await Promise.all([ + this.dependencies.artifactManifestReader.readBuiltArtifactManifest(), + this.dependencies.archiveReader.readArchive(request.archiveRef), + this.dependencies.controllerStateProbe.inspectControllerState(), + this.dependencies.targetProbe.inspectTarget(), + ]); + if (!archive) { + return refuseMissingArchive(controllerState, targetState, request); + } + + const [artifactIntegrity, immutableVerification] = await Promise.all([ + this.dependencies.artifactIntegrityProbe.verify(artifact), + this.dependencies.archiveIntegrityProbe.verify(archive), + ]); + const stateAdmission = evaluateHostedStateAdmission({ + artifactManifest: artifact.manifest, + artifactIntegrity: artifactIntegrity.status === 'verified' ? 'verified' : 'failed', + stateHeader: archive.stateHeader, + migrationJournal: archive.migrationJournal, + }); + return evaluateOfflineRestoreAdmission({ + mode: request.mode, + controllerState, + sourceOfflineAttested: request.sourceOfflineAttested, + targetState, + archive: createRestoreArchiveEvidence( + archive, + immutableVerification, + request.expectedRestoreSet + ), + stateAdmission, + }); + } +} + +export function createRestoreSetIdentity( + manifest: BackupManifest, + snapshotMetadata: CoordinationSnapshotMetadata +): RestoreSetIdentity { + return Object.freeze({ + format: HOSTED_STATE_RESTORE_SET_FORMAT, + schemaVersion: HOSTED_STATE_RESTORE_SET_SCHEMA_VERSION, + deploymentId: manifest.deploymentId, + backupRunId: manifest.backupRunId, + manifestHash: manifest.manifestHash, + fenceGeneration: manifest.fenceGeneration, + stateCompatibilityManifest: Object.freeze({ + ...manifest.coordinationBarrier.stateCompatibilityManifest, + }), + snapshot: Object.freeze({ + deploymentId: snapshotMetadata.deploymentId, + eventEpoch: snapshotMetadata.eventEpoch, + replayCursor: snapshotMetadata.replayCursor, + }), + }); +} + +function createManifestSnapshotIdentity(manifest: BackupManifest): RestoreSetIdentity['snapshot'] { + return Object.freeze({ + deploymentId: manifest.deploymentId, + eventEpoch: manifest.coordinationBarrier.eventEpoch, + replayCursor: manifest.coordinationBarrier.eventCursor, + }); +} + +function createRestoreArchiveEvidence( + archive: OfflineArchiveReadModel, + immutableVerification: ImmutableBackupVerification, + expectedRestoreSet: RestoreSetIdentity +): RestoreArchiveEvidence { + if (immutableVerification.status === 'invalid') { + return { + publication: 'committed', + immutableVerification, + expectedRestoreSet, + observedRestoreSet: expectedRestoreSet, + manifestSnapshot: expectedRestoreSet.snapshot, + expectedChecksums: checksumsFromManifest(archive.verificationPlan.manifest), + observedChecksums: checksumsFromManifest(archive.verificationPlan.manifest), + sqliteIntegrity: 'ok', + }; + } + const observedManifest = immutableVerification.inspection.manifest; + return { + publication: 'committed', + immutableVerification, + expectedRestoreSet, + observedRestoreSet: createRestoreSetIdentity(observedManifest, archive.snapshotMetadata), + manifestSnapshot: createManifestSnapshotIdentity(observedManifest), + expectedChecksums: checksumsFromManifest(archive.verificationPlan.manifest), + observedChecksums: checksumsFromMeasurements(immutableVerification.inspection.measuredEntries), + sqliteIntegrity: observedManifest.sqliteIntegrity.integrityCheck === 'ok' ? 'ok' : 'failed', + }; +} + +function checksumsFromManifest(manifest: BackupManifest): readonly ArchiveEntryChecksum[] { + return Object.freeze( + manifest.entries.map((entry) => + Object.freeze({ + entryId: entry.entryId, + byteLength: entry.byteLength, + mode: entry.mode, + sha256: entry.sha256, + }) + ) + ); +} + +function checksumsFromMeasurements( + entries: readonly MeasuredBackupEntry[] +): readonly ArchiveEntryChecksum[] { + return Object.freeze(entries.map((entry) => Object.freeze({ ...entry }))); +} + +function refuseMissingArchive( + controllerState: 'stopped' | 'running' | 'unknown', + targetState: 'empty' | 'non_empty' | 'unavailable', + request: OfflineRestoreRequest +): OfflineRestoreAdmission { + const reasons: Extract['reasons'][number][] = [ + 'archive_incomplete', + ]; + if (request.mode !== 'replace_deployment') reasons.push('restore_mode_unsupported'); + if (controllerState !== 'stopped') reasons.push('controller_not_stopped'); + if (!request.sourceOfflineAttested) reasons.push('source_offline_not_attested'); + if (targetState === 'non_empty') reasons.push('target_not_empty'); + if (targetState === 'unavailable') reasons.push('target_unavailable'); + return { status: 'refused', reasons: Object.freeze(reasons) }; +} diff --git a/src/features/hosted-state-compatibility/core/application/EvaluateHostedStateStartup.ts b/src/features/hosted-state-compatibility/core/application/EvaluateHostedStateStartup.ts new file mode 100644 index 0000000000..8d2eb93f68 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/application/EvaluateHostedStateStartup.ts @@ -0,0 +1,35 @@ +import { evaluateHostedStateAdmission } from '../domain'; + +import type { HostedStateAdmission } from '../../contracts'; +import type { + BuiltArtifactStateManifestIntegrityProbePort, + BuiltArtifactStateManifestReaderPort, + HostedStateHeaderReaderPort, + HostedStateMigrationJournalReaderPort, +} from './ports'; + +export interface EvaluateHostedStateStartupDependencies { + readonly artifactManifestReader: BuiltArtifactStateManifestReaderPort; + readonly artifactIntegrityProbe: BuiltArtifactStateManifestIntegrityProbePort; + readonly stateHeaderReader: HostedStateHeaderReaderPort; + readonly migrationJournalReader: HostedStateMigrationJournalReaderPort; +} + +export class EvaluateHostedStateStartup { + constructor(private readonly dependencies: EvaluateHostedStateStartupDependencies) {} + + async execute(): Promise { + const [artifact, stateHeader, migrationJournal] = await Promise.all([ + this.dependencies.artifactManifestReader.readBuiltArtifactManifest(), + this.dependencies.stateHeaderReader.readStateHeader(), + this.dependencies.migrationJournalReader.readMigrationJournal(), + ]); + const artifactIntegrity = await this.dependencies.artifactIntegrityProbe.verify(artifact); + return evaluateHostedStateAdmission({ + artifactManifest: artifact.manifest, + artifactIntegrity: artifactIntegrity.status === 'verified' ? 'verified' : 'failed', + stateHeader, + migrationJournal, + }); + } +} diff --git a/src/features/hosted-state-compatibility/core/application/index.ts b/src/features/hosted-state-compatibility/core/application/index.ts new file mode 100644 index 0000000000..adcc2b9aa3 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/application/index.ts @@ -0,0 +1,3 @@ +export * from './AdmitOfflineRestore'; +export * from './EvaluateHostedStateStartup'; +export type * from './ports'; diff --git a/src/features/hosted-state-compatibility/core/application/ports.ts b/src/features/hosted-state-compatibility/core/application/ports.ts new file mode 100644 index 0000000000..ab44ebadb8 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/application/ports.ts @@ -0,0 +1,84 @@ +import type { + BuiltArtifactStateManifestEnvelope, + HostedStateHeader, + HostedStateMigrationJournal, + HostedStateMigrationPhase, + OfflineArchiveReadModel, + OfflineRestoreAdmission, +} from '../../contracts'; +import type { + ImmutableBackupVerification, + StateCompatibilityManifestRef, +} from '@features/coordination-backup/contracts'; + +export interface BuiltArtifactStateManifestReaderPort { + readBuiltArtifactManifest(): Promise; +} + +export interface BuiltArtifactStateManifestIntegrityProbePort { + verify( + input: BuiltArtifactStateManifestEnvelope + ): Promise< + { readonly status: 'verified' } | { readonly status: 'invalid'; readonly reason: string } + >; +} + +export interface HostedStateHeaderReaderPort { + readStateHeader(): Promise; +} + +export interface HostedStateMigrationJournalReaderPort { + readMigrationJournal(): Promise; +} + +/** Compare-and-set journal writes are owned by the migration adapter. */ +export interface HostedStateMigrationJournalWriterPort { + prepare(journal: HostedStateMigrationJournal): Promise; + advance(input: { + readonly migrationId: string; + readonly expectedPhase: HostedStateMigrationPhase; + readonly nextPhase: HostedStateMigrationPhase; + }): Promise; + clearVerified(input: { + readonly migrationId: string; + readonly expectedStateVersion: number; + }): Promise; +} + +/** Commits only the hosted schema header; adapters own their transactional storage mechanism. */ +export interface HostedStateVersionWriterPort { + commitMigratedVersion(input: { + readonly deploymentId: string; + readonly expectedVersion: number; + readonly nextVersion: number; + readonly migrationId: string; + }): Promise; +} + +export interface OfflineArchiveReaderPort { + readArchive(archiveRef: string): Promise; +} + +export interface OfflineArchiveIntegrityProbePort { + verify(input: OfflineArchiveReadModel): Promise; +} + +export interface OfflineControllerStateProbePort { + inspectControllerState(): Promise<'stopped' | 'running' | 'unknown'>; +} + +export interface OfflineRestoreTargetProbePort { + inspectTarget(): Promise<'empty' | 'non_empty' | 'unavailable'>; +} + +/** The application admits a restore set before any writer receives it. */ +export interface OfflineRestoreWriterPort { + restore(input: { + readonly archiveRef: string; + readonly admission: Extract; + }): Promise; +} + +export interface StateCompatibilityManifestRefWriterPort { + publishCurrent(ref: StateCompatibilityManifestRef): Promise; +} diff --git a/src/features/hosted-state-compatibility/core/domain/index.ts b/src/features/hosted-state-compatibility/core/domain/index.ts new file mode 100644 index 0000000000..1330c65bf0 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/domain/index.ts @@ -0,0 +1,3 @@ +export * from './manifestPolicy'; +export * from './restorePolicy'; +export * from './stateAdmissionPolicy'; diff --git a/src/features/hosted-state-compatibility/core/domain/manifestPolicy.ts b/src/features/hosted-state-compatibility/core/domain/manifestPolicy.ts new file mode 100644 index 0000000000..20e14d4b07 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/domain/manifestPolicy.ts @@ -0,0 +1,205 @@ +import { + type BuiltArtifactStateManifest, + HOSTED_STATE_COMPATIBILITY_MANIFEST_FORMAT, + HOSTED_STATE_COMPATIBILITY_MANIFEST_SCHEMA_VERSION, + type HostedStateMigrationDescriptor, +} from '../../contracts'; + +import type { Sha256Digest } from '@features/coordination-backup/contracts'; + +export type BuiltArtifactManifestInvalidReason = + | 'manifest_not_an_object' + | 'manifest_fields_invalid' + | 'manifest_format_unsupported' + | 'manifest_schema_unsupported' + | 'manifest_version_range_invalid' + | 'migration_descriptor_invalid' + | 'migration_order_invalid'; + +export type BuiltArtifactManifestInspection = + | { readonly status: 'valid'; readonly manifest: BuiltArtifactStateManifest } + | { + readonly status: 'invalid'; + readonly reasons: readonly BuiltArtifactManifestInvalidReason[]; + }; + +const MANIFEST_KEYS = Object.freeze([ + 'artifactVersion', + 'format', + 'hostedStateSchemaVersion', + 'manifestId', + 'minimumReadableHostedStateVersion', + 'orderedMigrations', + 'schemaVersion', +] as const); +const MIGRATION_KEYS = Object.freeze([ + 'backupRequirement', + 'fromVersion', + 'migrationId', + 'sha256', + 'toVersion', +] as const); +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/; +const ARTIFACT_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; + +export function inspectBuiltArtifactStateManifest(value: unknown): BuiltArtifactManifestInspection { + if (!isDataRecord(value)) { + return invalidManifest('manifest_not_an_object'); + } + + const reasons: BuiltArtifactManifestInvalidReason[] = []; + if (!hasExactDataKeys(value, MANIFEST_KEYS)) reasons.push('manifest_fields_invalid'); + if (readData(value, 'format') !== HOSTED_STATE_COMPATIBILITY_MANIFEST_FORMAT) { + reasons.push('manifest_format_unsupported'); + } + if (readData(value, 'schemaVersion') !== HOSTED_STATE_COMPATIBILITY_MANIFEST_SCHEMA_VERSION) { + reasons.push('manifest_schema_unsupported'); + } + + const manifestId = readData(value, 'manifestId'); + const artifactVersion = readData(value, 'artifactVersion'); + const currentVersion = readData(value, 'hostedStateSchemaVersion'); + const minimumVersion = readData(value, 'minimumReadableHostedStateVersion'); + if ( + !isIdentifier(manifestId) || + typeof artifactVersion !== 'string' || + !ARTIFACT_VERSION_PATTERN.test(artifactVersion) + ) { + reasons.push('manifest_fields_invalid'); + } + if ( + !isPositiveVersion(currentVersion) || + !isPositiveVersion(minimumVersion) || + minimumVersion > currentVersion + ) { + reasons.push('manifest_version_range_invalid'); + } + + const rawMigrations = readData(value, 'orderedMigrations'); + const migrations: HostedStateMigrationDescriptor[] = []; + if (!Array.isArray(rawMigrations)) { + reasons.push('migration_descriptor_invalid'); + } else { + for (const rawMigration of rawMigrations) { + const migration = materializeMigration(rawMigration); + if (!migration) reasons.push('migration_descriptor_invalid'); + else migrations.push(migration); + } + } + + if ( + isPositiveVersion(currentVersion) && + isPositiveVersion(minimumVersion) && + !isCompleteOrderedMigrationPath(migrations, minimumVersion, currentVersion) + ) { + reasons.push('migration_order_invalid'); + } + + const uniqueReasons = Object.freeze([...new Set(reasons)]); + if (uniqueReasons.length > 0) return { status: 'invalid', reasons: uniqueReasons }; + + return { + status: 'valid', + manifest: Object.freeze({ + format: HOSTED_STATE_COMPATIBILITY_MANIFEST_FORMAT, + schemaVersion: HOSTED_STATE_COMPATIBILITY_MANIFEST_SCHEMA_VERSION, + manifestId: manifestId as string, + artifactVersion: artifactVersion as string, + hostedStateSchemaVersion: currentVersion as number, + minimumReadableHostedStateVersion: minimumVersion as number, + orderedMigrations: Object.freeze(migrations), + }), + }; +} + +function materializeMigration(value: unknown): HostedStateMigrationDescriptor | null { + if (!isDataRecord(value) || !hasExactDataKeys(value, MIGRATION_KEYS)) return null; + const migrationId = readData(value, 'migrationId'); + const fromVersion = readData(value, 'fromVersion'); + const toVersion = readData(value, 'toVersion'); + const sha256 = readData(value, 'sha256'); + const backupRequirement = readData(value, 'backupRequirement'); + if ( + !isIdentifier(migrationId) || + !isPositiveVersion(fromVersion) || + !isPositiveVersion(toVersion) || + toVersion !== fromVersion + 1 || + typeof sha256 !== 'string' || + !SHA256_PATTERN.test(sha256) || + (backupRequirement !== 'none' && backupRequirement !== 'verified_offline_archive') + ) { + return null; + } + return Object.freeze({ + migrationId, + fromVersion, + toVersion, + sha256: sha256 as Sha256Digest, + backupRequirement, + }); +} + +function isCompleteOrderedMigrationPath( + migrations: readonly HostedStateMigrationDescriptor[], + minimumVersion: number, + currentVersion: number +): boolean { + if (migrations.length !== currentVersion - minimumVersion) return false; + const ids = new Set(); + for (let index = 0; index < migrations.length; index += 1) { + const migration = migrations[index]; + const expectedFrom = minimumVersion + index; + if ( + migration.fromVersion !== expectedFrom || + migration.toVersion !== expectedFrom + 1 || + ids.has(migration.migrationId) + ) { + return false; + } + ids.add(migration.migrationId); + } + return true; +} + +function invalidManifest( + reason: BuiltArtifactManifestInvalidReason +): BuiltArtifactManifestInspection { + return { status: 'invalid', reasons: Object.freeze([reason]) }; +} + +export function isSha256Digest(value: unknown): value is Sha256Digest { + return typeof value === 'string' && SHA256_PATTERN.test(value); +} + +export function isPositiveVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +export function isIdentifier(value: unknown): value is string { + return typeof value === 'string' && IDENTIFIER_PATTERN.test(value); +} + +export function isDataRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function readData(record: Readonly>, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; +} + +function hasExactDataKeys( + record: Readonly>, + expectedKeys: readonly string[] +): boolean { + const keys = Object.keys(record).sort((left, right) => left.localeCompare(right)); + return ( + keys.length === expectedKeys.length && + keys.every((key, index) => key === expectedKeys[index]) && + expectedKeys.every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + return descriptor !== undefined && 'value' in descriptor; + }) + ); +} diff --git a/src/features/hosted-state-compatibility/core/domain/restorePolicy.ts b/src/features/hosted-state-compatibility/core/domain/restorePolicy.ts new file mode 100644 index 0000000000..f6bbbd5479 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/domain/restorePolicy.ts @@ -0,0 +1,201 @@ +import { COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION } from '@features/coordination-backup/contracts'; + +import { + type ArchiveEntryChecksum, + HOSTED_STATE_RESTORE_SET_FORMAT, + HOSTED_STATE_RESTORE_SET_SCHEMA_VERSION, + type OfflineRestoreAdmission, + type OfflineRestoreAdmissionInput, + type OfflineRestoreRefusalReason, + type RestoreArchiveEvidence, + type RestoreArchiveInspection, + type RestoreArchiveRefusalReason, + type RestoreSetIdentity, +} from '../../contracts'; + +import { isIdentifier, isSha256Digest } from './manifestPolicy'; + +export function inspectRestoreArchive(evidence: RestoreArchiveEvidence): RestoreArchiveInspection { + const reasons: RestoreArchiveRefusalReason[] = []; + if (evidence.publication !== 'committed') reasons.push('archive_incomplete'); + if (evidence.immutableVerification.status !== 'verified') { + reasons.push('archive_integrity_failed'); + } + const expectedIdentityValid = isValidRestoreSetIdentity(evidence.expectedRestoreSet); + const observedIdentityValid = isValidRestoreSetIdentity(evidence.observedRestoreSet); + if ( + !expectedIdentityValid || + !observedIdentityValid || + !sameRestoreSetBase(evidence.expectedRestoreSet, evidence.observedRestoreSet) + ) { + reasons.push('restore_set_identity_mismatch'); + } + if ( + !isValidSnapshotIdentity(evidence.expectedRestoreSet.snapshot) || + !isValidSnapshotIdentity(evidence.observedRestoreSet.snapshot) || + !isValidSnapshotIdentity(evidence.manifestSnapshot) || + !sameSnapshotIdentity( + evidence.expectedRestoreSet.snapshot, + evidence.observedRestoreSet.snapshot + ) || + !sameSnapshotIdentity(evidence.manifestSnapshot, evidence.observedRestoreSet.snapshot) || + evidence.observedRestoreSet.snapshot.deploymentId !== evidence.observedRestoreSet.deploymentId + ) { + reasons.push('snapshot_topology_mismatch'); + } + reasons.push(...inspectChecksums(evidence.expectedChecksums, evidence.observedChecksums)); + if (evidence.sqliteIntegrity !== 'ok') reasons.push('sqlite_integrity_failed'); + + const uniqueReasons = Object.freeze([...new Set(reasons)]); + if (uniqueReasons.length > 0) return { status: 'invalid', reasons: uniqueReasons }; + return { status: 'verified', restoreSet: evidence.observedRestoreSet }; +} + +export function evaluateOfflineRestoreAdmission( + input: OfflineRestoreAdmissionInput +): OfflineRestoreAdmission { + const reasons: OfflineRestoreRefusalReason[] = []; + if (input.mode !== 'replace_deployment') reasons.push('restore_mode_unsupported'); + if (input.controllerState !== 'stopped') reasons.push('controller_not_stopped'); + if (!input.sourceOfflineAttested) reasons.push('source_offline_not_attested'); + if (input.targetState === 'non_empty') reasons.push('target_not_empty'); + else if (input.targetState === 'unavailable') reasons.push('target_unavailable'); + + const archiveInspection = inspectRestoreArchive(input.archive); + if (archiveInspection.status === 'invalid') reasons.push(...archiveInspection.reasons); + + if (input.stateAdmission.status === 'refused') { + reasons.push(input.stateAdmission.reason); + } else if (input.stateAdmission.status === 'migration_recovery_required') { + reasons.push('source_migration_interrupted'); + } + + const uniqueReasons = Object.freeze([...new Set(reasons)]); + if (uniqueReasons.length > 0 || archiveInspection.status === 'invalid') { + return { status: 'refused', reasons: uniqueReasons }; + } + return { + status: 'admitted', + restoreSet: archiveInspection.restoreSet, + postRestore: Object.freeze({ + preserveLogicalIdentities: true, + rotateBootId: true, + rotateEventEpoch: true, + revokeBrowserAuthority: true, + revokeRuntimeAuthority: true, + establishFreshMountBindings: true, + }), + }; +} + +function inspectChecksums( + expected: readonly ArchiveEntryChecksum[], + observed: readonly ArchiveEntryChecksum[] +): RestoreArchiveRefusalReason[] { + const expectedById = indexChecksums(expected); + const observedById = indexChecksums(observed); + if ( + !expectedById || + !observedById || + expectedById.size !== observedById.size || + [...expectedById.keys()].some((entryId) => !observedById.has(entryId)) + ) { + return ['archive_entry_set_mismatch']; + } + for (const [entryId, expectedEntry] of expectedById) { + const observedEntry = observedById.get(entryId); + if ( + !observedEntry || + observedEntry.byteLength !== expectedEntry.byteLength || + observedEntry.mode !== expectedEntry.mode || + observedEntry.sha256 !== expectedEntry.sha256 + ) { + return ['archive_checksum_mismatch']; + } + } + return []; +} + +function indexChecksums( + entries: readonly ArchiveEntryChecksum[] +): ReadonlyMap | null { + if (!Array.isArray(entries)) return null; + const result = new Map(); + for (const entry of entries) { + if (!isValidChecksum(entry) || result.has(entry.entryId)) return null; + result.set(entry.entryId, entry); + } + return result; +} + +function isValidChecksum(entry: ArchiveEntryChecksum): boolean { + return ( + typeof entry?.entryId === 'string' && + entry.entryId.length > 0 && + Number.isSafeInteger(entry.byteLength) && + entry.byteLength >= 0 && + Number.isSafeInteger(entry.mode) && + entry.mode >= 0 && + isSha256Digest(entry.sha256) + ); +} + +function isValidRestoreSetIdentity(identity: RestoreSetIdentity): boolean { + return ( + identity?.format === HOSTED_STATE_RESTORE_SET_FORMAT && + identity.schemaVersion === HOSTED_STATE_RESTORE_SET_SCHEMA_VERSION && + isIdentifier(identity.deploymentId) && + isIdentifier(identity.backupRunId) && + isSha256Digest(identity.manifestHash) && + Number.isSafeInteger(identity.fenceGeneration) && + identity.fenceGeneration > 0 && + isIdentifier(identity.stateCompatibilityManifest?.manifestId) && + identity.stateCompatibilityManifest.schemaVersion === + COORDINATION_BACKUP_COMPATIBILITY_SCHEMA_VERSION && + isSha256Digest(identity.stateCompatibilityManifest.sha256) && + isValidSnapshotIdentity(identity.snapshot) + ); +} + +function isValidSnapshotIdentity(identity: RestoreSetIdentity['snapshot']): boolean { + return ( + isIdentifier(identity?.deploymentId) && + isNonEmptyBoundedString(identity.eventEpoch) && + isNonEmptyBoundedString(identity.replayCursor, 4096) + ); +} + +function isNonEmptyBoundedString(value: unknown, maximumLength = 256): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maximumLength && + value.trim() === value + ); +} + +function sameRestoreSetBase(left: RestoreSetIdentity, right: RestoreSetIdentity): boolean { + return ( + left.format === right.format && + left.schemaVersion === right.schemaVersion && + left.deploymentId === right.deploymentId && + left.backupRunId === right.backupRunId && + left.manifestHash === right.manifestHash && + left.fenceGeneration === right.fenceGeneration && + left.stateCompatibilityManifest.manifestId === right.stateCompatibilityManifest.manifestId && + left.stateCompatibilityManifest.schemaVersion === + right.stateCompatibilityManifest.schemaVersion && + left.stateCompatibilityManifest.sha256 === right.stateCompatibilityManifest.sha256 + ); +} + +function sameSnapshotIdentity( + left: RestoreSetIdentity['snapshot'], + right: RestoreSetIdentity['snapshot'] +): boolean { + return ( + left.deploymentId === right.deploymentId && + left.eventEpoch === right.eventEpoch && + left.replayCursor === right.replayCursor + ); +} diff --git a/src/features/hosted-state-compatibility/core/domain/stateAdmissionPolicy.ts b/src/features/hosted-state-compatibility/core/domain/stateAdmissionPolicy.ts new file mode 100644 index 0000000000..f6afe89e89 --- /dev/null +++ b/src/features/hosted-state-compatibility/core/domain/stateAdmissionPolicy.ts @@ -0,0 +1,167 @@ +import { + HOSTED_STATE_HEADER_FORMAT, + HOSTED_STATE_HEADER_SCHEMA_VERSION, + HOSTED_STATE_MIGRATION_JOURNAL_FORMAT, + HOSTED_STATE_MIGRATION_JOURNAL_SCHEMA_VERSION, + HOSTED_STATE_MIGRATION_PHASES, + type HostedStateAdmission, + type HostedStateHeader, + type HostedStateMigrationDescriptor, + type HostedStateMigrationJournal, +} from '../../contracts'; + +import { + inspectBuiltArtifactStateManifest, + isDataRecord, + isIdentifier, + isPositiveVersion, + isSha256Digest, + readData, +} from './manifestPolicy'; + +export interface EvaluateHostedStateAdmissionInput { + readonly artifactManifest: unknown; + readonly artifactIntegrity: 'verified' | 'failed'; + readonly stateHeader: unknown; + readonly migrationJournal: unknown | null; +} + +export function evaluateHostedStateAdmission( + input: EvaluateHostedStateAdmissionInput +): HostedStateAdmission { + const manifestInspection = inspectBuiltArtifactStateManifest(input.artifactManifest); + if (manifestInspection.status === 'invalid') return refused('artifact_manifest_invalid'); + if (input.artifactIntegrity !== 'verified') { + return refused('artifact_manifest_integrity_failed'); + } + const stateHeader = materializeStateHeader(input.stateHeader); + if (!stateHeader) return refused('state_header_invalid'); + + const manifest = manifestInspection.manifest; + if (input.migrationJournal !== null) { + const journal = materializeMigrationJournal(input.migrationJournal); + if (!journal) return refused('migration_journal_invalid'); + return evaluateInterruptedMigration(manifest.orderedMigrations, stateHeader, journal); + } + + const observedVersion = stateHeader.hostedStateSchemaVersion; + if (observedVersion > manifest.hostedStateSchemaVersion) return refused('future_state_version'); + if (observedVersion < manifest.minimumReadableHostedStateVersion) { + return refused('state_version_too_old'); + } + if (observedVersion === manifest.hostedStateSchemaVersion) { + return { status: 'read_write', hostedStateSchemaVersion: observedVersion }; + } + + const migrations = manifest.orderedMigrations.filter( + (migration) => migration.fromVersion >= observedVersion + ); + if ( + migrations.length === 0 || + migrations[0].fromVersion !== observedVersion || + migrations[migrations.length - 1].toVersion !== manifest.hostedStateSchemaVersion + ) { + return refused('migration_path_unavailable'); + } + return { + status: 'migration_required', + fromVersion: observedVersion, + toVersion: manifest.hostedStateSchemaVersion, + orderedMigrations: Object.freeze(migrations), + backupRequired: migrations.some( + (migration) => migration.backupRequirement === 'verified_offline_archive' + ), + }; +} + +function evaluateInterruptedMigration( + migrations: readonly HostedStateMigrationDescriptor[], + stateHeader: HostedStateHeader, + journal: HostedStateMigrationJournal +): HostedStateAdmission { + if (journal.deploymentId !== stateHeader.deploymentId) { + return refused('migration_journal_mismatch'); + } + const migration = migrations.find((candidate) => candidate.migrationId === journal.migrationId); + if ( + !migration || + migration.fromVersion !== journal.fromVersion || + migration.toVersion !== journal.toVersion || + migration.sha256 !== journal.migrationSha256 + ) { + return refused('migration_journal_mismatch'); + } + if (stateHeader.hostedStateSchemaVersion === migration.fromVersion) { + return { + status: 'migration_recovery_required', + recovery: 'resume_idempotently', + migration, + journalPhase: journal.phase, + }; + } + if (stateHeader.hostedStateSchemaVersion === migration.toVersion) { + return { + status: 'migration_recovery_required', + recovery: 'verify_before_commit', + migration, + journalPhase: journal.phase, + }; + } + return refused('migration_journal_mismatch'); +} + +function materializeStateHeader(value: unknown): HostedStateHeader | null { + if (!isDataRecord(value) || Object.keys(value).length !== 4) return null; + const format = readData(value, 'format'); + const schemaVersion = readData(value, 'schemaVersion'); + const deploymentId = readData(value, 'deploymentId'); + const hostedStateSchemaVersion = readData(value, 'hostedStateSchemaVersion'); + if ( + format !== HOSTED_STATE_HEADER_FORMAT || + schemaVersion !== HOSTED_STATE_HEADER_SCHEMA_VERSION || + !isIdentifier(deploymentId) || + !isPositiveVersion(hostedStateSchemaVersion) + ) { + return null; + } + return Object.freeze({ format, schemaVersion, deploymentId, hostedStateSchemaVersion }); +} + +function materializeMigrationJournal(value: unknown): HostedStateMigrationJournal | null { + if (!isDataRecord(value) || Object.keys(value).length !== 8) return null; + const format = readData(value, 'format'); + const schemaVersion = readData(value, 'schemaVersion'); + const deploymentId = readData(value, 'deploymentId'); + const migrationId = readData(value, 'migrationId'); + const fromVersion = readData(value, 'fromVersion'); + const toVersion = readData(value, 'toVersion'); + const migrationSha256 = readData(value, 'migrationSha256'); + const phase = readData(value, 'phase'); + if ( + format !== HOSTED_STATE_MIGRATION_JOURNAL_FORMAT || + schemaVersion !== HOSTED_STATE_MIGRATION_JOURNAL_SCHEMA_VERSION || + !isIdentifier(deploymentId) || + !isIdentifier(migrationId) || + !isPositiveVersion(fromVersion) || + !isPositiveVersion(toVersion) || + toVersion !== fromVersion + 1 || + !isSha256Digest(migrationSha256) || + !HOSTED_STATE_MIGRATION_PHASES.includes(phase as (typeof HOSTED_STATE_MIGRATION_PHASES)[number]) + ) { + return null; + } + return Object.freeze({ + format, + schemaVersion, + deploymentId, + migrationId, + fromVersion, + toVersion, + migrationSha256, + phase: phase as HostedStateMigrationJournal['phase'], + }); +} + +function refused(reason: Extract['reason']) { + return { status: 'refused' as const, reason }; +} diff --git a/src/features/hosted-state-compatibility/index.ts b/src/features/hosted-state-compatibility/index.ts new file mode 100644 index 0000000000..a6c74fad00 --- /dev/null +++ b/src/features/hosted-state-compatibility/index.ts @@ -0,0 +1,3 @@ +export * from './contracts'; +export * from './core/application'; +export * from './core/domain'; diff --git a/src/features/hosted-state-compatibility/main/application/hostedOfflineRestoreRotation.ts b/src/features/hosted-state-compatibility/main/application/hostedOfflineRestoreRotation.ts new file mode 100644 index 0000000000..bec3f9cb7a --- /dev/null +++ b/src/features/hosted-state-compatibility/main/application/hostedOfflineRestoreRotation.ts @@ -0,0 +1,32 @@ +export interface HostedOfflineRestoreRotationRequest { + readonly format: 'hosted-restored-authority-rotation/v1'; + readonly schemaVersion: 1; + readonly deploymentId: string; + readonly restoreGeneration: number; + readonly bootId: string; + readonly eventEpoch: string; + readonly browserAuthorityRotated: true; + readonly runtimeAuthorityRotationRequired: true; + readonly freshMountBindingsRequired: true; +} + +export interface HostedOfflineRestoreRotationProof { + readonly deploymentId: string; + readonly restoreGeneration: number; + readonly bootId: string; + readonly eventEpoch: string; + readonly browserSessionsRevoked: true; + readonly runtimeAuthorityRotated: true; + readonly mountBindingsRotated: true; +} + +/** Narrow host seam implemented by the operations composition lane. */ +export interface HostedStateCompatibilityRuntime { + sha256(body: string): string; + ensureDirectory(path: string, mode: number): Promise; + readDirectory(path: string): Promise; + /** Open with no-follow semantics, verify a regular descriptor, bound the read, then recheck it. */ + readRegularBoundedUtf8(path: string, maximumBytes: number): Promise; + writeExclusiveDurable(path: string, body: string, mode: number): Promise; + removeFile(path: string): Promise; +} diff --git a/src/features/hosted-state-compatibility/main/application/index.ts b/src/features/hosted-state-compatibility/main/application/index.ts new file mode 100644 index 0000000000..ecc36ebf68 --- /dev/null +++ b/src/features/hosted-state-compatibility/main/application/index.ts @@ -0,0 +1,5 @@ +export type { + HostedOfflineRestoreRotationProof, + HostedOfflineRestoreRotationRequest, + HostedStateCompatibilityRuntime, +} from './hostedOfflineRestoreRotation'; diff --git a/src/features/hosted-state-compatibility/main/composition/createHostedStateCompatibilityAdmission.ts b/src/features/hosted-state-compatibility/main/composition/createHostedStateCompatibilityAdmission.ts new file mode 100644 index 0000000000..be71dcc3eb --- /dev/null +++ b/src/features/hosted-state-compatibility/main/composition/createHostedStateCompatibilityAdmission.ts @@ -0,0 +1,89 @@ +import { EvaluateHostedStateStartup } from '../../core/application'; +import { inspectBuiltArtifactStateManifest } from '../../core/domain'; +import { + NodeBuiltArtifactStateManifestAdapter, + NodeHostedStateMetadataAdapter, +} from '../infrastructure/NodeHostedStateCompatibilityAdapters'; + +import type { HostedStateAdmission } from '../../contracts'; +import type { + HostedOfflineRestoreRotationProof, + HostedOfflineRestoreRotationRequest, + HostedStateCompatibilityRuntime, +} from '../application'; + +export interface HostedStateCompatibilityAdmissionOptions { + readonly artifactDirectory: string; + readonly stateDirectory: string; + readonly expectedDeploymentId: string; + readonly runtime: HostedStateCompatibilityRuntime; +} + +export class HostedStateStartupRefusedError extends Error { + constructor( + readonly admission: HostedStateAdmission | null, + readonly diagnostic: string + ) { + super(`hosted-state-startup-refused:${diagnostic}`); + this.name = 'HostedStateStartupRefusedError'; + } +} + +export interface HostedStateCompatibilityAdmissionComposition { + /** Must resolve before any network listener or mutable storage backend is constructed. */ + admitBeforeListenerExposure(): Promise>; + /** Narrow operations-lane seam; this feature does not own shutdown or runtime rotation. */ + inspectPendingOfflineRestoreRotation(): Promise; + completeOfflineRestoreRotation(proof: HostedOfflineRestoreRotationProof): Promise; +} + +export function createHostedStateCompatibilityAdmission( + options: HostedStateCompatibilityAdmissionOptions +): HostedStateCompatibilityAdmissionComposition { + const artifact = new NodeBuiltArtifactStateManifestAdapter( + options.artifactDirectory, + options.runtime + ); + const state = new NodeHostedStateMetadataAdapter(options.stateDirectory, options.runtime); + return Object.freeze({ + async admitBeforeListenerExposure() { + try { + const envelope = await artifact.readBuiltArtifactManifest(); + const manifestInspection = inspectBuiltArtifactStateManifest(envelope.manifest); + if (manifestInspection.status === 'invalid') { + throw new HostedStateStartupRefusedError(null, 'artifact_manifest_invalid'); + } + if ((await artifact.verify(envelope)).status !== 'verified') { + throw new HostedStateStartupRefusedError(null, 'artifact_manifest_integrity_failed'); + } + await state.initializeEmptyState( + options.expectedDeploymentId, + manifestInspection.manifest.hostedStateSchemaVersion + ); + if (await state.readPendingRestoreRotation()) { + throw new HostedStateStartupRefusedError(null, 'offline_restore_rotation_pending'); + } + const admission = await new EvaluateHostedStateStartup({ + artifactManifestReader: artifact, + artifactIntegrityProbe: artifact, + stateHeaderReader: state, + migrationJournalReader: state, + }).execute(); + if (admission.status !== 'read_write') { + throw new HostedStateStartupRefusedError(admission, admission.status); + } + const header = await state.readStateHeader(); + if (header.deploymentId !== options.expectedDeploymentId) { + throw new HostedStateStartupRefusedError(null, 'state_deployment_mismatch'); + } + return admission; + } catch (error) { + if (error instanceof HostedStateStartupRefusedError) throw error; + throw new HostedStateStartupRefusedError(null, 'state_metadata_invalid'); + } + }, + inspectPendingOfflineRestoreRotation: () => state.readPendingRestoreRotation(), + completeOfflineRestoreRotation: (proof: HostedOfflineRestoreRotationProof) => + state.completePendingRestoreRotation(proof), + }); +} diff --git a/src/features/hosted-state-compatibility/main/composition/index.ts b/src/features/hosted-state-compatibility/main/composition/index.ts new file mode 100644 index 0000000000..5567336f92 --- /dev/null +++ b/src/features/hosted-state-compatibility/main/composition/index.ts @@ -0,0 +1 @@ +export * from './createHostedStateCompatibilityAdmission'; diff --git a/src/features/hosted-state-compatibility/main/index.ts b/src/features/hosted-state-compatibility/main/index.ts new file mode 100644 index 0000000000..1bcccde531 --- /dev/null +++ b/src/features/hosted-state-compatibility/main/index.ts @@ -0,0 +1,6 @@ +export type { + HostedOfflineRestoreRotationProof, + HostedOfflineRestoreRotationRequest, + HostedStateCompatibilityRuntime, +} from './application'; +export * from './composition'; diff --git a/src/features/hosted-state-compatibility/main/infrastructure/NodeHostedStateCompatibilityAdapters.ts b/src/features/hosted-state-compatibility/main/infrastructure/NodeHostedStateCompatibilityAdapters.ts new file mode 100644 index 0000000000..7dc9b2a4cd --- /dev/null +++ b/src/features/hosted-state-compatibility/main/infrastructure/NodeHostedStateCompatibilityAdapters.ts @@ -0,0 +1,256 @@ +import { + type BuiltArtifactStateManifest, + type BuiltArtifactStateManifestEnvelope, + HOSTED_STATE_HEADER_FORMAT, + HOSTED_STATE_HEADER_SCHEMA_VERSION, + type HostedStateHeader, + type HostedStateMigrationJournal, +} from '../../contracts'; + +import type { + BuiltArtifactStateManifestIntegrityProbePort, + BuiltArtifactStateManifestReaderPort, + HostedStateHeaderReaderPort, + HostedStateMigrationJournalReaderPort, +} from '../../core/application'; +import type { + HostedOfflineRestoreRotationProof, + HostedOfflineRestoreRotationRequest, + HostedStateCompatibilityRuntime, +} from '../application'; +import type { Sha256Digest } from '@features/coordination-backup/contracts'; + +const MANIFEST_FILE = 'manifest.json'; +const STATE_HEADER_FILE = 'hosted-state-header.v1.json'; +const MIGRATION_JOURNAL_FILE = 'hosted-state-migration-journal.v1.json'; +const RESTORE_ROTATION_FILE = 'hosted-restore-rotation.v1.json'; +const RESTORE_JOURNAL_FILE = 'hosted-restore-journal.v1.json'; +const COMPLETED_RESTORE_ROTATION_FILE = 'hosted-restore-rotation.completed.v1.json'; +const MAX_METADATA_BYTES = 256 * 1024; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; + +export class NodeBuiltArtifactStateManifestAdapter + implements BuiltArtifactStateManifestReaderPort, BuiltArtifactStateManifestIntegrityProbePort +{ + constructor( + private readonly artifactDirectory: string, + private readonly runtime: HostedStateCompatibilityRuntime + ) {} + + async readBuiltArtifactManifest(): Promise { + const path = metadataPath(this.artifactDirectory, MANIFEST_FILE); + const [body, digestBody] = await Promise.all([ + readRegularBoundedFile(this.runtime, path), + readRegularBoundedFile(this.runtime, `${path}.sha256`), + ]); + const digest = digestBody.trim(); + if (!SHA256_PATTERN.test(digest)) throw new Error('artifact_manifest_digest_invalid'); + return Object.freeze({ + manifest: JSON.parse(body) as BuiltArtifactStateManifest, + ref: Object.freeze({ + manifestId: readManifestId(body), + schemaVersion: 3, + sha256: digest as Sha256Digest, + }), + }); + } + + async verify( + input: BuiltArtifactStateManifestEnvelope + ): Promise< + { readonly status: 'verified' } | { readonly status: 'invalid'; readonly reason: string } + > { + try { + const path = metadataPath(this.artifactDirectory, MANIFEST_FILE); + const body = await readRegularBoundedFile(this.runtime, path); + const digest = this.runtime.sha256(body); + return digest === input.ref.sha256 && readManifestId(body) === input.manifest.manifestId + ? ({ status: 'verified' } as const) + : ({ status: 'invalid', reason: 'manifest-hash-mismatch' } as const); + } catch { + return { status: 'invalid', reason: 'manifest-unreadable' } as const; + } + } +} + +export class NodeHostedStateMetadataAdapter + implements HostedStateHeaderReaderPort, HostedStateMigrationJournalReaderPort +{ + constructor( + private readonly stateDirectory: string, + private readonly runtime: HostedStateCompatibilityRuntime + ) {} + + async initializeEmptyState( + deploymentId: string, + hostedStateSchemaVersion: number + ): Promise { + await this.runtime.ensureDirectory(this.stateDirectory, 0o700); + const entries = await this.runtime.readDirectory(this.stateDirectory); + if (entries.includes(STATE_HEADER_FILE)) return; + if (entries.length > 0) throw new Error('hosted_state_header_missing_from_non_empty_state'); + const header: HostedStateHeader = Object.freeze({ + format: HOSTED_STATE_HEADER_FORMAT, + schemaVersion: HOSTED_STATE_HEADER_SCHEMA_VERSION, + deploymentId, + hostedStateSchemaVersion, + }); + await this.runtime.writeExclusiveDurable( + metadataPath(this.stateDirectory, STATE_HEADER_FILE), + `${JSON.stringify(header)}\n`, + 0o600 + ); + } + + async readStateHeader(): Promise { + return JSON.parse( + await readRegularBoundedFile( + this.runtime, + metadataPath(this.stateDirectory, STATE_HEADER_FILE) + ) + ) as HostedStateHeader; + } + + async readMigrationJournal(): Promise { + const path = metadataPath(this.stateDirectory, MIGRATION_JOURNAL_FILE); + try { + return JSON.parse( + await readRegularBoundedFile(this.runtime, path) + ) as HostedStateMigrationJournal; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + } + + async readPendingRestoreRotation(): Promise { + try { + const value = JSON.parse( + await readRegularBoundedFile( + this.runtime, + metadataPath(this.stateDirectory, RESTORE_ROTATION_FILE) + ) + ) as HostedOfflineRestoreRotationRequest; + validateRotationRequest(value); + return Object.freeze({ ...value }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + } + + async completePendingRestoreRotation(proof: HostedOfflineRestoreRotationProof): Promise { + const request = await this.readPendingRestoreRotation(); + if (!request || !rotationProofMatches(request, proof)) { + throw new Error('hosted_restore_rotation_proof_invalid'); + } + const completedPath = metadataPath(this.stateDirectory, COMPLETED_RESTORE_ROTATION_FILE); + const completed = await readOptionalRotationRequest(this.runtime, completedPath); + if (completed && !rotationRequestsMatch(request, completed)) { + throw new Error('hosted_restore_rotation_completion_mismatch'); + } + if (!completed) { + await this.runtime.writeExclusiveDurable( + completedPath, + `${JSON.stringify(request)}\n`, + 0o600 + ); + } + await this.runtime.removeFile(metadataPath(this.stateDirectory, RESTORE_ROTATION_FILE)); + try { + await this.runtime.removeFile(metadataPath(this.stateDirectory, RESTORE_JOURNAL_FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +} + +async function readOptionalRotationRequest( + runtime: HostedStateCompatibilityRuntime, + path: string +): Promise { + try { + const value = JSON.parse( + await readRegularBoundedFile(runtime, path) + ) as HostedOfflineRestoreRotationRequest; + validateRotationRequest(value); + return value; + } catch (error) { + if ((error as { readonly code?: unknown }).code === 'ENOENT') return null; + throw error; + } +} + +async function readRegularBoundedFile( + runtime: HostedStateCompatibilityRuntime, + path: string +): Promise { + return runtime.readRegularBoundedUtf8(path, MAX_METADATA_BYTES); +} + +function readManifestId(body: string): string { + const value = JSON.parse(body) as { readonly manifestId?: unknown }; + if (typeof value.manifestId !== 'string') throw new Error('artifact_manifest_id_invalid'); + return value.manifestId; +} + +function metadataPath(directory: string, fileName: string): string { + let end = directory.length; + while (end > 0 && directory.charCodeAt(end - 1) === 47) end -= 1; + return `${directory.slice(0, end)}/${fileName}`; +} + +export const HOSTED_STATE_METADATA_FILES = Object.freeze({ + header: STATE_HEADER_FILE, + migrationJournal: MIGRATION_JOURNAL_FILE, + restoreRotation: RESTORE_ROTATION_FILE, +}); + +function validateRotationRequest(value: HostedOfflineRestoreRotationRequest): void { + if ( + value?.format !== 'hosted-restored-authority-rotation/v1' || + value.schemaVersion !== 1 || + typeof value.deploymentId !== 'string' || + !Number.isSafeInteger(value.restoreGeneration) || + value.restoreGeneration <= 0 || + typeof value.bootId !== 'string' || + typeof value.eventEpoch !== 'string' || + value.browserAuthorityRotated !== true || + value.runtimeAuthorityRotationRequired !== true || + value.freshMountBindingsRequired !== true + ) { + throw new Error('hosted_restore_rotation_request_invalid'); + } +} + +function rotationProofMatches( + request: HostedOfflineRestoreRotationRequest, + proof: HostedOfflineRestoreRotationProof +): boolean { + return ( + proof.deploymentId === request.deploymentId && + proof.restoreGeneration === request.restoreGeneration && + proof.bootId === request.bootId && + proof.eventEpoch === request.eventEpoch && + proof.browserSessionsRevoked === true && + proof.runtimeAuthorityRotated === true && + proof.mountBindingsRotated === true + ); +} + +function rotationRequestsMatch( + left: HostedOfflineRestoreRotationRequest, + right: HostedOfflineRestoreRotationRequest +): boolean { + return ( + left.format === right.format && + left.schemaVersion === right.schemaVersion && + left.deploymentId === right.deploymentId && + left.restoreGeneration === right.restoreGeneration && + left.bootId === right.bootId && + left.eventEpoch === right.eventEpoch && + left.browserAuthorityRotated === right.browserAuthorityRotated && + left.runtimeAuthorityRotationRequired === right.runtimeAuthorityRotationRequired && + left.freshMountBindingsRequired === right.freshMountBindingsRequired + ); +} diff --git a/src/features/hosted-state-compatibility/main/infrastructure/index.ts b/src/features/hosted-state-compatibility/main/infrastructure/index.ts new file mode 100644 index 0000000000..15dc689838 --- /dev/null +++ b/src/features/hosted-state-compatibility/main/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './NodeHostedStateCompatibilityAdapters'; diff --git a/src/features/instance-lease/contracts/index.ts b/src/features/instance-lease/contracts/index.ts new file mode 100644 index 0000000000..2a01bc12bb --- /dev/null +++ b/src/features/instance-lease/contracts/index.ts @@ -0,0 +1,11 @@ +export { + INSTANCE_LEASE_CONTROL_FD, + INSTANCE_LEASE_EVIDENCE_MAX_BYTES, + INSTANCE_LEASE_FD, + INSTANCE_LEASE_PROTOCOL_VERSION, + type InstanceLeaseAdmissionInspection, + type InstanceLeaseAnchorEvidence, + type InstanceLeaseGuardState, + type InstanceLeaseLauncherEvidence, + type VerifiedInstanceLeaseHandle, +} from './instanceLeaseContracts'; diff --git a/src/features/instance-lease/contracts/instanceLeaseContracts.ts b/src/features/instance-lease/contracts/instanceLeaseContracts.ts new file mode 100644 index 0000000000..889c1a5cc4 --- /dev/null +++ b/src/features/instance-lease/contracts/instanceLeaseContracts.ts @@ -0,0 +1,46 @@ +export const INSTANCE_LEASE_PROTOCOL_VERSION = 1 as const; +export const INSTANCE_LEASE_FD = 3 as const; +export const INSTANCE_LEASE_CONTROL_FD = 4 as const; +export const INSTANCE_LEASE_EVIDENCE_MAX_BYTES = 512 as const; + +export interface InstanceLeaseAnchorEvidence { + readonly device: string; + readonly inode: string; + readonly mode: number; + readonly uid: number; + readonly linkCount: number; +} + +export interface InstanceLeaseLauncherEvidence { + readonly protocolVersion: typeof INSTANCE_LEASE_PROTOCOL_VERSION; + readonly launcherPid: number; + readonly controllerPid: number; + readonly anchor: InstanceLeaseAnchorEvidence; +} + +/** + * A process-specific adapter may expose this port only after validating the + * inherited descriptors and launcher evidence. Core code never receives raw + * descriptor numbers or filesystem paths. + */ +export interface VerifiedInstanceLeaseHandle { + readonly evidence: InstanceLeaseLauncherEvidence; + assertValid(): void; + close(): void; +} + +export type InstanceLeaseGuardState = 'held' | 'released'; + +/** + * Safe, path-free evidence for admission checks. `invalid` deliberately does + * not expose adapter errors because descriptor and launcher diagnostics can + * carry process-local details that must not cross the composition boundary. + */ +export type InstanceLeaseAdmissionInspection = + | { + readonly status: 'held'; + readonly evidence: InstanceLeaseLauncherEvidence; + } + | { + readonly status: 'invalid' | 'released'; + }; diff --git a/src/features/instance-lease/core/domain/InstanceLeaseGuard.ts b/src/features/instance-lease/core/domain/InstanceLeaseGuard.ts new file mode 100644 index 0000000000..315da5dd70 --- /dev/null +++ b/src/features/instance-lease/core/domain/InstanceLeaseGuard.ts @@ -0,0 +1,131 @@ +import { + INSTANCE_LEASE_PROTOCOL_VERSION, + type InstanceLeaseAdmissionInspection, + type InstanceLeaseGuardState, + type InstanceLeaseLauncherEvidence, + type VerifiedInstanceLeaseHandle, +} from '../../contracts'; + +const DECIMAL_KERNEL_ID = /^(?:0|[1-9][0-9]*)$/; + +export class InstanceLeaseGuardError extends Error { + constructor(readonly code: 'invalid_handle' | 'released') { + super(`instance-lease-guard:${code}`); + this.name = 'InstanceLeaseGuardError'; + } +} + +function isSafePid(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function snapshotEvidence(evidence: InstanceLeaseLauncherEvidence): InstanceLeaseLauncherEvidence { + if ( + evidence.protocolVersion !== INSTANCE_LEASE_PROTOCOL_VERSION || + !isSafePid(evidence.launcherPid) || + !isSafePid(evidence.controllerPid) || + !DECIMAL_KERNEL_ID.test(evidence.anchor.device) || + !DECIMAL_KERNEL_ID.test(evidence.anchor.inode) || + evidence.anchor.inode === '0' || + !Number.isSafeInteger(evidence.anchor.mode) || + evidence.anchor.mode < 0 || + (evidence.anchor.mode & 0o170000) !== 0o100000 || + (evidence.anchor.mode & 0o22) !== 0 || + !Number.isSafeInteger(evidence.anchor.uid) || + evidence.anchor.uid !== 0 || + !Number.isSafeInteger(evidence.anchor.linkCount) || + evidence.anchor.linkCount !== 1 + ) { + throw new InstanceLeaseGuardError('invalid_handle'); + } + + return Object.freeze({ + protocolVersion: INSTANCE_LEASE_PROTOCOL_VERSION, + launcherPid: evidence.launcherPid, + controllerPid: evidence.controllerPid, + anchor: Object.freeze({ + device: evidence.anchor.device, + inode: evidence.anchor.inode, + mode: evidence.anchor.mode, + uid: evidence.anchor.uid, + linkCount: evidence.anchor.linkCount, + }), + }); +} + +/** + * Process-agnostic ownership gate for the one inherited ADR-16 lease. A guard + * has no reacquire transition: after release, a new process lifecycle is + * required before hosted mutation/runtime admission can open again. + */ +export class InstanceLeaseGuard { + private stateValue: InstanceLeaseGuardState = 'held'; + private readonly evidenceValue: InstanceLeaseLauncherEvidence; + + private constructor(private readonly handle: VerifiedInstanceLeaseHandle) { + this.evidenceValue = snapshotEvidence(handle.evidence); + } + + static takeOwnership(handle: VerifiedInstanceLeaseHandle): InstanceLeaseGuard { + try { + handle.assertValid(); + return new InstanceLeaseGuard(handle); + } catch (error) { + if (error instanceof InstanceLeaseGuardError) { + throw error; + } + throw new InstanceLeaseGuardError('invalid_handle'); + } + } + + get state(): InstanceLeaseGuardState { + return this.stateValue; + } + + get evidence(): InstanceLeaseLauncherEvidence { + return this.evidenceValue; + } + + assertHeld(): InstanceLeaseLauncherEvidence { + if (this.stateValue !== 'held') { + throw new InstanceLeaseGuardError('released'); + } + let validationFailed = false; + let validationError: unknown; + try { + this.handle.assertValid(); + } catch (error) { + validationFailed = true; + validationError = error; + } + if (this.state !== 'held') { + throw new InstanceLeaseGuardError('released'); + } + if (validationFailed) { + throw validationError; + } + return this.evidenceValue; + } + + inspectForAdmission(): InstanceLeaseAdmissionInspection { + if (this.stateValue === 'released') { + return Object.freeze({ status: 'released' }); + } + try { + this.handle.assertValid(); + } catch { + return Object.freeze({ status: this.state === 'released' ? 'released' : 'invalid' }); + } + return this.state === 'released' + ? Object.freeze({ status: 'released' }) + : Object.freeze({ status: 'held', evidence: this.evidenceValue }); + } + + release(): void { + if (this.stateValue === 'released') { + return; + } + this.stateValue = 'released'; + this.handle.close(); + } +} diff --git a/src/features/instance-lease/core/domain/index.ts b/src/features/instance-lease/core/domain/index.ts new file mode 100644 index 0000000000..d9cdab0aed --- /dev/null +++ b/src/features/instance-lease/core/domain/index.ts @@ -0,0 +1 @@ +export { InstanceLeaseGuard, InstanceLeaseGuardError } from './InstanceLeaseGuard'; diff --git a/src/features/instance-lease/index.ts b/src/features/instance-lease/index.ts new file mode 100644 index 0000000000..b4a935e37d --- /dev/null +++ b/src/features/instance-lease/index.ts @@ -0,0 +1,12 @@ +export { + INSTANCE_LEASE_CONTROL_FD, + INSTANCE_LEASE_EVIDENCE_MAX_BYTES, + INSTANCE_LEASE_FD, + INSTANCE_LEASE_PROTOCOL_VERSION, + type InstanceLeaseAdmissionInspection, + type InstanceLeaseAnchorEvidence, + type InstanceLeaseGuardState, + type InstanceLeaseLauncherEvidence, + type VerifiedInstanceLeaseHandle, +} from './contracts'; +export { InstanceLeaseGuard, InstanceLeaseGuardError } from './core/domain'; diff --git a/src/features/instance-lease/main/adapters/output/NodeInheritedInstanceLease.ts b/src/features/instance-lease/main/adapters/output/NodeInheritedInstanceLease.ts new file mode 100644 index 0000000000..785e2e4e77 --- /dev/null +++ b/src/features/instance-lease/main/adapters/output/NodeInheritedInstanceLease.ts @@ -0,0 +1,296 @@ +import { + type BigIntStats, + closeSync, + constants as fsConstants, + fstatSync, + openSync, + readSync, +} from 'node:fs'; + +import { + INSTANCE_LEASE_CONTROL_FD, + INSTANCE_LEASE_EVIDENCE_MAX_BYTES, + INSTANCE_LEASE_FD, + INSTANCE_LEASE_PROTOCOL_VERSION, + type InstanceLeaseLauncherEvidence, + type VerifiedInstanceLeaseHandle, +} from '../../../contracts'; +import { + NodeInheritedInstanceLeaseError, + type NodeInheritedInstanceLeaseErrorCode, +} from '../../application/instanceLeaseMainErrors'; + +import type { StdioOptions } from 'node:child_process'; + +const EVIDENCE_KEYS = [ + 'protocolVersion', + 'launcherPid', + 'controllerPid', + 'device', + 'inode', + 'mode', + 'uid', + 'nlink', +] as const; +const DECIMAL_KERNEL_ID = /^(?:0|[1-9][0-9]*)$/; + +export { + NodeInheritedInstanceLeaseError, + type NodeInheritedInstanceLeaseErrorCode, +} from '../../application/instanceLeaseMainErrors'; + +function isPlainExactRecord( + value: unknown, + expectedKeys: readonly string[] +): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return false; + } + const keys = Reflect.ownKeys(value); + return ( + keys.length === expectedKeys.length && + keys.every((key) => typeof key === 'string' && expectedKeys.includes(key)) + ); +} + +function isSafePid(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function isSafeNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isCanonicalKernelId(value: unknown, allowZero: boolean): value is string { + return typeof value === 'string' && DECIMAL_KERNEL_ID.test(value) && (allowZero || value !== '0'); +} + +function readLauncherEvidence(controlFd: number): InstanceLeaseLauncherEvidence { + const bytes: number[] = []; + const byte = Buffer.allocUnsafe(1); + while (bytes.length < INSTANCE_LEASE_EVIDENCE_MAX_BYTES) { + let count: number; + try { + count = readSync(controlFd, byte, 0, 1, null); + } catch { + throw new NodeInheritedInstanceLeaseError('evidence_invalid'); + } + if (count !== 1) { + throw new NodeInheritedInstanceLeaseError('evidence_invalid'); + } + if (byte[0] === 0x0a) { + break; + } + if (byte[0] < 0x20 || byte[0] > 0x7e) { + throw new NodeInheritedInstanceLeaseError('evidence_invalid'); + } + bytes.push(byte[0]); + } + if (bytes.length === 0 || bytes.length >= INSTANCE_LEASE_EVIDENCE_MAX_BYTES) { + throw new NodeInheritedInstanceLeaseError('evidence_invalid'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(bytes).toString('ascii')) as unknown; + } catch { + throw new NodeInheritedInstanceLeaseError('evidence_invalid'); + } + if (!isPlainExactRecord(parsed, EVIDENCE_KEYS)) { + throw new NodeInheritedInstanceLeaseError('evidence_invalid'); + } + + const protocolVersion = parsed.protocolVersion; + const launcherPid = parsed.launcherPid; + const controllerPid = parsed.controllerPid; + const device = parsed.device; + const inode = parsed.inode; + const mode = parsed.mode; + const uid = parsed.uid; + const nlink = parsed.nlink; + if ( + protocolVersion !== INSTANCE_LEASE_PROTOCOL_VERSION || + !isSafePid(launcherPid) || + !isSafePid(controllerPid) || + !isCanonicalKernelId(device, true) || + !isCanonicalKernelId(inode, false) || + !isSafeNonNegativeInteger(mode) || + !isSafeNonNegativeInteger(uid) || + !isSafeNonNegativeInteger(nlink) + ) { + throw new NodeInheritedInstanceLeaseError('evidence_invalid'); + } + + return Object.freeze({ + protocolVersion, + launcherPid, + controllerPid, + anchor: Object.freeze({ device, inode, mode, uid, linkCount: nlink }), + }); +} + +function statDescriptor(fd: number, code: NodeInheritedInstanceLeaseErrorCode): BigIntStats { + try { + return fstatSync(fd, { bigint: true }); + } catch { + throw new NodeInheritedInstanceLeaseError(code); + } +} + +function validateControlDescriptor(controlFd: number): void { + const stat = statDescriptor(controlFd, 'control_fd_invalid'); + if ((stat.mode & BigInt(fsConstants.S_IFMT)) !== BigInt(fsConstants.S_IFIFO)) { + throw new NodeInheritedInstanceLeaseError('control_fd_invalid'); + } +} + +function validateLeaseDescriptor(leaseFd: number, evidence: InstanceLeaseLauncherEvidence): void { + const stat = statDescriptor(leaseFd, 'lease_fd_invalid'); + const mode = Number(stat.mode); + if ( + (stat.mode & BigInt(fsConstants.S_IFMT)) !== BigInt(fsConstants.S_IFREG) || + stat.uid !== 0n || + (stat.mode & 0o22n) !== 0n || + stat.nlink !== 1n || + stat.dev.toString() !== evidence.anchor.device || + stat.ino.toString() !== evidence.anchor.inode || + mode !== evidence.anchor.mode || + Number(stat.uid) !== evidence.anchor.uid || + Number(stat.nlink) !== evidence.anchor.linkCount + ) { + throw new NodeInheritedInstanceLeaseError('evidence_mismatch'); + } +} + +function assertLauncherConnected(controlFd: number): void { + const byte = Buffer.allocUnsafe(1); + try { + const count = readSync(controlFd, byte, 0, 1, null); + if (count === 0) { + throw new NodeInheritedInstanceLeaseError('launcher_disconnected'); + } + throw new NodeInheritedInstanceLeaseError('control_fd_invalid'); + } catch (error) { + if (error instanceof NodeInheritedInstanceLeaseError) { + throw error; + } + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EAGAIN' && code !== 'EWOULDBLOCK') { + throw new NodeInheritedInstanceLeaseError('control_fd_invalid'); + } + } +} + +class NodeInheritedInstanceLeaseHandle implements VerifiedInstanceLeaseHandle { + private closed = false; + + constructor( + private readonly leaseFd: number, + private readonly controlFd: number, + readonly evidence: InstanceLeaseLauncherEvidence + ) {} + + assertValid(): void { + if (this.closed) { + throw new NodeInheritedInstanceLeaseError('closed'); + } + validateControlDescriptor(this.controlFd); + validateLeaseDescriptor(this.leaseFd, this.evidence); + assertLauncherConnected(this.controlFd); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + try { + closeSync(this.controlFd); + } finally { + closeSync(this.leaseFd); + } + } +} + +/** + * Expands a Node stdio policy so the two inherited protocol descriptors are + * overwritten in every descendant. Numeric attempts to remap either secret + * descriptor to another child slot are rejected rather than silently copied. + */ +export interface NodeInstanceLeaseChildStdioPolicy { + readonly stdio: StdioOptions; + close(): void; +} + +export function createInstanceLeaseChildStdioPolicy( + stdio: StdioOptions = 'pipe' +): NodeInstanceLeaseChildStdioPolicy { + if (process.platform !== 'linux') { + throw new NodeInheritedInstanceLeaseError('platform_unsupported'); + } + let guarded: Exclude; + if (Array.isArray(stdio)) { + guarded = [...stdio]; + } else if (stdio === 'inherit') { + guarded = [0, 1, 2]; + } else { + guarded = [stdio, stdio, stdio]; + } + const referencesProtocolFd = (entry: (typeof guarded)[number]): boolean => { + if (entry === INSTANCE_LEASE_FD || entry === INSTANCE_LEASE_CONTROL_FD) { + return true; + } + if (typeof entry !== 'object' || entry === null || !('fd' in entry)) { + return false; + } + const fd = (entry as { fd?: unknown }).fd; + return fd === INSTANCE_LEASE_FD || fd === INSTANCE_LEASE_CONTROL_FD; + }; + if ( + guarded.some(referencesProtocolFd) || + guarded[INSTANCE_LEASE_FD] != null || + guarded[INSTANCE_LEASE_CONTROL_FD] != null + ) { + throw new NodeInheritedInstanceLeaseError('child_stdio_invalid'); + } + // Node's `ignore` policy for extra descriptor slots may leave an already-open + // inherited descriptor untouched. Explicitly dup /dev/null over both child + // slots, then let the caller close this parent-only source after spawn. + const nullFd = openSync('/dev/null', fsConstants.O_RDWR); + guarded[INSTANCE_LEASE_FD] = nullFd; + guarded[INSTANCE_LEASE_CONTROL_FD] = nullFd; + Object.freeze(guarded); + let closed = false; + return { + stdio: guarded, + close() { + if (closed) return; + closed = true; + closeSync(nullFd); + }, + }; +} + +/** Validates and adopts the two fixed descriptors installed by the Linux launcher. */ +export function openNodeInheritedInstanceLease(): VerifiedInstanceLeaseHandle { + if (process.platform !== 'linux') { + throw new NodeInheritedInstanceLeaseError('platform_unsupported'); + } + + validateControlDescriptor(INSTANCE_LEASE_CONTROL_FD); + const evidence = readLauncherEvidence(INSTANCE_LEASE_CONTROL_FD); + if (evidence.launcherPid !== process.ppid || evidence.controllerPid !== process.pid) { + throw new NodeInheritedInstanceLeaseError('evidence_mismatch'); + } + validateLeaseDescriptor(INSTANCE_LEASE_FD, evidence); + assertLauncherConnected(INSTANCE_LEASE_CONTROL_FD); + return new NodeInheritedInstanceLeaseHandle( + INSTANCE_LEASE_FD, + INSTANCE_LEASE_CONTROL_FD, + evidence + ); +} diff --git a/src/features/instance-lease/main/application/instanceLeaseMainErrors.ts b/src/features/instance-lease/main/application/instanceLeaseMainErrors.ts new file mode 100644 index 0000000000..97532a26ad --- /dev/null +++ b/src/features/instance-lease/main/application/instanceLeaseMainErrors.ts @@ -0,0 +1,16 @@ +export type NodeInheritedInstanceLeaseErrorCode = + | 'child_stdio_invalid' + | 'closed' + | 'control_fd_invalid' + | 'evidence_invalid' + | 'evidence_mismatch' + | 'launcher_disconnected' + | 'lease_fd_invalid' + | 'platform_unsupported'; + +export class NodeInheritedInstanceLeaseError extends Error { + constructor(readonly code: NodeInheritedInstanceLeaseErrorCode) { + super(`node-inherited-instance-lease:${code}`); + this.name = 'NodeInheritedInstanceLeaseError'; + } +} diff --git a/src/features/instance-lease/main/index.ts b/src/features/instance-lease/main/index.ts new file mode 100644 index 0000000000..7a6ac4c130 --- /dev/null +++ b/src/features/instance-lease/main/index.ts @@ -0,0 +1,4 @@ +export { + NodeInheritedInstanceLeaseError, + type NodeInheritedInstanceLeaseErrorCode, +} from './application/instanceLeaseMainErrors'; diff --git a/src/features/internal-storage/contracts/externalWriterObservationStorageContracts.ts b/src/features/internal-storage/contracts/externalWriterObservationStorageContracts.ts new file mode 100644 index 0000000000..8fa5cab8b6 --- /dev/null +++ b/src/features/internal-storage/contracts/externalWriterObservationStorageContracts.ts @@ -0,0 +1,64 @@ +import type { TeamIdentityChecksum } from './teamIdentityStorageContracts'; +import type { FileObservationStateCheckpoint } from '@features/external-writer-coordination/contracts'; +import type { DeploymentId } from '@shared/contracts/hosted'; +import type { TeamId } from '@shared/contracts/hosted'; + +export interface ExternalWriterObservationCheckpointIdentity { + readonly deploymentId: DeploymentId; + readonly observerId: string; +} + +export interface ExternalWriterObservationCheckpointRecord { + readonly revision: number; + readonly checkpoint: FileObservationStateCheckpoint; +} + +export interface ExternalWriterObservationCheckpointSaveRequest extends ExternalWriterObservationCheckpointIdentity { + /** Null creates the row. A stale revision fails closed. */ + readonly expectedRevision: number | null; + readonly checkpoint: FileObservationStateCheckpoint; +} + +export interface ExternalWriterObservationRetirementProof { + readonly teamId: TeamId; + readonly identityChecksum: TeamIdentityChecksum; + readonly tombstonedAt: string; +} + +export interface ExternalWriterCleanHandoffPlan { + readonly handoffId: string; + readonly oldCatalogToken: string; + readonly nextCatalogToken: string; + readonly retainedRegistrations: readonly ExternalWriterRetainedRegistration[]; + readonly retirementProofs: readonly ExternalWriterObservationRetirementProof[]; + readonly createdAt: string; +} + +export interface ExternalWriterCleanHandoffSaveRequest extends ExternalWriterObservationCheckpointSaveRequest { + readonly plan: ExternalWriterCleanHandoffPlan; +} + +export interface ExternalWriterRetainedRegistration { + readonly teamId: TeamId; + readonly featureKey: string; + readonly fileKey: string; +} + +export interface ExternalWriterCleanHandoffConsumeRequest extends ExternalWriterObservationCheckpointIdentity { + readonly consumeAttemptId: string; +} + +export interface ExternalWriterObservationCheckpointStorageGateway { + loadExternalWriterObservationCheckpoint( + identity: ExternalWriterObservationCheckpointIdentity + ): Promise; + saveExternalWriterObservationCheckpoint( + request: ExternalWriterObservationCheckpointSaveRequest + ): Promise; + saveExternalWriterCleanHandoffEligibility( + request: ExternalWriterCleanHandoffSaveRequest + ): Promise; + consumeExternalWriterCleanHandoffEligibility( + request: ExternalWriterCleanHandoffConsumeRequest + ): Promise; +} diff --git a/src/features/internal-storage/contracts/externalWriterReconciliationStorageContracts.ts b/src/features/internal-storage/contracts/externalWriterReconciliationStorageContracts.ts new file mode 100644 index 0000000000..b968f089fd --- /dev/null +++ b/src/features/internal-storage/contracts/externalWriterReconciliationStorageContracts.ts @@ -0,0 +1,30 @@ +import type { CoordinationEventDraft, CoordinationJsonValue } from '@features/coordination-events'; + +export interface ExternalWriterReconciliationReceipt { + readonly reconciliationId: string; + readonly inputSha256: string; + readonly eventId: string; + readonly sourceGeneration: number; + readonly featureRevision: number; + readonly eventBodyJson: string; + readonly committedAt: string; +} + +export interface ExternalWriterReconciliationCommitRequest { + readonly deploymentId: string; + readonly receipt: ExternalWriterReconciliationReceipt; + readonly event: CoordinationEventDraft; +} + +export interface ExternalWriterReconciliationStorageGateway { + getExternalWriterReconciliation(input: { + readonly deploymentId: string; + readonly reconciliationId: string; + }): Promise; + commitExternalWriterReconciliation( + input: ExternalWriterReconciliationCommitRequest + ): Promise<{ + readonly outcome: 'committed' | 'idempotent_replay' | 'input_conflict'; + readonly receipt: ExternalWriterReconciliationReceipt | null; + }>; +} diff --git a/src/features/internal-storage/contracts/hostedAuthStorageContracts.ts b/src/features/internal-storage/contracts/hostedAuthStorageContracts.ts new file mode 100644 index 0000000000..000fcfab20 --- /dev/null +++ b/src/features/internal-storage/contracts/hostedAuthStorageContracts.ts @@ -0,0 +1,36 @@ +export type HostedAuthStorageOperation = + | 'configuration.claimMode' + | 'configuration.read' + | 'configuration.resetMode' + | 'configuration.markSecretsRotated' + | 'authority.load' + | 'authority.initialize' + | 'authority.compareAndSwap' + | 'oidcAttempt.create' + | 'oidcAttempt.consume' + | 'identity.bind' + | 'personal.ensureOwner' + | 'session.create' + | 'session.findByHash' + | 'session.touch' + | 'session.revoke' + | 'backchannel.apply' + | 'user.get' + | 'user.list' + | 'user.setStatus' + | 'role.getLocal' + | 'role.setLocal' + | 'role.clearLocal' + | 'workspace.isRegistered' + | 'workspace.seed' + | 'workspace.list' + | 'workspace.register' + | 'workspace.disable' + | 'workspace.grant.list' + | 'workspace.grant.set' + | 'workspace.grant.revoke' + | 'audit.append'; + +export interface HostedAuthStorageGateway { + hostedAuthCall(operation: HostedAuthStorageOperation, payload: unknown): Promise; +} diff --git a/src/features/internal-storage/contracts/hostedTeamApprovalAuthorityStorageContracts.ts b/src/features/internal-storage/contracts/hostedTeamApprovalAuthorityStorageContracts.ts new file mode 100644 index 0000000000..f347ca16e9 --- /dev/null +++ b/src/features/internal-storage/contracts/hostedTeamApprovalAuthorityStorageContracts.ts @@ -0,0 +1,264 @@ +/** + * Storage-only durable approval authority contracts. They deliberately use + * opaque delivery references instead of runtime/provider APIs so the storage + * worker cannot become a lifecycle or provider owner. + */ + +/** Durable terminal outcome; `timeout` is lifecycle-owned and never accepted from a browser. */ +export type HostedTeamApprovalStorageDecision = 'allow' | 'deny' | 'timeout'; + +export interface HostedTeamApprovalAuthorityScope { + readonly principalId: string; + readonly workspaceId: string; + readonly teamId: string; + readonly authorityGeneration: string; + readonly restoreGeneration: number; +} + +/** Durable approval state is partitioned by runtime authority, never by the viewing actor. */ +export interface HostedTeamApprovalPartition { + readonly teamId: string; + readonly runId: string; +} + +export interface HostedTeamApprovalPreviewStorageRecord { + readonly previewRef: string; + readonly content: string; + readonly byteLength: number; + readonly truncated: boolean; + readonly isBinary: boolean; +} + +/** Trusted runtime observation submitted by the external lifecycle owner. */ +export interface HostedTeamApprovalPendingStorageRecord { + readonly scope: HostedTeamApprovalAuthorityScope; + readonly runId: string; + readonly requestId: string; + readonly approvalId: string; + readonly approvalGeneration: string; + readonly category: 'file_change' | 'command' | 'network' | 'other'; + readonly summary: string; + readonly requestedAtMs: number; + readonly expiresAtMs: number | null; + readonly preview: HostedTeamApprovalPreviewStorageRecord | null; + /** Opaque runtime-owned target; it is never a filesystem path. */ + readonly deliveryRef: string; + readonly observedAtMs: number; + readonly deadlineAtMs: number; +} + +export interface HostedTeamApprovalPendingReadRequest { + readonly scope: HostedTeamApprovalAuthorityScope; + readonly expectedRunId: string; + readonly afterApprovalId: string | null; + readonly afterApprovalGenerationHash: string | null; + readonly limit: number; + readonly deadlineAtMs: number; +} + +export interface HostedTeamApprovalPendingReadRecord { + readonly runId: string; + readonly requestId: string; + readonly approvalId: string; + readonly approvalGeneration: string; + readonly category: 'file_change' | 'command' | 'network' | 'other'; + readonly summary: string; + readonly requestedAtMs: number; + readonly expiresAtMs: number | null; + readonly previewRef: string | null; +} + +export interface HostedTeamApprovalPendingReadResult { + readonly records: readonly HostedTeamApprovalPendingReadRecord[]; + readonly hasMore: boolean; +} + +export interface HostedTeamApprovalPreviewReadRequest { + readonly scope: HostedTeamApprovalAuthorityScope; + readonly expectedRunId: string; + readonly approvalId: string; + readonly expectedApprovalGeneration: string; + readonly previewRef: string; + readonly deadlineAtMs: number; +} + +export type HostedTeamApprovalPreviewReadResult = + | { readonly kind: 'found'; readonly preview: HostedTeamApprovalPreviewStorageRecord } + | { readonly kind: 'stale_generation'; readonly currentApprovalGeneration: string } + | { readonly kind: 'not_found' }; + +export interface HostedTeamApprovalDecisionAudit { + readonly auditId: string; + readonly principalId: string; + readonly sessionId: string; +} + +export interface HostedTeamApprovalDeliveryIntent { + readonly deliveryId: string; +} + +export interface HostedTeamApprovalDecisionStorageRequest { + readonly scope: HostedTeamApprovalAuthorityScope; + readonly expectedRunId: string; + readonly approvalId: string; + readonly expectedApprovalGeneration: string; + readonly idempotencyKey: string; + readonly decision: HostedTeamApprovalStorageDecision; + /** SHA-256 of the canonical redacted decision intent. */ + readonly payloadHash: string; + readonly audit: HostedTeamApprovalDecisionAudit; + readonly delivery: HostedTeamApprovalDeliveryIntent; + readonly deadlineAtMs: number; +} + +export interface HostedTeamApprovalDecisionStorageReceipt { + readonly approvalGeneration: string; + readonly decision: HostedTeamApprovalStorageDecision; + readonly revision: number; +} + +export type HostedTeamApprovalDecisionStorageResult = + | { readonly kind: 'committed'; readonly receipt: HostedTeamApprovalDecisionStorageReceipt } + | { + readonly kind: 'idempotent_replay'; + readonly receipt: HostedTeamApprovalDecisionStorageReceipt; + } + | { + readonly kind: 'already_resolved'; + readonly approvalGeneration: string; + readonly decision: HostedTeamApprovalStorageDecision; + } + | { readonly kind: 'stale_generation'; readonly currentApprovalGeneration: string } + | { readonly kind: 'conflict'; readonly reason: 'idempotency_mismatch' } + | { readonly kind: 'expired' } + | { readonly kind: 'not_found' }; + +export interface HostedTeamApprovalDeliveryClaimRequest { + readonly workspaceId: string; + readonly teamId: string; + readonly authorityGeneration: string; + readonly restoreGeneration: number; + readonly ownerId: string; + readonly leaseToken: string; + /** Requested duration only; the storage clock owns both lease timestamps. */ + readonly leaseDurationMs: number; + readonly limit: number; + readonly deadlineAtMs: number; +} + +export interface HostedTeamApprovalDeliveryRecord { + readonly deliveryId: string; + readonly principal: + | Readonly<{ readonly kind: 'operator'; readonly actorId: string }> + | Readonly<{ readonly kind: 'system_timeout' }>; + readonly workspaceId: string; + readonly authorityGeneration: string; + readonly restoreGeneration: number; + readonly partition: HostedTeamApprovalPartition; + readonly requestId: string; + readonly approvalId: string; + readonly approvalGeneration: string; + readonly decision: HostedTeamApprovalStorageDecision; + readonly payloadHash: string; + readonly deliveryRef: string; + readonly deliveryGeneration: number; + readonly ownerId: string; + readonly leaseToken: string; + readonly claimedAtMs: number; + readonly leaseExpiresAtMs: number; + readonly createdAtMs: number; +} + +export interface HostedTeamApprovalDeliveryAcknowledgeRequest { + readonly workspaceId: string; + readonly authorityGeneration: string; + readonly restoreGeneration: number; + readonly partition: HostedTeamApprovalPartition; + readonly deliveryId: string; + readonly deliveryGeneration: number; + readonly ownerId: string; + readonly leaseToken: string; + readonly deadlineAtMs: number; +} + +/** Exact lease-fenced quarantine persisted before the lifecycle owner crosses the provider boundary. */ +export interface HostedTeamApprovalDeliveryOperatorRequiredRequest extends HostedTeamApprovalDeliveryAcknowledgeRequest { + readonly approvalGeneration: string; + readonly reconciliationRef: string; + /** Storage-clock duration fencing the provider boundary; it must outlive the owner exchange. */ + readonly boundaryLeaseDurationMs: number; +} + +/** Exact durable binding required before the lifecycle owner may reconcile an ambiguous effect. */ +export interface HostedTeamApprovalDeliveryReconciliationRequest { + readonly workspaceId: string; + readonly authorityGeneration: string; + readonly restoreGeneration: number; + readonly partition: HostedTeamApprovalPartition; + readonly deliveryId: string; + readonly approvalGeneration: string; + readonly deliveryGeneration: number; + readonly reconciliationRef: string; + readonly ownerId: string; + readonly leaseToken: string; + readonly leaseDurationMs: number; + readonly deadlineAtMs: number; +} + +export type HostedTeamApprovalDeliveryReconciliationReadResult = + | { readonly kind: 'claimed'; readonly deliveryGeneration: number } + | { readonly kind: 'unavailable' } + | { readonly kind: 'stale_binding' } + | { readonly kind: 'not_found' }; + +export interface HostedTeamApprovalDeliveryReconciliationSettleRequest extends Omit< + HostedTeamApprovalDeliveryReconciliationRequest, + 'leaseDurationMs' +> { + /** `not_delivered` is the only policy outcome that authorizes a fresh pending claim. */ + readonly outcome: 'delivered' | 'not_delivered'; +} + +/** Production scheduler audit. `nextAuditTimeMs` is retained across timer callbacks to survive wall-clock rollback. */ +export interface HostedTeamApprovalTimeoutAuditRequest { + readonly nextAuditTimeMs: number; + readonly deadlineAtMs: number; +} + +export interface HostedTeamApprovalTimeoutAuditResult { + readonly resolvedCount: number; + readonly nextAuditTimeMs: number | null; +} + +export interface HostedTeamApprovalAuthorityStorageGateway { + hostedTeamApprovalObserve( + record: HostedTeamApprovalPendingStorageRecord + ): Promise; + hostedTeamApprovalReadPending( + request: HostedTeamApprovalPendingReadRequest + ): Promise; + hostedTeamApprovalReadPreview( + request: HostedTeamApprovalPreviewReadRequest + ): Promise; + hostedTeamApprovalDecide( + request: HostedTeamApprovalDecisionStorageRequest + ): Promise; + hostedTeamApprovalClaimDeliveries( + request: HostedTeamApprovalDeliveryClaimRequest + ): Promise; + hostedTeamApprovalAcknowledgeDelivery( + request: HostedTeamApprovalDeliveryAcknowledgeRequest + ): Promise; + hostedTeamApprovalMarkDeliveryOperatorRequired( + request: HostedTeamApprovalDeliveryOperatorRequiredRequest + ): Promise; + hostedTeamApprovalReadDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationRequest + ): Promise; + hostedTeamApprovalSettleDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationSettleRequest + ): Promise; + hostedTeamApprovalAuditTimeouts( + request: HostedTeamApprovalTimeoutAuditRequest + ): Promise; +} diff --git a/src/features/internal-storage/contracts/hostedTeamConfigurationStorageContracts.ts b/src/features/internal-storage/contracts/hostedTeamConfigurationStorageContracts.ts new file mode 100644 index 0000000000..c5e647180d --- /dev/null +++ b/src/features/internal-storage/contracts/hostedTeamConfigurationStorageContracts.ts @@ -0,0 +1,262 @@ +import { + parseRevision, + parseTeamId, + parseWorkspaceId, + type Revision, + type TeamId, + type WorkspaceId, +} from '@shared/contracts/hosted'; + +export interface HostedTeamConfigurationStorageDraft { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; + readonly revision: Revision; + readonly metadata: Readonly<{ + name: string; + description?: string; + color?: string; + language?: string; + }>; + readonly members: readonly Readonly<{ name: string }>[]; +} + +export interface HostedTeamConfigurationStorageCreateRequest { + readonly workspaceId: WorkspaceId; + readonly idempotencyKey: string; + readonly payloadHash: string; + readonly metadata: Readonly<{ name: string }>; + readonly members: readonly Readonly<{ name: string }>[]; + readonly deadlineAtMs: number; +} + +export type HostedTeamConfigurationStorageCreateResult = + | Readonly<{ + kind: 'created'; + teamId: TeamId; + revision: Revision; + outcome: 'created' | 'idempotent_replay'; + }> + | Readonly<{ kind: 'conflict'; reason: 'idempotency_mismatch' }>; + +export type HostedTeamConfigurationStorageReadResult = + | Readonly<{ kind: 'found'; draft: HostedTeamConfigurationStorageDraft }> + | Readonly<{ kind: 'not_found' }>; + +export interface HostedTeamConfigurationStorageUpdateRequest { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; + readonly expectedRevision: Revision; + readonly updates: Readonly<{ + name?: string; + description?: string; + color?: string; + language?: string; + }>; + readonly deadlineAtMs: number; +} + +export type HostedTeamConfigurationStorageUpdateResult = + | Readonly<{ kind: 'updated'; draft: HostedTeamConfigurationStorageDraft }> + | Readonly<{ kind: 'not_found' }> + | Readonly<{ kind: 'conflict'; reason: 'revision_mismatch' }>; + +export interface HostedTeamConfigurationStorageDeleteRequest { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; + readonly expectedRevision: Revision; + readonly deadlineAtMs: number; +} + +export interface HostedTeamConfigurationStorageMutationOptions { + readonly signal: AbortSignal; +} + +export type HostedTeamConfigurationStorageDeleteResult = + | Readonly<{ kind: 'deleted'; outcome: 'deleted' | 'already_absent' }> + | Readonly<{ kind: 'conflict'; reason: 'revision_mismatch' }>; + +export interface HostedTeamConfigurationStorageGateway { + createHostedTeamConfiguration( + request: HostedTeamConfigurationStorageCreateRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise; + readHostedTeamConfiguration(input: { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; + }): Promise; + updateHostedTeamConfiguration( + request: HostedTeamConfigurationStorageUpdateRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise; + deleteHostedTeamConfiguration( + request: HostedTeamConfigurationStorageDeleteRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise; +} + +const HASH = /^[a-f0-9]{64}$/; +const KEY = /^idempotency_[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/; +const MEMBER = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; +const LIMITS = { name: 128, description: 4_000, color: 64, language: 64 } as const; + +function deadlineAtMs(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new TypeError('hosted-team-configuration-storage-deadline-invalid'); + } + return value as number; +} + +function record(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('hosted-team-configuration-storage-shape-invalid'); + } + return value as Record; +} + +function exact(value: unknown, keys: readonly string[]): Record { + const parsed = record(value); + const actual = Reflect.ownKeys(parsed); + if ( + actual.length !== keys.length || + actual.some((key) => typeof key !== 'string' || !keys.includes(key)) + ) { + throw new TypeError('hosted-team-configuration-storage-fields-invalid'); + } + return parsed; +} + +function text(value: unknown, limit: number): string { + if ( + typeof value !== 'string' || + value.length < 1 || + value.length > limit || + value.trim() !== value + ) { + throw new TypeError('hosted-team-configuration-storage-text-invalid'); + } + return value; +} + +function metadata( + value: unknown, + createOnly = false +): HostedTeamConfigurationStorageDraft['metadata'] { + const input = record(value); + const keys = Object.keys(input); + if ( + keys.length < 1 || + keys.some((key) => !Object.hasOwn(LIMITS, key)) || + (createOnly && (keys.length !== 1 || keys[0] !== 'name')) + ) { + throw new TypeError('hosted-team-configuration-storage-metadata-invalid'); + } + const output: Record = {}; + for (const key of keys as (keyof typeof LIMITS)[]) output[key] = text(input[key], LIMITS[key]); + return Object.freeze(output) as HostedTeamConfigurationStorageDraft['metadata']; +} + +function members(value: unknown): HostedTeamConfigurationStorageDraft['members'] { + if (!Array.isArray(value) || value.length < 1 || value.length > 32) { + throw new TypeError('hosted-team-configuration-storage-members-invalid'); + } + const names = new Set(); + const output = value.map((candidate) => { + const name = text(exact(candidate, ['name']).name, 64); + if (!MEMBER.test(name) || names.has(name)) { + throw new TypeError('hosted-team-configuration-storage-member-invalid'); + } + names.add(name); + return Object.freeze({ name }); + }); + return Object.freeze(output); +} + +export function parseHostedTeamConfigurationStorageCreateRequest( + value: unknown +): HostedTeamConfigurationStorageCreateRequest { + const input = exact(value, [ + 'workspaceId', + 'idempotencyKey', + 'payloadHash', + 'metadata', + 'members', + 'deadlineAtMs', + ]); + if ( + typeof input.idempotencyKey !== 'string' || + !KEY.test(input.idempotencyKey) || + typeof input.payloadHash !== 'string' || + !HASH.test(input.payloadHash) + ) { + throw new TypeError('hosted-team-configuration-storage-create-invalid'); + } + return Object.freeze({ + workspaceId: parseWorkspaceId(input.workspaceId), + idempotencyKey: input.idempotencyKey, + payloadHash: input.payloadHash, + metadata: metadata(input.metadata, true) as Readonly<{ name: string }>, + members: members(input.members), + deadlineAtMs: deadlineAtMs(input.deadlineAtMs), + }); +} + +export function parseHostedTeamConfigurationStorageIdentity(value: unknown): { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; +} { + const input = exact(value, ['workspaceId', 'teamId']); + return Object.freeze({ + workspaceId: parseWorkspaceId(input.workspaceId), + teamId: parseTeamId(input.teamId), + }); +} + +export function parseHostedTeamConfigurationStorageUpdateRequest( + value: unknown +): HostedTeamConfigurationStorageUpdateRequest { + const input = exact(value, [ + 'workspaceId', + 'teamId', + 'expectedRevision', + 'updates', + 'deadlineAtMs', + ]); + return Object.freeze({ + ...parseHostedTeamConfigurationStorageIdentity({ + workspaceId: input.workspaceId, + teamId: input.teamId, + }), + expectedRevision: parseRevision(input.expectedRevision), + updates: metadata(input.updates), + deadlineAtMs: deadlineAtMs(input.deadlineAtMs), + }); +} + +export function parseHostedTeamConfigurationStorageDeleteRequest( + value: unknown +): HostedTeamConfigurationStorageDeleteRequest { + const input = exact(value, ['workspaceId', 'teamId', 'expectedRevision', 'deadlineAtMs']); + return Object.freeze({ + ...parseHostedTeamConfigurationStorageIdentity({ + workspaceId: input.workspaceId, + teamId: input.teamId, + }), + expectedRevision: parseRevision(input.expectedRevision), + deadlineAtMs: deadlineAtMs(input.deadlineAtMs), + }); +} + +export function parseHostedTeamConfigurationStorageDraft( + value: unknown +): HostedTeamConfigurationStorageDraft { + const input = exact(value, ['workspaceId', 'teamId', 'revision', 'metadata', 'members']); + return Object.freeze({ + ...parseHostedTeamConfigurationStorageIdentity({ + workspaceId: input.workspaceId, + teamId: input.teamId, + }), + revision: parseRevision(input.revision), + metadata: metadata(input.metadata), + members: members(input.members), + }); +} diff --git a/src/features/internal-storage/contracts/index.ts b/src/features/internal-storage/contracts/index.ts new file mode 100644 index 0000000000..eafac50274 --- /dev/null +++ b/src/features/internal-storage/contracts/index.ts @@ -0,0 +1,8 @@ +export type * from './externalWriterObservationStorageContracts'; +export type * from './hostedAuthStorageContracts'; +export type * from './hostedTeamApprovalAuthorityStorageContracts'; +export type * from './hostedTeamConfigurationStorageContracts'; +export * from './internalStorageContracts'; +export * from './memberWorkSyncTeamIdentity'; +export * from './teamIdentityStorageContracts'; +export * from './teamRosterStorageContracts'; diff --git a/src/features/internal-storage/contracts/teamIdentityStorageContracts.ts b/src/features/internal-storage/contracts/teamIdentityStorageContracts.ts new file mode 100644 index 0000000000..bda5b2781e --- /dev/null +++ b/src/features/internal-storage/contracts/teamIdentityStorageContracts.ts @@ -0,0 +1,368 @@ +import { + parseTeamId, + parseWorkspaceId, + type TeamId, + type WorkspaceId, +} from '@shared/contracts/hosted/identifiers'; + +declare const teamIdentityStorageBrand: unique symbol; + +export type LegacyTeamKey = string & { + readonly [teamIdentityStorageBrand]: 'LegacyTeamKey'; +}; +export type DirectoryFingerprint = string & { + readonly [teamIdentityStorageBrand]: 'DirectoryFingerprint'; +}; +export type TeamIdentityChecksum = string & { + readonly [teamIdentityStorageBrand]: 'TeamIdentityChecksum'; +}; +export type TeamAdoptionIntentId = string & { + readonly [teamIdentityStorageBrand]: 'TeamAdoptionIntentId'; +}; +export type TeamAdoptionIntentChecksum = string & { + readonly [teamIdentityStorageBrand]: 'TeamAdoptionIntentChecksum'; +}; + +const LEGACY_TEAM_KEY_MAX_LENGTH = 128; +const LEGACY_TEAM_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; +const RESERVED_LEGACY_TEAM_KEYS = new Set([ + 'aux', + 'con', + 'nul', + 'prn', + ...Array.from({ length: 9 }, (_, index) => `com${index + 1}`), + ...Array.from({ length: 9 }, (_, index) => `lpt${index + 1}`), +]); +const LOWER_HEX_64 = /^[0-9a-f]{64}$/; +const ADOPTION_INTENT_ID = /^adoption_[0-9a-f]{32}$/; + +/** + * Legacy keys are exact direct-child compatibility keys, not display names or + * filesystem paths. Callers may suggest a slug before this boundary, but this + * parser never trims, folds case, or performs Unicode normalization. + */ +export function parseLegacyTeamKey(value: unknown): LegacyTeamKey { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > LEGACY_TEAM_KEY_MAX_LENGTH || + !LEGACY_TEAM_KEY_PATTERN.test(value) || + RESERVED_LEGACY_TEAM_KEYS.has(value) + ) { + throw new TypeError('team-identity-legacy-key-invalid'); + } + return value as LegacyTeamKey; +} + +export function parseDirectoryFingerprint(value: unknown): DirectoryFingerprint { + if (typeof value !== 'string' || !LOWER_HEX_64.test(value)) { + throw new TypeError('team-identity-directory-fingerprint-invalid'); + } + return value as DirectoryFingerprint; +} + +export function parseTeamIdentityChecksum(value: unknown): TeamIdentityChecksum { + if (typeof value !== 'string' || !LOWER_HEX_64.test(value)) { + throw new TypeError('team-identity-checksum-invalid'); + } + return value as TeamIdentityChecksum; +} + +export function parseTeamAdoptionIntentId(value: unknown): TeamAdoptionIntentId { + if (typeof value !== 'string' || !ADOPTION_INTENT_ID.test(value)) { + throw new TypeError('team-adoption-intent-id-invalid'); + } + return value as TeamAdoptionIntentId; +} + +export function parseTeamAdoptionIntentChecksum(value: unknown): TeamAdoptionIntentChecksum { + if (typeof value !== 'string' || !LOWER_HEX_64.test(value)) { + throw new TypeError('team-adoption-intent-checksum-invalid'); + } + return value as TeamAdoptionIntentChecksum; +} + +export interface TeamWorkspaceBindingEvidence { + workspaceId: WorkspaceId; + generation: number; +} + +export type TeamIdentityRecordState = + | 'reserved' + | 'adoption_prepared' + | 'file_published' + | 'active' + | 'tombstoned'; + +export interface TeamIdentityRecord { + teamId: TeamId; + state: TeamIdentityRecordState; + legacyKey: LegacyTeamKey; + directoryFingerprint: DirectoryFingerprint; + workspaceBinding: TeamWorkspaceBindingEvidence | null; + adoptionIntentId: TeamAdoptionIntentId | null; + identityChecksum: TeamIdentityChecksum | null; + createdAt: string; + activatedAt: string | null; + tombstonedAt: string | null; +} + +const TEAM_IDENTITY_RECORD_KEYS = Object.freeze([ + 'teamId', + 'state', + 'legacyKey', + 'directoryFingerprint', + 'workspaceBinding', + 'adoptionIntentId', + 'identityChecksum', + 'createdAt', + 'activatedAt', + 'tombstonedAt', +] as const); + +export function parseIdentityTimestamp(value: unknown): string { + if ( + typeof value !== 'string' || + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString() !== value + ) { + throw new TypeError('team-identity-timestamp-invalid'); + } + return value; +} + +export function parseTeamIdentityRecord(value: unknown): TeamIdentityRecord { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('team-identity-record-invalid'); + } + const record = value as Record; + const keys = Reflect.ownKeys(record); + if ( + keys.length !== TEAM_IDENTITY_RECORD_KEYS.length || + keys.some((key) => typeof key !== 'string' || !TEAM_IDENTITY_RECORD_KEYS.includes(key as never)) + ) { + throw new TypeError('team-identity-record-invalid'); + } + const state = record.state; + if ( + state !== 'reserved' && + state !== 'adoption_prepared' && + state !== 'file_published' && + state !== 'active' && + state !== 'tombstoned' + ) { + throw new TypeError('team-identity-state-unknown'); + } + const workspaceValue = record.workspaceBinding; + const workspaceBinding = + workspaceValue === null + ? null + : (() => { + if (typeof workspaceValue !== 'object' || Array.isArray(workspaceValue)) { + throw new TypeError('team-identity-workspace-binding-invalid'); + } + const candidate = workspaceValue as Record; + if ( + Reflect.ownKeys(candidate).length !== 2 || + !Object.hasOwn(candidate, 'workspaceId') || + !Object.hasOwn(candidate, 'generation') || + !Number.isSafeInteger(candidate.generation) || + (candidate.generation as number) < 1 + ) { + throw new TypeError('team-identity-workspace-binding-invalid'); + } + return Object.freeze({ + workspaceId: parseWorkspaceId(candidate.workspaceId), + generation: candidate.generation as number, + }); + })(); + const identity: TeamIdentityRecord = Object.freeze({ + teamId: parseTeamId(record.teamId), + state, + legacyKey: parseLegacyTeamKey(record.legacyKey), + directoryFingerprint: parseDirectoryFingerprint(record.directoryFingerprint), + workspaceBinding, + adoptionIntentId: + record.adoptionIntentId === null ? null : parseTeamAdoptionIntentId(record.adoptionIntentId), + identityChecksum: + record.identityChecksum === null ? null : parseTeamIdentityChecksum(record.identityChecksum), + createdAt: parseIdentityTimestamp(record.createdAt), + activatedAt: record.activatedAt === null ? null : parseIdentityTimestamp(record.activatedAt), + tombstonedAt: record.tombstonedAt === null ? null : parseIdentityTimestamp(record.tombstonedAt), + }); + const stateFieldsValid = + (identity.state === 'reserved' && + identity.adoptionIntentId === null && + identity.identityChecksum === null && + identity.activatedAt === null && + identity.tombstonedAt === null) || + (identity.state === 'adoption_prepared' && + identity.adoptionIntentId !== null && + identity.identityChecksum === null && + identity.activatedAt === null && + identity.tombstonedAt === null) || + (identity.state === 'file_published' && + identity.adoptionIntentId !== null && + identity.identityChecksum !== null && + identity.activatedAt === null && + identity.tombstonedAt === null) || + (identity.state === 'active' && + identity.adoptionIntentId !== null && + identity.identityChecksum !== null && + identity.activatedAt !== null && + identity.tombstonedAt === null) || + (identity.state === 'tombstoned' && identity.tombstonedAt !== null); + if (!stateFieldsValid) throw new TypeError('team-identity-state-fields-invalid'); + return identity; +} + +export const MAX_TEAM_IDENTITY_READ_RECORDS = 1_000; + +/** + * The hosted read composition receives identity only from the durable SQLite + * component. Implementations must validate the complete persisted identity + * graph before returning any value; there is deliberately no JSON or + * directory-discovery implementation of this port. + */ +export interface TeamIdentityReadGateway { + listTeamIdentities(): Promise; + getTeamIdentity(teamId: TeamId): Promise; +} + +export interface ExternalWriterIdentityInventoryCaptureRequest { + readonly retirementCandidates: readonly TeamId[]; +} + +export interface ExternalWriterIdentityRetirementProof { + readonly teamId: TeamId; + readonly identityChecksum: TeamIdentityChecksum; + readonly tombstonedAt: string; +} + +export interface ExternalWriterIdentityInventoryCapture { + readonly active: readonly TeamIdentityRecord[]; + readonly retiredCandidates: readonly ExternalWriterIdentityRetirementProof[]; +} + +export type LegacyTeamKeyReservationState = 'active' | 'tombstoned'; + +export type LegacyTeamKeyTombstoneReason = 'draft_deleted' | 'team_deleted' | 'legacy_conflict'; + +export interface LegacyTeamKeyReservation { + legacyKey: LegacyTeamKey; + teamId: TeamId; + state: LegacyTeamKeyReservationState; + reservedAt: string; + tombstonedAt: string | null; + tombstoneReason: LegacyTeamKeyTombstoneReason | null; +} + +export type TeamAdoptionIntentState = 'prepared' | 'file_published' | 'committed'; + +export interface TeamAdoptionIntent { + intentId: TeamAdoptionIntentId; + teamId: TeamId; + state: TeamAdoptionIntentState; + legacyKey: LegacyTeamKey; + directoryFingerprint: DirectoryFingerprint; + workspaceBinding: TeamWorkspaceBindingEvidence | null; + expectedIdentityChecksum: TeamIdentityChecksum; + intentChecksum: TeamAdoptionIntentChecksum; + preparedAt: string; + filePublishedAt: string | null; + publishedIdentityChecksum: TeamIdentityChecksum | null; + committedAt: string | null; + committedIdentityChecksum: TeamIdentityChecksum | null; +} + +export interface ReserveTeamIdentityInput { + teamId: TeamId; + legacyKey: LegacyTeamKey; + directoryFingerprint: DirectoryFingerprint; + workspaceBinding: TeamWorkspaceBindingEvidence | null; + createdAt: string; +} + +export interface PrepareTeamAdoptionInput { + intentId: TeamAdoptionIntentId; + teamId: TeamId; + legacyKey: LegacyTeamKey; + directoryFingerprint: DirectoryFingerprint; + workspaceBinding: TeamWorkspaceBindingEvidence | null; + expectedIdentityChecksum: TeamIdentityChecksum; + preparedAt: string; +} + +export interface CommitTeamAdoptionInput { + intentId: TeamAdoptionIntentId; + teamId: TeamId; + intentChecksum: TeamAdoptionIntentChecksum; + identityChecksum: TeamIdentityChecksum; + committedAt: string; +} + +export interface RecordTeamIdentityFilePublishedInput { + intentId: TeamAdoptionIntentId; + teamId: TeamId; + intentChecksum: TeamAdoptionIntentChecksum; + identityChecksum: TeamIdentityChecksum; + filePublishedAt: string; +} + +export interface TombstoneLegacyTeamKeyInput { + teamId: TeamId; + legacyKey: LegacyTeamKey; + reason: LegacyTeamKeyTombstoneReason; + tombstonedAt: string; +} + +export interface TeamIdentityReservationResult { + outcome: 'created' | 'already_reserved'; + identity: TeamIdentityRecord; + reservation: LegacyTeamKeyReservation; +} + +export interface TeamAdoptionPrepareResult { + outcome: 'prepared' | 'already_prepared' | 'already_file_published' | 'already_committed'; + identity: TeamIdentityRecord; + reservation: LegacyTeamKeyReservation; + intent: TeamAdoptionIntent; +} + +export interface TeamIdentityFilePublishedResult { + outcome: 'file_published' | 'already_file_published' | 'already_committed'; + identity: TeamIdentityRecord; + reservation: LegacyTeamKeyReservation; + intent: TeamAdoptionIntent; +} + +export interface TeamAdoptionCommitResult { + outcome: 'committed' | 'already_committed'; + identity: TeamIdentityRecord; + reservation: LegacyTeamKeyReservation; + intent: TeamAdoptionIntent; +} + +export interface LegacyTeamKeyTombstoneResult { + outcome: 'tombstoned' | 'already_tombstoned'; + identity: TeamIdentityRecord; + reservation: LegacyTeamKeyReservation; +} + +export const TeamIdentityStorageErrorCode = { + InvalidInput: 'invalid_input', + UnknownSchema: 'unknown_schema', + UnknownState: 'unknown_state', + DuplicateIdentity: 'duplicate_identity', + LegacyKeyConflict: 'legacy_key_conflict', + LegacyKeyTombstoned: 'legacy_key_tombstoned', + AdoptionIntentNotFound: 'adoption_intent_not_found', + AdoptionIntentMismatch: 'adoption_intent_mismatch', + ChecksumDisagreement: 'checksum_disagreement', + TamperingDetected: 'tampering_detected', + ReadLimitExceeded: 'read_limit_exceeded', + IllegalTransition: 'illegal_transition', +} as const; + +export type TeamIdentityStorageErrorCode = + (typeof TeamIdentityStorageErrorCode)[keyof typeof TeamIdentityStorageErrorCode]; diff --git a/src/features/internal-storage/contracts/teamRosterStorageContracts.ts b/src/features/internal-storage/contracts/teamRosterStorageContracts.ts new file mode 100644 index 0000000000..d0812677f2 --- /dev/null +++ b/src/features/internal-storage/contracts/teamRosterStorageContracts.ts @@ -0,0 +1,215 @@ +import { + parseLegacyMemberKey, + parseMemberId, + parseTeamId, + type TeamId, +} from '@shared/contracts/hosted/identifiers'; + +import type { TeamProviderId } from '@shared/types'; + +export const TEAM_ROSTER_STORAGE_SCHEMA_VERSION = 1 as const; + +export interface TeamRosterMemberRecord { + ordinal: number; + memberId: string; + legacyMemberKey: string; + memberRevision: number; + state: 'active' | 'removed'; + providerId: TeamProviderId; + model: string | null; + role: string | null; + workflow: string | null; + isolation: 'worktree' | null; +} + +export interface TeamRosterSnapshotRecord { + schemaVersion: typeof TEAM_ROSTER_STORAGE_SCHEMA_VERSION; + teamId: string; + rosterGeneration: number; + adoptionFingerprint: string; + adoptedAt: string; + members: TeamRosterMemberRecord[]; +} + +export type TeamRosterAdoptRecordResult = + | { outcome: 'created'; roster: TeamRosterSnapshotRecord } + | { outcome: 'existing'; roster: TeamRosterSnapshotRecord }; + +export interface TeamRosterStorageGateway { + getTeamRoster(teamId: TeamId): Promise; + adoptTeamRoster(record: TeamRosterSnapshotRecord): Promise; +} + +const TEAM_ROSTER_RECORD_KEYS = Object.freeze([ + 'adoptedAt', + 'adoptionFingerprint', + 'members', + 'rosterGeneration', + 'schemaVersion', + 'teamId', +] as const); +const TEAM_ROSTER_MEMBER_RECORD_KEYS = Object.freeze([ + 'isolation', + 'legacyMemberKey', + 'memberId', + 'memberRevision', + 'model', + 'ordinal', + 'providerId', + 'role', + 'state', + 'workflow', +] as const); +const TEAM_PROVIDER_IDS = new Set(['anthropic', 'codex', 'gemini', 'opencode']); + +export function parseTeamRosterSnapshotRecord(value: unknown): TeamRosterSnapshotRecord { + const record = exactRecord(value, TEAM_ROSTER_RECORD_KEYS, 'team-roster-storage-record-invalid'); + if (record.schemaVersion !== TEAM_ROSTER_STORAGE_SCHEMA_VERSION) { + throw new TypeError('team-roster-storage-schema-version-unsupported'); + } + const teamId = parseTeamId(record.teamId); + const rosterGeneration = positiveInteger( + record.rosterGeneration, + 'team-roster-storage-generation-invalid' + ); + if ( + typeof record.adoptionFingerprint !== 'string' || + !/^sha256:[0-9a-f]{64}$/.test(record.adoptionFingerprint) + ) { + throw new TypeError('team-roster-storage-fingerprint-invalid'); + } + const adoptedAt = canonicalTimestamp(record.adoptedAt); + if (!Array.isArray(record.members)) { + throw new TypeError('team-roster-storage-members-invalid'); + } + + const memberIds = new Set(); + const exactKeys = new Set(); + const foldedKeys = new Set(); + let previousKey: string | null = null; + const members = record.members.map((candidate, index) => { + if (!Object.hasOwn(record.members as unknown[], index)) { + throw new TypeError('team-roster-storage-members-sparse'); + } + const member = exactRecord( + candidate, + TEAM_ROSTER_MEMBER_RECORD_KEYS, + 'team-roster-storage-member-invalid' + ); + if (member.ordinal !== index) { + throw new TypeError('team-roster-storage-member-order-invalid'); + } + const memberId = parseMemberId(member.memberId); + const legacyMemberKey = parseLegacyMemberKey(member.legacyMemberKey); + const foldedKey = legacyMemberKey.toLowerCase(); + if ( + memberIds.has(memberId) || + exactKeys.has(legacyMemberKey) || + foldedKeys.has(foldedKey) || + (previousKey !== null && previousKey >= legacyMemberKey) + ) { + throw new TypeError('team-roster-storage-member-identity-ambiguous'); + } + if (member.state !== 'active' && member.state !== 'removed') { + throw new TypeError('team-roster-storage-member-state-invalid'); + } + if (!TEAM_PROVIDER_IDS.has(member.providerId as TeamProviderId)) { + throw new TypeError('team-roster-storage-member-provider-invalid'); + } + if (member.isolation !== null && member.isolation !== 'worktree') { + throw new TypeError('team-roster-storage-member-isolation-invalid'); + } + const parsed = { + ordinal: index, + memberId, + legacyMemberKey, + memberRevision: positiveInteger( + member.memberRevision, + 'team-roster-storage-member-revision-invalid' + ), + state: member.state, + providerId: member.providerId as TeamProviderId, + model: nullableBoundedString(member.model, 512, 'team-roster-storage-member-model-invalid'), + role: nullableBoundedString(member.role, 4_096, 'team-roster-storage-member-role-invalid'), + workflow: nullableBoundedString( + member.workflow, + 131_072, + 'team-roster-storage-member-workflow-invalid' + ), + isolation: member.isolation, + } satisfies TeamRosterMemberRecord; + memberIds.add(memberId); + exactKeys.add(legacyMemberKey); + foldedKeys.add(foldedKey); + previousKey = legacyMemberKey; + return parsed; + }); + assertNoAutoSuffixAmbiguity(members.map(({ legacyMemberKey }) => legacyMemberKey)); + return { + schemaVersion: TEAM_ROSTER_STORAGE_SCHEMA_VERSION, + teamId, + rosterGeneration, + adoptionFingerprint: record.adoptionFingerprint, + adoptedAt, + members, + }; +} + +function exactRecord( + value: unknown, + expectedKeys: readonly string[], + diagnostic: string +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(diagnostic); + } + const record = value as Record; + const actualKeys = Reflect.ownKeys(record); + if ( + actualKeys.length !== expectedKeys.length || + actualKeys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + throw new TypeError(diagnostic); + } + return record; +} + +function positiveInteger(value: unknown, diagnostic: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) throw new TypeError(diagnostic); + return value as number; +} + +function canonicalTimestamp(value: unknown): string { + if ( + typeof value !== 'string' || + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString() !== value + ) { + throw new TypeError('team-roster-storage-timestamp-invalid'); + } + return value; +} + +function nullableBoundedString( + value: unknown, + maximumLength: number, + diagnostic: string +): string | null { + if ( + value !== null && + (typeof value !== 'string' || value.length === 0 || value.length > maximumLength) + ) { + throw new TypeError(diagnostic); + } + return value; +} + +function assertNoAutoSuffixAmbiguity(keys: readonly string[]): void { + const folded = new Set(keys.map((key) => key.toLowerCase())); + for (const key of keys) { + const match = /^(.+)-(\d+)$/.exec(key); + if (match?.[1] && Number(match[2]) >= 2 && folded.has(match[1].toLowerCase())) { + throw new TypeError('team-roster-storage-member-auto-suffix-ambiguous'); + } + } +} diff --git a/src/features/internal-storage/main/adapters/output/InternalStorageExternalWriterObservationStateStore.ts b/src/features/internal-storage/main/adapters/output/InternalStorageExternalWriterObservationStateStore.ts new file mode 100644 index 0000000000..c2735b38e2 --- /dev/null +++ b/src/features/internal-storage/main/adapters/output/InternalStorageExternalWriterObservationStateStore.ts @@ -0,0 +1,141 @@ +import { + type ExternalWriterCleanHandoffEligibilityPlan, + type ExternalWriterObservationStateStore, + FileObservationState, + type FileObservationStateCheckpoint, +} from '@features/external-writer-coordination'; + +import { + type ExternalWriterObservationCheckpointIdentity, + type ExternalWriterObservationCheckpointStorageGateway, +} from '../../../contracts/externalWriterObservationStorageContracts'; +import { parseTeamIdentityChecksum } from '../../../contracts/teamIdentityStorageContracts'; + +import type { TeamId } from '@shared/contracts/hosted'; + +const CHECKPOINT_LIMITS = Object.freeze({ + maxPendingObservations: 1_024, + maxSelfWriteIntents: 1_024, + maxObservationAttempts: 3, + maxScopes: 1_024, + maxObservedFiles: 100_000, +}); + +function validatedCheckpoint( + checkpoint: FileObservationStateCheckpoint +): FileObservationStateCheckpoint { + return FileObservationState.restore(checkpoint, CHECKPOINT_LIMITS).snapshot(); +} + +/** + * Binds one observer generation to the durable checkpoint CAS. A second process + * or stale supervisor instance cannot overwrite a newer observation watermark. + */ +export class InternalStorageExternalWriterObservationStateStore implements ExternalWriterObservationStateStore { + private revision: number | null = null; + private loaded = false; + private operationTail: Promise = Promise.resolve(); + private consumeAttemptId: string | null = null; + + constructor( + private readonly gateway: ExternalWriterObservationCheckpointStorageGateway, + private readonly identity: ExternalWriterObservationCheckpointIdentity + ) {} + + load(): Promise { + return this.schedule(async () => { + const record = await this.gateway.loadExternalWriterObservationCheckpoint(this.identity); + this.loaded = true; + this.revision = record?.revision ?? null; + return record ? validatedCheckpoint(record.checkpoint) : null; + }); + } + + consumeCleanHandoffEligibility(): Promise { + return this.schedule(async () => { + const consumeAttemptId = (this.consumeAttemptId ??= crypto.randomUUID()); + const record = await this.gateway.consumeExternalWriterCleanHandoffEligibility({ + ...this.identity, + consumeAttemptId, + }); + this.consumeAttemptId = null; + if (!record) return null; + this.loaded = true; + this.revision = record.revision; + return validatedCheckpoint(record.checkpoint); + }); + } + + listHotTeamIds(): Promise { + return this.schedule(async () => { + const record = await this.gateway.loadExternalWriterObservationCheckpoint(this.identity); + this.loaded = true; + this.revision = record?.revision ?? null; + if (!record) return Object.freeze([]); + const checkpoint = validatedCheckpoint(record.checkpoint); + return Object.freeze([ + ...new Set([ + ...checkpoint.fileWriterEpochs.map((entry) => entry.teamId), + ...checkpoint.teamObservationWatermarks.map((entry) => entry.teamId), + ...checkpoint.pendingObservations.map((entry) => entry.scope.teamId), + ...checkpoint.dirtyScopes.map((entry) => entry.scope.teamId), + ...checkpoint.selfWriteIntents.map((entry) => entry.scope.teamId), + ...checkpoint.observedFiles.map((entry) => entry.scope.teamId), + ]), + ]); + }); + } + + save(checkpoint: FileObservationStateCheckpoint): Promise { + return this.schedule(async () => { + if (!this.loaded) { + throw new Error('external-writer-observation-state-store-not-loaded'); + } + const record = await this.gateway.saveExternalWriterObservationCheckpoint({ + ...this.identity, + expectedRevision: this.revision, + checkpoint: validatedCheckpoint(checkpoint), + }); + this.revision = record.revision; + }); + } + + saveCleanHandoffEligibility( + checkpoint: FileObservationStateCheckpoint, + plan: ExternalWriterCleanHandoffEligibilityPlan + ): Promise { + return this.schedule(async () => { + if (!this.loaded) throw new Error('external-writer-observation-state-store-not-loaded'); + const record = await this.gateway.saveExternalWriterCleanHandoffEligibility({ + ...this.identity, + expectedRevision: this.revision, + checkpoint: validatedCheckpoint(checkpoint), + plan: { + handoffId: plan.handoffId, + oldCatalogToken: plan.oldCatalogToken, + nextCatalogToken: plan.nextCatalogToken, + retainedRegistrations: plan.retainedRegistrations.map((entry) => ({ + teamId: entry.scope.teamId, + featureKey: entry.scope.featureKey, + fileKey: entry.fileKey, + })), + retirementProofs: plan.retirementProofs.map((proof) => ({ + ...proof, + identityChecksum: parseTeamIdentityChecksum(proof.identityChecksum), + })), + createdAt: plan.createdAt, + }, + }); + this.revision = record.revision; + }); + } + + private schedule(operation: () => Promise): Promise { + const result = this.operationTail.then(operation, operation); + this.operationTail = result.then( + () => undefined, + () => undefined + ); + return result; + } +} diff --git a/src/features/internal-storage/main/application/coordinationDurabilityStorage.ts b/src/features/internal-storage/main/application/coordinationDurabilityStorage.ts new file mode 100644 index 0000000000..838ba049ce --- /dev/null +++ b/src/features/internal-storage/main/application/coordinationDurabilityStorage.ts @@ -0,0 +1,156 @@ +import type { + BackupFenceCompletionDisposition, + BackupRunRecord, + BackupRunState, +} from '@features/coordination-backup/contracts'; +import type { + CoordinationEventDraft, + CoordinationJsonValue, +} from '@features/coordination-events/contracts'; + +export interface StoredEventJournalMetadata { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly retentionFloorSequence: number; + readonly highWatermarkSequence: number; +} + +export interface StoredCoordinationEventRow { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly eventSequence: number; + readonly eventId: string; + readonly bodyJson: string; +} + +export interface CoordinationDrainStorageEvidence { + readonly backupRunId: string; + readonly fenceGeneration: number; + readonly throughCommandSequence: number; + readonly throughEventSequence: number; + readonly eventEpoch: string; + readonly durableBarrier: string; +} + +export interface SqliteOnlineBackupStorageResult { + readonly status: 'completed' | 'busy_timeout' | 'deadline_exceeded' | 'source_corrupt'; + readonly applicationId?: number; + readonly userVersion?: number; + readonly byteLength?: number; + readonly mode?: number; + readonly sha256?: string; +} + +export interface SqliteBackupChunkStorageResult { + readonly offset: number; + readonly totalByteLength: number; + readonly bytes: Uint8Array; + readonly eof: boolean; +} + +export type SqliteSnapshotVerificationStorageResult = + | { + readonly status: 'valid'; + readonly applicationId: number; + readonly userVersion: number; + readonly requiredTables: readonly string[]; + } + | { + readonly status: 'invalid'; + readonly reason: + | 'integrity_check_failed' + | 'application_id_mismatch' + | 'schema_mismatch' + | 'migration_incomplete' + | 'required_identity_missing'; + }; + +/** + * Main-process capability over the one internal-storage worker. Snapshot + * staging uses backup-run-bound chunks; no filesystem destination capability + * crosses this interface or the worker protocol. + */ +export interface CoordinationDurabilityStorageGateway { + coordinationEventInitialize(input: { + readonly deploymentId: string; + readonly eventEpoch?: string; + readonly nowIso: string; + }): Promise; + coordinationEventGetWatermark(deploymentId: string): Promise; + coordinationEventRead(input: { + readonly deploymentId: string; + readonly afterSequence: number; + readonly throughSequence: number; + readonly limit: number; + }): Promise<{ + readonly rows: readonly StoredCoordinationEventRow[]; + readonly watermark: StoredEventJournalMetadata; + }>; + coordinationEventAppend(input: { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly draft: CoordinationEventDraft; + readonly bodyJson: string; + readonly nowIso: string; + }): Promise<{ + readonly row: StoredCoordinationEventRow; + readonly watermark: StoredEventJournalMetadata; + }>; + coordinationEventPrune(input: { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly throughSequence: number; + readonly nowIso: string; + }): Promise; + coordinationBackupRunCreate(record: BackupRunRecord): Promise; + coordinationBackupRunGet(backupRunId: string): Promise; + coordinationBackupRunListRecoverable(): Promise; + coordinationBackupRunCompareAndSet(input: { + readonly backupRunId: string; + readonly expectedRevision: number; + readonly expectedState: BackupRunState; + readonly record: BackupRunRecord; + }): Promise; + coordinationBackupFenceAcquire(input: { + readonly deploymentId: string; + readonly backupRunId: string; + readonly expectedGeneration: number | null; + readonly leaseId: string; + readonly acquiredAt: string; + }): Promise< + | { readonly status: 'acquired'; readonly generation: number; readonly leaseId: string } + | { readonly status: 'busy'; readonly activeRunId: string } + >; + coordinationBackupFenceComplete(input: { + readonly deploymentId: string; + readonly backupRunId: string; + readonly generation: number; + readonly leaseId: string; + readonly disposition: BackupFenceCompletionDisposition; + readonly completedAt: string; + }): Promise; + coordinationBackupDrain(input: { + readonly deploymentId: string; + readonly backupRunId: string; + readonly fenceGeneration: number; + }): Promise; + coordinationBackupCapture(input: { + readonly deploymentId: string; + readonly evidence: CoordinationDrainStorageEvidence; + }): Promise; + coordinationBackupSqliteOnline(input: { + readonly backupRunId: string; + readonly deadlineAtMs: number; + readonly busyRetryMs: number; + readonly pagesPerStep: number; + }): Promise; + coordinationBackupSqliteVerify(input: { + readonly backupRunId: string; + }): Promise; + coordinationBackupSqliteReadChunk(input: { + readonly backupRunId: string; + readonly offset: number; + readonly maximumBytes: number; + }): Promise; + coordinationBackupSqliteDiscard(backupRunId: string): Promise; +} diff --git a/src/features/internal-storage/main/application/hostedAuthorityProjectionStorage.ts b/src/features/internal-storage/main/application/hostedAuthorityProjectionStorage.ts new file mode 100644 index 0000000000..40a9c14924 --- /dev/null +++ b/src/features/internal-storage/main/application/hostedAuthorityProjectionStorage.ts @@ -0,0 +1,452 @@ +import { stableJsonStringify } from '@features/application-command-ledger'; + +import type { + HostedAuthorityProjectionCommitResult, + HostedAuthorityProjectionPersistRequest, + HostedAuthorityProjectionReadRequest, + HostedAuthorityProjectionReceiptRecord, + HostedAuthorityProjectionRecord, +} from '@features/application-command-ledger'; + +const MAX_IDENTIFIER_LENGTH = 512; +const MAX_IDEMPOTENCY_KEY_LENGTH = 1_024; +const MAX_JSON_BYTES = 64 * 1_024; +export const HOSTED_AUTHORITY_PROJECTION_RECEIPT_CODEC_VERSION = 1 as const; + +type UnknownRecord = Record; + +export interface HostedAuthorityProjectionReceiptEnvelope { + readonly codecVersion: typeof HOSTED_AUTHORITY_PROJECTION_RECEIPT_CODEC_VERSION; + readonly deploymentId: string; + readonly commandId: string; + readonly projectionKind: string; + readonly projectionKey: string; + readonly expectedGeneration: number; + readonly expectedRevision: number; + readonly generation: number; + readonly revision: number; + readonly stateJson: string; + readonly eventId: string; + readonly receiptJson: string; + readonly committedAt: string; +} + +export function parseHostedAuthorityProjectionPersistRequest( + value: unknown +): HostedAuthorityProjectionPersistRequest { + const input = exactRecord( + value, + [ + 'commandId', + 'scope', + 'fingerprint', + 'auditSessionId', + 'projection', + 'receiptJson', + 'outbox', + 'attribution', + 'committedAtIso', + 'deadlineAtMs', + 'descriptor', + 'retentionClass', + 'effectPlan', + ], + 'request' + ); + boundedIdentifier(input.commandId, 'command-id'); + boundedIdentifier(input.retentionClass, 'retention-class'); + if (input.auditSessionId !== null) boundedIdentifier(input.auditSessionId, 'audit-session-id'); + finiteTimestamp(input.committedAtIso, 'committed-at'); + positiveInteger(input.deadlineAtMs, 'deadline'); + + const scope = exactRecord( + input.scope, + ['deploymentId', 'stableActorId', 'commandKind', 'idempotencyKey'], + 'scope' + ); + boundedIdentifier(scope.deploymentId, 'deployment-id'); + boundedIdentifier(scope.stableActorId, 'stable-actor-id'); + boundedIdentifier(scope.commandKind, 'command-kind'); + boundedIdentifier(scope.idempotencyKey, 'idempotency-key', MAX_IDEMPOTENCY_KEY_LENGTH); + + const fingerprint = exactRecord( + input.fingerprint, + [ + 'descriptorId', + 'descriptorVersion', + 'schemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + 'keyVersion', + 'digest', + ], + 'fingerprint' + ); + boundedIdentifier(fingerprint.descriptorId, 'fingerprint-descriptor-id'); + positiveInteger(fingerprint.descriptorVersion, 'fingerprint-descriptor-version'); + positiveInteger(fingerprint.schemaVersion, 'fingerprint-schema-version'); + boundedIdentifier(fingerprint.fingerprintVersion, 'fingerprint-version'); + positiveInteger(fingerprint.effectPlanVersion, 'fingerprint-effect-plan-version'); + boundedIdentifier(fingerprint.keyVersion, 'fingerprint-key-version'); + if (typeof fingerprint.digest !== 'string' || !/^[a-f0-9]{64}$/.test(fingerprint.digest)) { + throw new TypeError('hosted-authority-projection-fingerprint-digest-invalid'); + } + + const descriptor = exactRecord( + input.descriptor, + [ + 'descriptorId', + 'descriptorVersion', + 'commandKind', + 'inputSchemaVersion', + 'fingerprintVersion', + 'effectPlanVersion', + ], + 'descriptor' + ); + boundedIdentifier(descriptor.descriptorId, 'descriptor-id'); + positiveInteger(descriptor.descriptorVersion, 'descriptor-version'); + boundedIdentifier(descriptor.commandKind, 'descriptor-command-kind'); + positiveInteger(descriptor.inputSchemaVersion, 'descriptor-input-schema-version'); + boundedIdentifier(descriptor.fingerprintVersion, 'descriptor-fingerprint-version'); + positiveInteger(descriptor.effectPlanVersion, 'descriptor-effect-plan-version'); + + if (!Array.isArray(input.effectPlan) || input.effectPlan.length === 0) { + throw new TypeError('hosted-authority-projection-effect-plan-invalid'); + } + input.effectPlan.forEach((effect, ordinal) => { + const parsed = exactRecord( + effect, + ['effectId', 'effectVersion', 'recoveryClass', 'evidenceSchemaVersion', 'ordinal', 'state'], + `effect-${ordinal}` + ); + boundedIdentifier(parsed.effectId, `effect-${ordinal}-id`); + positiveInteger(parsed.effectVersion, `effect-${ordinal}-version`); + boundedIdentifier(parsed.recoveryClass, `effect-${ordinal}-recovery-class`); + positiveInteger(parsed.evidenceSchemaVersion, `effect-${ordinal}-evidence-schema-version`); + nonNegativeInteger(parsed.ordinal, `effect-${ordinal}-ordinal`); + boundedIdentifier(parsed.state, `effect-${ordinal}-state`); + }); + + parseProjectionMutation(input.projection); + canonicalJson(input.receiptJson, 'receipt-json'); + parseOutbox(input.outbox, input.projection, input.committedAtIso); + parseAttribution(input.attribution); + return value as HostedAuthorityProjectionPersistRequest; +} + +export function parseHostedAuthorityProjectionReadRequest( + value: unknown +): HostedAuthorityProjectionReadRequest { + const input = exactRecord( + value, + ['deploymentId', 'projectionKind', 'projectionKey', 'deadlineAtMs'], + 'read-request' + ); + boundedIdentifier(input.deploymentId, 'deployment-id'); + boundedIdentifier(input.projectionKind, 'projection-kind'); + boundedIdentifier(input.projectionKey, 'projection-key'); + positiveInteger(input.deadlineAtMs, 'deadline'); + return value as HostedAuthorityProjectionReadRequest; +} + +export function parseHostedAuthorityProjectionRecord( + value: unknown +): HostedAuthorityProjectionRecord { + const record = exactRecord( + value, + [ + 'deploymentId', + 'projectionKind', + 'projectionKey', + 'generation', + 'revision', + 'stateJson', + 'lastCommandId', + 'updatedAt', + ], + 'record' + ); + boundedIdentifier(record.deploymentId, 'deployment-id'); + boundedIdentifier(record.projectionKind, 'projection-kind'); + boundedIdentifier(record.projectionKey, 'projection-key'); + positiveInteger(record.generation, 'generation'); + positiveInteger(record.revision, 'revision'); + canonicalJson(record.stateJson, 'state-json'); + boundedIdentifier(record.lastCommandId, 'last-command-id'); + finiteTimestamp(record.updatedAt, 'updated-at'); + return Object.freeze({ ...record }) as unknown as HostedAuthorityProjectionRecord; +} + +export function parseHostedAuthorityProjectionCommitResult( + value: unknown +): HostedAuthorityProjectionCommitResult { + const result = looseRecord(value, 'result'); + if (result.outcome === 'fingerprint_conflict') { + exactKeys(result, ['outcome'], 'result'); + return Object.freeze({ outcome: 'fingerprint_conflict' }); + } + if (result.outcome === 'stale_generation' || result.outcome === 'stale_revision') { + exactKeys(result, ['outcome', 'currentGeneration', 'currentRevision'], 'result'); + nonNegativeInteger(result.currentGeneration, 'current-generation'); + nonNegativeInteger(result.currentRevision, 'current-revision'); + return Object.freeze({ + outcome: result.outcome, + currentGeneration: result.currentGeneration, + currentRevision: result.currentRevision, + }); + } + if (result.outcome !== 'committed' && result.outcome !== 'idempotent_replay') { + throw new TypeError('hosted-authority-projection-result-outcome-invalid'); + } + exactKeys(result, ['outcome', 'projection', 'receipt'], 'result'); + return Object.freeze({ + outcome: result.outcome, + projection: parseHostedAuthorityProjectionRecord(result.projection), + receipt: parseHostedAuthorityProjectionReceiptRecord(result.receipt), + }); +} + +export function encodeHostedAuthorityProjectionReceiptEnvelope( + value: HostedAuthorityProjectionReceiptEnvelope +): string { + return stableJsonStringify(value); +} + +export function parseHostedAuthorityProjectionReceiptEnvelope( + value: unknown +): HostedAuthorityProjectionReceiptEnvelope { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_JSON_BYTES) { + throw new TypeError('hosted-authority-projection-receipt-envelope-invalid'); + } + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch { + throw new TypeError('hosted-authority-projection-receipt-envelope-invalid'); + } + const envelope = exactRecord( + parsed, + [ + 'codecVersion', + 'deploymentId', + 'commandId', + 'projectionKind', + 'projectionKey', + 'expectedGeneration', + 'expectedRevision', + 'generation', + 'revision', + 'stateJson', + 'eventId', + 'receiptJson', + 'committedAt', + ], + 'receipt-envelope' + ); + if (envelope.codecVersion !== HOSTED_AUTHORITY_PROJECTION_RECEIPT_CODEC_VERSION) { + throw new TypeError('hosted-authority-projection-receipt-codec-invalid'); + } + boundedIdentifier(envelope.deploymentId, 'deployment-id'); + boundedIdentifier(envelope.commandId, 'command-id'); + boundedIdentifier(envelope.projectionKind, 'projection-kind'); + boundedIdentifier(envelope.projectionKey, 'projection-key'); + positiveInteger(envelope.expectedGeneration, 'expected-generation'); + nonNegativeInteger(envelope.expectedRevision, 'expected-revision'); + positiveInteger(envelope.generation, 'generation'); + positiveInteger(envelope.revision, 'revision'); + canonicalJson(envelope.stateJson, 'state-json'); + boundedIdentifier(envelope.eventId, 'event-id'); + canonicalJson(envelope.receiptJson, 'receipt-json'); + finiteTimestamp(envelope.committedAt, 'committed-at'); + if (stableJsonStringify(envelope) !== value) { + throw new TypeError('hosted-authority-projection-receipt-envelope-noncanonical'); + } + return Object.freeze({ ...envelope }) as unknown as HostedAuthorityProjectionReceiptEnvelope; +} + +function parseProjectionMutation(value: unknown): void { + const projection = exactRecord( + value, + [ + 'projectionKind', + 'projectionKey', + 'expectedGeneration', + 'expectedRevision', + 'nextGeneration', + 'nextRevision', + 'stateJson', + ], + 'projection' + ); + boundedIdentifier(projection.projectionKind, 'projection-kind'); + boundedIdentifier(projection.projectionKey, 'projection-key'); + positiveInteger(projection.expectedGeneration, 'expected-generation'); + nonNegativeInteger(projection.expectedRevision, 'expected-revision'); + positiveInteger(projection.nextGeneration, 'next-generation'); + positiveInteger(projection.nextRevision, 'next-revision'); + if ( + projection.nextRevision !== projection.expectedRevision + 1 || + (projection.nextGeneration !== projection.expectedGeneration && + projection.nextGeneration !== projection.expectedGeneration + 1) + ) { + throw new TypeError('hosted-authority-projection-monotonicity-invalid'); + } + canonicalJson(projection.stateJson, 'state-json'); +} + +function parseOutbox(outboxValue: unknown, projectionValue: unknown, committedAt: unknown): void { + const outbox = exactRecord( + outboxValue, + [ + 'eventId', + 'eventType', + 'scopeKind', + 'scopeId', + 'schemaVersion', + 'semanticRevision', + 'payloadJson', + 'createdAtIso', + ], + 'outbox' + ); + boundedIdentifier(outbox.eventId, 'event-id'); + boundedIdentifier(outbox.eventType, 'event-type'); + boundedIdentifier(outbox.scopeKind, 'scope-kind'); + boundedIdentifier(outbox.scopeId, 'scope-id'); + positiveInteger(outbox.schemaVersion, 'event-schema-version'); + positiveInteger(outbox.semanticRevision, 'semantic-revision'); + canonicalJson(outbox.payloadJson, 'event-payload-json'); + finiteTimestamp(outbox.createdAtIso, 'event-created-at'); + const projection = projectionValue as { readonly nextRevision: number }; + if (outbox.semanticRevision !== projection.nextRevision || outbox.createdAtIso !== committedAt) { + throw new TypeError('hosted-authority-projection-outbox-binding-invalid'); + } +} + +function parseAttribution(value: unknown): void { + const attribution = exactRecord(value, ['actor'], 'attribution'); + const actor = looseRecord(attribution.actor, 'actor'); + if (actor.kind === 'operator' || actor.kind === 'recovery') { + exactKeys(actor, ['kind', 'actorRef'], 'actor'); + } else if (actor.kind === 'verified_runtime') { + exactKeys( + actor, + actor.memberId === undefined + ? ['kind', 'actorRef', 'runId'] + : ['kind', 'actorRef', 'runId', 'memberId'], + 'actor' + ); + boundedIdentifier(actor.runId, 'actor-run-id'); + if (actor.memberId !== undefined) boundedIdentifier(actor.memberId, 'actor-member-id'); + } else { + throw new TypeError('hosted-authority-projection-actor-kind-invalid'); + } + boundedIdentifier(actor.actorRef, 'actor-ref'); +} + +function parseHostedAuthorityProjectionReceiptRecord( + value: unknown +): HostedAuthorityProjectionReceiptRecord { + const receipt = exactRecord( + value, + [ + 'deploymentId', + 'projectionKind', + 'projectionKey', + 'commandId', + 'generation', + 'revision', + 'eventId', + 'receiptJson', + 'committedAt', + ], + 'receipt' + ); + boundedIdentifier(receipt.deploymentId, 'deployment-id'); + boundedIdentifier(receipt.projectionKind, 'projection-kind'); + boundedIdentifier(receipt.projectionKey, 'projection-key'); + boundedIdentifier(receipt.commandId, 'command-id'); + positiveInteger(receipt.generation, 'generation'); + positiveInteger(receipt.revision, 'revision'); + boundedIdentifier(receipt.eventId, 'event-id'); + canonicalJson(receipt.receiptJson, 'receipt-json'); + finiteTimestamp(receipt.committedAt, 'committed-at'); + return Object.freeze({ ...receipt }) as unknown as HostedAuthorityProjectionReceiptRecord; +} + +function canonicalJson(value: unknown, reason: string): void { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_JSON_BYTES) { + throw new TypeError(`hosted-authority-projection-${reason}-invalid`); + } + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch { + throw new TypeError(`hosted-authority-projection-${reason}-invalid`); + } + if (stableJsonStringify(parsed) !== value) { + throw new TypeError(`hosted-authority-projection-${reason}-noncanonical`); + } +} + +function boundedIdentifier(value: unknown, reason: string, maximum = MAX_IDENTIFIER_LENGTH): void { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximum || + value.trim() !== value || + value.includes('\0') + ) { + throw new TypeError(`hosted-authority-projection-${reason}-invalid`); + } +} + +function finiteTimestamp(value: unknown, reason: string): void { + boundedIdentifier(value, reason); + if (!Number.isFinite(Date.parse(value as string))) { + throw new TypeError(`hosted-authority-projection-${reason}-invalid`); + } +} + +function positiveInteger(value: unknown, reason: string): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new TypeError(`hosted-authority-projection-${reason}-invalid`); + } +} + +function nonNegativeInteger(value: unknown, reason: string): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`hosted-authority-projection-${reason}-invalid`); + } +} + +function exactRecord(value: unknown, keys: readonly string[], reason: string): UnknownRecord { + const record = looseRecord(value, reason); + exactKeys(record, keys, reason); + return record; +} + +function exactKeys(record: UnknownRecord, keys: readonly string[], reason: string): void { + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new TypeError(`hosted-authority-projection-${reason}-fields-invalid`); + } +} + +function looseRecord(value: unknown, reason: string): UnknownRecord { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Reflect.ownKeys(value).some((key) => typeof key !== 'string') || + Object.values(Object.getOwnPropertyDescriptors(value)).some( + (descriptor) => !descriptor.enumerable || !('value' in descriptor) + ) + ) { + throw new TypeError(`hosted-authority-projection-${reason}-invalid`); + } + return value as UnknownRecord; +} diff --git a/src/features/internal-storage/main/application/hostedTeamApprovalAuthorityStorage.ts b/src/features/internal-storage/main/application/hostedTeamApprovalAuthorityStorage.ts new file mode 100644 index 0000000000..6f0bbbdb39 --- /dev/null +++ b/src/features/internal-storage/main/application/hostedTeamApprovalAuthorityStorage.ts @@ -0,0 +1,783 @@ +import { RUNTIME_PERMISSION_APPROVAL_SUMMARY_MAXIMUM_BYTES } from '@features/team-runtime-control/contracts'; +import { + parseActorId, + parseSessionId, + parseTeamId, + parseWorkspaceId, +} from '@shared/contracts/hosted'; + +import type { + HostedTeamApprovalAuthorityScope, + HostedTeamApprovalDecisionStorageRequest, + HostedTeamApprovalDeliveryAcknowledgeRequest, + HostedTeamApprovalDeliveryClaimRequest, + HostedTeamApprovalDeliveryRecord, + HostedTeamApprovalPendingReadRecord, + HostedTeamApprovalPendingReadRequest, + HostedTeamApprovalPendingReadResult, + HostedTeamApprovalPendingStorageRecord, + HostedTeamApprovalPreviewReadRequest, + HostedTeamApprovalPreviewReadResult, + HostedTeamApprovalPreviewStorageRecord, + HostedTeamApprovalStorageDecision, + HostedTeamApprovalTimeoutAuditRequest, +} from '../../contracts/hostedTeamApprovalAuthorityStorageContracts'; + +export { + hashHostedTeamApprovalDecision, + hashHostedTeamApprovalGeneration, + hashHostedTeamApprovalIdentity, + hashHostedTeamApprovalTimeout, + parseHostedTeamApprovalDecisionStorageResult, + parseHostedTeamApprovalDeliveryReconciliationReadResult, + parseHostedTeamApprovalTimeoutAuditResult, + parseHostedTeamApprovalVoidResult, + serializeHostedTeamApprovalDeliveryIntent, +} from './hostedTeamApprovalAuthorityStorageOutputs'; + +const MAX_PREVIEW_BYTES = 64 * 1024; +const MAX_DELIVERY_BATCH = 50; +const MAX_DELIVERY_LEASE_MS = 5 * 60 * 1000; +const MAX_IDENTIFIER_LENGTH = 256; +const MAX_IDEMPOTENCY_KEY_LENGTH = 128; +const MAX_PAYLOAD_HASH_LENGTH = 64; +const APPROVAL_ID = /^approval_[0-9a-f]{32}$/; +const GENERATION = /^generation_[A-Za-z0-9][A-Za-z0-9._-]{0,245}$/; +const PREVIEW_REF = /^approval_preview_[A-Za-z0-9][A-Za-z0-9._-]{0,239}$/; +const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const AUDIT_ID = /^approval_audit_[A-Za-z0-9][A-Za-z0-9._-]{0,239}$/; +const DELIVERY_ID = /^approval_delivery_[A-Za-z0-9][A-Za-z0-9._-]{0,231}$/; +const DELIVERY_REF = /^delivery_ref_[A-Za-z0-9][A-Za-z0-9._-]{0,239}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const CATEGORIES = new Set(['file_change', 'command', 'network', 'other']); +const STORED_DECISIONS = new Set(['allow', 'deny', 'timeout']); +const HOST_PATH = + /(?:^|[\s"'`(])(?:~[\\/]|[A-Za-z]:[\\/]|\/(?:Users|home|var|tmp|etc|private|mnt|Volumes|opt)\/)/; + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function exactRecord(value: unknown, keys: readonly string[], label: string): UnknownRecord { + if (!isRecord(value)) throw new TypeError(`${label}-invalid`); + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key)) || + !keys.every((key) => Object.hasOwn(value, key)) + ) { + throw new TypeError(`${label}-invalid`); + } + return value; +} + +function finiteNonNegative(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${label}-invalid`); + } + return value as number; +} + +function positive(value: unknown, label: string): number { + const parsed = finiteNonNegative(value, label); + if (parsed < 1) throw new TypeError(`${label}-invalid`); + return parsed; +} + +function identifier(value: unknown, label: string, maximum = MAX_IDENTIFIER_LENGTH): string { + if ( + typeof value !== 'string' || + value.length < 1 || + new TextEncoder().encode(value).byteLength > maximum || + value.trim() !== value || + value.includes('\0') || + HOST_PATH.test(value) + ) { + throw new TypeError(`${label}-invalid`); + } + return value; +} + +function safeText( + value: unknown, + label: string, + maximum: number, + allowNewlines: boolean, + allowEmpty = false +): string { + if ( + typeof value !== 'string' || + (!allowEmpty && value.length < 1) || + new TextEncoder().encode(value).byteLength > maximum || + HOST_PATH.test(value) + ) { + throw new TypeError(`${label}-invalid`); + } + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.charCodeAt(index); + const allowed = codePoint === 9 || (allowNewlines && (codePoint === 10 || codePoint === 13)); + if ((codePoint <= 31 && !allowed) || codePoint === 127) { + throw new TypeError(`${label}-invalid`); + } + } + return value; +} + +function approvalId(value: unknown): string { + if (typeof value !== 'string' || !APPROVAL_ID.test(value)) { + throw new TypeError('hosted-team-approval-storage-approval-id-invalid'); + } + return value; +} + +function generation(value: unknown, label: string): string { + if (typeof value !== 'string' || !GENERATION.test(value)) { + throw new TypeError(`${label}-invalid`); + } + return value; +} + +function storedDecision(value: unknown): HostedTeamApprovalStorageDecision { + if (!STORED_DECISIONS.has(value as HostedTeamApprovalStorageDecision)) { + throw new TypeError('hosted-team-approval-storage-decision-invalid'); + } + return value as HostedTeamApprovalStorageDecision; +} + +function browserDecision(value: unknown): 'allow' | 'deny' { + if (value !== 'allow' && value !== 'deny') { + throw new TypeError('hosted-team-approval-storage-decision-invalid'); + } + return value; +} + +function previewRef(value: unknown): string { + if (typeof value !== 'string' || !PREVIEW_REF.test(value)) { + throw new TypeError('hosted-team-approval-storage-preview-ref-invalid'); + } + return value; +} + +function payloadHash(value: unknown): string { + if ( + typeof value !== 'string' || + value.length !== MAX_PAYLOAD_HASH_LENGTH || + !SHA256.test(value) + ) { + throw new TypeError('hosted-team-approval-storage-payload-hash-invalid'); + } + return value; +} + +export function parseHostedTeamApprovalAuthorityScope( + value: unknown +): HostedTeamApprovalAuthorityScope { + const input = exactRecord( + value, + ['principalId', 'workspaceId', 'teamId', 'authorityGeneration', 'restoreGeneration'], + 'hosted-team-approval-storage-scope' + ); + return Object.freeze({ + principalId: parseActorId(input.principalId), + workspaceId: parseWorkspaceId(input.workspaceId), + teamId: parseTeamId(input.teamId), + authorityGeneration: generation( + input.authorityGeneration, + 'hosted-team-approval-storage-authority-generation' + ), + restoreGeneration: finiteNonNegative( + input.restoreGeneration, + 'hosted-team-approval-storage-restore-generation' + ), + }); +} + +export function parseHostedTeamApprovalPreviewStorageRecord( + value: unknown +): HostedTeamApprovalPreviewStorageRecord { + const input = exactRecord( + value, + ['previewRef', 'content', 'byteLength', 'truncated', 'isBinary'], + 'hosted-team-approval-storage-preview' + ); + const content = safeText( + input.content, + 'hosted-team-approval-storage-preview-content', + MAX_PREVIEW_BYTES, + true, + true + ); + const byteLength = finiteNonNegative( + input.byteLength, + 'hosted-team-approval-storage-preview-byte-length' + ); + if ( + byteLength > MAX_PREVIEW_BYTES || + typeof input.truncated !== 'boolean' || + typeof input.isBinary !== 'boolean' || + new TextEncoder().encode(content).byteLength > byteLength || + (input.isBinary && content !== '') + ) { + throw new TypeError('hosted-team-approval-storage-preview-invalid'); + } + return Object.freeze({ + previewRef: previewRef(input.previewRef), + content, + byteLength, + truncated: input.truncated, + isBinary: input.isBinary, + }); +} + +export function parseHostedTeamApprovalPendingStorageRecord( + value: unknown +): HostedTeamApprovalPendingStorageRecord { + const input = exactRecord( + value, + [ + 'scope', + 'runId', + 'requestId', + 'approvalId', + 'approvalGeneration', + 'category', + 'summary', + 'requestedAtMs', + 'expiresAtMs', + 'preview', + 'deliveryRef', + 'observedAtMs', + 'deadlineAtMs', + ], + 'hosted-team-approval-storage-pending' + ); + const requestedAtMs = finiteNonNegative( + input.requestedAtMs, + 'hosted-team-approval-storage-requested-at' + ); + const expiresAtMs = + input.expiresAtMs === null + ? null + : finiteNonNegative(input.expiresAtMs, 'hosted-team-approval-storage-expires-at'); + if ( + (expiresAtMs !== null && expiresAtMs <= requestedAtMs) || + !Number.isSafeInteger(input.observedAtMs) || + (input.observedAtMs as number) < requestedAtMs || + !CATEGORIES.has(input.category as string) || + typeof input.deliveryRef !== 'string' || + !DELIVERY_REF.test(input.deliveryRef) + ) { + throw new TypeError('hosted-team-approval-storage-pending-invalid'); + } + return Object.freeze({ + scope: parseHostedTeamApprovalAuthorityScope(input.scope), + runId: identifier(input.runId, 'hosted-team-approval-storage-run-id'), + requestId: identifier(input.requestId, 'hosted-team-approval-storage-request-id'), + approvalId: approvalId(input.approvalId), + approvalGeneration: generation( + input.approvalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + category: input.category as HostedTeamApprovalPendingStorageRecord['category'], + summary: safeText( + input.summary, + 'hosted-team-approval-storage-summary', + RUNTIME_PERMISSION_APPROVAL_SUMMARY_MAXIMUM_BYTES, + false + ), + requestedAtMs, + expiresAtMs, + preview: + input.preview === null ? null : parseHostedTeamApprovalPreviewStorageRecord(input.preview), + deliveryRef: input.deliveryRef, + observedAtMs: finiteNonNegative(input.observedAtMs, 'hosted-team-approval-storage-observed-at'), + deadlineAtMs: positive(input.deadlineAtMs, 'hosted-team-approval-storage-deadline'), + }); +} + +export function parseHostedTeamApprovalPendingReadRequest( + value: unknown +): HostedTeamApprovalPendingReadRequest { + const input = exactRecord( + value, + [ + 'scope', + 'expectedRunId', + 'afterApprovalId', + 'afterApprovalGenerationHash', + 'limit', + 'deadlineAtMs', + ], + 'hosted-team-approval-storage-pending-read-request' + ); + const limit = positive(input.limit, 'hosted-team-approval-storage-pending-read-limit'); + const afterApprovalId = input.afterApprovalId === null ? null : approvalId(input.afterApprovalId); + const afterApprovalGenerationHash = + input.afterApprovalGenerationHash === null + ? null + : payloadHash(input.afterApprovalGenerationHash); + if ( + limit > MAX_DELIVERY_BATCH + 1 || + (afterApprovalId === null) !== (afterApprovalGenerationHash === null) + ) { + throw new TypeError('hosted-team-approval-storage-pending-read-limit-invalid'); + } + return Object.freeze({ + scope: parseHostedTeamApprovalAuthorityScope(input.scope), + expectedRunId: identifier(input.expectedRunId, 'hosted-team-approval-storage-run-id'), + afterApprovalId, + afterApprovalGenerationHash, + limit, + deadlineAtMs: positive(input.deadlineAtMs, 'hosted-team-approval-storage-deadline'), + }); +} + +export function parseHostedTeamApprovalPendingReadRecord( + value: unknown +): HostedTeamApprovalPendingReadRecord { + const input = exactRecord( + value, + [ + 'runId', + 'requestId', + 'approvalId', + 'approvalGeneration', + 'category', + 'summary', + 'requestedAtMs', + 'expiresAtMs', + 'previewRef', + ], + 'hosted-team-approval-storage-pending-read-record' + ); + const requestedAtMs = finiteNonNegative( + input.requestedAtMs, + 'hosted-team-approval-storage-requested-at' + ); + const expiresAtMs = + input.expiresAtMs === null + ? null + : finiteNonNegative(input.expiresAtMs, 'hosted-team-approval-storage-expires-at'); + if ( + (expiresAtMs !== null && expiresAtMs <= requestedAtMs) || + !CATEGORIES.has(input.category as string) + ) { + throw new TypeError('hosted-team-approval-storage-pending-read-record-invalid'); + } + return Object.freeze({ + runId: identifier(input.runId, 'hosted-team-approval-storage-run-id'), + requestId: identifier(input.requestId, 'hosted-team-approval-storage-request-id'), + approvalId: approvalId(input.approvalId), + approvalGeneration: generation( + input.approvalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + category: input.category as HostedTeamApprovalPendingReadRecord['category'], + summary: safeText( + input.summary, + 'hosted-team-approval-storage-summary', + RUNTIME_PERMISSION_APPROVAL_SUMMARY_MAXIMUM_BYTES, + false + ), + requestedAtMs, + expiresAtMs, + previewRef: input.previewRef === null ? null : previewRef(input.previewRef), + }); +} + +export function parseHostedTeamApprovalPendingReadResult( + value: unknown +): HostedTeamApprovalPendingReadResult { + const input = exactRecord( + value, + ['records', 'hasMore'], + 'hosted-team-approval-storage-pending-read-result' + ); + if ( + !Array.isArray(input.records) || + input.records.length > MAX_DELIVERY_BATCH + 1 || + typeof input.hasMore !== 'boolean' + ) { + throw new TypeError('hosted-team-approval-storage-pending-read-result-invalid'); + } + const records = input.records.map(parseHostedTeamApprovalPendingReadRecord); + const ids = new Set(records.map((record) => record.approvalId)); + if (ids.size !== records.length) { + throw new TypeError('hosted-team-approval-storage-pending-read-result-invalid'); + } + return Object.freeze({ records: Object.freeze(records), hasMore: input.hasMore }); +} + +export function parseHostedTeamApprovalPreviewReadRequest( + value: unknown +): HostedTeamApprovalPreviewReadRequest { + const input = exactRecord( + value, + [ + 'scope', + 'expectedRunId', + 'approvalId', + 'expectedApprovalGeneration', + 'previewRef', + 'deadlineAtMs', + ], + 'hosted-team-approval-storage-preview-read-request' + ); + return Object.freeze({ + scope: parseHostedTeamApprovalAuthorityScope(input.scope), + expectedRunId: identifier(input.expectedRunId, 'hosted-team-approval-storage-run-id'), + approvalId: approvalId(input.approvalId), + expectedApprovalGeneration: generation( + input.expectedApprovalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + previewRef: previewRef(input.previewRef), + deadlineAtMs: positive(input.deadlineAtMs, 'hosted-team-approval-storage-deadline'), + }); +} + +export function parseHostedTeamApprovalPreviewReadResult( + value: unknown +): HostedTeamApprovalPreviewReadResult { + if (!isRecord(value)) { + throw new TypeError('hosted-team-approval-storage-preview-read-result-invalid'); + } + const input = exactRecord( + value, + Object.hasOwn(value, 'kind') && value.kind === 'found' + ? ['kind', 'preview'] + : Object.hasOwn(value, 'kind') && value.kind === 'stale_generation' + ? ['kind', 'currentApprovalGeneration'] + : ['kind'], + 'hosted-team-approval-storage-preview-read-result' + ); + if (input.kind === 'found') { + return Object.freeze({ + kind: 'found', + preview: parseHostedTeamApprovalPreviewStorageRecord(input.preview), + }); + } + if (input.kind === 'stale_generation') { + return Object.freeze({ + kind: 'stale_generation', + currentApprovalGeneration: generation( + input.currentApprovalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + }); + } + if (input.kind === 'not_found') return Object.freeze({ kind: 'not_found' }); + throw new TypeError('hosted-team-approval-storage-preview-read-result-invalid'); +} + +export function parseHostedTeamApprovalDecisionStorageRequest( + value: unknown +): HostedTeamApprovalDecisionStorageRequest { + const input = exactRecord( + value, + [ + 'scope', + 'expectedRunId', + 'approvalId', + 'expectedApprovalGeneration', + 'idempotencyKey', + 'decision', + 'payloadHash', + 'audit', + 'delivery', + 'deadlineAtMs', + ], + 'hosted-team-approval-storage-decision-request' + ); + if ( + typeof input.idempotencyKey !== 'string' || + input.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH || + !IDEMPOTENCY_KEY.test(input.idempotencyKey) + ) { + throw new TypeError('hosted-team-approval-storage-idempotency-key-invalid'); + } + const scope = parseHostedTeamApprovalAuthorityScope(input.scope); + const audit = exactRecord( + input.audit, + ['auditId', 'principalId', 'sessionId'], + 'hosted-team-approval-storage-audit' + ); + if ( + typeof audit.auditId !== 'string' || + !AUDIT_ID.test(audit.auditId) || + audit.principalId !== scope.principalId + ) { + throw new TypeError('hosted-team-approval-storage-audit-invalid'); + } + const delivery = exactRecord( + input.delivery, + ['deliveryId'], + 'hosted-team-approval-storage-delivery' + ); + if (typeof delivery.deliveryId !== 'string' || !DELIVERY_ID.test(delivery.deliveryId)) { + throw new TypeError('hosted-team-approval-storage-delivery-invalid'); + } + return Object.freeze({ + scope, + expectedRunId: identifier(input.expectedRunId, 'hosted-team-approval-storage-run-id'), + approvalId: approvalId(input.approvalId), + expectedApprovalGeneration: generation( + input.expectedApprovalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + idempotencyKey: input.idempotencyKey, + decision: browserDecision(input.decision), + payloadHash: payloadHash(input.payloadHash), + audit: Object.freeze({ + auditId: audit.auditId, + principalId: scope.principalId, + sessionId: parseSessionId(audit.sessionId), + }), + delivery: Object.freeze({ + deliveryId: delivery.deliveryId, + }), + deadlineAtMs: positive(input.deadlineAtMs, 'hosted-team-approval-storage-deadline'), + }); +} + +export function parseHostedTeamApprovalDeliveryClaimRequest( + value: unknown +): HostedTeamApprovalDeliveryClaimRequest { + const input = exactRecord( + value, + [ + 'workspaceId', + 'teamId', + 'authorityGeneration', + 'restoreGeneration', + 'ownerId', + 'leaseToken', + 'leaseDurationMs', + 'limit', + 'deadlineAtMs', + ], + 'hosted-team-approval-storage-delivery-claim-request' + ); + const leaseDurationMs = positive( + input.leaseDurationMs, + 'hosted-team-approval-storage-delivery-lease-duration' + ); + const limit = positive(input.limit, 'hosted-team-approval-storage-delivery-limit'); + if (leaseDurationMs > MAX_DELIVERY_LEASE_MS || limit > MAX_DELIVERY_BATCH) { + throw new TypeError('hosted-team-approval-storage-delivery-claim-request-invalid'); + } + return Object.freeze({ + workspaceId: parseWorkspaceId(input.workspaceId), + teamId: parseTeamId(input.teamId), + authorityGeneration: generation( + input.authorityGeneration, + 'hosted-team-approval-storage-authority-generation' + ), + restoreGeneration: finiteNonNegative( + input.restoreGeneration, + 'hosted-team-approval-storage-restore-generation' + ), + ownerId: identifier(input.ownerId, 'hosted-team-approval-storage-delivery-owner'), + leaseToken: identifier(input.leaseToken, 'hosted-team-approval-storage-delivery-lease-token'), + leaseDurationMs, + limit, + deadlineAtMs: positive(input.deadlineAtMs, 'hosted-team-approval-storage-deadline'), + }); +} + +export function parseHostedTeamApprovalDeliveryRecord( + value: unknown +): HostedTeamApprovalDeliveryRecord { + const input = exactRecord( + value, + [ + 'deliveryId', + 'principal', + 'workspaceId', + 'authorityGeneration', + 'restoreGeneration', + 'partition', + 'requestId', + 'approvalId', + 'approvalGeneration', + 'decision', + 'payloadHash', + 'deliveryRef', + 'deliveryGeneration', + 'ownerId', + 'leaseToken', + 'claimedAtMs', + 'leaseExpiresAtMs', + 'createdAtMs', + ], + 'hosted-team-approval-storage-delivery-record' + ); + if ( + typeof input.deliveryId !== 'string' || + !DELIVERY_ID.test(input.deliveryId) || + typeof input.deliveryRef !== 'string' || + !DELIVERY_REF.test(input.deliveryRef) + ) { + throw new TypeError('hosted-team-approval-storage-delivery-record-invalid'); + } + const claimedAtMs = finiteNonNegative( + input.claimedAtMs, + 'hosted-team-approval-storage-delivery-claimed-at' + ); + const leaseExpiresAtMs = positive( + input.leaseExpiresAtMs, + 'hosted-team-approval-storage-delivery-lease-expires-at' + ); + if (leaseExpiresAtMs <= claimedAtMs) { + throw new TypeError('hosted-team-approval-storage-delivery-record-invalid'); + } + return Object.freeze({ + deliveryId: input.deliveryId, + principal: parseDeliveryPrincipal(input.principal, input.decision), + workspaceId: parseWorkspaceId(input.workspaceId), + authorityGeneration: generation( + input.authorityGeneration, + 'hosted-team-approval-storage-authority-generation' + ), + restoreGeneration: finiteNonNegative( + input.restoreGeneration, + 'hosted-team-approval-storage-restore-generation' + ), + partition: (() => { + const partition = exactRecord( + input.partition, + ['teamId', 'runId'], + 'hosted-team-approval-storage-delivery-partition' + ); + return Object.freeze({ + teamId: identifier(partition.teamId, 'hosted-team-approval-storage-team-id'), + runId: identifier(partition.runId, 'hosted-team-approval-storage-run-id'), + }); + })(), + requestId: identifier(input.requestId, 'hosted-team-approval-storage-request-id'), + approvalId: approvalId(input.approvalId), + approvalGeneration: generation( + input.approvalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + decision: storedDecision(input.decision), + payloadHash: payloadHash(input.payloadHash), + deliveryRef: input.deliveryRef, + deliveryGeneration: positive( + input.deliveryGeneration, + 'hosted-team-approval-storage-delivery-generation' + ), + ownerId: identifier(input.ownerId, 'hosted-team-approval-storage-delivery-owner'), + leaseToken: identifier(input.leaseToken, 'hosted-team-approval-storage-delivery-lease-token'), + claimedAtMs, + leaseExpiresAtMs, + createdAtMs: finiteNonNegative( + input.createdAtMs, + 'hosted-team-approval-storage-delivery-created-at' + ), + }); +} + +function parseDeliveryPrincipal( + value: unknown, + decision: unknown +): HostedTeamApprovalDeliveryRecord['principal'] { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('hosted-team-approval-storage-principal-invalid'); + } + const principal = value as Record; + if ( + principal.kind === 'operator' && + Object.keys(principal).length === 2 && + Object.hasOwn(principal, 'actorId') && + decision !== 'timeout' + ) { + return Object.freeze({ kind: 'operator', actorId: parseActorId(principal.actorId) }); + } + if ( + principal.kind === 'system_timeout' && + Object.keys(principal).length === 1 && + decision === 'timeout' + ) { + return Object.freeze({ kind: 'system_timeout' }); + } + throw new TypeError('hosted-team-approval-storage-principal-invalid'); +} + +export function parseHostedTeamApprovalDeliveryAcknowledgeRequest( + value: unknown +): HostedTeamApprovalDeliveryAcknowledgeRequest { + const input = exactRecord( + value, + [ + 'workspaceId', + 'authorityGeneration', + 'restoreGeneration', + 'partition', + 'deliveryId', + 'deliveryGeneration', + 'ownerId', + 'leaseToken', + 'deadlineAtMs', + ], + 'hosted-team-approval-storage-delivery-acknowledge-request' + ); + if (typeof input.deliveryId !== 'string' || !DELIVERY_ID.test(input.deliveryId)) { + throw new TypeError('hosted-team-approval-storage-delivery-acknowledge-request-invalid'); + } + return Object.freeze({ + workspaceId: parseWorkspaceId(input.workspaceId), + authorityGeneration: generation( + input.authorityGeneration, + 'hosted-team-approval-storage-authority-generation' + ), + restoreGeneration: finiteNonNegative( + input.restoreGeneration, + 'hosted-team-approval-storage-restore-generation' + ), + partition: (() => { + const partition = exactRecord( + input.partition, + ['teamId', 'runId'], + 'hosted-team-approval-storage-delivery-partition' + ); + return Object.freeze({ + teamId: identifier(partition.teamId, 'hosted-team-approval-storage-team-id'), + runId: identifier(partition.runId, 'hosted-team-approval-storage-run-id'), + }); + })(), + deliveryId: input.deliveryId, + deliveryGeneration: positive( + input.deliveryGeneration, + 'hosted-team-approval-storage-delivery-generation' + ), + ownerId: identifier(input.ownerId, 'hosted-team-approval-storage-delivery-owner'), + leaseToken: identifier(input.leaseToken, 'hosted-team-approval-storage-delivery-lease-token'), + deadlineAtMs: positive(input.deadlineAtMs, 'hosted-team-approval-storage-deadline'), + }); +} + +export function parseHostedTeamApprovalTimeoutAuditRequest( + value: unknown +): HostedTeamApprovalTimeoutAuditRequest { + const input = exactRecord( + value, + ['nextAuditTimeMs', 'deadlineAtMs'], + 'hosted-team-approval-storage-timeout-audit-request' + ); + return Object.freeze({ + nextAuditTimeMs: finiteNonNegative( + input.nextAuditTimeMs, + 'hosted-team-approval-storage-next-audit-time' + ), + deadlineAtMs: positive(input.deadlineAtMs, 'hosted-team-approval-storage-deadline'), + }); +} + +export { + parseHostedTeamApprovalDeliveryOperatorRequiredRequest, + parseHostedTeamApprovalDeliveryReconciliationRequest, + parseHostedTeamApprovalDeliveryReconciliationSettleRequest, +} from './hostedTeamApprovalDeliveryReconciliationValidation'; diff --git a/src/features/internal-storage/main/application/hostedTeamApprovalAuthorityStorageOutputs.ts b/src/features/internal-storage/main/application/hostedTeamApprovalAuthorityStorageOutputs.ts new file mode 100644 index 0000000000..1d61a71085 --- /dev/null +++ b/src/features/internal-storage/main/application/hostedTeamApprovalAuthorityStorageOutputs.ts @@ -0,0 +1,264 @@ +import { createHash } from 'node:crypto'; + +import type { + HostedTeamApprovalDecisionStorageResult, + HostedTeamApprovalDeliveryReconciliationReadResult, + HostedTeamApprovalPendingStorageRecord, + HostedTeamApprovalStorageDecision, + HostedTeamApprovalTimeoutAuditResult, +} from '../../contracts/hostedTeamApprovalAuthorityStorageContracts'; + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function exactRecord(value: unknown, keys: readonly string[], label: string): UnknownRecord { + if (!isRecord(value)) throw new TypeError(`${label}-invalid`); + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key)) || + !keys.every((key) => Object.hasOwn(value, key)) + ) { + throw new TypeError(`${label}-invalid`); + } + return value; +} + +function finiteNonNegative(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${label}-invalid`); + } + return value as number; +} + +function positive(value: unknown, label: string): number { + const parsed = finiteNonNegative(value, label); + if (parsed < 1) throw new TypeError(`${label}-invalid`); + return parsed; +} + +function generation(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^generation_[A-Za-z0-9][A-Za-z0-9._-]{0,245}$/.test(value)) { + throw new TypeError(`${label}-invalid`); + } + return value; +} + +function browserDecision(value: unknown): 'allow' | 'deny' { + if (value !== 'allow' && value !== 'deny') { + throw new TypeError('hosted-team-approval-storage-decision-invalid'); + } + return value; +} + +function parseDecisionReceipt(value: unknown): { + readonly approvalGeneration: string; + readonly decision: HostedTeamApprovalStorageDecision; + readonly revision: number; +} { + const input = exactRecord( + value, + ['approvalGeneration', 'decision', 'revision'], + 'hosted-team-approval-storage-decision-receipt' + ); + return Object.freeze({ + approvalGeneration: generation( + input.approvalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + decision: browserDecision(input.decision), + revision: positive(input.revision, 'hosted-team-approval-storage-revision'), + }); +} + +export function parseHostedTeamApprovalDecisionStorageResult( + value: unknown +): HostedTeamApprovalDecisionStorageResult { + if (!isRecord(value) || typeof value.kind !== 'string') { + throw new TypeError('hosted-team-approval-storage-decision-result-invalid'); + } + if (value.kind === 'committed' || value.kind === 'idempotent_replay') { + const input = exactRecord( + value, + ['kind', 'receipt'], + 'hosted-team-approval-storage-decision-result' + ); + return Object.freeze({ + kind: input.kind as 'committed' | 'idempotent_replay', + receipt: parseDecisionReceipt(input.receipt), + }); + } + if (value.kind === 'already_resolved') { + const input = exactRecord( + value, + ['kind', 'approvalGeneration', 'decision'], + 'hosted-team-approval-storage-decision-result' + ); + return Object.freeze({ + kind: 'already_resolved', + approvalGeneration: generation( + input.approvalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + decision: browserDecision(input.decision), + }); + } + if (value.kind === 'stale_generation') { + const input = exactRecord( + value, + ['kind', 'currentApprovalGeneration'], + 'hosted-team-approval-storage-decision-result' + ); + return Object.freeze({ + kind: 'stale_generation', + currentApprovalGeneration: generation( + input.currentApprovalGeneration, + 'hosted-team-approval-storage-approval-generation' + ), + }); + } + if (value.kind === 'conflict') { + const input = exactRecord( + value, + ['kind', 'reason'], + 'hosted-team-approval-storage-decision-result' + ); + if (input.reason !== 'idempotency_mismatch') { + throw new TypeError('hosted-team-approval-storage-decision-result-invalid'); + } + return Object.freeze({ kind: 'conflict', reason: 'idempotency_mismatch' }); + } + if (value.kind === 'expired' || value.kind === 'not_found') { + exactRecord(value, ['kind'], 'hosted-team-approval-storage-decision-result'); + return Object.freeze({ kind: value.kind }); + } + throw new TypeError('hosted-team-approval-storage-decision-result-invalid'); +} + +export function parseHostedTeamApprovalVoidResult(value: unknown): void { + if (value !== undefined) { + throw new TypeError('hosted-team-approval-storage-void-result-invalid'); + } +} + +export function parseHostedTeamApprovalDeliveryReconciliationReadResult( + value: unknown +): HostedTeamApprovalDeliveryReconciliationReadResult { + const input = exactRecord( + value, + value !== null && typeof value === 'object' && Reflect.get(value, 'kind') === 'claimed' + ? ['kind', 'deliveryGeneration'] + : ['kind'], + 'hosted-team-approval-storage-delivery-reconciliation-read-result' + ); + if ( + input.kind !== 'claimed' && + input.kind !== 'unavailable' && + input.kind !== 'stale_binding' && + input.kind !== 'not_found' + ) { + throw new TypeError('hosted-team-approval-storage-delivery-reconciliation-read-result-invalid'); + } + if (input.kind === 'claimed') { + if (!Number.isSafeInteger(input.deliveryGeneration) || (input.deliveryGeneration as number) < 1) + throw new TypeError( + 'hosted-team-approval-storage-delivery-reconciliation-read-result-invalid' + ); + return Object.freeze({ + kind: 'claimed', + deliveryGeneration: input.deliveryGeneration as number, + }); + } + return Object.freeze({ kind: input.kind }); +} + +export function parseHostedTeamApprovalTimeoutAuditResult( + value: unknown +): HostedTeamApprovalTimeoutAuditResult { + const input = exactRecord( + value, + ['resolvedCount', 'nextAuditTimeMs'], + 'hosted-team-approval-storage-timeout-audit-result' + ); + return Object.freeze({ + resolvedCount: finiteNonNegative( + input.resolvedCount, + 'hosted-team-approval-storage-timeout-resolved-count' + ), + nextAuditTimeMs: + input.nextAuditTimeMs === null + ? null + : finiteNonNegative(input.nextAuditTimeMs, 'hosted-team-approval-storage-next-audit-time'), + }); +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function hashHostedTeamApprovalGeneration(value: string): string { + return sha256(value); +} + +export function hashHostedTeamApprovalIdentity( + input: HostedTeamApprovalPendingStorageRecord +): string { + return sha256( + JSON.stringify({ + schemaVersion: 1, + teamId: input.scope.teamId, + runId: input.runId, + requestId: input.requestId, + approvalId: input.approvalId, + approvalGeneration: input.approvalGeneration, + category: input.category, + summary: input.summary, + requestedAtMs: input.requestedAtMs, + expiresAtMs: input.expiresAtMs, + preview: input.preview, + deliveryRef: input.deliveryRef, + observedAtMs: input.observedAtMs, + }) + ); +} + +export function hashHostedTeamApprovalDecision( + browserIntentHash: string, + approvalIdentityHash: string +): string { + return sha256(JSON.stringify({ schemaVersion: 1, browserIntentHash, approvalIdentityHash })); +} + +export function hashHostedTeamApprovalTimeout(approvalIdentityHash: string): string { + return sha256(JSON.stringify({ schemaVersion: 1, outcome: 'timeout', approvalIdentityHash })); +} + +export function serializeHostedTeamApprovalDeliveryIntent(input: { + readonly partition: Readonly<{ teamId: string; runId: string }>; + readonly requestId: string; + readonly approvalId: string; + readonly approvalGeneration: string; + readonly decision: HostedTeamApprovalStorageDecision; + readonly payloadHash: string; + readonly deliveryId: string; + readonly principal: + | Readonly<{ readonly kind: 'operator'; readonly actorId: string }> + | Readonly<{ readonly kind: 'system_timeout' }>; + readonly deliveryRef: string; +}): string { + return JSON.stringify({ + schemaVersion: 1, + deliveryId: input.deliveryId, + principal: input.principal, + partition: input.partition, + requestId: input.requestId, + approvalId: input.approvalId, + approvalGeneration: input.approvalGeneration, + decision: input.decision, + payloadHash: input.payloadHash, + deliveryRef: input.deliveryRef, + }); +} diff --git a/src/features/internal-storage/main/application/hostedTeamApprovalDeliveryReconciliationValidation.ts b/src/features/internal-storage/main/application/hostedTeamApprovalDeliveryReconciliationValidation.ts new file mode 100644 index 0000000000..0596fc23c8 --- /dev/null +++ b/src/features/internal-storage/main/application/hostedTeamApprovalDeliveryReconciliationValidation.ts @@ -0,0 +1,180 @@ +import { parseTeamId, parseWorkspaceId } from '@shared/contracts/hosted'; + +import type { + HostedTeamApprovalDeliveryOperatorRequiredRequest, + HostedTeamApprovalDeliveryReconciliationRequest, + HostedTeamApprovalDeliveryReconciliationSettleRequest, +} from '../../contracts/hostedTeamApprovalAuthorityStorageContracts'; + +type UnknownRecord = Record; +const DELIVERY_ID = /^approval_delivery_[A-Za-z0-9][A-Za-z0-9._-]{0,231}$/; +const GENERATION = /^generation_[A-Za-z0-9][A-Za-z0-9._-]{0,245}$/; +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const RECONCILIATION_REF = /^approval-reconciliation_[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/; +const MAX_BOUNDARY_LEASE_DURATION_MS = 5 * 60 * 1_000; +const MAX_RECONCILIATION_LEASE_DURATION_MS = 5 * 60 * 1_000; + +function exact(value: unknown, keys: readonly string[], label: string): UnknownRecord { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${label}-invalid`); + } + const input = value as UnknownRecord; + const actual = Reflect.ownKeys(input); + if ( + actual.length !== keys.length || + actual.some((key) => typeof key !== 'string' || !keys.includes(key)) || + !keys.every((key) => Object.hasOwn(input, key)) + ) { + throw new TypeError(`${label}-invalid`); + } + return input; +} + +function positive(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) + throw new TypeError(`${label}-invalid`); + return value as number; +} + +function nonNegative(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) + throw new TypeError(`${label}-invalid`); + return value as number; +} + +function identifier(value: unknown, pattern: RegExp, label: string): string { + if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(`${label}-invalid`); + return value; +} + +function reconciliation(value: unknown): HostedTeamApprovalDeliveryReconciliationRequest { + const input = exact( + value, + [ + 'workspaceId', + 'authorityGeneration', + 'restoreGeneration', + 'partition', + 'deliveryId', + 'approvalGeneration', + 'deliveryGeneration', + 'reconciliationRef', + 'ownerId', + 'leaseToken', + 'leaseDurationMs', + 'deadlineAtMs', + ], + 'hosted-team-approval-storage-delivery-reconciliation-request' + ); + const partition = exact( + input.partition, + ['teamId', 'runId'], + 'hosted-team-approval-storage-delivery-partition' + ); + return Object.freeze({ + workspaceId: parseWorkspaceId(input.workspaceId), + authorityGeneration: identifier(input.authorityGeneration, GENERATION, 'authority-generation'), + restoreGeneration: nonNegative(input.restoreGeneration, 'restore-generation'), + partition: Object.freeze({ + teamId: parseTeamId(partition.teamId), + runId: identifier(partition.runId, IDENTIFIER, 'run-id'), + }), + deliveryId: identifier(input.deliveryId, DELIVERY_ID, 'delivery-id'), + approvalGeneration: identifier(input.approvalGeneration, GENERATION, 'approval-generation'), + deliveryGeneration: positive(input.deliveryGeneration, 'delivery-generation'), + reconciliationRef: identifier( + input.reconciliationRef, + RECONCILIATION_REF, + 'reconciliation-ref' + ), + ownerId: identifier(input.ownerId, IDENTIFIER, 'reconciliation-owner'), + leaseToken: identifier(input.leaseToken, IDENTIFIER, 'reconciliation-lease-token'), + leaseDurationMs: (() => { + const duration = positive(input.leaseDurationMs, 'reconciliation-lease-duration'); + if (duration > MAX_RECONCILIATION_LEASE_DURATION_MS) + throw new TypeError('reconciliation-lease-duration-invalid'); + return duration; + })(), + deadlineAtMs: positive(input.deadlineAtMs, 'deadline'), + }); +} + +export function parseHostedTeamApprovalDeliveryOperatorRequiredRequest( + value: unknown +): HostedTeamApprovalDeliveryOperatorRequiredRequest { + const input = exact( + value, + [ + 'workspaceId', + 'authorityGeneration', + 'restoreGeneration', + 'partition', + 'deliveryId', + 'approvalGeneration', + 'deliveryGeneration', + 'ownerId', + 'leaseToken', + 'deadlineAtMs', + 'reconciliationRef', + 'boundaryLeaseDurationMs', + ], + 'hosted-team-approval-storage-delivery-operator-required-request' + ); + const base = reconciliation({ + workspaceId: input.workspaceId, + authorityGeneration: input.authorityGeneration, + restoreGeneration: input.restoreGeneration, + partition: input.partition, + deliveryId: input.deliveryId, + approvalGeneration: input.approvalGeneration, + deliveryGeneration: input.deliveryGeneration, + reconciliationRef: input.reconciliationRef, + ownerId: input.ownerId, + leaseToken: input.leaseToken, + leaseDurationMs: 1, + deadlineAtMs: input.deadlineAtMs, + }); + return Object.freeze({ + ...base, + ownerId: identifier(input.ownerId, IDENTIFIER, 'delivery-owner'), + leaseToken: identifier(input.leaseToken, IDENTIFIER, 'delivery-lease-token'), + boundaryLeaseDurationMs: (() => { + const duration = positive(input.boundaryLeaseDurationMs, 'delivery-boundary-lease-duration'); + if (duration > MAX_BOUNDARY_LEASE_DURATION_MS) { + throw new TypeError('delivery-boundary-lease-duration-invalid'); + } + return duration; + })(), + }); +} + +export const parseHostedTeamApprovalDeliveryReconciliationRequest = reconciliation; + +export function parseHostedTeamApprovalDeliveryReconciliationSettleRequest( + value: unknown +): HostedTeamApprovalDeliveryReconciliationSettleRequest { + const input = exact( + value, + [ + 'workspaceId', + 'authorityGeneration', + 'restoreGeneration', + 'partition', + 'deliveryId', + 'approvalGeneration', + 'deliveryGeneration', + 'reconciliationRef', + 'deadlineAtMs', + 'outcome', + 'ownerId', + 'leaseToken', + ], + 'hosted-team-approval-storage-delivery-reconciliation-settle-request' + ); + const { outcome, ...base } = input; + if (outcome !== 'delivered' && outcome !== 'not_delivered') { + throw new TypeError('hosted-team-approval-storage-delivery-reconciliation-outcome-invalid'); + } + const { leaseDurationMs: _, ...settle } = reconciliation({ ...base, leaseDurationMs: 1 }); + return Object.freeze({ ...settle, outcome }); +} diff --git a/src/features/internal-storage/main/application/hostedTeamConfigurationStorage.ts b/src/features/internal-storage/main/application/hostedTeamConfigurationStorage.ts new file mode 100644 index 0000000000..baa010b5a1 --- /dev/null +++ b/src/features/internal-storage/main/application/hostedTeamConfigurationStorage.ts @@ -0,0 +1,10 @@ +export type { + HostedTeamConfigurationStorageCreateRequest, + HostedTeamConfigurationStorageCreateResult, + HostedTeamConfigurationStorageDeleteRequest, + HostedTeamConfigurationStorageDeleteResult, + HostedTeamConfigurationStorageDraft, + HostedTeamConfigurationStorageReadResult, + HostedTeamConfigurationStorageUpdateRequest, + HostedTeamConfigurationStorageUpdateResult, +} from '../../contracts/hostedTeamConfigurationStorageContracts'; diff --git a/src/features/internal-storage/main/application/internalStorageBackupContract.ts b/src/features/internal-storage/main/application/internalStorageBackupContract.ts new file mode 100644 index 0000000000..28fe4b0bba --- /dev/null +++ b/src/features/internal-storage/main/application/internalStorageBackupContract.ts @@ -0,0 +1,45 @@ +/** "ATAI" in big-endian ASCII. Backups reject databases owned by another application. */ +export const INTERNAL_STORAGE_APPLICATION_ID = 0x41544149; + +/** Released SQLite user_version owned by the append-only migration ledger. */ +export const INTERNAL_STORAGE_SCHEMA_VERSION = 27; + +/** Tables that must survive an internal-storage coordination backup. */ +export const INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES = Object.freeze([ + 'auth_audit_events', + 'coordination_backup_runs', + 'coordination_backup_writer_fences', + 'coordination_event_journal', + 'coordination_event_journal_metadata', + 'durable_application_command_outbox', + 'durable_application_commands', + 'external_identities', + 'external_writer_observation_checkpoints', + 'external_writer_observation_handoff_eligibility', + 'external_writer_observation_consume_receipts', + 'external_writer_observation_retired_team_floors', + 'external_writer_reconciliation_receipts', + 'hosted_access_authority', + 'hosted_auth_configuration', + 'hosted_authority_projections', + 'hosted_team_approval_audit', + 'hosted_team_approval_delivery_outbox', + 'hosted_team_approval_idempotency', + 'hosted_team_approval_records', + 'hosted_team_configuration_create_keys', + 'hosted_team_configuration_drafts', + 'hosted_workspace_grants', + 'hosted_workspaces', + 'local_role_assignments', + 'oidc_login_attempts', + 'oidc_logout_replay', + 'operator_sessions', + 'personal_owners', + 'role_snapshots', + 'team_identity_records', + 'team_identity_storage_metadata', + 'team_roster_members', + 'team_roster_storage_metadata', + 'team_rosters', + 'users', +] as const); diff --git a/src/features/internal-storage/main/application/processOwnershipStorage.ts b/src/features/internal-storage/main/application/processOwnershipStorage.ts new file mode 100644 index 0000000000..f2ce818528 --- /dev/null +++ b/src/features/internal-storage/main/application/processOwnershipStorage.ts @@ -0,0 +1,92 @@ +export const PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION = 1 as const; + +export type StoredProcessOwnershipPhase = + | 'spawn_intent' + | 'owned' + | 'stopping' + | 'drained' + | 'unclassified_residual'; + +/** + * Immutable lookup scope copied from the process-supervision contract. Keeping this storage seam + * primitive-only prevents internal-storage from depending on another feature's domain objects. + */ +export interface ProcessOwnershipStorageScope { + readonly teamId: string; + readonly runId: string; + readonly planGeneration: number; + readonly planHash: string; + readonly executionUnitId: string; +} + +/** + * The JSON value is a strict versioned process-supervision codec envelope. It contains typed state, + * digests, and opaque references only; raw launch or native-process data has no storage field. + */ +export interface StoredProcessOwnershipState { + readonly scope: ProcessOwnershipStorageScope; + readonly processRef: string; + readonly codecVersion: typeof PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION; + readonly stateVersion: number; + readonly revision: number; + readonly phase: StoredProcessOwnershipPhase; + readonly stateJson: string; +} + +export type ProcessOwnershipStorageLoadResult = + | { readonly status: 'found'; readonly record: StoredProcessOwnershipState } + | { readonly status: 'missing' }; + +export type ProcessOwnershipStorageCompareAndSwapResult = + | { readonly status: 'applied'; readonly record: StoredProcessOwnershipState } + | { readonly status: 'conflict' }; + +/** + * In-process admission context. The cancellation probe deliberately stays out of the worker + * payload; the client uses it to remove work that has not yet crossed the worker boundary. + */ +export interface ProcessOwnershipStorageCallContext { + readonly deadlineAtMs: number; + readonly isCancellationRequested: () => boolean; +} + +export function isProcessOwnershipStorageCallAdmitted( + context: ProcessOwnershipStorageCallContext | undefined +): boolean { + if (!context) return true; + try { + return Date.now() < context.deadlineAtMs && context.isCancellationRequested() === false; + } catch { + return false; + } +} + +export interface ProcessOwnershipStorageCompareAndSwapRequest { + readonly scope: ProcessOwnershipStorageScope; + readonly expectedRevision: number | null; + /** + * Canonically decoded by process supervision before CAS. SQLite requires byte-for-byte equality + * with this snapshot, so same-revision durable bytes cannot be replaced after validation. + */ + readonly expectedCurrent: StoredProcessOwnershipState | null; + readonly next: StoredProcessOwnershipState; +} + +/** SQLite worker boundary used by the process-supervision output adapter. */ +export interface ProcessOwnershipStorageGateway { + loadProcessOwnershipByScope( + scope: ProcessOwnershipStorageScope, + context: ProcessOwnershipStorageCallContext + ): Promise; + loadProcessOwnershipByProcessRef( + processRef: string, + context: ProcessOwnershipStorageCallContext + ): Promise; + listProcessOwnershipRecords( + context: ProcessOwnershipStorageCallContext + ): Promise; + compareAndSwapProcessOwnership( + request: ProcessOwnershipStorageCompareAndSwapRequest, + context: ProcessOwnershipStorageCallContext + ): Promise; +} diff --git a/src/features/internal-storage/main/composition/createHostedAuthStorageBackend.ts b/src/features/internal-storage/main/composition/createHostedAuthStorageBackend.ts new file mode 100644 index 0000000000..d887290ba9 --- /dev/null +++ b/src/features/internal-storage/main/composition/createHostedAuthStorageBackend.ts @@ -0,0 +1,122 @@ +import { InternalStorageWorkerClient } from '../infrastructure/InternalStorageWorkerClient'; + +import type { ExternalWriterObservationCheckpointStorageGateway } from '../../contracts/externalWriterObservationStorageContracts'; +import type { + ExternalWriterCleanHandoffConsumeRequest, + ExternalWriterCleanHandoffSaveRequest, + ExternalWriterObservationCheckpointIdentity, + ExternalWriterObservationCheckpointSaveRequest, +} from '../../contracts/externalWriterObservationStorageContracts'; +import type { ExternalWriterReconciliationStorageGateway } from '../../contracts/externalWriterReconciliationStorageContracts'; +import type { HostedAuthStorageGateway } from '../../contracts/hostedAuthStorageContracts'; +import type { HostedTeamApprovalAuthorityStorageGateway } from '../../contracts/hostedTeamApprovalAuthorityStorageContracts'; +import type { HostedTeamConfigurationStorageGateway } from '../../contracts/hostedTeamConfigurationStorageContracts'; +import type { TeamIdentityReadGateway } from '../../contracts/teamIdentityStorageContracts'; +import type { CoordinationDurabilityStorageGateway } from '../application/coordinationDurabilityStorage'; + +export type HostedCoordinationEventStorageGateway = Pick< + CoordinationDurabilityStorageGateway, + | 'coordinationEventInitialize' + | 'coordinationEventGetWatermark' + | 'coordinationEventRead' + | 'coordinationEventAppend' + | 'coordinationEventPrune' +>; + +export interface HostedAuthStorageBackend { + readonly gateway: HostedAuthStorageGateway; + /** Live canonical identities served by this same serialized hosted worker. */ + readonly teamIdentities: TeamIdentityReadGateway; + /** Durable team-configuration operations on the same hosted-only worker. */ + readonly teamConfigurations: HostedTeamConfigurationStorageGateway; + /** Durable approval authority and delivery outbox on the hosted worker. */ + readonly teamApprovals: HostedTeamApprovalAuthorityStorageGateway; + /** Event-journal operations on the same worker/client as hosted auth. */ + readonly coordinationEvents: HostedCoordinationEventStorageGateway; + /** Complete observer checkpoints on the same serialized hosted worker. */ + readonly externalWriterObservations: ExternalWriterObservationCheckpointStorageGateway; + readonly externalWriterReconciliations: ExternalWriterReconciliationStorageGateway; + dispose(): Promise; +} + +/** + * Narrow standalone composition for hosted authentication. It deliberately + * exposes no desktop journals or fallback stores. The narrow identity view is deliberately the + * same live worker/client as auth so hosted reads cannot freeze a second startup snapshot. + */ +export function createHostedAuthStorageBackend(databasePath: string): HostedAuthStorageBackend { + const client = new InternalStorageWorkerClient({ + databasePath, + }); + if (!client.isAvailable()) { + throw new Error('Hosted authentication storage worker is unavailable.'); + } + const coordinationEvents: HostedCoordinationEventStorageGateway = Object.freeze({ + coordinationEventInitialize: (input) => client.coordinationEventInitialize(input), + coordinationEventGetWatermark: (deploymentId) => + client.coordinationEventGetWatermark(deploymentId), + coordinationEventRead: (input) => client.coordinationEventRead(input), + coordinationEventAppend: (input) => client.coordinationEventAppend(input), + coordinationEventPrune: (input) => client.coordinationEventPrune(input), + }); + const teamConfigurations: HostedTeamConfigurationStorageGateway = Object.freeze({ + createHostedTeamConfiguration: ( + request: Parameters< + HostedTeamConfigurationStorageGateway['createHostedTeamConfiguration'] + >[0], + options: Parameters[1] + ) => client.createHostedTeamConfiguration(request, options), + readHostedTeamConfiguration: ( + input: Parameters[0] + ) => client.readHostedTeamConfiguration(input), + updateHostedTeamConfiguration: ( + request: Parameters< + HostedTeamConfigurationStorageGateway['updateHostedTeamConfiguration'] + >[0], + options: Parameters[1] + ) => client.updateHostedTeamConfiguration(request, options), + deleteHostedTeamConfiguration: ( + request: Parameters< + HostedTeamConfigurationStorageGateway['deleteHostedTeamConfiguration'] + >[0], + options: Parameters[1] + ) => client.deleteHostedTeamConfiguration(request, options), + }); + const externalWriterObservations: ExternalWriterObservationCheckpointStorageGateway = + Object.freeze({ + loadExternalWriterObservationCheckpoint: ( + identity: ExternalWriterObservationCheckpointIdentity + ) => client.loadExternalWriterObservationCheckpoint(identity), + saveExternalWriterObservationCheckpoint: ( + request: ExternalWriterObservationCheckpointSaveRequest + ) => client.saveExternalWriterObservationCheckpoint(request), + saveExternalWriterCleanHandoffEligibility: (request: ExternalWriterCleanHandoffSaveRequest) => + client.saveExternalWriterCleanHandoffEligibility(request), + consumeExternalWriterCleanHandoffEligibility: ( + request: ExternalWriterCleanHandoffConsumeRequest + ) => client.consumeExternalWriterCleanHandoffEligibility(request), + }); + const externalWriterReconciliations: ExternalWriterReconciliationStorageGateway = Object.freeze({ + getExternalWriterReconciliation: ( + input: Parameters< + ExternalWriterReconciliationStorageGateway['getExternalWriterReconciliation'] + >[0] + ) => client.getExternalWriterReconciliation(input), + commitExternalWriterReconciliation: ( + input: Parameters< + ExternalWriterReconciliationStorageGateway['commitExternalWriterReconciliation'] + >[0] + ) => client.commitExternalWriterReconciliation(input), + }); + let disposal: Promise | null = null; + return Object.freeze({ + gateway: client, + teamIdentities: client, + coordinationEvents, + teamConfigurations, + teamApprovals: client, + externalWriterObservations, + externalWriterReconciliations, + dispose: () => (disposal ??= client.close()), + }); +} diff --git a/src/features/internal-storage/main/composition/createHostedTeamIdentityReadBackend.ts b/src/features/internal-storage/main/composition/createHostedTeamIdentityReadBackend.ts new file mode 100644 index 0000000000..5cc3c5b3ca --- /dev/null +++ b/src/features/internal-storage/main/composition/createHostedTeamIdentityReadBackend.ts @@ -0,0 +1,54 @@ +import * as path from 'node:path'; + +import { + INTERNAL_STORAGE_DATABASE_FILENAME, + INTERNAL_STORAGE_DIRNAME, +} from '../../contracts/internalStorageContracts'; +import { InternalStorageWorkerClient } from '../infrastructure/InternalStorageWorkerClient'; + +import type { + ExternalWriterIdentityInventoryCapture, + TeamIdentityReadGateway, +} from '../../contracts/teamIdentityStorageContracts'; +import type { TeamId } from '@shared/contracts/hosted'; + +export interface HostedTeamIdentityReadGateway extends TeamIdentityReadGateway { + captureExternalWriterTeamIdentities(input: { + readonly retirementCandidates: readonly TeamId[]; + }): Promise; +} + +export interface HostedTeamIdentityReadBackend { + readonly gateway: HostedTeamIdentityReadGateway; + dispose(): Promise; +} + +/** Opens the launcher-admitted identity database through a dedicated query-only worker. */ +export function createHostedTeamIdentityReadBackend( + appDataRoot: string +): HostedTeamIdentityReadBackend { + const client = new InternalStorageWorkerClient({ + databasePath: path.join( + appDataRoot, + INTERNAL_STORAGE_DIRNAME, + INTERNAL_STORAGE_DATABASE_FILENAME + ), + mode: 'team-identity-read-only', + }); + if (!client.isAvailable()) { + throw new Error('hosted-team-identity-read-worker-unavailable'); + } + const gateway: HostedTeamIdentityReadGateway = Object.freeze({ + listTeamIdentities: () => client.listTeamIdentities(), + getTeamIdentity: (teamId: TeamId) => client.getTeamIdentity(teamId), + captureExternalWriterTeamIdentities: (input: { + readonly retirementCandidates: readonly TeamId[]; + }) => + client.captureExternalWriterTeamIdentities(input), + }); + let disposal: Promise | null = null; + return Object.freeze({ + gateway, + dispose: () => (disposal ??= client.close()), + }); +} diff --git a/src/features/internal-storage/main/composition/createInternalStorageFeature.ts b/src/features/internal-storage/main/composition/createInternalStorageFeature.ts index 218bd717c2..3c8f564702 100644 --- a/src/features/internal-storage/main/composition/createInternalStorageFeature.ts +++ b/src/features/internal-storage/main/composition/createInternalStorageFeature.ts @@ -28,10 +28,20 @@ import { InternalStorageWorkerClient } from '../infrastructure/InternalStorageWo import { BackendSelectingTaskCommentNotificationJournalStore } from './BackendSelectingTaskCommentNotificationJournalStore'; import { BackendSelectingTaskStallJournalStore } from './BackendSelectingTaskStallJournalStore'; +import { + createHostedAuthStorageBackend, + type HostedAuthStorageBackend, +} from './createHostedAuthStorageBackend'; import { InternalStorageBackendSelector } from './InternalStorageBackendSelector'; +import type { HostedAuthStorageGateway } from '../../contracts/hostedAuthStorageContracts'; +import type { HostedTeamApprovalAuthorityStorageGateway } from '../../contracts/hostedTeamApprovalAuthorityStorageContracts'; import type { InternalStorageBackendKind } from '../../contracts/internalStorageContracts'; +import type { TeamIdentityReadGateway } from '../../contracts/teamIdentityStorageContracts'; +import type { TeamRosterStorageGateway } from '../../contracts/teamRosterStorageContracts'; import type { MemberWorkSyncStorageGateway } from '../../core/application/ports'; +import type { CoordinationDurabilityStorageGateway } from '../application/coordinationDurabilityStorage'; +import type { ProcessOwnershipStorageGateway } from '../application/processOwnershipStorage'; import type { ApplicationCommandLedgerStorageGateway } from '@features/application-command-ledger'; import type { TaskStallJournalStore } from '@main/services/team/stallMonitor/TaskStallJournalStore'; import type { TaskCommentNotificationJournalStore } from '@main/services/team/TaskCommentNotificationJournalStore'; @@ -43,6 +53,12 @@ export interface InternalStorageFeatureDeps { userDataPath: string; } +export interface InternalStorageHostedAuthFeatureDeps extends InternalStorageFeatureDeps { + readonly scope: 'hosted-auth'; +} + +export type InternalStorageHostedAuthFeature = HostedAuthStorageBackend; + export interface InternalStorageMemberWorkSyncBackend { gateway: MemberWorkSyncStorageGateway; selector: InternalStorageBackendSelector; @@ -54,6 +70,33 @@ export interface InternalStorageApplicationCommandLedgerBackend { selector: InternalStorageBackendSelector; } +export interface InternalStorageTeamIdentityReadBackend { + gateway: TeamIdentityReadGateway; +} + +export interface InternalStorageTeamRosterBackend { + gateway: TeamRosterStorageGateway; +} + +export interface InternalStorageCoordinationDurabilityBackend { + gateway: CoordinationDurabilityStorageGateway; + selector: InternalStorageBackendSelector; +} + +export interface InternalStorageProcessOwnershipBackend { + gateway: ProcessOwnershipStorageGateway; +} + +export interface InternalStorageHostedAuthBackend { + gateway: HostedAuthStorageGateway; +} + +/** Durable approval authority has no JSON fallback or lifecycle behavior. */ +export interface InternalStorageHostedTeamApprovalAuthorityBackend { + gateway: HostedTeamApprovalAuthorityStorageGateway; + selector: InternalStorageBackendSelector; +} + export interface InternalStorageFeature { taskStallJournalStore: TaskStallJournalStore; taskCommentNotificationJournalStore: TaskCommentNotificationJournalStore; @@ -69,6 +112,18 @@ export interface InternalStorageFeature { * worker bundle is unavailable, so callers must leave durable commands off. */ applicationCommandLedgerBackend: InternalStorageApplicationCommandLedgerBackend | null; + /** Durable identity reads never degrade to directory or JSON discovery. */ + teamIdentityReadBackend: InternalStorageTeamIdentityReadBackend | null; + /** TeamRoster identity is SQLite-only and never falls back to name-keyed JSON. */ + teamRosterBackend: InternalStorageTeamRosterBackend | null; + /** Critical coordination durability never degrades to a JSON fallback. */ + coordinationDurabilityBackend: InternalStorageCoordinationDurabilityBackend | null; + /** Process ownership is SQLite-only; unavailable storage closes runtime-control admission. */ + processOwnershipBackend: InternalStorageProcessOwnershipBackend | null; + /** Hosted authentication is SQLite-only and fails closed without the worker. */ + hostedAuthBackend: InternalStorageHostedAuthBackend | null; + /** Hosted approval decisions are SQLite-only and do not mount runtime delivery. */ + hostedTeamApprovalAuthorityBackend: InternalStorageHostedTeamApprovalAuthorityBackend | null; /** Forces the lazy backend decision for startup diagnostics and packaged smoke checks. */ probeBackend(): Promise; getBackendKind(): InternalStorageBackendKind; @@ -79,10 +134,19 @@ export function getInternalStorageDatabasePath(userDataPath: string): string { return path.join(userDataPath, INTERNAL_STORAGE_DIRNAME, INTERNAL_STORAGE_DATABASE_FILENAME); } +export function createInternalStorageFeature( + deps: InternalStorageHostedAuthFeatureDeps +): InternalStorageHostedAuthFeature; export function createInternalStorageFeature( deps: InternalStorageFeatureDeps -): InternalStorageFeature { +): InternalStorageFeature; +export function createInternalStorageFeature( + deps: InternalStorageFeatureDeps | InternalStorageHostedAuthFeatureDeps +): InternalStorageFeature | InternalStorageHostedAuthFeature { const databasePath = getInternalStorageDatabasePath(deps.userDataPath); + if ('scope' in deps && deps.scope === 'hosted-auth') { + return createHostedAuthStorageBackend(databasePath); + } // Replica ownership is per team/store. A missing replica means that store was // never touched through SQLite; a dirty replica fails closed on its own. // App-wide database existence cannot be used here because a healthy database @@ -158,6 +222,12 @@ export function createInternalStorageFeature( ), memberWorkSyncBackend: { gateway: client, selector, fallbackRequiresReplica }, applicationCommandLedgerBackend: workerAvailable ? { gateway: client, selector } : null, + teamIdentityReadBackend: workerAvailable ? { gateway: client } : null, + teamRosterBackend: workerAvailable ? { gateway: client } : null, + coordinationDurabilityBackend: workerAvailable ? { gateway: client, selector } : null, + processOwnershipBackend: workerAvailable ? { gateway: client } : null, + hostedAuthBackend: workerAvailable ? { gateway: client } : null, + hostedTeamApprovalAuthorityBackend: workerAvailable ? { gateway: client, selector } : null, probeBackend: () => selector.select('sqlite', 'json-fallback'), getBackendKind: () => selector.getBackendKind(), dispose: () => client.close(), diff --git a/src/features/internal-storage/main/hosted.ts b/src/features/internal-storage/main/hosted.ts new file mode 100644 index 0000000000..64cc9d1149 --- /dev/null +++ b/src/features/internal-storage/main/hosted.ts @@ -0,0 +1,6 @@ +export { InternalStorageExternalWriterObservationStateStore } from './adapters/output/InternalStorageExternalWriterObservationStateStore'; +export { + createHostedTeamIdentityReadBackend, + type HostedTeamIdentityReadBackend, + type HostedTeamIdentityReadGateway, +} from './composition/createHostedTeamIdentityReadBackend'; diff --git a/src/features/internal-storage/main/index.ts b/src/features/internal-storage/main/index.ts index dff8852b31..a5ebfce21c 100644 --- a/src/features/internal-storage/main/index.ts +++ b/src/features/internal-storage/main/index.ts @@ -1,16 +1,56 @@ +export type * from '../contracts/externalWriterReconciliationStorageContracts'; +export type { + HostedAuthStorageGateway, + HostedAuthStorageOperation, +} from '../contracts/hostedAuthStorageContracts'; +export type { TeamIdentityReadGateway } from '../contracts/teamIdentityStorageContracts'; export { KeyedMutex } from '../core/application/KeyedMutex'; export type { MemberWorkSyncStorageGateway } from '../core/application/ports'; export { archiveFileWithGenerations, listPreSqliteArchiveGenerations, } from './adapters/output/TeamScopedLegacyJsonSource'; +export type { + CoordinationDrainStorageEvidence, + CoordinationDurabilityStorageGateway, + SqliteBackupChunkStorageResult, + SqliteOnlineBackupStorageResult, + SqliteSnapshotVerificationStorageResult, + StoredCoordinationEventRow, + StoredEventJournalMetadata, +} from './application/coordinationDurabilityStorage'; +export type * from './application/hostedTeamConfigurationStorage'; +export { + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES, + INTERNAL_STORAGE_SCHEMA_VERSION, +} from './application/internalStorageBackupContract'; +export { + PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION, + type ProcessOwnershipStorageCallContext, + type ProcessOwnershipStorageCompareAndSwapRequest, + type ProcessOwnershipStorageCompareAndSwapResult, + type ProcessOwnershipStorageGateway, + type ProcessOwnershipStorageLoadResult, + type ProcessOwnershipStorageScope, + type StoredProcessOwnershipPhase, + type StoredProcessOwnershipState, +} from './application/processOwnershipStorage'; export { BackendSelectingTaskCommentNotificationJournalStore } from './composition/BackendSelectingTaskCommentNotificationJournalStore'; export { BackendSelectingTaskStallJournalStore } from './composition/BackendSelectingTaskStallJournalStore'; +export type { HostedCoordinationEventStorageGateway } from './composition/createHostedAuthStorageBackend'; export type { InternalStorageApplicationCommandLedgerBackend, + InternalStorageCoordinationDurabilityBackend, InternalStorageFeature, InternalStorageFeatureDeps, + InternalStorageHostedAuthBackend, + InternalStorageHostedAuthFeature, + InternalStorageHostedAuthFeatureDeps, + InternalStorageHostedTeamApprovalAuthorityBackend, InternalStorageMemberWorkSyncBackend, + InternalStorageProcessOwnershipBackend, + InternalStorageTeamRosterBackend, } from './composition/createInternalStorageFeature'; export { createInternalStorageFeature, diff --git a/src/features/internal-storage/main/infrastructure/CoordinationDurabilityStorageGateway.ts b/src/features/internal-storage/main/infrastructure/CoordinationDurabilityStorageGateway.ts new file mode 100644 index 0000000000..ba317a621b --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/CoordinationDurabilityStorageGateway.ts @@ -0,0 +1,9 @@ +export type { + CoordinationDrainStorageEvidence, + CoordinationDurabilityStorageGateway, + SqliteBackupChunkStorageResult, + SqliteOnlineBackupStorageResult, + SqliteSnapshotVerificationStorageResult, + StoredCoordinationEventRow, + StoredEventJournalMetadata, +} from '../application/coordinationDurabilityStorage'; diff --git a/src/features/internal-storage/main/infrastructure/CoordinationDurabilityStorageGatewayClient.ts b/src/features/internal-storage/main/infrastructure/CoordinationDurabilityStorageGatewayClient.ts new file mode 100644 index 0000000000..0dd1285f23 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/CoordinationDurabilityStorageGatewayClient.ts @@ -0,0 +1,210 @@ +import { ProcessOwnershipStorageGatewayClient } from './ProcessOwnershipStorageGateway'; + +import type { CoordinationDurabilityStorageGateway } from '../application/coordinationDurabilityStorage'; +import type { + InternalStorageWorkerCallOptions, + InternalStorageWorkerPayloadFor, +} from './InternalStorageWorkerTransport'; +import type { + CoordinationDrainStorageEvidence, + InternalStorageWorkerRequest, + SqliteBackupChunkStorageResult, + SqliteOnlineBackupStorageResult, + SqliteSnapshotVerificationStorageResult, + StoredCoordinationEventRow, + StoredEventJournalMetadata, +} from './worker/internalStorageWorkerProtocol'; +import type { + BackupFenceCompletionDisposition, + BackupRunRecord, + BackupRunState, +} from '@features/coordination-backup/contracts'; +import type { + CoordinationEventDraft, + CoordinationJsonValue, +} from '@features/coordination-events/contracts'; + +/** Typed coordination facade shared by internal-storage worker clients. */ +export abstract class CoordinationDurabilityStorageGatewayClient + extends ProcessOwnershipStorageGatewayClient + implements CoordinationDurabilityStorageGateway +{ + protected abstract callCoordinationWorker( + op: TOp, + payload: InternalStorageWorkerPayloadFor, + options?: InternalStorageWorkerCallOptions + ): Promise; + + async coordinationEventInitialize(input: { + readonly deploymentId: string; + readonly eventEpoch?: string; + readonly nowIso: string; + }): Promise { + return (await this.callCoordinationWorker( + 'coordinationEvents.initialize', + input + )) as StoredEventJournalMetadata; + } + + async coordinationEventGetWatermark(deploymentId: string): Promise { + return (await this.callCoordinationWorker('coordinationEvents.getWatermark', { + deploymentId, + })) as StoredEventJournalMetadata; + } + + async coordinationEventRead(input: { + readonly deploymentId: string; + readonly afterSequence: number; + readonly throughSequence: number; + readonly limit: number; + }): Promise<{ + readonly rows: readonly StoredCoordinationEventRow[]; + readonly watermark: StoredEventJournalMetadata; + }> { + return (await this.callCoordinationWorker('coordinationEvents.read', input)) as { + readonly rows: readonly StoredCoordinationEventRow[]; + readonly watermark: StoredEventJournalMetadata; + }; + } + + async coordinationEventAppend(input: { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly draft: CoordinationEventDraft; + readonly bodyJson: string; + readonly nowIso: string; + }): Promise<{ + readonly row: StoredCoordinationEventRow; + readonly watermark: StoredEventJournalMetadata; + }> { + return (await this.callCoordinationWorker('coordinationEvents.append', input)) as { + readonly row: StoredCoordinationEventRow; + readonly watermark: StoredEventJournalMetadata; + }; + } + + async coordinationEventPrune(input: { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly throughSequence: number; + readonly nowIso: string; + }): Promise { + return (await this.callCoordinationWorker( + 'coordinationEvents.prune', + input + )) as StoredEventJournalMetadata; + } + + async coordinationBackupRunCreate(record: BackupRunRecord): Promise { + return (await this.callCoordinationWorker('coordinationBackupRuns.create', { + record, + })) as BackupRunRecord; + } + + async coordinationBackupRunGet(backupRunId: string): Promise { + return (await this.callCoordinationWorker('coordinationBackupRuns.get', { + backupRunId, + })) as BackupRunRecord | null; + } + + async coordinationBackupRunListRecoverable(): Promise { + return (await this.callCoordinationWorker( + 'coordinationBackupRuns.listRecoverable', + {} + )) as readonly BackupRunRecord[]; + } + + async coordinationBackupRunCompareAndSet(input: { + readonly backupRunId: string; + readonly expectedRevision: number; + readonly expectedState: BackupRunState; + readonly record: BackupRunRecord; + }): Promise { + return (await this.callCoordinationWorker( + 'coordinationBackupRuns.compareAndSet', + input + )) as BackupRunRecord; + } + + async coordinationBackupFenceAcquire(input: { + readonly deploymentId: string; + readonly backupRunId: string; + readonly expectedGeneration: number | null; + readonly leaseId: string; + readonly acquiredAt: string; + }): Promise< + | { readonly status: 'acquired'; readonly generation: number; readonly leaseId: string } + | { readonly status: 'busy'; readonly activeRunId: string } + > { + return (await this.callCoordinationWorker('coordinationBackupFence.acquire', input)) as + | { readonly status: 'acquired'; readonly generation: number; readonly leaseId: string } + | { readonly status: 'busy'; readonly activeRunId: string }; + } + + async coordinationBackupFenceComplete(input: { + readonly deploymentId: string; + readonly backupRunId: string; + readonly generation: number; + readonly leaseId: string; + readonly disposition: BackupFenceCompletionDisposition; + readonly completedAt: string; + }): Promise { + await this.callCoordinationWorker('coordinationBackupFence.complete', input); + } + + async coordinationBackupDrain(input: { + readonly deploymentId: string; + readonly backupRunId: string; + readonly fenceGeneration: number; + }): Promise { + return (await this.callCoordinationWorker( + 'coordinationBackupFlush.drain', + input + )) as CoordinationDrainStorageEvidence; + } + + async coordinationBackupCapture(input: { + readonly deploymentId: string; + readonly evidence: CoordinationDrainStorageEvidence; + }): Promise { + return (await this.callCoordinationWorker( + 'coordinationBackupFlush.capture', + input + )) as CoordinationDrainStorageEvidence; + } + + async coordinationBackupSqliteOnline(input: { + readonly backupRunId: string; + readonly deadlineAtMs: number; + readonly busyRetryMs: number; + readonly pagesPerStep: number; + }): Promise { + return (await this.callCoordinationWorker('coordinationBackup.sqlite.online', input, { + timeoutAtMs: input.deadlineAtMs + 2_000, + })) as SqliteOnlineBackupStorageResult; + } + + async coordinationBackupSqliteVerify(input: { + readonly backupRunId: string; + }): Promise { + return (await this.callCoordinationWorker( + 'coordinationBackup.sqlite.verify', + input + )) as SqliteSnapshotVerificationStorageResult; + } + + async coordinationBackupSqliteReadChunk(input: { + readonly backupRunId: string; + readonly offset: number; + readonly maximumBytes: number; + }): Promise { + return (await this.callCoordinationWorker( + 'coordinationBackup.sqlite.readChunk', + input + )) as SqliteBackupChunkStorageResult; + } + + async coordinationBackupSqliteDiscard(backupRunId: string): Promise { + await this.callCoordinationWorker('coordinationBackup.sqlite.discard', { backupRunId }); + } +} diff --git a/src/features/internal-storage/main/infrastructure/HostedTeamApprovalWorkerClient.ts b/src/features/internal-storage/main/infrastructure/HostedTeamApprovalWorkerClient.ts new file mode 100644 index 0000000000..e53a570fe5 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/HostedTeamApprovalWorkerClient.ts @@ -0,0 +1,38 @@ +import { CoordinationDurabilityStorageGatewayClient } from './CoordinationDurabilityStorageGatewayClient'; +import { HostedTeamStorageWorkerClient } from './HostedTeamStorageWorkerClient'; + +import type { HostedTeamApprovalAuthorityStorageGateway } from '../../contracts/hostedTeamApprovalAuthorityStorageContracts'; + +export abstract class HostedTeamApprovalWorkerClient + extends CoordinationDurabilityStorageGatewayClient + implements HostedTeamApprovalAuthorityStorageGateway +{ + protected hostedTeamStorage!: HostedTeamStorageWorkerClient; + + protected initializeHostedTeamStorage( + ...input: ConstructorParameters + ): void { + this.hostedTeamStorage = new HostedTeamStorageWorkerClient(...input); + } + + hostedTeamApprovalObserve: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalObserve'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalObserve(request); + hostedTeamApprovalReadPending: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalReadPending'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalReadPending(request); + hostedTeamApprovalReadPreview: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalReadPreview'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalReadPreview(request); + hostedTeamApprovalDecide: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalDecide'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalDecide(request); + hostedTeamApprovalClaimDeliveries: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalClaimDeliveries'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalClaimDeliveries(request); + hostedTeamApprovalAcknowledgeDelivery: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalAcknowledgeDelivery'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalAcknowledgeDelivery(request); + hostedTeamApprovalMarkDeliveryOperatorRequired: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalMarkDeliveryOperatorRequired'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalMarkDeliveryOperatorRequired(request); + hostedTeamApprovalReadDeliveryReconciliation: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalReadDeliveryReconciliation'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalReadDeliveryReconciliation(request); + hostedTeamApprovalSettleDeliveryReconciliation: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalSettleDeliveryReconciliation'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalSettleDeliveryReconciliation(request); + hostedTeamApprovalAuditTimeouts: HostedTeamApprovalAuthorityStorageGateway['hostedTeamApprovalAuditTimeouts'] = + (request) => this.hostedTeamStorage.hostedTeamApprovalAuditTimeouts(request); +} diff --git a/src/features/internal-storage/main/infrastructure/HostedTeamStorageWorkerClient.ts b/src/features/internal-storage/main/infrastructure/HostedTeamStorageWorkerClient.ts new file mode 100644 index 0000000000..7daf2d58ca --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/HostedTeamStorageWorkerClient.ts @@ -0,0 +1,251 @@ +import { + type HostedTeamConfigurationStorageCreateRequest, + type HostedTeamConfigurationStorageCreateResult, + type HostedTeamConfigurationStorageDeleteRequest, + type HostedTeamConfigurationStorageDeleteResult, + type HostedTeamConfigurationStorageMutationOptions, + type HostedTeamConfigurationStorageReadResult, + type HostedTeamConfigurationStorageUpdateRequest, + type HostedTeamConfigurationStorageUpdateResult, + parseHostedTeamConfigurationStorageCreateRequest, + parseHostedTeamConfigurationStorageDeleteRequest, + parseHostedTeamConfigurationStorageUpdateRequest, +} from '../../contracts/hostedTeamConfigurationStorageContracts'; +import { + parseHostedTeamApprovalDecisionStorageRequest, + parseHostedTeamApprovalDecisionStorageResult, + parseHostedTeamApprovalDeliveryAcknowledgeRequest, + parseHostedTeamApprovalDeliveryClaimRequest, + parseHostedTeamApprovalDeliveryOperatorRequiredRequest, + parseHostedTeamApprovalDeliveryReconciliationReadResult, + parseHostedTeamApprovalDeliveryReconciliationRequest, + parseHostedTeamApprovalDeliveryReconciliationSettleRequest, + parseHostedTeamApprovalDeliveryRecord, + parseHostedTeamApprovalPendingReadRecord, + parseHostedTeamApprovalPendingReadRequest, + parseHostedTeamApprovalPendingReadResult, + parseHostedTeamApprovalPendingStorageRecord, + parseHostedTeamApprovalPreviewReadRequest, + parseHostedTeamApprovalPreviewReadResult, + parseHostedTeamApprovalTimeoutAuditRequest, + parseHostedTeamApprovalTimeoutAuditResult, + parseHostedTeamApprovalVoidResult, +} from '../application/hostedTeamApprovalAuthorityStorage'; + +import type { + HostedTeamApprovalDecisionStorageRequest, + HostedTeamApprovalDecisionStorageResult, + HostedTeamApprovalDeliveryAcknowledgeRequest, + HostedTeamApprovalDeliveryClaimRequest, + HostedTeamApprovalDeliveryOperatorRequiredRequest, + HostedTeamApprovalDeliveryReconciliationReadResult, + HostedTeamApprovalDeliveryReconciliationRequest, + HostedTeamApprovalDeliveryReconciliationSettleRequest, + HostedTeamApprovalDeliveryRecord, + HostedTeamApprovalPendingReadRecord, + HostedTeamApprovalPendingReadRequest, + HostedTeamApprovalPendingReadResult, + HostedTeamApprovalPendingStorageRecord, + HostedTeamApprovalPreviewReadRequest, + HostedTeamApprovalPreviewReadResult, + HostedTeamApprovalTimeoutAuditRequest, + HostedTeamApprovalTimeoutAuditResult, +} from '../../contracts/hostedTeamApprovalAuthorityStorageContracts'; +import type { + InternalStorageWorkerCallOptions, + InternalStorageWorkerPayloadFor, + InternalStorageWorkerTransport, +} from './InternalStorageWorkerTransport'; +import type { + HostedTeamConfigurationWorkerPayloadByOp, + InternalStorageWorkerRequest, +} from './worker/internalStorageWorkerProtocol'; +import type { TeamId, WorkspaceId } from '@shared/contracts/hosted'; + +type HostedTeamConfigurationCall = ( + op: TOp, + payload: HostedTeamConfigurationWorkerPayloadByOp[TOp], + options?: InternalStorageWorkerCallOptions +) => Promise; + +export class HostedTeamStorageWorkerClient { + constructor( + private readonly callWorker: InternalStorageWorkerTransport['call'], + private readonly callHostedTeamConfigurationWorker: HostedTeamConfigurationCall + ) {} + + callHostedTeamConfiguration( + op: TOp, + payload: HostedTeamConfigurationWorkerPayloadByOp[TOp], + options: InternalStorageWorkerCallOptions = {} + ): Promise { + return this.call(op, payload as InternalStorageWorkerPayloadFor, options); + } + + async createHostedTeamConfiguration( + request: HostedTeamConfigurationStorageCreateRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise { + const input = parseHostedTeamConfigurationStorageCreateRequest(request); + return (await this.callHostedTeamConfigurationWorker('hostedTeamConfiguration.create', input, { + signal: options.signal, + timeoutAtMs: input.deadlineAtMs, + })) as HostedTeamConfigurationStorageCreateResult; + } + + async readHostedTeamConfiguration(input: { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; + }): Promise { + return (await this.callHostedTeamConfigurationWorker( + 'hostedTeamConfiguration.read', + input + )) as HostedTeamConfigurationStorageReadResult; + } + + async updateHostedTeamConfiguration( + request: HostedTeamConfigurationStorageUpdateRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise { + const input = parseHostedTeamConfigurationStorageUpdateRequest(request); + return (await this.callHostedTeamConfigurationWorker('hostedTeamConfiguration.update', input, { + signal: options.signal, + timeoutAtMs: input.deadlineAtMs, + })) as HostedTeamConfigurationStorageUpdateResult; + } + + async deleteHostedTeamConfiguration( + request: HostedTeamConfigurationStorageDeleteRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise { + const input = parseHostedTeamConfigurationStorageDeleteRequest(request); + return (await this.callHostedTeamConfigurationWorker('hostedTeamConfiguration.delete', input, { + signal: options.signal, + timeoutAtMs: input.deadlineAtMs, + })) as HostedTeamConfigurationStorageDeleteResult; + } + + async hostedTeamApprovalObserve( + record: HostedTeamApprovalPendingStorageRecord + ): Promise { + const input = parseHostedTeamApprovalPendingStorageRecord(record); + return parseHostedTeamApprovalPendingReadRecord( + await this.call('hostedTeamApprovalAuthority.observe', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalReadPending( + request: HostedTeamApprovalPendingReadRequest + ): Promise { + const input = parseHostedTeamApprovalPendingReadRequest(request); + return parseHostedTeamApprovalPendingReadResult( + await this.call('hostedTeamApprovalAuthority.readPending', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalReadPreview( + request: HostedTeamApprovalPreviewReadRequest + ): Promise { + const input = parseHostedTeamApprovalPreviewReadRequest(request); + return parseHostedTeamApprovalPreviewReadResult( + await this.call('hostedTeamApprovalAuthority.readPreview', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalDecide( + request: HostedTeamApprovalDecisionStorageRequest + ): Promise { + const input = parseHostedTeamApprovalDecisionStorageRequest(request); + return parseHostedTeamApprovalDecisionStorageResult( + await this.call('hostedTeamApprovalAuthority.decide', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalClaimDeliveries( + request: HostedTeamApprovalDeliveryClaimRequest + ): Promise { + const input = parseHostedTeamApprovalDeliveryClaimRequest(request); + const value = await this.call('hostedTeamApprovalAuthority.claimDeliveries', input, { + timeoutAtMs: input.deadlineAtMs, + }); + if ( + !Array.isArray(value) || + value.length > input.limit || + Reflect.ownKeys(value).length !== value.length + 1 + ) { + throw new TypeError('hosted-team-approval-storage-delivery-claim-result-invalid'); + } + return Object.freeze(value.map(parseHostedTeamApprovalDeliveryRecord)); + } + + async hostedTeamApprovalAcknowledgeDelivery( + request: HostedTeamApprovalDeliveryAcknowledgeRequest + ): Promise { + const input = parseHostedTeamApprovalDeliveryAcknowledgeRequest(request); + parseHostedTeamApprovalVoidResult( + await this.call('hostedTeamApprovalAuthority.acknowledgeDelivery', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalMarkDeliveryOperatorRequired( + request: HostedTeamApprovalDeliveryOperatorRequiredRequest + ): Promise { + const input = parseHostedTeamApprovalDeliveryOperatorRequiredRequest(request); + parseHostedTeamApprovalVoidResult( + await this.call('hostedTeamApprovalAuthority.markDeliveryOperatorRequired', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalReadDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationRequest + ): Promise { + const input = parseHostedTeamApprovalDeliveryReconciliationRequest(request); + return parseHostedTeamApprovalDeliveryReconciliationReadResult( + await this.call('hostedTeamApprovalAuthority.readDeliveryReconciliation', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalSettleDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationSettleRequest + ): Promise { + const input = parseHostedTeamApprovalDeliveryReconciliationSettleRequest(request); + parseHostedTeamApprovalVoidResult( + await this.call('hostedTeamApprovalAuthority.settleDeliveryReconciliation', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + async hostedTeamApprovalAuditTimeouts( + request: HostedTeamApprovalTimeoutAuditRequest + ): Promise { + const input = parseHostedTeamApprovalTimeoutAuditRequest(request); + return parseHostedTeamApprovalTimeoutAuditResult( + await this.call('hostedTeamApprovalAuthority.auditTimeouts', input, { + timeoutAtMs: input.deadlineAtMs, + }) + ); + } + + private call( + op: TOp, + payload: InternalStorageWorkerPayloadFor, + options: InternalStorageWorkerCallOptions = {} + ): Promise { + return this.callWorker(op, payload, options); + } +} diff --git a/src/features/internal-storage/main/infrastructure/InternalStorageWorkerClient.ts b/src/features/internal-storage/main/infrastructure/InternalStorageWorkerClient.ts index 287dbc6cd0..677a7818ff 100644 --- a/src/features/internal-storage/main/infrastructure/InternalStorageWorkerClient.ts +++ b/src/features/internal-storage/main/infrastructure/InternalStorageWorkerClient.ts @@ -1,10 +1,65 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Worker } from 'node:worker_threads'; - -import { createLogger } from '@shared/utils/logger'; +import { parseTeamId, type TeamId, type WorkspaceId } from '@shared/contracts/hosted'; + +import { + type ExternalWriterIdentityInventoryCapture, + MAX_TEAM_IDENTITY_READ_RECORDS, + parseIdentityTimestamp, + parseTeamIdentityChecksum, + parseTeamIdentityRecord, +} from '../../contracts/teamIdentityStorageContracts'; +import { + parseTeamRosterSnapshotRecord, + type TeamRosterAdoptRecordResult, + type TeamRosterSnapshotRecord, + type TeamRosterStorageGateway, +} from '../../contracts/teamRosterStorageContracts'; +import { + parseHostedAuthorityProjectionCommitResult, + parseHostedAuthorityProjectionRecord, +} from '../application/hostedAuthorityProjectionStorage'; + +import { parseExternalWriterObservationCheckpointRecord } from './worker/externalWriterObservationCheckpointSupport'; +import { + type HostedTeamConfigurationWorkerPayloadByOp, + type InternalStorageWorkerRequest, + type ProcessOwnershipWorkerPayloadByOp, +} from './worker/internalStorageWorkerProtocol'; +import { HostedTeamApprovalWorkerClient } from './HostedTeamApprovalWorkerClient'; +import { resolveInternalStorageWorkerPath } from './internalStorageWorkerPath'; +import { + type InternalStorageWorkerCallOptions, + type InternalStorageWorkerPayloadFor, + InternalStorageWorkerTransport, +} from './InternalStorageWorkerTransport'; +import type { + ExternalWriterCleanHandoffConsumeRequest, + ExternalWriterCleanHandoffSaveRequest, + ExternalWriterObservationCheckpointIdentity, + ExternalWriterObservationCheckpointRecord, + ExternalWriterObservationCheckpointSaveRequest, + ExternalWriterObservationCheckpointStorageGateway, +} from '../../contracts/externalWriterObservationStorageContracts'; +import type { + ExternalWriterReconciliationCommitRequest, + ExternalWriterReconciliationReceipt, + ExternalWriterReconciliationStorageGateway, +} from '../../contracts/externalWriterReconciliationStorageContracts'; +import type { + HostedAuthStorageGateway, + HostedAuthStorageOperation, +} from '../../contracts/hostedAuthStorageContracts'; +import type { + HostedTeamConfigurationStorageCreateRequest, + HostedTeamConfigurationStorageCreateResult, + HostedTeamConfigurationStorageDeleteRequest, + HostedTeamConfigurationStorageDeleteResult, + HostedTeamConfigurationStorageGateway, + HostedTeamConfigurationStorageMutationOptions, + HostedTeamConfigurationStorageReadResult, + HostedTeamConfigurationStorageUpdateRequest, + HostedTeamConfigurationStorageUpdateResult, +} from '../../contracts/hostedTeamConfigurationStorageContracts'; import type { CommentJournalEntryRecord, InternalStorageBackendInfo, @@ -17,15 +72,16 @@ import type { MemberWorkSyncTeamSnapshotRecords, StallJournalEntryRecord, } from '../../contracts/internalStorageContracts'; +import type { + TeamIdentityReadGateway, + TeamIdentityRecord, +} from '../../contracts/teamIdentityStorageContracts'; import type { InternalStorageGateway, MemberWorkSyncStorageGateway, } from '../../core/application/ports'; -import type { - InternalStorageWorkerData, - InternalStorageWorkerRequest, - InternalStorageWorkerResponse, -} from './worker/internalStorageWorkerProtocol'; +import type { CoordinationDurabilityStorageGateway } from '../application/coordinationDurabilityStorage'; +import type { ProcessOwnershipStorageCallContext } from '../application/processOwnershipStorage'; import type { ApplicationCommandLedgerBeginRequest, ApplicationCommandLedgerBeginResult, @@ -36,145 +92,261 @@ import type { ApplicationCommandLedgerReadByIdempotencyKeyRequest, ApplicationCommandLedgerRecord, ApplicationCommandLedgerStorageGateway, + DurableApplicationCommandAttemptLeaseRequest, + DurableApplicationCommandClaimResult, + DurableApplicationCommandClaimStatusRequest, + DurableApplicationCommandCommitRequest, + DurableApplicationCommandConsumerApplyRequest, + DurableApplicationCommandConsumerApplyResult, + DurableApplicationCommandConsumerProjectionRecord, + DurableApplicationCommandConsumerProjectionRequest, + DurableApplicationCommandEffectTransitionRequest, + DurableApplicationCommandLedgerStorageGateway, + DurableApplicationCommandOutboxClaimRequest, + DurableApplicationCommandOutboxDeliveryAcknowledgementRequest, + DurableApplicationCommandOutboxListRequest, + DurableApplicationCommandOutboxRecord, + DurableApplicationCommandPersistClaimRequest, + DurableApplicationCommandRecord, + DurableApplicationCommandStatusRequest, + DurableApplicationCommandTransitionRequest, + HostedAuthorityProjectionCommitResult, + HostedAuthorityProjectionPersistRequest, + HostedAuthorityProjectionReadRequest, + HostedAuthorityProjectionRecord, } from '@features/application-command-ledger'; -const logger = createLogger('Service:InternalStorageWorkerClient'); - -// Keeps per-op payload typing for the journal ops; mws.* ops share one wire -// shape and are typed by the public gateway methods instead. -type InternalStorageWorkerPayloadFor = TOp extends - | `appCommandLedger.${string}` - | `mws.${string}` - ? unknown - : Extract['payload']; - -const WORKER_CALL_TIMEOUT_MS = 20_000; -const WORKER_FILENAME = 'internal-storage-worker.cjs'; - -interface PendingEntry { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - op: InternalStorageWorkerRequest['op']; - createdAt: number; -} - -interface QueuedEntry extends PendingEntry { - id: string; - payload: InternalStorageWorkerRequest['payload']; -} - -function makeId(): string { - return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; -} - -function resolveWorkerPath(): string | null { - // Same candidate strategy as team-fs-worker: co-located with the bundled - // main output first, then the dev dist folder. - const baseDir = - typeof __dirname === 'string' && __dirname.length > 0 - ? __dirname - : path.dirname(fileURLToPath(import.meta.url)); - - const candidates = [ - path.join(baseDir, WORKER_FILENAME), - path.join(process.cwd(), 'dist-electron', 'main', WORKER_FILENAME), - ]; - - for (const candidate of candidates) { - try { - if (fs.existsSync(candidate)) { - return candidate; - } - } catch { - // ignore - } - } - - return null; -} - -/** - * Async facade over the internal-storage worker thread. Requests run one at a - * time (SQLite access is serialized anyway); a timeout or worker crash rejects - * all in-flight requests and the worker is recreated on the next call. - */ +/** Async facade over the serialized internal-storage worker thread. */ export class InternalStorageWorkerClient + extends HostedTeamApprovalWorkerClient implements InternalStorageGateway, MemberWorkSyncStorageGateway, - ApplicationCommandLedgerStorageGateway + ApplicationCommandLedgerStorageGateway, + DurableApplicationCommandLedgerStorageGateway, + TeamIdentityReadGateway, + TeamRosterStorageGateway, + CoordinationDurabilityStorageGateway, + HostedAuthStorageGateway, + HostedTeamConfigurationStorageGateway, + ExternalWriterObservationCheckpointStorageGateway, + ExternalWriterReconciliationStorageGateway { - private worker: Worker | null = null; - private readonly workerPath: string | null = resolveWorkerPath(); - private pending = new Map(); - private queue: QueuedEntry[] = []; - private activeCallId: string | null = null; - private activeTimeout: ReturnType | null = null; - private closed = false; - - constructor(private readonly options: { databasePath: string }) {} - + private readonly workerPath: string | null = resolveInternalStorageWorkerPath(); + private readonly transport: InternalStorageWorkerTransport; + + constructor(options: { + databasePath: string; + mode?: 'team-identity-read-only'; + }) { + super(); + this.transport = new InternalStorageWorkerTransport(options, () => this.workerPath); + this.initializeHostedTeamStorage( + (op, payload, callOptions) => this.call(op, payload, callOptions), + (op, payload, callOptions) => this.callHostedTeamConfiguration(op, payload, callOptions) + ); + } isAvailable(): boolean { - return this.workerPath !== null; + return this.transport.isAvailable(); } - getWorkerPathCandidatesForDiagnostics(): string[] { - const baseDir = - typeof __dirname === 'string' && __dirname.length > 0 - ? __dirname - : path.dirname(fileURLToPath(import.meta.url)); - return [ - path.join(baseDir, WORKER_FILENAME), - path.join(process.cwd(), 'dist-electron', 'main', WORKER_FILENAME), - ]; + return this.transport.getWorkerPathCandidatesForDiagnostics(); } - async ping(): Promise { const result = await this.call('ping', {}); return result as InternalStorageBackendInfo; } + async hostedAuthCall(operation: HostedAuthStorageOperation, payload: unknown): Promise { + return this.call('hostedAuth.call', { operation, payload }); + } + + async loadExternalWriterObservationCheckpoint( + identity: ExternalWriterObservationCheckpointIdentity + ): Promise { + const value = await this.call('externalWriterObservation.load', identity); + return value === null ? null : parseExternalWriterObservationCheckpointRecord(value); + } + async saveExternalWriterObservationCheckpoint( + request: ExternalWriterObservationCheckpointSaveRequest + ): Promise { + return parseExternalWriterObservationCheckpointRecord( + await this.call('externalWriterObservation.save', request) + ); + } + async saveExternalWriterCleanHandoffEligibility( + request: ExternalWriterCleanHandoffSaveRequest + ): Promise { + return parseExternalWriterObservationCheckpointRecord( + await this.call('externalWriterObservation.saveCleanHandoff', request) + ); + } + async consumeExternalWriterCleanHandoffEligibility( + request: ExternalWriterCleanHandoffConsumeRequest + ): Promise { + const value = await this.call('externalWriterObservation.consumeCleanHandoff', request); + return value === null ? null : parseExternalWriterObservationCheckpointRecord(value); + } + async callHostedTeamConfiguration( + op: TOp, + payload: HostedTeamConfigurationWorkerPayloadByOp[TOp], + options: InternalStorageWorkerCallOptions = {} + ): Promise { + return this.hostedTeamStorage.callHostedTeamConfiguration(op, payload, options); + } + async createHostedTeamConfiguration( + request: HostedTeamConfigurationStorageCreateRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise { + return this.hostedTeamStorage.createHostedTeamConfiguration(request, options); + } + async readHostedTeamConfiguration(input: { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; + }): Promise { + return this.hostedTeamStorage.readHostedTeamConfiguration(input); + } + async updateHostedTeamConfiguration( + request: HostedTeamConfigurationStorageUpdateRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise { + return this.hostedTeamStorage.updateHostedTeamConfiguration(request, options); + } + async deleteHostedTeamConfiguration( + request: HostedTeamConfigurationStorageDeleteRequest, + options: HostedTeamConfigurationStorageMutationOptions + ): Promise { + return this.hostedTeamStorage.deleteHostedTeamConfiguration(request, options); + } async loadStallJournalEntries(teamName: string): Promise { const result = await this.call('stallJournal.load', { teamName }); return result as StallJournalEntryRecord[]; } - async replaceStallJournalEntries( teamName: string, entries: StallJournalEntryRecord[] ): Promise { await this.call('stallJournal.replace', { teamName, entries }); } - async loadCommentJournalEntries(teamName: string): Promise { const result = await this.call('commentJournal.load', { teamName }); return result as CommentJournalEntryRecord[]; } - async replaceCommentJournalEntries( teamName: string, entries: CommentJournalEntryRecord[] ): Promise { await this.call('commentJournal.replace', { teamName, entries }); } - async commentJournalExists(teamName: string): Promise { const result = await this.call('commentJournal.exists', { teamName }); return result === true; } - async ensureCommentJournalInitialized(teamName: string): Promise { await this.call('commentJournal.ensureInitialized', { teamName }); } - async recordStoreImport(storeId: string, teamName: string, entryCount: number): Promise { await this.call('storeImports.record', { storeId, teamName, entryCount }); } - async hasStoreImport(storeId: string, teamName: string): Promise { return (await this.call('storeImports.has', { storeId, teamName })) === true; } - + async listTeamIdentities(): Promise { + const value = await this.call('teamIdentity.list', {}); + return this.parseIdentityList(value); + } + async listActiveTeamIdentities(): Promise { + const value = await this.call('teamIdentity.listActive', {}); + return this.parseIdentityList(value); + } + async captureExternalWriterTeamIdentities(request: { + readonly retirementCandidates: readonly TeamId[]; + }): Promise { + const value = await this.call('teamIdentity.captureExternalWriterInventory', { + retirementCandidates: request.retirementCandidates, + }); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('external-writer-inventory-capture-invalid'); + } + const record = value as Record; + if ( + Reflect.ownKeys(record).length !== 2 || + !Object.hasOwn(record, 'active') || + !Object.hasOwn(record, 'retiredCandidates') || + !Array.isArray(record.retiredCandidates) || + record.retiredCandidates.length > 1_024 + ) { + throw new TypeError('external-writer-inventory-capture-invalid'); + } + const active = this.parseIdentityList(record.active); + const retiredCandidates = record.retiredCandidates.map((proof) => { + if (typeof proof !== 'object' || proof === null || Array.isArray(proof)) { + throw new TypeError('external-writer-inventory-capture-invalid'); + } + const candidate = proof as Record; + if ( + Reflect.ownKeys(candidate).length !== 3 || + !Object.hasOwn(candidate, 'teamId') || + !Object.hasOwn(candidate, 'identityChecksum') || + !Object.hasOwn(candidate, 'tombstonedAt') + ) { + throw new TypeError('external-writer-inventory-capture-invalid'); + } + const identity = active.find((entry) => entry.teamId === candidate.teamId); + if (identity) throw new TypeError('external-writer-inventory-capture-invalid'); + return Object.freeze({ + teamId: parseTeamId(candidate.teamId), + identityChecksum: parseTeamIdentityChecksum(candidate.identityChecksum), + tombstonedAt: parseIdentityTimestamp(candidate.tombstonedAt), + }); + }); + return Object.freeze({ active, retiredCandidates: Object.freeze(retiredCandidates) }); + } + private parseIdentityList(value: unknown): readonly TeamIdentityRecord[] { + if (!Array.isArray(value) || value.length > MAX_TEAM_IDENTITY_READ_RECORDS) { + throw new TypeError('team-identity-list-invalid'); + } + const identities: TeamIdentityRecord[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError('team-identity-list-invalid'); + identities.push(parseTeamIdentityRecord(value[index])); + } + return Object.freeze(identities); + } + async getTeamIdentity(teamId: TeamId): Promise { + const value = await this.call('teamIdentity.get', { teamId }); + return value === null ? null : parseTeamIdentityRecord(value); + } + async getTeamRoster(teamId: TeamId): Promise { + const value = await this.call('teamRoster.get', { teamId }); + return value === null ? null : parseTeamRosterSnapshotRecord(value); + } + async adoptTeamRoster(record: TeamRosterSnapshotRecord): Promise { + const roster = parseTeamRosterSnapshotRecord(record); + const value = await this.call('teamRoster.adopt', { roster }); + if ( + typeof value !== 'object' || + value === null || + ((value as { outcome?: unknown }).outcome !== 'created' && + (value as { outcome?: unknown }).outcome !== 'existing') + ) { + throw new TypeError('team-roster-storage-adopt-result-invalid'); + } + const result = value as { outcome: 'created' | 'existing'; roster?: unknown }; + return { + outcome: result.outcome, + roster: parseTeamRosterSnapshotRecord(result.roster), + }; + } + protected callProcessOwnershipWorker( + op: TOp, + payload: ProcessOwnershipWorkerPayloadByOp[TOp], + context: ProcessOwnershipStorageCallContext + ): Promise { + return this.call(op, payload as InternalStorageWorkerPayloadFor, { + admission: context, + }); + } async statusRead( teamName: string, memberKey: string @@ -184,34 +356,28 @@ export class InternalStorageWorkerClient memberKey, })) as MemberWorkSyncStatusRecord | null; } - async statusWrite( record: MemberWorkSyncStatusRecord, events: MemberWorkSyncMetricEventRecord[] ): Promise { await this.call('mws.status.write', { record, events }); } - async statusList(teamName: string): Promise { return (await this.call('mws.status.list', { teamName })) as MemberWorkSyncStatusRecord[]; } - async metricEventsList(teamName: string): Promise { return (await this.call('mws.metricEvents.list', { teamName, })) as MemberWorkSyncMetricEventRecord[]; } - async reportsAppend(record: MemberWorkSyncReportIntentRecord): Promise { await this.call('mws.reports.append', { record }); } - async reportsListPending(teamName: string): Promise { return (await this.call('mws.reports.listPending', { teamName, })) as MemberWorkSyncReportIntentRecord[]; } - async reportsMarkProcessed( teamName: string, id: string, @@ -219,7 +385,6 @@ export class InternalStorageWorkerClient ): Promise { await this.call('mws.reports.markProcessed', { teamName, id, ...result }); } - async outboxEnsurePending( input: MemberWorkSyncOutboxEnsureRecordInput ): Promise { @@ -228,7 +393,6 @@ export class InternalStorageWorkerClient input )) as MemberWorkSyncOutboxEnsureRecordResult; } - async outboxClaimDue(input: { teamName: string; claimedBy: string; @@ -237,7 +401,6 @@ export class InternalStorageWorkerClient }): Promise { return (await this.call('mws.outbox.claimDue', input)) as MemberWorkSyncOutboxItemRecord[]; } - async outboxMarkDelivered(input: { teamName: string; id: string; @@ -249,7 +412,6 @@ export class InternalStorageWorkerClient }): Promise { await this.call('mws.outbox.markDelivered', input); } - async outboxMarkSuperseded(input: { teamName: string; id: string; @@ -258,7 +420,6 @@ export class InternalStorageWorkerClient }): Promise { await this.call('mws.outbox.markSuperseded', input); } - async outboxMarkFailed(input: { teamName: string; id: string; @@ -270,7 +431,6 @@ export class InternalStorageWorkerClient }): Promise { await this.call('mws.outbox.markFailed', input); } - async outboxCountRecentDelivered(input: { teamName: string; memberKey: string; @@ -279,7 +439,6 @@ export class InternalStorageWorkerClient }): Promise { return (await this.call('mws.outbox.countRecentDelivered', input)) as number; } - async outboxCountDeliveredForAgenda(input: { teamName: string; memberKey: string; @@ -288,7 +447,6 @@ export class InternalStorageWorkerClient }): Promise { return (await this.call('mws.outbox.countDeliveredForAgenda', input)) as number; } - async outboxFindDeliveredReviewPickupEventIds(input: { teamName: string; memberKey: string; @@ -296,7 +454,6 @@ export class InternalStorageWorkerClient }): Promise { return (await this.call('mws.outbox.findDeliveredReviewPickupEventIds', input)) as string[]; } - async outboxFindRecentRecoveryByIntent(input: { teamName: string; memberKey: string; @@ -308,13 +465,11 @@ export class InternalStorageWorkerClient input )) as MemberWorkSyncOutboxItemRecord | null; } - async listTeamSnapshot(teamName: string): Promise { return (await this.call('mws.snapshot.list', { teamName, })) as MemberWorkSyncTeamSnapshotRecords; } - async importTeam(teamName: string, snapshot: MemberWorkSyncTeamSnapshotRecords): Promise { await this.call('mws.importTeam', { teamName, snapshot }); } @@ -367,171 +522,169 @@ export class InternalStorageWorkerClient )) as ApplicationCommandLedgerRecord[]; } - async close(): Promise { - this.closed = true; - const worker = this.worker; - if (!worker) { - return; - } - try { - await this.call('close', {}, { allowWhenClosed: true }); - } catch (error) { - logger.warn( - `internal-storage close op failed; terminating worker anyway: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - this.worker = null; - await worker.terminate().catch(() => undefined); + async applicationCommandLedgerDurableClaim( + request: DurableApplicationCommandPersistClaimRequest + ): Promise> { + return (await this.call( + 'appCommandLedger.durable.claim', + request + )) as DurableApplicationCommandClaimResult; } - private failWorker(worker: Worker, error: Error): void { - if (this.worker !== worker) return; + async applicationCommandLedgerDurableGetStatus( + request: DurableApplicationCommandStatusRequest + ): Promise | null> { + return (await this.call( + 'appCommandLedger.durable.getStatus', + request + )) as DurableApplicationCommandRecord | null; + } - this.worker = null; - this.clearActiveCall(); - const pendingEntries = Array.from(this.pending.values()); - const queuedEntries = [...this.queue]; - this.pending.clear(); - this.queue = []; + async applicationCommandLedgerDurableGetByClaim( + request: DurableApplicationCommandClaimStatusRequest + ): Promise | null> { + return (await this.call( + 'appCommandLedger.durable.getByClaim', + request + )) as DurableApplicationCommandRecord | null; + } - for (const entry of pendingEntries) { - entry.reject(error); - } - for (const entry of queuedEntries) { - entry.reject(error); - } + async applicationCommandLedgerDurableRenewAttemptLease( + request: DurableApplicationCommandAttemptLeaseRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.renewAttemptLease', + request + )) as DurableApplicationCommandRecord; } - private ensureWorker(): Worker { - if (!this.workerPath) { - throw new Error('internal-storage worker is not available in this environment'); - } - if (this.worker) { - return this.worker; - } + async applicationCommandLedgerDurableTransitionCommand( + request: DurableApplicationCommandTransitionRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.transitionCommand', + request + )) as DurableApplicationCommandRecord; + } - const workerData: InternalStorageWorkerData = { databasePath: this.options.databasePath }; - const worker = new Worker(this.workerPath, { workerData }); - this.worker = worker; - worker.on('message', (msg: InternalStorageWorkerResponse) => { - const entry = this.pending.get(msg.id); - if (!entry) return; - this.pending.delete(msg.id); - this.clearActiveCall(msg.id); - if (msg.ok) { - entry.resolve(msg.result); - } else { - entry.reject(new Error(msg.error)); - } - this.processQueue(); - }); - worker.on('error', (err) => { - logger.error('internal-storage worker error', err); - this.failWorker(worker, err instanceof Error ? err : new Error(String(err))); - }); - worker.on('exit', (code) => { - if (code !== 0) { - logger.warn(`internal-storage worker exited with code ${code}`); - } - this.failWorker(worker, new Error(`internal-storage worker exited with code ${code}`)); - }); + async applicationCommandLedgerDurableTransitionEffect( + request: DurableApplicationCommandEffectTransitionRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.transitionEffect', + request + )) as DurableApplicationCommandRecord; + } - return worker; + async applicationCommandLedgerDurableCommit( + request: DurableApplicationCommandCommitRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.commit', + request + )) as DurableApplicationCommandRecord; } - private clearActiveCall(id?: string): void { - if (id && this.activeCallId !== id) { - return; - } - if (this.activeTimeout) { - clearTimeout(this.activeTimeout); - this.activeTimeout = null; - } - this.activeCallId = null; + async applicationCommandLedgerDurableListOutbox( + request: DurableApplicationCommandOutboxListRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.listOutbox', + request + )) as DurableApplicationCommandOutboxRecord[]; } - private processQueue(): void { - if (this.activeCallId || this.queue.length === 0) { - return; - } + async applicationCommandLedgerDurableClaimOutbox( + request: DurableApplicationCommandOutboxClaimRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.claimOutbox', + request + )) as DurableApplicationCommandOutboxRecord[]; + } - const entry = this.queue.shift(); - if (!entry) { - return; - } + async applicationCommandLedgerDurableAcknowledgeOutboxDelivery( + request: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest + ): Promise { + await this.call('appCommandLedger.durable.acknowledgeOutboxDelivery', request); + } - let worker: Worker; - try { - worker = this.ensureWorker(); - } catch (error) { - entry.reject(error instanceof Error ? error : new Error(String(error))); - this.processQueue(); - return; - } + async applicationCommandLedgerDurableApplyConsumerEvent( + request: DurableApplicationCommandConsumerApplyRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.applyConsumerEvent', + request + )) as DurableApplicationCommandConsumerApplyResult; + } - this.pending.set(entry.id, entry); - this.activeCallId = entry.id; - this.activeTimeout = setTimeout(() => { - if (this.activeCallId !== entry.id) { - return; - } - const timeoutError = new Error( - `internal-storage worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${entry.op})` - ); - logger.warn( - `worker call timeout op=${entry.op} ms=${Date.now() - entry.createdAt} pendingNow=${this.pending.size} queued=${this.queue.length}` - ); - this.failWorker(worker, timeoutError); - // The worker may be stuck in native IO; terminate and recreate lazily. - // SQLite's journal makes a mid-transaction kill safe (auto-rollback). - void worker.terminate().catch(() => undefined); - }, WORKER_CALL_TIMEOUT_MS); - - try { - worker.postMessage({ - id: entry.id, - op: entry.op, - payload: entry.payload, - } as InternalStorageWorkerRequest); - } catch (error) { - const postError = error instanceof Error ? error : new Error(String(error)); - this.pending.delete(entry.id); - this.clearActiveCall(entry.id); - entry.reject(postError); - this.processQueue(); - } + async applicationCommandLedgerDurableGetConsumerProjection( + request: DurableApplicationCommandConsumerProjectionRequest + ): Promise { + return (await this.call( + 'appCommandLedger.durable.getConsumerProjection', + request + )) as DurableApplicationCommandConsumerProjectionRecord | null; + } + + async applicationCommandLedgerHostedAuthorityProjectionCommit( + request: HostedAuthorityProjectionPersistRequest + ): Promise { + return parseHostedAuthorityProjectionCommitResult( + await this.call('appCommandLedger.hostedAuthorityProjection.commit', request, { + timeoutAtMs: request.deadlineAtMs, + }) + ); + } + + async applicationCommandLedgerHostedAuthorityProjectionGet( + request: HostedAuthorityProjectionReadRequest + ): Promise { + const result = await this.call('appCommandLedger.hostedAuthorityProjection.get', request, { + timeoutAtMs: request.deadlineAtMs, + }); + return result === null ? null : parseHostedAuthorityProjectionRecord(result); + } + + async getExternalWriterReconciliation(input: { + readonly deploymentId: string; + readonly reconciliationId: string; + }): Promise { + return (await this.call( + 'externalWriterReconciliation.get', + input + )) as ExternalWriterReconciliationReceipt | null; + } + + async commitExternalWriterReconciliation( + input: ExternalWriterReconciliationCommitRequest + ): Promise<{ + readonly outcome: 'committed' | 'idempotent_replay' | 'input_conflict'; + readonly receipt: ExternalWriterReconciliationReceipt | null; + }> { + return (await this.call('externalWriterReconciliation.commit', input)) as { + readonly outcome: 'committed' | 'idempotent_replay' | 'input_conflict'; + readonly receipt: ExternalWriterReconciliationReceipt | null; + }; + } + + async close(): Promise { + await this.transport.close(); + } + + protected callCoordinationWorker( + op: TOp, + payload: InternalStorageWorkerPayloadFor, + options: InternalStorageWorkerCallOptions = {} + ): Promise { + return this.transport.call(op, payload, options); } private call( op: TOp, payload: InternalStorageWorkerPayloadFor, - options: { allowWhenClosed?: boolean } = {} + options: InternalStorageWorkerCallOptions = {} ): Promise { - if (this.closed && !options.allowWhenClosed) { - return Promise.reject(new Error('internal-storage client is closed')); - } - const id = makeId(); - const createdAt = Date.now(); - return new Promise((resolve, reject) => { - this.queue.push({ - id, - op, - payload, - createdAt, - resolve: (value) => { - const ms = Date.now() - createdAt; - if (ms >= 1500) { - logger.warn( - `worker call slow op=${op} ms=${ms} pendingNow=${this.pending.size} queued=${this.queue.length}` - ); - } - resolve(value); - }, - reject, - }); - this.processQueue(); - }); + return this.transport.call(op, payload, options); } } diff --git a/src/features/internal-storage/main/infrastructure/InternalStorageWorkerTransport.ts b/src/features/internal-storage/main/infrastructure/InternalStorageWorkerTransport.ts new file mode 100644 index 0000000000..3cf12ce08b --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/InternalStorageWorkerTransport.ts @@ -0,0 +1,317 @@ +import { Worker } from 'node:worker_threads'; + +import { createLogger } from '@shared/utils/logger'; + +import { + isProcessOwnershipStorageCallAdmitted, + type ProcessOwnershipStorageCallContext, +} from '../application/processOwnershipStorage'; + +import { + type ApplicationCommandLedgerWorkerPayloadByOp, + type ExternalWriterObservationWorkerPayloadByOp, + type HostedTeamConfigurationWorkerPayloadByOp, + type InternalStorageWorkerData, + type InternalStorageWorkerRequest, + type InternalStorageWorkerResponse, + parseInternalStorageWorkerResponseForPending, +} from './worker/internalStorageWorkerProtocol'; +import { + getInternalStorageWorkerPathCandidates, + resolveInternalStorageWorkerPath, +} from './internalStorageWorkerPath'; + +const logger = createLogger('Service:InternalStorageWorkerClient'); +const WORKER_CALL_TIMEOUT_MS = 20_000; + +export type InternalStorageWorkerPayloadFor = + TOp extends keyof ApplicationCommandLedgerWorkerPayloadByOp + ? ApplicationCommandLedgerWorkerPayloadByOp[TOp] + : TOp extends keyof ExternalWriterObservationWorkerPayloadByOp + ? ExternalWriterObservationWorkerPayloadByOp[TOp] + : TOp extends keyof HostedTeamConfigurationWorkerPayloadByOp + ? HostedTeamConfigurationWorkerPayloadByOp[TOp] + : TOp extends `appCommandLedger.${string}` | `mws.${string}` + ? unknown + : Extract['payload']; + +export interface InternalStorageWorkerCallOptions { + readonly allowWhenClosed?: boolean; + readonly timeoutAtMs?: number; + readonly admission?: ProcessOwnershipStorageCallContext; + readonly signal?: AbortSignal; +} + +interface PendingEntry { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + op: InternalStorageWorkerRequest['op']; + createdAt: number; + timeoutAtMs?: number; +} + +interface QueuedEntry extends PendingEntry { + id: string; + payload: InternalStorageWorkerRequest['payload']; + admission?: ProcessOwnershipStorageCallContext; + signal?: AbortSignal; + detachAbortListener?: () => void; +} + +function makeId(): string { + return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; +} + +function isHostedTeamConfigurationMutation(op: InternalStorageWorkerRequest['op']): boolean { + return ( + op === 'hostedTeamConfiguration.create' || + op === 'hostedTeamConfiguration.update' || + op === 'hostedTeamConfiguration.delete' + ); +} + +/** + * Owns worker lifecycle and serialized request dispatch. The public storage + * client only maps feature gateway methods onto this transport. + */ +export class InternalStorageWorkerTransport { + private worker: Worker | null = null; + private pending = new Map(); + private queue: QueuedEntry[] = []; + private activeCallId: string | null = null; + private activeTimeout: ReturnType | null = null; + private closed = false; + + constructor( + private readonly options: { + databasePath: string; + mode?: InternalStorageWorkerData['mode']; + }, + private readonly getWorkerPath: () => string | null = resolveInternalStorageWorkerPath + ) {} + + isAvailable(): boolean { + return this.getWorkerPath() !== null; + } + + getWorkerPathCandidatesForDiagnostics(): string[] { + return getInternalStorageWorkerPathCandidates(); + } + + async close(): Promise { + this.closed = true; + const worker = this.worker; + if (!worker) { + return; + } + try { + await this.call('close', {}, { allowWhenClosed: true }); + } catch (error) { + logger.warn( + `internal-storage close op failed; terminating worker anyway: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + this.worker = null; + await worker.terminate().catch(() => undefined); + } + + private failWorker(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + + this.worker = null; + this.clearActiveCall(); + const pendingEntries = Array.from(this.pending.values()); + const queuedEntries = [...this.queue]; + this.pending.clear(); + this.queue = []; + + for (const entry of pendingEntries) { + entry.reject(error); + } + for (const entry of queuedEntries) { + entry.detachAbortListener?.(); + entry.reject(error); + } + } + + private ensureWorker(): Worker { + const workerPath = this.getWorkerPath(); + if (!workerPath) { + throw new Error('internal-storage worker is not available in this environment'); + } + if (this.worker) { + return this.worker; + } + + const workerData: InternalStorageWorkerData = { + databasePath: this.options.databasePath, + ...(this.options.mode === undefined ? {} : { mode: this.options.mode }), + }; + const worker = new Worker(workerPath, { workerData }); + this.worker = worker; + worker.on('message', (value: unknown) => { + let msg: InternalStorageWorkerResponse; + try { + msg = parseInternalStorageWorkerResponseForPending(value, (id) => this.pending.get(id)?.op); + } catch (error) { + this.failWorker(worker, error instanceof Error ? error : new Error(String(error))); + void worker.terminate().catch(() => undefined); + return; + } + const entry = this.pending.get(msg.id); + if (!entry) return; + this.pending.delete(msg.id); + this.clearActiveCall(msg.id); + if (msg.ok) { + entry.resolve(msg.result); + } else { + entry.reject(new Error(msg.error)); + } + this.processQueue(); + }); + worker.on('error', (err) => { + logger.error('internal-storage worker error', err); + this.failWorker(worker, err instanceof Error ? err : new Error(String(err))); + }); + worker.on('exit', (code) => { + if (code !== 0 && !this.closed && this.worker === worker) { + logger.warn(`internal-storage worker exited with code ${code}`); + } + this.failWorker(worker, new Error(`internal-storage worker exited with code ${code}`)); + }); + return worker; + } + + private clearActiveCall(id?: string): void { + if (id && this.activeCallId !== id) { + return; + } + if (this.activeTimeout) { + clearTimeout(this.activeTimeout); + this.activeTimeout = null; + } + this.activeCallId = null; + } + + private processQueue(): void { + if (this.activeCallId || this.queue.length === 0) { + return; + } + const entry = this.queue.shift(); + if (!entry) { + return; + } + if ( + entry.signal?.aborted || + (entry.timeoutAtMs !== undefined && Date.now() >= entry.timeoutAtMs) || + !isProcessOwnershipStorageCallAdmitted(entry.admission) + ) { + entry.detachAbortListener?.(); + entry.reject(new Error('process-ownership-storage-call-admission-expired')); + this.processQueue(); + return; + } + entry.detachAbortListener?.(); + + let worker: Worker; + try { + worker = this.ensureWorker(); + } catch (error) { + entry.reject(error instanceof Error ? error : new Error(String(error))); + this.processQueue(); + return; + } + + this.pending.set(entry.id, entry); + this.activeCallId = entry.id; + const dispatchedAt = Date.now(); + let timeoutMs = + entry.timeoutAtMs === undefined || isHostedTeamConfigurationMutation(entry.op) + ? WORKER_CALL_TIMEOUT_MS + : Math.max(1, entry.timeoutAtMs - dispatchedAt); + if (entry.admission) { + timeoutMs = Math.min(timeoutMs, Math.max(1, entry.admission.deadlineAtMs - dispatchedAt)); + } + this.activeTimeout = setTimeout(() => { + if (this.activeCallId !== entry.id) { + return; + } + const timeoutError = new Error( + `internal-storage worker call timeout after ${Date.now() - entry.createdAt}ms (${entry.op})` + ); + logger.warn( + `worker call timeout op=${entry.op} ms=${Date.now() - entry.createdAt} pendingNow=${this.pending.size} queued=${this.queue.length}` + ); + this.failWorker(worker, timeoutError); + void worker.terminate().catch(() => undefined); + }, timeoutMs); + + try { + worker.postMessage({ + id: entry.id, + op: entry.op, + payload: entry.payload, + } as InternalStorageWorkerRequest); + } catch (error) { + const postError = error instanceof Error ? error : new Error(String(error)); + this.pending.delete(entry.id); + this.clearActiveCall(entry.id); + entry.reject(postError); + this.processQueue(); + } + } + + call( + op: TOp, + payload: InternalStorageWorkerPayloadFor, + options: InternalStorageWorkerCallOptions = {} + ): Promise { + if (this.closed && !options.allowWhenClosed) { + return Promise.reject(new Error('internal-storage client is closed')); + } + const id = makeId(); + const createdAt = Date.now(); + return new Promise((resolve, reject) => { + const entry: QueuedEntry = { + id, + op, + payload, + createdAt, + timeoutAtMs: options.timeoutAtMs, + admission: options.admission, + signal: options.signal, + resolve: (value) => { + const ms = Date.now() - createdAt; + if (ms >= 1500) { + logger.warn( + `worker call slow op=${op} ms=${ms} pendingNow=${this.pending.size} queued=${this.queue.length}` + ); + } + resolve(value); + }, + reject, + }; + let rejectQueuedAbort: (() => void) | undefined; + if (options.signal) { + rejectQueuedAbort = () => { + const index = this.queue.indexOf(entry); + if (index < 0) return; + this.queue.splice(index, 1); + entry.detachAbortListener?.(); + reject(new Error('internal-storage-worker-call-aborted-before-dispatch')); + }; + const abortListener = rejectQueuedAbort; + options.signal.addEventListener('abort', abortListener, { once: true }); + entry.detachAbortListener = () => + options.signal?.removeEventListener('abort', abortListener); + } + this.queue.push(entry); + if (options.signal?.aborted) { + rejectQueuedAbort?.(); + } + this.processQueue(); + }); + } +} diff --git a/src/features/internal-storage/main/infrastructure/ProcessOwnershipStorageGateway.ts b/src/features/internal-storage/main/infrastructure/ProcessOwnershipStorageGateway.ts new file mode 100644 index 0000000000..c133a8cd8f --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/ProcessOwnershipStorageGateway.ts @@ -0,0 +1,85 @@ +import { + type ProcessOwnershipStorageCallContext, + type ProcessOwnershipStorageCompareAndSwapRequest, + type ProcessOwnershipStorageCompareAndSwapResult, + type ProcessOwnershipStorageGateway, + type ProcessOwnershipStorageLoadResult, + type ProcessOwnershipStorageScope, + type StoredProcessOwnershipState, +} from '../application/processOwnershipStorage'; + +import { + parseProcessOwnershipWorkerResult, + type ProcessOwnershipWorkerPayloadByOp, +} from './worker/internalStorageWorkerProtocol'; + +export { + isProcessOwnershipStorageCallAdmitted, + PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION, + type ProcessOwnershipStorageCallContext, + type ProcessOwnershipStorageCompareAndSwapRequest, + type ProcessOwnershipStorageCompareAndSwapResult, + type ProcessOwnershipStorageGateway, + type ProcessOwnershipStorageLoadResult, + type ProcessOwnershipStorageScope, + type StoredProcessOwnershipPhase, + type StoredProcessOwnershipState, +} from '../application/processOwnershipStorage'; + +/** Shared typed ownership facade; concrete clients retain ownership of queue admission. */ +export abstract class ProcessOwnershipStorageGatewayClient implements ProcessOwnershipStorageGateway { + protected abstract callProcessOwnershipWorker< + TOp extends keyof ProcessOwnershipWorkerPayloadByOp, + >( + op: TOp, + payload: ProcessOwnershipWorkerPayloadByOp[TOp], + context: ProcessOwnershipStorageCallContext + ): Promise; + + async loadProcessOwnershipByScope( + scope: ProcessOwnershipStorageScope, + context: ProcessOwnershipStorageCallContext + ): Promise { + return parseProcessOwnershipWorkerResult( + 'processOwnership.loadByScope', + await this.callProcessOwnershipWorker('processOwnership.loadByScope', { scope }, context) + ); + } + + async loadProcessOwnershipByProcessRef( + processRef: string, + context: ProcessOwnershipStorageCallContext + ): Promise { + return parseProcessOwnershipWorkerResult( + 'processOwnership.loadByProcessRef', + await this.callProcessOwnershipWorker( + 'processOwnership.loadByProcessRef', + { processRef }, + context + ) + ); + } + + async listProcessOwnershipRecords( + context: ProcessOwnershipStorageCallContext + ): Promise { + return parseProcessOwnershipWorkerResult( + 'processOwnership.list', + await this.callProcessOwnershipWorker('processOwnership.list', {}, context) + ); + } + + async compareAndSwapProcessOwnership( + request: ProcessOwnershipStorageCompareAndSwapRequest, + context: ProcessOwnershipStorageCallContext + ): Promise { + return parseProcessOwnershipWorkerResult( + 'processOwnership.compareAndSwap', + await this.callProcessOwnershipWorker( + 'processOwnership.compareAndSwap', + { request, admission: { deadlineAtMs: context.deadlineAtMs } }, + context + ) + ); + } +} diff --git a/src/features/internal-storage/main/infrastructure/internalStorageWorkerPath.ts b/src/features/internal-storage/main/infrastructure/internalStorageWorkerPath.ts new file mode 100644 index 0000000000..1b41f3b4d2 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/internalStorageWorkerPath.ts @@ -0,0 +1,29 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const WORKER_FILENAME = 'internal-storage-worker.cjs'; + +export function getInternalStorageWorkerPathCandidates(): string[] { + const baseDir = + typeof __dirname === 'string' && __dirname.length > 0 + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + return [ + path.join(baseDir, WORKER_FILENAME), + path.join(baseDir, 'assets', WORKER_FILENAME), + path.join(process.cwd(), 'dist-standalone', 'assets', WORKER_FILENAME), + path.join(process.cwd(), 'dist-electron', 'main', WORKER_FILENAME), + ]; +} + +export function resolveInternalStorageWorkerPath(): string | null { + for (const candidate of getInternalStorageWorkerPathCandidates()) { + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + // An inaccessible candidate is unavailable; the next packaged/dev path may still work. + } + } + return null; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/InternalStorageWorkerCore.ts b/src/features/internal-storage/main/infrastructure/worker/InternalStorageWorkerCore.ts index 75d81d150a..a0083fd24f 100644 --- a/src/features/internal-storage/main/infrastructure/worker/InternalStorageWorkerCore.ts +++ b/src/features/internal-storage/main/infrastructure/worker/InternalStorageWorkerCore.ts @@ -8,6 +8,15 @@ import { ApplicationCommandLedgerWorkerOps, handleApplicationCommandLedgerOp, } from './applicationCommandLedgerWorkerOps'; +import { + assertInternalStorageMutationAdmissionOpen, + CoordinationDurabilityWorkerOps, +} from './coordinationDurabilityWorkerOps'; +import { ExternalWriterObservationStorageOps } from './externalWriterObservationStorageOps'; +import { ExternalWriterReconciliationStorageOps } from './externalWriterReconciliationStorageOps'; +import { HostedAuthStorageOps } from './hostedAuthStorageOps'; +import { HostedTeamApprovalAuthorityStorageOps } from './hostedTeamApprovalAuthorityStorageOps'; +import { HostedTeamConfigurationStorageOps } from './hostedTeamConfigurationStorageOps'; import { INTERNAL_STORAGE_SCHEMA_VERSION, readSchemaVersion, @@ -19,8 +28,17 @@ import { stallJournalEntries, storeImports, } from './internalStorageSchema'; -import { parseJournalReplacePayload } from './internalStorageWorkerProtocol'; +import { + parseJournalReplacePayload, + parseProcessOwnershipWorkerPayload, +} from './internalStorageWorkerProtocol'; import { handleMemberWorkSyncOp, MemberWorkSyncWorkerOps } from './memberWorkSyncWorkerOps'; +import { + ProcessOwnershipStorageOps, + recordProcessOwnershipCorruptionMarker, +} from './processOwnershipStorageOps'; +import { TeamIdentityStorageOps } from './teamIdentityStorageOps'; +import { TeamRosterStorageOps } from './teamRosterStorageOps'; import type { CommentJournalEntryRecord, @@ -40,6 +58,13 @@ type SqliteDatabase = InstanceType; const INSERT_CHUNK_SIZE = 400; const INTEGRITY_CHECK_ERROR_PREFIX = 'integrity_check failed'; +const TEAM_IDENTITY_READ_ONLY_OPS = new Set([ + 'teamIdentity.list', + 'teamIdentity.listActive', + 'teamIdentity.captureExternalWriterInventory', + 'teamIdentity.get', + 'close', +]); /** * Only confirmed corruption may trigger the backup-and-recreate path; @@ -61,8 +86,12 @@ function isLikelyCorruptionError(error: unknown): boolean { export interface InternalStorageWorkerCoreOptions { databasePath: string; + mode?: 'team-identity-read-only'; /** Injected so tests can pass a Node-ABI build of better-sqlite3. */ - createDatabase(databasePath: string): SqliteDatabase; + createDatabase( + databasePath: string, + options?: { readonly?: boolean; fileMustExist?: boolean } + ): SqliteDatabase; now?(): Date; } @@ -79,13 +108,55 @@ interface OpenState { export class InternalStorageWorkerCore { private state: OpenState | null = null; private readonly applicationCommandLedgerOps = new ApplicationCommandLedgerWorkerOps( - () => this.open().orm + () => this.open().orm, + () => this.open().db ); + private readonly coordinationDurabilityOps: CoordinationDurabilityWorkerOps; private readonly memberWorkSyncOps = new MemberWorkSyncWorkerOps(() => this.open().orm); + private readonly hostedAuthOps = new HostedAuthStorageOps(() => this.open().db); + private readonly externalWriterObservationOps = new ExternalWriterObservationStorageOps( + () => this.open().db + ); + private readonly externalWriterReconciliationOps = new ExternalWriterReconciliationStorageOps( + () => this.open().db + ); + private readonly hostedTeamApprovalAuthorityOps = new HostedTeamApprovalAuthorityStorageOps( + () => this.open().db, + () => (this.options.now?.() ?? new Date()).getTime() + ); + private readonly hostedTeamConfigurationOps = new HostedTeamConfigurationStorageOps( + () => this.open().db, + () => (this.options.now?.() ?? new Date()).getTime() + ); + private readonly processOwnershipOps = new ProcessOwnershipStorageOps( + () => this.open().db, + () => (this.options.now?.() ?? new Date()).getTime() + ); + private readonly teamIdentityOps = new TeamIdentityStorageOps(() => this.open().db); + private readonly teamRosterOps = new TeamRosterStorageOps(() => this.open().db); - constructor(private readonly options: InternalStorageWorkerCoreOptions) {} + constructor(private readonly options: InternalStorageWorkerCoreOptions) { + this.coordinationDurabilityOps = new CoordinationDurabilityWorkerOps( + () => this.open().db, + (databasePath, databaseOptions) => this.options.createDatabase(databasePath, databaseOptions), + this.options.databasePath + ); + } handle(op: InternalStorageWorkerOp, payload: InternalStorageWorkerRequest['payload']): unknown { + if (this.options.mode === 'team-identity-read-only' && !TEAM_IDENTITY_READ_ONLY_OPS.has(op)) { + throw new Error('internal-storage-team-identity-read-only-operation-rejected'); + } + if (op === 'stallJournal.replace' || op === 'commentJournal.replace') { + parseJournalReplacePayload(op, payload); + } + if (op === 'processOwnership.compareAndSwap') { + const typed = parseProcessOwnershipWorkerPayload(op, payload); + if ((this.options.now?.() ?? new Date()).getTime() >= typed.admission.deadlineAtMs) { + throw new Error('process-ownership-storage-deadline-expired'); + } + } + this.assertMutationAdmission(op, payload); switch (op) { case 'ping': return this.ping(); @@ -117,13 +188,79 @@ export class InternalStorageWorkerCore { const typed = payload as { storeId: string; teamName: string }; return this.hasStoreImport(typed.storeId, typed.teamName); } + case 'teamIdentity.list': + return this.teamIdentityOps.listIdentities(); + case 'teamIdentity.listActive': + return this.teamIdentityOps.listActiveIdentities(); + case 'teamIdentity.captureExternalWriterInventory': + return this.teamIdentityOps.captureExternalWriterInventory( + ( + payload as Extract< + InternalStorageWorkerRequest, + { op: 'teamIdentity.captureExternalWriterInventory' } + >['payload'] + ).retirementCandidates + ); + case 'teamIdentity.get': + return this.teamIdentityOps.getIdentity( + (payload as Extract['payload']) + .teamId + ); + case 'teamRoster.get': + return this.teamRosterOps.getRoster( + (payload as Extract['payload']) + .teamId + ); + case 'teamRoster.adopt': + return this.teamRosterOps.adoptRoster( + (payload as Extract['payload']) + .roster + ); + case 'processOwnership.loadByScope': { + const typed = parseProcessOwnershipWorkerPayload(op, payload); + return this.processOwnershipOps.loadByScope(typed.scope); + } + case 'processOwnership.loadByProcessRef': { + const typed = parseProcessOwnershipWorkerPayload(op, payload); + return this.processOwnershipOps.loadByProcessRef(typed.processRef); + } + case 'processOwnership.list': + parseProcessOwnershipWorkerPayload(op, payload); + return this.processOwnershipOps.list(); + case 'processOwnership.compareAndSwap': { + const typed = parseProcessOwnershipWorkerPayload(op, payload); + return this.processOwnershipOps.compareAndSwap(typed.request, typed.admission.deadlineAtMs); + } + case 'hostedAuth.call': + return this.hostedAuthOps.handle(payload); + case 'externalWriterObservation.load': + return this.externalWriterObservationOps.load(payload); + case 'externalWriterObservation.save': + return this.externalWriterObservationOps.save(payload); + case 'externalWriterObservation.saveCleanHandoff': + return this.externalWriterObservationOps.saveCleanHandoff(payload); + case 'externalWriterObservation.consumeCleanHandoff': + return this.externalWriterObservationOps.consumeCleanHandoff(payload); + case 'externalWriterReconciliation.get': + return this.externalWriterReconciliationOps.get(payload as never); + case 'externalWriterReconciliation.commit': + return this.externalWriterReconciliationOps.commit(payload as never); case 'close': this.close(); return null; default: { + if (typeof op === 'string' && op.startsWith('coordination')) { + return this.coordinationDurabilityOps.handle(op as never, payload as never); + } if (typeof op === 'string' && op.startsWith('appCommandLedger.')) { return handleApplicationCommandLedgerOp(this.applicationCommandLedgerOps, op, payload); } + if (typeof op === 'string' && op.startsWith('hostedTeamApprovalAuthority.')) { + return this.hostedTeamApprovalAuthorityOps.handle(op as never, payload); + } + if (typeof op === 'string' && op.startsWith('hostedTeamConfiguration.')) { + return this.hostedTeamConfigurationOps.handle(op, payload); + } if (typeof op === 'string' && op.startsWith('mws.')) { return handleMemberWorkSyncOp(this.memberWorkSyncOps, op, payload); } @@ -132,6 +269,38 @@ export class InternalStorageWorkerCore { } } + /** + * Async operations remain serialized by the worker client and are awaited + * before a response is posted. In particular, Database#backup() must never + * escape over the worker wire as an unresolved Promise. + */ + async handleAsync( + op: InternalStorageWorkerOp, + payload: InternalStorageWorkerRequest['payload'] + ): Promise { + if (this.options.mode === 'team-identity-read-only' && !TEAM_IDENTITY_READ_ONLY_OPS.has(op)) { + throw new Error('internal-storage-team-identity-read-only-operation-rejected'); + } + if (typeof op === 'string' && op.startsWith('coordination')) { + this.assertMutationAdmission(op, payload); + return this.coordinationDurabilityOps.handleAsync(op as never, payload as never); + } + return this.handle(op, payload); + } + + private assertMutationAdmission( + op: InternalStorageWorkerOp, + payload: InternalStorageWorkerRequest['payload'] + ): void { + if (!isInternalStorageMutation(op)) return; + if (op === 'coordinationBackupFence.acquire' || op === 'coordinationBackupFence.complete') { + // These two operations validate the full durable fence identity atomically. + return; + } + const admittedBackupRunId = backupOwnedMutationRunId(op, payload); + assertInternalStorageMutationAdmissionOpen(this.open().db, admittedBackupRunId); + } + private ping(): InternalStorageBackendInfo { const state = this.open(); return { @@ -245,7 +414,7 @@ export class InternalStorageWorkerCore { const { db } = this.state; this.state = null; try { - db.pragma('wal_checkpoint(TRUNCATE)'); + if (this.options.mode !== 'team-identity-read-only') db.pragma('wal_checkpoint(TRUNCATE)'); } finally { db.close(); } @@ -261,6 +430,7 @@ export class InternalStorageWorkerCore { try { db = this.openOnce(); } catch (initialError) { + if (this.options.mode === 'team-identity-read-only') throw initialError; if (!isLikelyCorruptionError(initialError)) { throw initialError; } @@ -270,6 +440,10 @@ export class InternalStorageWorkerCore { integrity = 'recovered'; try { db = this.openOnce(); + recordProcessOwnershipCorruptionMarker( + db, + (this.options.now?.() ?? new Date()).toISOString() + ); } catch (retryError) { const retryMessage = retryError instanceof Error ? retryError.message : String(retryError); const initialMessage = @@ -285,9 +459,31 @@ export class InternalStorageWorkerCore { } private openOnce(): SqliteDatabase { + if (this.options.mode === 'team-identity-read-only') { + const db = this.options.createDatabase(this.options.databasePath, { + readonly: true, + fileMustExist: true, + }); + try { + db.pragma('query_only = ON'); + db.pragma('busy_timeout = 5000'); + const integrityResult = db.pragma('integrity_check', { simple: true }); + if (integrityResult !== 'ok') { + throw new Error(`integrity_check failed: ${String(integrityResult)}`); + } + return db; + } catch (error) { + db.close(); + throw error; + } + } fs.mkdirSync(path.dirname(this.options.databasePath), { recursive: true }); const db = this.options.createDatabase(this.options.databasePath); try { + db.pragma('foreign_keys = ON'); + if (db.pragma('foreign_keys', { simple: true }) !== 1) { + throw new Error('SQLite foreign key enforcement could not be enabled'); + } db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 5000'); db.pragma('synchronous = NORMAL'); @@ -297,9 +493,9 @@ export class InternalStorageWorkerCore { } const schemaBefore = readSchemaVersion(db); if (schemaBefore > INTERNAL_STORAGE_SCHEMA_VERSION) { - // A newer app version already migrated this database. Schema v1+ is - // append-only, so reading known tables is safe; never migrate down. - return db; + throw new Error( + `Unsupported future internal storage schema version: ${schemaBefore} > ${INTERNAL_STORAGE_SCHEMA_VERSION}` + ); } runInternalStorageMigrations(db); return db; @@ -327,3 +523,99 @@ export class InternalStorageWorkerCore { } } } + +const READ_ONLY_APPLICATION_COMMAND_OPS = new Set([ + 'appCommandLedger.getByCommandId', + 'appCommandLedger.getByIdempotencyKey', + 'appCommandLedger.listByScope', + 'appCommandLedger.durable.getStatus', + 'appCommandLedger.durable.getByClaim', + 'appCommandLedger.durable.listOutbox', + 'appCommandLedger.durable.getConsumerProjection', +]); + +const READ_ONLY_MEMBER_WORK_SYNC_OPS = new Set([ + 'mws.status.read', + 'mws.status.list', + 'mws.metricEvents.list', + 'mws.reports.listPending', + 'mws.outbox.countRecentDelivered', + 'mws.outbox.countDeliveredForAgenda', + 'mws.outbox.findDeliveredReviewPickupEventIds', + 'mws.outbox.findRecentRecoveryByIntent', + 'mws.snapshot.list', +]); + +const READ_ONLY_COORDINATION_OPS = new Set([ + // Initialization performs its own admission check only when metadata is absent. + 'coordinationEvents.initialize', + 'coordinationEvents.getWatermark', + 'coordinationEvents.read', + 'coordinationBackupRuns.get', + 'coordinationBackupRuns.listRecoverable', + 'coordinationBackup.sqlite.verify', + 'coordinationBackup.sqlite.readChunk', +]); + +const READ_ONLY_HOSTED_TEAM_APPROVAL_AUTHORITY_OPS = new Set([ + 'hostedTeamApprovalAuthority.readPending', + 'hostedTeamApprovalAuthority.readPreview', +]); + +const READ_ONLY_HOSTED_TEAM_CONFIGURATION_OPS = new Set([ + 'hostedTeamConfiguration.read', +]); + +function isInternalStorageMutation(op: InternalStorageWorkerOp): boolean { + switch (op) { + case 'ping': + case 'stallJournal.load': + case 'commentJournal.load': + case 'commentJournal.exists': + case 'storeImports.has': + case 'teamIdentity.list': + case 'teamIdentity.listActive': + case 'teamIdentity.captureExternalWriterInventory': + case 'teamIdentity.get': + case 'teamRoster.get': + case 'externalWriterObservation.load': + case 'externalWriterReconciliation.get': + case 'processOwnership.loadByScope': + case 'processOwnership.loadByProcessRef': + case 'processOwnership.list': + case 'close': + return false; + default: + if (op.startsWith('appCommandLedger.')) return !READ_ONLY_APPLICATION_COMMAND_OPS.has(op); + if (op.startsWith('mws.')) return !READ_ONLY_MEMBER_WORK_SYNC_OPS.has(op); + if (op.startsWith('coordination')) return !READ_ONLY_COORDINATION_OPS.has(op); + if (op.startsWith('hostedTeamApprovalAuthority.')) { + return !READ_ONLY_HOSTED_TEAM_APPROVAL_AUTHORITY_OPS.has(op); + } + if (op.startsWith('hostedTeamConfiguration.')) { + return !READ_ONLY_HOSTED_TEAM_CONFIGURATION_OPS.has(op); + } + return true; + } +} + +function backupOwnedMutationRunId( + op: InternalStorageWorkerOp, + payload: InternalStorageWorkerRequest['payload'] +): string | null { + if ( + op === 'coordinationBackupRuns.compareAndSet' || + op === 'coordinationBackupFlush.drain' || + op === 'coordinationBackup.sqlite.online' || + op === 'coordinationBackup.sqlite.discard' + ) { + return (payload as { readonly backupRunId?: string }).backupRunId ?? null; + } + if (op === 'coordinationBackupFlush.capture') { + return ( + (payload as { readonly evidence?: { readonly backupRunId?: string } }).evidence + ?.backupRunId ?? null + ); + } + return null; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerMapping.ts b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerMapping.ts new file mode 100644 index 0000000000..9af58fc7e2 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerMapping.ts @@ -0,0 +1,283 @@ +import { + EFFECT_RECOVERY_CLASSES, + HMAC_SHA256_LD_V1, +} from '@features/application-command-ledger/contracts'; + +import * as validation from './applicationCommandLedgerValidation'; +import { MAX_IDEMPOTENCY_KEY_LENGTH } from './applicationCommandLedgerWorkerTypes'; +import { + canonicalCoordinationStorageJson, + materializeCommandCoordinationAttribution, +} from './coordinationDurabilityWorkerOps'; + +import type { + DurableCommandRow, + DurableConsumerApplicationRow, + DurableConsumerProjectionRow, + DurableEffectEvidenceRow, + DurableEffectRow, + DurableOutboxRow, +} from './applicationCommandLedgerWorkerTypes'; +import type { StoredCommandCoordinationAttribution } from './internalStorageWorkerProtocol'; +import type { + DurableApplicationCommandCommitRequest, + DurableApplicationCommandConsumerApplicationRecord, + DurableApplicationCommandConsumerProjectionRecord, + DurableApplicationCommandEffectEvidenceRecord, + DurableApplicationCommandOutboxRecord, +} from '@features/application-command-ledger'; +import type { EffectRecoveryClass } from '@features/application-command-ledger/contracts'; + +export function assertKnownDurableCommandRow(row: DurableCommandRow): void { + validation.assertIdentifier('commandId', row.commandId); + validation.assertIdentifier('deploymentId', row.deploymentId); + validation.assertIdentifier('stableActorId', row.stableActorId); + validation.assertIdentifier('commandKind', row.commandKind); + validation.assertIdentifier('idempotencyKey', row.idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH); + validation.assertIdentifier('descriptorId', row.descriptorId); + validation.assertPositiveVersion('descriptorVersion', row.descriptorVersion); + validation.assertPositiveVersion('inputSchemaVersion', row.inputSchemaVersion); + if (row.fingerprintVersion !== HMAC_SHA256_LD_V1) { + throw new Error( + `Unsupported durable application command fingerprint version: ${row.fingerprintVersion}` + ); + } + validation.assertPositiveVersion('effectPlanVersion', row.effectPlanVersion); + validation.assertIdentifier('fingerprintKeyVersion', row.fingerprintKeyVersion); + if (!/^[a-f0-9]{64}$/.test(row.fingerprintDigest)) { + throw new Error('Invalid persisted durable application command fingerprint digest'); + } + validation.assertPositiveVersion('attemptGeneration', row.attemptGeneration); + validation.assertIdentifier('attemptId', row.attemptId); + validation.assertIdentifier('attemptOwnerId', row.attemptOwnerId); + validation.assertIdentifier('attemptLeaseToken', row.attemptLeaseToken); + validation.assertLeaseWindow( + row.attemptClaimedAt, + row.attemptLeaseExpiresAt, + 'persisted attempt' + ); + validation.assertKnownCommandState(row.state); + validation.assertIdentifier('retentionClass', row.retentionClass); + if (row.auditSessionId !== null) + validation.assertIdentifier('auditSessionId', row.auditSessionId); + if (typeof row.coordinationAttributionJson !== 'string') { + throw new Error('Invalid durable application command coordination attribution'); + } + try { + const attribution = materializeCommandCoordinationAttribution( + JSON.parse(row.coordinationAttributionJson) as StoredCommandCoordinationAttribution + ); + if (canonicalCoordinationStorageJson(attribution) !== row.coordinationAttributionJson) { + throw new Error('non-canonical'); + } + } catch (error) { + throw new Error('Invalid durable application command coordination attribution', { + cause: error, + }); + } + if (row.outcomeJson !== null) validation.assertSafeJson('outcomeJson', row.outcomeJson); + if (row.errorCode !== null) validation.assertIdentifier('errorCode', row.errorCode); + if (row.errorJson !== null) validation.assertSafeJson('errorJson', row.errorJson); + validation.assertIsoTimestamp('createdAt', row.createdAt); + validation.assertIsoTimestamp('updatedAt', row.updatedAt); + if (row.committedAt !== null) validation.assertIsoTimestamp('committedAt', row.committedAt); + if (row.state === 'committed') { + if ( + row.committedAt === null || + row.outcomeJson === null || + row.errorCode !== null || + row.errorJson !== null + ) { + throw new Error( + `Invalid persisted durable application command terminal shape: ${row.commandId}` + ); + } + return; + } + if (row.committedAt !== null || row.outcomeJson !== null) { + throw new Error( + `Invalid persisted durable application command terminal shape: ${row.commandId}` + ); + } + const requiresError = row.state === 'failed' || row.state === 'operator_required'; + if (requiresError !== (row.errorCode !== null)) { + throw new Error(`Invalid persisted durable application command error shape: ${row.commandId}`); + } + if (!requiresError && row.errorJson !== null) { + throw new Error(`Invalid persisted durable application command error shape: ${row.commandId}`); + } +} + +export function assertKnownDurableEffectRow( + row: DurableEffectRow, + commandId: string, + expectedOrdinal: number +): void { + if (row.commandId !== commandId || row.ordinal !== expectedOrdinal) { + throw new Error(`Invalid persisted durable application command effect order: ${commandId}`); + } + validation.assertIdentifier('effectId', row.effectId); + validation.assertPositiveVersion('effectVersion', row.effectVersion); + if (!EFFECT_RECOVERY_CLASSES.includes(row.recoveryClass as EffectRecoveryClass)) { + throw new Error( + `Unsupported durable application command effect recovery class: ${row.recoveryClass}` + ); + } + validation.assertPositiveVersion('evidenceSchemaVersion', row.evidenceSchemaVersion); + validation.assertKnownEffectState(row.state); + validation.assertIsoTimestamp('effect.updatedAt', row.updatedAt); +} + +export function mapEffectEvidence( + row: DurableEffectEvidenceRow, + effect: DurableEffectRow, + expectedSequence: number +): DurableApplicationCommandEffectEvidenceRecord { + if ( + row.commandId !== effect.commandId || + row.ordinal !== effect.ordinal || + row.sequence !== expectedSequence + ) { + throw new Error('Invalid persisted durable application command effect evidence identity'); + } + if (row.outcome !== 'observed_succeeded' && row.outcome !== 'observed_absent') { + throw new Error( + `Unsupported durable application command effect evidence outcome: ${row.outcome}` + ); + } + if (row.evidenceSchemaVersion !== effect.evidenceSchemaVersion) { + throw new Error( + `Unsupported durable application command effect evidence schema: ${row.evidenceSchemaVersion}` + ); + } + validation.assertSafeJson('evidenceJson', row.evidenceJson); + validation.assertIsoTimestamp('evidence.recordedAt', row.recordedAt); + return { + sequence: row.sequence, + effectId: effect.effectId, + effectVersion: effect.effectVersion, + recoveryClass: effect.recoveryClass as EffectRecoveryClass, + evidenceSchemaVersion: row.evidenceSchemaVersion, + outcome: row.outcome, + evidenceJson: row.evidenceJson, + recordedAt: row.recordedAt, + }; +} + +export function mapOutboxRow(row: DurableOutboxRow): DurableApplicationCommandOutboxRecord { + if (!Number.isSafeInteger(row.sequence) || row.sequence <= 0) { + throw new Error('Invalid durable application command outbox sequence'); + } + validation.assertIdentifier('outbox.eventId', row.eventId); + validation.assertIdentifier('outbox.commandId', row.commandId); + validation.assertIdentifier('outbox.deploymentId', row.deploymentId); + validation.assertIdentifier('outbox.eventType', row.eventType); + validation.assertIdentifier('outbox.scopeKind', row.scopeKind); + validation.assertIdentifier('outbox.scopeId', row.scopeId); + validation.assertPositiveVersion('outbox.schemaVersion', row.schemaVersion); + validation.assertPositiveVersion('outbox.semanticRevision', row.semanticRevision); + validation.assertSafeJson('outbox.payloadJson', row.payloadJson); + validation.assertIsoTimestamp('outbox.createdAt', row.createdAt); + let deliveryLease: DurableApplicationCommandOutboxRecord['deliveryLease'] = null; + if (row.deliveryGeneration === 0) { + if ( + row.deliveryOwnerId !== null || + row.deliveryLeaseToken !== null || + row.deliveryClaimedAt !== null || + row.deliveryLeaseExpiresAt !== null + ) { + throw new Error('Invalid durable application command outbox delivery lease shape'); + } + } else { + validation.assertPositiveVersion('outbox.deliveryGeneration', row.deliveryGeneration); + validation.assertIdentifier('outbox.deliveryOwnerId', row.deliveryOwnerId); + validation.assertIdentifier('outbox.deliveryLeaseToken', row.deliveryLeaseToken); + if (row.deliveryClaimedAt === null || row.deliveryLeaseExpiresAt === null) { + throw new Error('Invalid durable application command outbox delivery lease shape'); + } + validation.assertLeaseWindow( + row.deliveryClaimedAt, + row.deliveryLeaseExpiresAt, + 'outbox delivery' + ); + deliveryLease = { + generation: row.deliveryGeneration, + ownerId: row.deliveryOwnerId, + leaseToken: row.deliveryLeaseToken, + claimedAt: row.deliveryClaimedAt, + leaseExpiresAt: row.deliveryLeaseExpiresAt, + }; + } + if (row.deliveryAcknowledgedAt !== null) { + validation.assertIsoTimestamp('outbox.deliveryAcknowledgedAt', row.deliveryAcknowledgedAt); + } + if (row.deliveryAcknowledgedAt !== null && deliveryLease === null) { + throw new Error('Acknowledged durable application command outbox event has no delivery lease'); + } + if ( + row.deliveryAcknowledgedAt !== null && + deliveryLease !== null && + (Date.parse(row.deliveryAcknowledgedAt) < Date.parse(deliveryLease.claimedAt) || + Date.parse(row.deliveryAcknowledgedAt) >= Date.parse(deliveryLease.leaseExpiresAt)) + ) { + throw new Error( + 'Acknowledged durable application command outbox event is outside its delivery lease' + ); + } + return { + sequence: row.sequence, + eventId: row.eventId, + commandId: row.commandId, + deploymentId: row.deploymentId, + eventType: row.eventType, + scopeKind: row.scopeKind, + scopeId: row.scopeId, + schemaVersion: row.schemaVersion, + semanticRevision: row.semanticRevision, + payloadJson: row.payloadJson, + createdAt: row.createdAt, + deliveryLease, + deliveryAcknowledgedAt: row.deliveryAcknowledgedAt, + }; +} + +export function sameOutboxInput( + row: DurableApplicationCommandOutboxRecord, + input: DurableApplicationCommandCommitRequest['outbox'] +): boolean { + return ( + row.eventId === input.eventId && + row.eventType === input.eventType && + row.scopeKind === input.scopeKind && + row.scopeId === input.scopeId && + row.schemaVersion === input.schemaVersion && + row.semanticRevision === input.semanticRevision && + row.payloadJson === input.payloadJson && + row.createdAt === input.createdAtIso + ); +} + +export function mapConsumerApplicationRow( + row: DurableConsumerApplicationRow +): DurableApplicationCommandConsumerApplicationRecord { + validation.assertIdentifier('consumer.consumerId', row.consumerId); + validation.assertIdentifier('consumer.eventId', row.eventId); + validation.assertPositiveVersion('consumer.semanticRevision', row.semanticRevision); + validation.assertIdentifier('consumer.projectionKey', row.projectionKey); + validation.assertSafeJson('consumer.stateJson', row.stateJson); + validation.assertIsoTimestamp('consumer.appliedAt', row.appliedAt); + return { ...row }; +} + +export function mapConsumerProjectionRow( + row: DurableConsumerProjectionRow +): DurableApplicationCommandConsumerProjectionRecord { + validation.assertIdentifier('consumer.consumerId', row.consumerId); + validation.assertIdentifier('consumer.projectionKey', row.projectionKey); + validation.assertPositiveVersion('consumer.semanticRevision', row.semanticRevision); + validation.assertIdentifier('consumer.lastEventId', row.lastEventId); + validation.assertSafeJson('consumer.stateJson', row.stateJson); + validation.assertPositiveVersion('consumer.applicationCount', row.applicationCount); + validation.assertIsoTimestamp('consumer.updatedAt', row.updatedAt); + return { ...row }; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerRecordRepository.ts b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerRecordRepository.ts new file mode 100644 index 0000000000..081ccfe54d --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerRecordRepository.ts @@ -0,0 +1,302 @@ +import { + createCommandClaimScope, + createDurableCommandDescriptorIdentity, + resolveCommandClaim, +} from '@features/application-command-ledger'; +import { HMAC_SHA256_LD_V1 } from '@features/application-command-ledger/contracts'; +import { and, asc, eq } from 'drizzle-orm'; + +import * as mapping from './applicationCommandLedgerMapping'; +import { + canonicalCoordinationStorageJson, + materializeCommandCoordinationAttribution, +} from './coordinationDurabilityWorkerOps'; +import { + applicationCommandLedger, + durableApplicationCommandEffectEvidence, + durableApplicationCommandEffects, + durableApplicationCommandOutbox, + durableApplicationCommands, +} from './internalStorageSchema'; + +import type { StoredCommandCoordinationAttribution } from './internalStorageWorkerProtocol'; +import type { + DurableApplicationCommandOutboxRecord, + DurableApplicationCommandRecord, +} from '@features/application-command-ledger'; +import type { + CommandClaimRecord, + CommandFingerprintRecord, + DurableCommandState, + DurableEffectState, + EffectRecoveryClass, +} from '@features/application-command-ledger/contracts'; +import type DatabaseConstructor from 'better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +type SqliteDatabase = InstanceType; + +import type { + AppCommandRecord, + DurableCommandRow, + DurableEffectEvidenceRow, + DurableEffectRow, +} from './applicationCommandLedgerWorkerTypes'; + +export class ApplicationCommandLedgerRecordRepository { + constructor( + private readonly getOrm: () => BetterSQLite3Database, + private readonly getDb: () => SqliteDatabase + ) {} + + requireDurableRecord(input: { + deploymentId: string; + commandId: string; + }): DurableApplicationCommandRecord { + const record = this.readDurableRecord(input); + if (!record) { + throw new Error(`Durable application command not found: ${input.commandId}`); + } + return record; + } + + readDurableRecord(input: { + deploymentId: string; + commandId: string; + }): DurableApplicationCommandRecord | null { + const rows = this.getOrm() + .select() + .from(durableApplicationCommands) + .where( + and( + eq(durableApplicationCommands.deploymentId, input.deploymentId), + eq(durableApplicationCommands.commandId, input.commandId) + ) + ) + .all() as DurableCommandRow[]; + return rows[0] ? this.mapDurableRecord(rows[0]) : null; + } + + readDurableRecordByClaim(input: { + scope: { + deploymentId: string; + stableActorId: string; + commandKind: TCommandKind; + idempotencyKey: string; + }; + }): DurableApplicationCommandRecord | null { + const rows = this.getOrm() + .select() + .from(durableApplicationCommands) + .where( + and( + eq(durableApplicationCommands.deploymentId, input.scope.deploymentId), + eq(durableApplicationCommands.stableActorId, input.scope.stableActorId), + eq(durableApplicationCommands.commandKind, input.scope.commandKind), + eq(durableApplicationCommands.idempotencyKey, input.scope.idempotencyKey) + ) + ) + .all() as DurableCommandRow[]; + return rows[0] + ? (this.mapDurableRecord(rows[0]) as DurableApplicationCommandRecord) + : null; + } + + mapDurableRecord(row: DurableCommandRow): DurableApplicationCommandRecord { + mapping.assertKnownDurableCommandRow(row); + const effects = ( + this.getOrm() + .select() + .from(durableApplicationCommandEffects) + .where(eq(durableApplicationCommandEffects.commandId, row.commandId)) + .orderBy(asc(durableApplicationCommandEffects.ordinal)) + .all() as DurableEffectRow[] + ).map((effect, ordinal) => { + mapping.assertKnownDurableEffectRow(effect, row.commandId, ordinal); + const evidence = this.readEffectEvidence(row.commandId, ordinal).map((item, index) => + mapping.mapEffectEvidence(item, effect, index + 1) + ); + return { + effectId: effect.effectId, + effectVersion: effect.effectVersion, + recoveryClass: effect.recoveryClass as EffectRecoveryClass, + evidenceSchemaVersion: effect.evidenceSchemaVersion, + ordinal: effect.ordinal, + state: effect.state as DurableEffectState, + updatedAt: effect.updatedAt, + evidence, + }; + }); + if (effects.length === 0) { + throw new Error(`Durable application command has an empty effect plan: ${row.commandId}`); + } + for (const effect of effects) { + if ( + (effect.state === 'observed_succeeded' || effect.state === 'observed_absent') && + effect.evidence.at(-1)?.outcome !== effect.state + ) { + throw new Error( + `Durable application command observed effect is missing matching evidence: ${row.commandId}:${effect.ordinal}` + ); + } + } + + const fingerprint: CommandFingerprintRecord = { + descriptorId: row.descriptorId, + descriptorVersion: row.descriptorVersion, + schemaVersion: row.inputSchemaVersion, + fingerprintVersion: HMAC_SHA256_LD_V1, + effectPlanVersion: row.effectPlanVersion, + keyVersion: row.fingerprintKeyVersion, + digest: row.fingerprintDigest, + }; + const claim: CommandClaimRecord = { + scope: createCommandClaimScope({ + deploymentId: row.deploymentId, + stableActorId: row.stableActorId, + commandKind: row.commandKind, + idempotencyKey: row.idempotencyKey, + }), + fingerprint, + }; + // Reuse the accepted contract validator for every status read. This is + // intentionally not a permissive cast: future algorithms fail closed. + resolveCommandClaim(null, claim); + const descriptor = createDurableCommandDescriptorIdentity({ + descriptorId: row.descriptorId, + descriptorVersion: row.descriptorVersion, + commandKind: row.commandKind, + inputSchemaVersion: row.inputSchemaVersion, + fingerprintVersion: HMAC_SHA256_LD_V1, + effectPlanVersion: row.effectPlanVersion, + }); + return { + commandId: row.commandId, + claim, + descriptor, + attempt: { + generation: row.attemptGeneration, + attemptId: row.attemptId, + ownerId: row.attemptOwnerId, + leaseToken: row.attemptLeaseToken, + claimedAt: row.attemptClaimedAt, + leaseExpiresAt: row.attemptLeaseExpiresAt, + }, + state: row.state as DurableCommandState, + retentionClass: row.retentionClass, + auditSessionId: row.auditSessionId, + outcomeJson: row.outcomeJson, + errorCode: row.errorCode, + errorJson: row.errorJson, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + committedAt: row.committedAt, + effects, + }; + } + + readEffectEvidence(commandId: string, ordinal: number): DurableEffectEvidenceRow[] { + return this.getOrm() + .select() + .from(durableApplicationCommandEffectEvidence) + .where( + and( + eq(durableApplicationCommandEffectEvidence.commandId, commandId), + eq(durableApplicationCommandEffectEvidence.ordinal, ordinal) + ) + ) + .orderBy(asc(durableApplicationCommandEffectEvidence.sequence)) + .all() as DurableEffectEvidenceRow[]; + } + + readOutboxByCommandId(commandId: string): DurableApplicationCommandOutboxRecord | null { + const rows = this.getOrm() + .select() + .from(durableApplicationCommandOutbox) + .where(eq(durableApplicationCommandOutbox.commandId, commandId)) + .all(); + return rows[0] ? mapping.mapOutboxRow(rows[0]) : null; + } + + requireCoordinationAttributionJson(commandId: string): string { + const row = this.getOrm() + .select({ value: durableApplicationCommands.coordinationAttributionJson }) + .from(durableApplicationCommands) + .where(eq(durableApplicationCommands.commandId, commandId)) + .all()[0]; + if (!row) throw new Error(`Durable application command not found: ${commandId}`); + return row.value; + } + + readCoordinationAttribution(commandId: string): StoredCommandCoordinationAttribution { + const value = this.requireCoordinationAttributionJson(commandId); + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch (error) { + throw new Error('Durable application command coordination attribution is corrupt', { + cause: error, + }); + } + const attribution = materializeCommandCoordinationAttribution( + parsed as StoredCommandCoordinationAttribution + ); + if (canonicalCoordinationStorageJson(attribution) !== value) { + throw new Error('Durable application command coordination attribution is not canonical'); + } + return attribution; + } + + readByCommandId(input: { + namespace: string; + scopeKey: string; + commandId: string; + }): AppCommandRecord | null { + const rows = this.getOrm() + .select() + .from(applicationCommandLedger) + .where( + and( + eq(applicationCommandLedger.namespace, input.namespace), + eq(applicationCommandLedger.scopeKey, input.scopeKey), + eq(applicationCommandLedger.commandId, input.commandId) + ) + ) + .all() as AppCommandRecord[]; + return rows[0] ?? null; + } + + readByIdempotencyKey(input: { + namespace: string; + scopeKey: string; + idempotencyKey: string; + }): AppCommandRecord | null { + const rows = this.getOrm() + .select() + .from(applicationCommandLedger) + .where( + and( + eq(applicationCommandLedger.namespace, input.namespace), + eq(applicationCommandLedger.scopeKey, input.scopeKey), + eq(applicationCommandLedger.idempotencyKey, input.idempotencyKey) + ) + ) + .all() as AppCommandRecord[]; + return rows[0] ?? null; + } + + replaceRow(row: AppCommandRecord): void { + const { namespace, scopeKey, commandId, ...values } = row; + this.getOrm() + .update(applicationCommandLedger) + .set(values) + .where( + and( + eq(applicationCommandLedger.namespace, namespace), + eq(applicationCommandLedger.scopeKey, scopeKey), + eq(applicationCommandLedger.commandId, commandId) + ) + ) + .run(); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerValidation.ts b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerValidation.ts new file mode 100644 index 0000000000..0c3c84376c --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerValidation.ts @@ -0,0 +1,495 @@ +import { + createCommandClaimScope, + createDurableCommandDescriptorIdentity, + createInitialEffectPlan, + resolveCommandClaim, +} from '@features/application-command-ledger'; +import { + ApplicationCommandBeginOutcome, + ApplicationCommandFailureKind, + ApplicationCommandLedgerStatus, + DURABLE_COMMAND_STATES, + DURABLE_EFFECT_STATES, +} from '@features/application-command-ledger/contracts'; + +import { + type AppCommandBeginRequest, + type AppCommandBeginResult, + type AppCommandRecord, + MAX_IDEMPOTENCY_KEY_LENGTH, + MAX_IDENTIFIER_LENGTH, + MAX_OUTBOX_PAGE_SIZE, + MAX_SAFE_JSON_BYTES, +} from './applicationCommandLedgerWorkerTypes'; + +import type { + DurableApplicationCommandAttemptClaim, + DurableApplicationCommandAttemptLeaseRequest, + DurableApplicationCommandAttemptReference, + DurableApplicationCommandCommitRequest, + DurableApplicationCommandConsumerApplyRequest, + DurableApplicationCommandConsumerProjectionRequest, + DurableApplicationCommandEffectTransitionRequest, + DurableApplicationCommandOutboxClaimRequest, + DurableApplicationCommandOutboxDeliveryAcknowledgementRequest, + DurableApplicationCommandOutboxRecord, + DurableApplicationCommandPersistClaimRequest, + DurableApplicationCommandRecord, + DurableApplicationCommandTransitionRequest, +} from '@features/application-command-ledger'; +import type { + ApplicationCommandConflictReason, + CommandClaimRecord, + DurableCommandState, + DurableEffectPlanItem, + DurableEffectState, + EffectDescriptor, + ValidatedDurableEffectEvidence, +} from '@features/application-command-ledger/contracts'; + +export function mutationConflict( + reason: ApplicationCommandConflictReason, + existing: AppCommandRecord | null, + requested: AppCommandBeginRequest +): AppCommandBeginResult { + return { + outcome: ApplicationCommandBeginOutcome.Conflict, + reason, + existing, + requested, + }; +} + +export function validateDurableClaim( + input: DurableApplicationCommandPersistClaimRequest +): DurableApplicationCommandPersistClaimRequest { + assertIdentifier('commandId', input.commandId); + const scope = createCommandClaimScope(input.scope); + assertIdentifier('idempotencyKey', scope.idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH); + assertIdentifier('retentionClass', input.retentionClass); + if (input.auditSessionId !== null) assertIdentifier('auditSessionId', input.auditSessionId); + assertIsoTimestamp('createdAtIso', input.createdAtIso); + validateDurableAttemptClaim(input.attempt); + if (Date.parse(input.createdAtIso) > Date.parse(input.attempt.claimedAtIso)) { + throw new Error('Durable application command cannot be claimed before it is created'); + } + + const descriptor = createDurableCommandDescriptorIdentity(input.descriptor); + const incoming: CommandClaimRecord = { scope, fingerprint: input.fingerprint }; + resolveCommandClaim(null, incoming); + if ( + descriptor.commandKind !== scope.commandKind || + descriptor.descriptorId !== input.fingerprint.descriptorId || + descriptor.descriptorVersion !== input.fingerprint.descriptorVersion || + descriptor.inputSchemaVersion !== input.fingerprint.schemaVersion || + descriptor.fingerprintVersion !== input.fingerprint.fingerprintVersion || + descriptor.effectPlanVersion !== input.fingerprint.effectPlanVersion + ) { + throw new Error('Durable application command claim descriptor and fingerprint do not match'); + } + + if (!Array.isArray(input.effectPlan) || input.effectPlan.length === 0) { + throw new Error('Durable application command effect plan must be non-empty'); + } + const expected = createInitialEffectPlan({ + descriptorId: descriptor.descriptorId, + effects: input.effectPlan.map(effectDescriptor) as [EffectDescriptor, ...EffectDescriptor[]], + }); + input.effectPlan.forEach((actual, ordinal) => { + const actualEffect = actual as DurableEffectPlanItem; + const expectedEffect: DurableEffectPlanItem | undefined = expected[ordinal]; + if ( + !expectedEffect || + actualEffect.ordinal !== ordinal || + actualEffect.state !== 'not_started' || + !sameEffectIdentity(expectedEffect, actualEffect) + ) { + throw new Error( + `Invalid initial durable application command effect plan: ordinal=${ordinal}` + ); + } + }); + return { + ...input, + scope, + descriptor, + effectPlan: expected, + }; +} + +export function validateDurableAttemptClaim(input: DurableApplicationCommandAttemptClaim): void { + assertIdentifier('attempt.attemptId', input.attemptId); + assertIdentifier('attempt.ownerId', input.ownerId); + assertIdentifier('attempt.leaseToken', input.leaseToken); + assertLeaseWindow(input.claimedAtIso, input.leaseExpiresAtIso, 'attempt'); +} + +export function validateDurableAttemptReference( + input: DurableApplicationCommandAttemptReference +): void { + assertPositiveVersion('attempt.generation', input.generation); + assertIdentifier('attempt.attemptId', input.attemptId); + assertIdentifier('attempt.ownerId', input.ownerId); + assertIdentifier('attempt.leaseToken', input.leaseToken); +} + +export function validateDurableAttemptLease( + input: DurableApplicationCommandAttemptLeaseRequest +): void { + assertIdentifier('deploymentId', input.deploymentId); + assertIdentifier('commandId', input.commandId); + validateDurableAttemptReference(input.attempt); + assertLeaseWindow(input.renewedAtIso, input.leaseExpiresAtIso, 'attempt renewal'); +} + +export function validateDurableCommandTransition( + input: DurableApplicationCommandTransitionRequest +): void { + assertIdentifier('deploymentId', input.deploymentId); + assertIdentifier('commandId', input.commandId); + validateDurableAttemptReference(input.attempt); + assertKnownCommandState(input.expectedState); + assertKnownCommandState(input.nextState); + if (input.errorCode !== null) assertIdentifier('errorCode', input.errorCode); + if (input.errorJson !== null) assertSafeJson('errorJson', input.errorJson); + assertIsoTimestamp('transitionedAtIso', input.transitionedAtIso); +} + +export function validateDurableEffectTransition( + input: DurableApplicationCommandEffectTransitionRequest +): void { + assertIdentifier('deploymentId', input.deploymentId); + assertIdentifier('commandId', input.commandId); + validateDurableAttemptReference(input.attempt); + if (!Number.isSafeInteger(input.ordinal) || input.ordinal < 0) { + throw new Error('Durable application command effect ordinal must be non-negative'); + } + assertKnownEffectState(input.expectedState); + assertKnownEffectState(input.nextState); + if (input.evidenceJson !== null) assertSafeJson('evidenceJson', input.evidenceJson); + assertIsoTimestamp('transitionedAtIso', input.transitionedAtIso); +} + +export function validateDurableCommit(input: DurableApplicationCommandCommitRequest): void { + assertIdentifier('deploymentId', input.deploymentId); + assertIdentifier('commandId', input.commandId); + validateDurableAttemptReference(input.attempt); + if (input.expectedState !== 'running' && input.expectedState !== 'recovering') { + throw new Error( + 'Durable application command commit expectedState must be running or recovering' + ); + } + assertSafeJson('outcomeJson', input.outcomeJson); + assertIsoTimestamp('committedAtIso', input.committedAtIso); + assertIdentifier('outbox.eventId', input.outbox.eventId); + assertIdentifier('outbox.eventType', input.outbox.eventType); + assertIdentifier('outbox.scopeKind', input.outbox.scopeKind); + assertIdentifier('outbox.scopeId', input.outbox.scopeId); + assertPositiveVersion('outbox.schemaVersion', input.outbox.schemaVersion); + assertPositiveVersion('outbox.semanticRevision', input.outbox.semanticRevision); + assertSafeJson('outbox.payloadJson', input.outbox.payloadJson); + assertIsoTimestamp('outbox.createdAtIso', input.outbox.createdAtIso); +} + +export function validateDurableOutboxClaim( + input: DurableApplicationCommandOutboxClaimRequest +): void { + assertIdentifier('outbox.ownerId', input.ownerId); + assertIdentifier('outbox.leaseToken', input.leaseToken); + assertLeaseWindow(input.claimedAtIso, input.leaseExpiresAtIso, 'outbox delivery'); + if ( + !Number.isSafeInteger(input.limit) || + input.limit <= 0 || + input.limit > MAX_OUTBOX_PAGE_SIZE + ) { + throw new Error( + `Durable application command outbox limit must be between 1 and ${MAX_OUTBOX_PAGE_SIZE}` + ); + } +} + +export function validateDurableOutboxDeliveryAcknowledgement( + input: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest +): void { + assertIdentifier('outbox.eventId', input.eventId); + assertPositiveVersion('outbox.deliveryGeneration', input.deliveryGeneration); + assertIdentifier('outbox.ownerId', input.ownerId); + assertIdentifier('outbox.leaseToken', input.leaseToken); + assertIsoTimestamp('outbox.acknowledgedAtIso', input.acknowledgedAtIso); +} + +export function validateDurableConsumerProjectionRequest( + input: DurableApplicationCommandConsumerProjectionRequest +): void { + assertIdentifier('consumer.consumerId', input.consumerId); + assertIdentifier('consumer.projectionKey', input.projectionKey); +} + +export function validateDurableConsumerApply( + input: DurableApplicationCommandConsumerApplyRequest +): void { + validateDurableConsumerProjectionRequest(input); + assertIdentifier('consumer.eventId', input.eventId); + assertPositiveVersion('consumer.semanticRevision', input.semanticRevision); + assertSafeJson('consumer.stateJson', input.stateJson); + assertIsoTimestamp('consumer.appliedAtIso', input.appliedAtIso); +} + +export function effectDescriptor( + effect: Pick< + DurableEffectPlanItem, + 'effectId' | 'effectVersion' | 'recoveryClass' | 'evidenceSchemaVersion' + > +): EffectDescriptor { + return { + effectId: effect.effectId, + effectVersion: effect.effectVersion, + recoveryClass: effect.recoveryClass, + evidenceSchemaVersion: effect.evidenceSchemaVersion, + }; +} + +export function sameEffectIdentity(left: EffectDescriptor, right: EffectDescriptor): boolean { + return ( + left.effectId === right.effectId && + left.effectVersion === right.effectVersion && + left.recoveryClass === right.recoveryClass && + left.evidenceSchemaVersion === right.evidenceSchemaVersion + ); +} + +export function assertEvidenceMatchesEffect( + evidence: unknown, + effect: EffectDescriptor, + expectedOutcome: 'observed_succeeded' | 'observed_absent' +): void { + const expectedKeys = [ + 'effectId', + 'effectVersion', + 'evidenceSchemaVersion', + 'outcome', + 'recoveryClass', + ]; + if ( + typeof evidence !== 'object' || + evidence === null || + Array.isArray(evidence) || + (Object.getPrototypeOf(evidence) !== Object.prototype && + Object.getPrototypeOf(evidence) !== null) || + Object.getOwnPropertySymbols(evidence).length > 0 + ) { + throw new Error('Validated durable effect evidence must be a plain data object'); + } + const keys = Object.getOwnPropertyNames(evidence).sort((left, right) => + left.localeCompare(right) + ); + const candidate = evidence as ValidatedDurableEffectEvidence; + if ( + keys.join(',') !== expectedKeys.join(',') || + keys.some((key) => { + const descriptor = Object.getOwnPropertyDescriptor(evidence, key); + return !descriptor?.enumerable || !('value' in descriptor); + }) || + !sameEffectIdentity(candidate, effect) || + candidate.outcome !== expectedOutcome + ) { + throw new Error( + 'Validated durable effect evidence does not match the persisted effect contract' + ); + } +} + +export function sameClaimScope(left: CommandClaimRecord, right: CommandClaimRecord): boolean { + return ( + left.scope.deploymentId === right.scope.deploymentId && + left.scope.stableActorId === right.scope.stableActorId && + left.scope.commandKind === right.scope.commandKind && + left.scope.idempotencyKey === right.scope.idempotencyKey + ); +} + +export function sameAttemptClaim( + current: DurableApplicationCommandRecord, + requested: DurableApplicationCommandAttemptClaim +): boolean { + return ( + current.attempt.attemptId === requested.attemptId && + current.attempt.ownerId === requested.ownerId && + current.attempt.leaseToken === requested.leaseToken && + current.attempt.claimedAt === requested.claimedAtIso && + current.attempt.leaseExpiresAt === requested.leaseExpiresAtIso + ); +} + +export function assertDurableAttemptFence( + current: DurableApplicationCommandRecord, + requested: DurableApplicationCommandAttemptReference, + operationAtIso: string +): void { + validateDurableAttemptReference(requested); + if ( + current.attempt.generation !== requested.generation || + current.attempt.attemptId !== requested.attemptId || + current.attempt.ownerId !== requested.ownerId || + current.attempt.leaseToken !== requested.leaseToken + ) { + throw new Error(`Durable application command attempt fence is stale: ${current.commandId}`); + } + assertIsoTimestamp('attempt operation timestamp', operationAtIso); + const operationAt = Date.parse(operationAtIso); + if ( + operationAt < Date.parse(current.attempt.claimedAt) || + operationAt < Date.parse(current.updatedAt) || + operationAt >= Date.parse(current.attempt.leaseExpiresAt) + ) { + throw new Error(`Durable application command attempt lease expired: ${current.commandId}`); + } +} + +export function isDurableCommandTerminal(state: DurableCommandState): boolean { + return state === 'committed' || state === 'failed' || state === 'operator_required'; +} + +export function sameOutboxDeliveryClaim( + current: DurableApplicationCommandOutboxRecord, + requested: DurableApplicationCommandOutboxClaimRequest +): boolean { + return ( + current.deliveryLease?.ownerId === requested.ownerId && + current.deliveryLease.leaseToken === requested.leaseToken + ); +} + +export function assertOutboxDeliveryFence( + current: DurableApplicationCommandOutboxRecord, + requested: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest +): void { + if ( + current.deliveryLease?.generation !== requested.deliveryGeneration || + current.deliveryLease.ownerId !== requested.ownerId || + current.deliveryLease.leaseToken !== requested.leaseToken + ) { + throw new Error( + `Durable application command outbox delivery fence is stale: ${current.eventId}` + ); + } +} + +export function staleDurableCommandState( + current: DurableApplicationCommandRecord, + requested: DurableCommandState +): Error { + return new Error( + `Durable application command state is stale: ${current.commandId} expected=${requested} actual=${current.state}` + ); +} + +export function assertKnownCommandState(value: string): asserts value is DurableCommandState { + if (!DURABLE_COMMAND_STATES.includes(value as DurableCommandState)) { + throw new Error(`Unsupported durable application command state: ${value}`); + } +} + +export function assertKnownEffectState(value: string): asserts value is DurableEffectState { + if (!DURABLE_EFFECT_STATES.includes(value as DurableEffectState)) { + throw new Error(`Unsupported durable application command effect state: ${value}`); + } +} + +export function assertIdentifier( + field: string, + value: unknown, + maxLength = MAX_IDENTIFIER_LENGTH +): asserts value is string { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + value.length > maxLength || + value.includes('\0') + ) { + throw new Error(`Durable application command ${field} must be a bounded non-empty string`); + } +} + +export function assertPositiveVersion(field: string, value: unknown): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new Error(`Durable application command ${field} must be a positive safe integer`); + } +} + +export function assertIsoTimestamp(field: string, value: string): void { + if (!Number.isFinite(Date.parse(value))) { + throw new Error(`Durable application command ${field} must be an ISO timestamp`); + } +} + +export function assertLeaseWindow( + claimedAtIso: string, + leaseExpiresAtIso: string, + field: string +): void { + assertIsoTimestamp(`${field}.claimedAt`, claimedAtIso); + assertIsoTimestamp(`${field}.leaseExpiresAt`, leaseExpiresAtIso); + if (Date.parse(leaseExpiresAtIso) <= Date.parse(claimedAtIso)) { + throw new Error(`Durable application command ${field} lease must expire after it is claimed`); + } +} + +export function assertSafeJson(field: string, value: string): void { + if (new TextEncoder().encode(value).byteLength > MAX_SAFE_JSON_BYTES) { + throw new Error(`Durable application command ${field} exceeds the storage budget`); + } + try { + JSON.parse(value); + } catch { + throw new Error(`Durable application command ${field} must be valid JSON`); + } +} + +export function canFinalize(status: ApplicationCommandLedgerStatus): boolean { + return ( + status === ApplicationCommandLedgerStatus.Started || + status === ApplicationCommandLedgerStatus.UnknownAfterTimeout + ); +} + +export function assertAttemptMatches( + current: AppCommandRecord, + requestedAttemptCount: number +): void { + if (current.attemptCount !== requestedAttemptCount) { + throw new Error( + `Application command attempt is stale: ${current.commandId} expected=${current.attemptCount} actual=${requestedAttemptCount}` + ); + } +} + +export function assertValidBeginTiming(input: AppCommandBeginRequest): void { + if (!Number.isSafeInteger(input.startedStaleAfterMs) || input.startedStaleAfterMs <= 0) { + throw new Error('Application command startedStaleAfterMs must be a positive integer'); + } + if (!Number.isFinite(Date.parse(input.nowIso))) { + throw new Error('Application command nowIso must be a valid ISO timestamp'); + } +} + +export function isStartedStale(current: AppCommandRecord, input: AppCommandBeginRequest): boolean { + const attemptStartedAtMs = Date.parse(current.updatedAt); + if (!Number.isFinite(attemptStartedAtMs)) { + return true; + } + return Date.parse(input.nowIso) - attemptStartedAtMs >= input.startedStaleAfterMs; +} + +export function statusForFailure( + failureKind: ApplicationCommandFailureKind +): ApplicationCommandLedgerStatus { + switch (failureKind) { + case ApplicationCommandFailureKind.Retryable: + return ApplicationCommandLedgerStatus.FailedRetryable; + case ApplicationCommandFailureKind.Terminal: + return ApplicationCommandLedgerStatus.FailedTerminal; + case ApplicationCommandFailureKind.UnknownAfterTimeout: + return ApplicationCommandLedgerStatus.UnknownAfterTimeout; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerWorkerOps.ts b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerWorkerOps.ts index 835ee23f1f..a949d8cf83 100644 --- a/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerWorkerOps.ts +++ b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerWorkerOps.ts @@ -1,26 +1,50 @@ import { - ApplicationCommandBeginOutcome, - ApplicationCommandConflictReason, - ApplicationCommandFailureKind, - type ApplicationCommandLedgerBeginRequest, - type ApplicationCommandLedgerBeginResult, type ApplicationCommandLedgerCompleteRequest, type ApplicationCommandLedgerFailRequest, type ApplicationCommandLedgerListScopeRequest, type ApplicationCommandLedgerReadByCommandIdRequest, type ApplicationCommandLedgerReadByIdempotencyKeyRequest, - type ApplicationCommandLedgerRecord, - ApplicationCommandLedgerStatus, } from '@features/application-command-ledger/contracts'; -import { and, asc, eq } from 'drizzle-orm'; - -import { applicationCommandLedger } from './internalStorageSchema'; +import { ApplicationCommandLedgerRecordRepository } from './applicationCommandLedgerRecordRepository'; +import { DurableApplicationCommandOutboxWorkerOps } from './durableApplicationCommandOutboxWorkerOps'; +import { DurableApplicationCommandWorkerOps } from './durableApplicationCommandWorkerOps'; +import { HostedAuthorityProjectionStorageOps } from './hostedAuthorityProjectionStorageOps'; +import { LegacyApplicationCommandLedgerWorkerOps } from './legacyApplicationCommandLedgerWorkerOps'; + +import type { + AppCommandBeginRequest, + AppCommandBeginResult, + AppCommandRecord, +} from './applicationCommandLedgerWorkerTypes'; +import type { + ApplicationCommandLedgerWorkerPayloadByOp, + StoredCommandCoordinationAttribution, +} from './internalStorageWorkerProtocol'; +import type { + ApplicationCommandJsonValue, + DurableApplicationCommandAttemptLeaseRequest, + DurableApplicationCommandClaimResult, + DurableApplicationCommandClaimStatusRequest, + DurableApplicationCommandCommitRequest, + DurableApplicationCommandConsumerApplyRequest, + DurableApplicationCommandConsumerApplyResult, + DurableApplicationCommandConsumerProjectionRecord, + DurableApplicationCommandConsumerProjectionRequest, + DurableApplicationCommandEffectTransitionRequest, + DurableApplicationCommandOutboxClaimRequest, + DurableApplicationCommandOutboxDeliveryAcknowledgementRequest, + DurableApplicationCommandOutboxListRequest, + DurableApplicationCommandOutboxRecord, + DurableApplicationCommandPersistClaimRequest, + DurableApplicationCommandRecord, + DurableApplicationCommandStatusRequest, + DurableApplicationCommandTransitionRequest, +} from '@features/application-command-ledger'; +import type DatabaseConstructor from 'better-sqlite3'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -type AppCommandRecord = ApplicationCommandLedgerRecord; -type AppCommandBeginRequest = ApplicationCommandLedgerBeginRequest; -type AppCommandBeginResult = ApplicationCommandLedgerBeginResult; +type SqliteDatabase = InstanceType; export function handleApplicationCommandLedgerOp( ops: ApplicationCommandLedgerWorkerOps, @@ -29,7 +53,9 @@ export function handleApplicationCommandLedgerOp( ): unknown { switch (op) { case 'appCommandLedger.begin': - return ops.begin(payload as AppCommandBeginRequest); + // Keep the existing JSON-domain boundary explicit while the legacy + // cross-feature edge remains pinned by the architecture ratchet. + return ops.begin(payload as AppCommandBeginRequest & ApplicationCommandJsonValue); case 'appCommandLedger.markCompleted': ops.markCompleted(payload as ApplicationCommandLedgerCompleteRequest); return null; @@ -44,372 +70,167 @@ export function handleApplicationCommandLedgerOp( ); case 'appCommandLedger.listByScope': return ops.listByScope(payload as ApplicationCommandLedgerListScopeRequest); + case 'appCommandLedger.durable.claim': + return ops.durableClaim( + payload as ApplicationCommandLedgerWorkerPayloadByOp['appCommandLedger.durable.claim'] + ); + case 'appCommandLedger.durable.getStatus': + return ops.durableGetStatus(payload as DurableApplicationCommandStatusRequest); + case 'appCommandLedger.durable.getByClaim': + return ops.durableGetByClaim(payload as DurableApplicationCommandClaimStatusRequest); + case 'appCommandLedger.durable.renewAttemptLease': + return ops.durableRenewAttemptLease(payload as DurableApplicationCommandAttemptLeaseRequest); + case 'appCommandLedger.durable.transitionCommand': + return ops.durableTransitionCommand(payload as DurableApplicationCommandTransitionRequest); + case 'appCommandLedger.durable.transitionEffect': + return ops.durableTransitionEffect( + payload as DurableApplicationCommandEffectTransitionRequest + ); + case 'appCommandLedger.durable.commit': + return ops.durableCommit(payload as DurableApplicationCommandCommitRequest); + case 'appCommandLedger.durable.listOutbox': + return ops.durableListOutbox(payload as DurableApplicationCommandOutboxListRequest); + case 'appCommandLedger.durable.claimOutbox': + return ops.durableClaimOutbox(payload as DurableApplicationCommandOutboxClaimRequest); + case 'appCommandLedger.durable.acknowledgeOutboxDelivery': + ops.durableAcknowledgeOutboxDelivery( + payload as DurableApplicationCommandOutboxDeliveryAcknowledgementRequest + ); + return null; + case 'appCommandLedger.durable.applyConsumerEvent': + return ops.durableApplyConsumerEvent( + payload as DurableApplicationCommandConsumerApplyRequest + ); + case 'appCommandLedger.durable.getConsumerProjection': + return ops.durableGetConsumerProjection( + payload as DurableApplicationCommandConsumerProjectionRequest + ); + case 'appCommandLedger.hostedAuthorityProjection.commit': + return ops.hostedAuthorityProjectionCommit(payload); + case 'appCommandLedger.hostedAuthorityProjection.get': + return ops.hostedAuthorityProjectionGet(payload); default: throw new Error(`Unknown internal-storage op: ${op}`); } } export class ApplicationCommandLedgerWorkerOps { - constructor(private readonly getOrm: () => BetterSQLite3Database) {} - - begin(input: AppCommandBeginRequest): AppCommandBeginResult { - assertValidBeginTiming(input); - const orm = this.getOrm(); - return orm.transaction((): AppCommandBeginResult => { - const currentByCommand = this.readByCommandId(input); - if (currentByCommand) { - return this.beginExistingCommand(currentByCommand, input); - } - - const currentByIdempotencyKey = this.readByIdempotencyKey(input); - if (currentByIdempotencyKey) { - return this.beginExistingIdempotencyKey(currentByIdempotencyKey, input); - } - - const created: AppCommandRecord = { - namespace: input.namespace, - scopeKey: input.scopeKey, - commandId: input.commandId, - idempotencyKey: input.idempotencyKey, - operation: input.operation, - payloadHash: input.payloadHash, - status: ApplicationCommandLedgerStatus.Started, - failureKind: null, - retryable: false, - attemptCount: 1, - resultHash: null, - resultJson: null, - metadataJson: input.metadataJson, - startedAt: input.nowIso, - updatedAt: input.nowIso, - completedAt: null, - lastError: null, - }; - orm.insert(applicationCommandLedger).values(created).run(); - return { outcome: ApplicationCommandBeginOutcome.Started, record: created }; - }); + private readonly durableCommands: DurableApplicationCommandWorkerOps; + private readonly durableOutbox: DurableApplicationCommandOutboxWorkerOps; + private readonly hostedAuthorityProjections: HostedAuthorityProjectionStorageOps; + private readonly legacy: LegacyApplicationCommandLedgerWorkerOps; + + constructor(getOrm: () => BetterSQLite3Database, getDb: () => SqliteDatabase) { + const repository = new ApplicationCommandLedgerRecordRepository(getOrm, getDb); + this.durableCommands = new DurableApplicationCommandWorkerOps(getOrm, getDb, repository); + this.durableOutbox = new DurableApplicationCommandOutboxWorkerOps(getOrm, repository); + this.hostedAuthorityProjections = new HostedAuthorityProjectionStorageOps(getDb, repository); + this.legacy = new LegacyApplicationCommandLedgerWorkerOps(getOrm, repository, getDb); + } + + durableClaim( + input: DurableApplicationCommandPersistClaimRequest & { + readonly coordinationAttribution?: StoredCommandCoordinationAttribution; + } + ): DurableApplicationCommandClaimResult { + return this.durableCommands.durableClaim(input); } - markCompleted(input: ApplicationCommandLedgerCompleteRequest): void { - const orm = this.getOrm(); - orm.transaction(() => { - const current = this.readByCommandId(input); - if (!current) { - throw new Error(`Application command ledger entry not found: ${input.commandId}`); - } - if (current.status === ApplicationCommandLedgerStatus.Completed) { - if (current.resultHash === input.resultHash && current.resultJson === input.resultJson) { - return; - } - throw new Error( - `Application command completion conflicts with stored result: ${input.commandId}` - ); - } - assertAttemptMatches(current, input.attemptCount); - if (!canFinalize(current.status)) { - throw new Error( - `Application command cannot be completed from status ${current.status}: ${input.commandId}` - ); - } - this.replaceRow({ - ...current, - status: ApplicationCommandLedgerStatus.Completed, - failureKind: null, - retryable: false, - resultHash: input.resultHash, - resultJson: input.resultJson, - updatedAt: input.completedAtIso, - completedAt: input.completedAtIso, - lastError: null, - }); - }); + durableGetStatus( + input: DurableApplicationCommandStatusRequest + ): DurableApplicationCommandRecord | null { + return this.durableCommands.durableGetStatus(input); } - markFailed(input: ApplicationCommandLedgerFailRequest): void { - const orm = this.getOrm(); - orm.transaction(() => { - const current = this.readByCommandId(input); - if (!current) { - throw new Error(`Application command ledger entry not found: ${input.commandId}`); - } - assertAttemptMatches(current, input.attemptCount); - const nextStatus = statusForFailure(input.failureKind); - if ( - current.status === nextStatus && - current.failureKind === input.failureKind && - current.lastError === input.errorMessage - ) { - return; - } - if (!canFinalize(current.status)) { - throw new Error( - `Application command cannot be failed from status ${current.status}: ${input.commandId}` - ); - } - this.replaceRow({ - ...current, - status: nextStatus, - failureKind: input.failureKind, - retryable: input.failureKind === ApplicationCommandFailureKind.Retryable, - resultHash: null, - resultJson: null, - updatedAt: input.completedAtIso, - completedAt: - input.failureKind === ApplicationCommandFailureKind.UnknownAfterTimeout - ? null - : input.completedAtIso, - lastError: input.errorMessage, - }); - }); + durableGetByClaim( + input: DurableApplicationCommandClaimStatusRequest + ): DurableApplicationCommandRecord | null { + return this.durableCommands.durableGetByClaim(input); } - getByCommandId(input: ApplicationCommandLedgerReadByCommandIdRequest): AppCommandRecord | null { - return this.readByCommandId(input); + durableRenewAttemptLease( + input: DurableApplicationCommandAttemptLeaseRequest + ): DurableApplicationCommandRecord { + return this.durableCommands.durableRenewAttemptLease(input); } - getByIdempotencyKey( - input: ApplicationCommandLedgerReadByIdempotencyKeyRequest - ): AppCommandRecord | null { - return this.readByIdempotencyKey(input); + durableTransitionCommand( + input: DurableApplicationCommandTransitionRequest + ): DurableApplicationCommandRecord { + return this.durableCommands.durableTransitionCommand(input); } - listByScope(input: ApplicationCommandLedgerListScopeRequest): AppCommandRecord[] { - return this.getOrm() - .select() - .from(applicationCommandLedger) - .where( - and( - eq(applicationCommandLedger.namespace, input.namespace), - eq(applicationCommandLedger.scopeKey, input.scopeKey) - ) - ) - .orderBy(asc(applicationCommandLedger.updatedAt), asc(applicationCommandLedger.commandId)) - .all() as AppCommandRecord[]; + durableTransitionEffect( + input: DurableApplicationCommandEffectTransitionRequest + ): DurableApplicationCommandRecord { + return this.durableCommands.durableTransitionEffect(input); } - private beginExistingCommand( - current: AppCommandRecord, - input: AppCommandBeginRequest - ): AppCommandBeginResult { - const conflict = - current.idempotencyKey !== input.idempotencyKey - ? ApplicationCommandConflictReason.CommandIdReused - : this.findSemanticConflict(current, input); - if (conflict) { - return { - outcome: ApplicationCommandBeginOutcome.Conflict, - reason: conflict, - existing: current, - requested: input, - }; - } - - return this.beginExistingMatchingCommand(current, input); + durableCommit(input: DurableApplicationCommandCommitRequest): DurableApplicationCommandRecord { + return this.durableCommands.durableCommit(input); } - private beginExistingIdempotencyKey( - current: AppCommandRecord, - input: AppCommandBeginRequest - ): AppCommandBeginResult { - const conflict = this.findSemanticConflict(current, input); - if (conflict) { - return { - outcome: ApplicationCommandBeginOutcome.Conflict, - reason: conflict, - existing: current, - requested: input, - }; - } - - return this.beginExistingMatchingCommand(current, input); + durableListOutbox( + input: DurableApplicationCommandOutboxListRequest + ): DurableApplicationCommandOutboxRecord[] { + return this.durableOutbox.durableListOutbox(input); } - private beginExistingMatchingCommand( - current: AppCommandRecord, - input: AppCommandBeginRequest - ): AppCommandBeginResult { - switch (current.status) { - case ApplicationCommandLedgerStatus.Started: - if (isStartedStale(current, input)) { - const next: AppCommandRecord = { - ...current, - status: ApplicationCommandLedgerStatus.UnknownAfterTimeout, - failureKind: ApplicationCommandFailureKind.UnknownAfterTimeout, - retryable: false, - updatedAt: input.nowIso, - completedAt: null, - lastError: `Started attempt ${current.attemptCount} exceeded ${input.startedStaleAfterMs}ms and requires reconciliation`, - }; - this.replaceRow(next); - return { - outcome: ApplicationCommandBeginOutcome.UnknownAfterTimeout, - record: next, - }; - } - return { outcome: ApplicationCommandBeginOutcome.AlreadyStarted, record: current }; - case ApplicationCommandLedgerStatus.Completed: - return { outcome: ApplicationCommandBeginOutcome.DuplicateCompleted, record: current }; - case ApplicationCommandLedgerStatus.FailedRetryable: - return this.restartRetryable(current, input); - case ApplicationCommandLedgerStatus.FailedTerminal: - return { outcome: ApplicationCommandBeginOutcome.FailedTerminal, record: current }; - case ApplicationCommandLedgerStatus.UnknownAfterTimeout: - return { outcome: ApplicationCommandBeginOutcome.UnknownAfterTimeout, record: current }; - default: - return { - outcome: ApplicationCommandBeginOutcome.Conflict, - reason: ApplicationCommandConflictReason.OperationMismatch, - existing: current, - requested: input, - }; - } + durableClaimOutbox( + input: DurableApplicationCommandOutboxClaimRequest + ): DurableApplicationCommandOutboxRecord[] { + return this.durableOutbox.durableClaimOutbox(input); } - private restartRetryable( - current: AppCommandRecord, - input: AppCommandBeginRequest - ): AppCommandBeginResult { - const next: AppCommandRecord = { - ...current, - operation: input.operation, - payloadHash: input.payloadHash, - status: ApplicationCommandLedgerStatus.Started, - failureKind: null, - retryable: false, - attemptCount: current.attemptCount + 1, - resultHash: null, - resultJson: null, - metadataJson: input.metadataJson, - updatedAt: input.nowIso, - completedAt: null, - lastError: null, - }; - this.replaceRow(next); - return { outcome: ApplicationCommandBeginOutcome.RetryStarted, record: next }; + durableAcknowledgeOutboxDelivery( + input: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest + ): void { + this.durableOutbox.durableAcknowledgeOutboxDelivery(input); } - private findSemanticConflict( - current: AppCommandRecord, - input: AppCommandBeginRequest - ): ApplicationCommandConflictReason | null { - if (current.operation !== input.operation) { - return ApplicationCommandConflictReason.OperationMismatch; - } - if (current.payloadHash !== input.payloadHash) { - return ApplicationCommandConflictReason.PayloadHashMismatch; - } - return null; + durableApplyConsumerEvent( + input: DurableApplicationCommandConsumerApplyRequest + ): DurableApplicationCommandConsumerApplyResult { + return this.durableOutbox.durableApplyConsumerEvent(input); } - private readByCommandId(input: { - namespace: string; - scopeKey: string; - commandId: string; - }): AppCommandRecord | null { - const rows = this.getOrm() - .select() - .from(applicationCommandLedger) - .where( - and( - eq(applicationCommandLedger.namespace, input.namespace), - eq(applicationCommandLedger.scopeKey, input.scopeKey), - eq(applicationCommandLedger.commandId, input.commandId) - ) - ) - .all() as AppCommandRecord[]; - return rows[0] ?? null; + durableGetConsumerProjection( + input: DurableApplicationCommandConsumerProjectionRequest + ): DurableApplicationCommandConsumerProjectionRecord | null { + return this.durableOutbox.durableGetConsumerProjection(input); } - private readByIdempotencyKey(input: { - namespace: string; - scopeKey: string; - idempotencyKey: string; - }): AppCommandRecord | null { - const rows = this.getOrm() - .select() - .from(applicationCommandLedger) - .where( - and( - eq(applicationCommandLedger.namespace, input.namespace), - eq(applicationCommandLedger.scopeKey, input.scopeKey), - eq(applicationCommandLedger.idempotencyKey, input.idempotencyKey) - ) - ) - .all() as AppCommandRecord[]; - return rows[0] ?? null; + hostedAuthorityProjectionCommit(input: unknown): unknown { + return this.hostedAuthorityProjections.commit(input); } - private replaceRow(row: AppCommandRecord): void { - this.getOrm() - .update(applicationCommandLedger) - .set({ - idempotencyKey: row.idempotencyKey, - operation: row.operation, - payloadHash: row.payloadHash, - status: row.status, - failureKind: row.failureKind, - retryable: row.retryable, - attemptCount: row.attemptCount, - resultHash: row.resultHash, - resultJson: row.resultJson, - metadataJson: row.metadataJson, - startedAt: row.startedAt, - updatedAt: row.updatedAt, - completedAt: row.completedAt, - lastError: row.lastError, - }) - .where( - and( - eq(applicationCommandLedger.namespace, row.namespace), - eq(applicationCommandLedger.scopeKey, row.scopeKey), - eq(applicationCommandLedger.commandId, row.commandId) - ) - ) - .run(); + hostedAuthorityProjectionGet(input: unknown): unknown { + return this.hostedAuthorityProjections.get(input); } -} -function canFinalize(status: ApplicationCommandLedgerStatus): boolean { - return ( - status === ApplicationCommandLedgerStatus.Started || - status === ApplicationCommandLedgerStatus.UnknownAfterTimeout - ); -} + begin(input: AppCommandBeginRequest): AppCommandBeginResult { + return this.legacy.begin(input); + } -function assertAttemptMatches(current: AppCommandRecord, requestedAttemptCount: number): void { - if (current.attemptCount !== requestedAttemptCount) { - throw new Error( - `Application command attempt is stale: ${current.commandId} expected=${current.attemptCount} actual=${requestedAttemptCount}` - ); + markCompleted(input: ApplicationCommandLedgerCompleteRequest): void { + this.legacy.markCompleted(input); } -} -function assertValidBeginTiming(input: AppCommandBeginRequest): void { - if (!Number.isSafeInteger(input.startedStaleAfterMs) || input.startedStaleAfterMs <= 0) { - throw new Error('Application command startedStaleAfterMs must be a positive integer'); + markFailed(input: ApplicationCommandLedgerFailRequest): void { + this.legacy.markFailed(input); } - if (!Number.isFinite(Date.parse(input.nowIso))) { - throw new Error('Application command nowIso must be a valid ISO timestamp'); + + getByCommandId(input: ApplicationCommandLedgerReadByCommandIdRequest): AppCommandRecord | null { + return this.legacy.getByCommandId(input); } -} -function isStartedStale(current: AppCommandRecord, input: AppCommandBeginRequest): boolean { - const attemptStartedAtMs = Date.parse(current.updatedAt); - if (!Number.isFinite(attemptStartedAtMs)) { - return true; + getByIdempotencyKey( + input: ApplicationCommandLedgerReadByIdempotencyKeyRequest + ): AppCommandRecord | null { + return this.legacy.getByIdempotencyKey(input); } - return Date.parse(input.nowIso) - attemptStartedAtMs >= input.startedStaleAfterMs; -} -function statusForFailure( - failureKind: ApplicationCommandFailureKind -): ApplicationCommandLedgerStatus { - switch (failureKind) { - case ApplicationCommandFailureKind.Retryable: - return ApplicationCommandLedgerStatus.FailedRetryable; - case ApplicationCommandFailureKind.Terminal: - return ApplicationCommandLedgerStatus.FailedTerminal; - case ApplicationCommandFailureKind.UnknownAfterTimeout: - return ApplicationCommandLedgerStatus.UnknownAfterTimeout; + listByScope(input: ApplicationCommandLedgerListScopeRequest): AppCommandRecord[] { + return this.legacy.listByScope(input); } } diff --git a/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerWorkerTypes.ts b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerWorkerTypes.ts new file mode 100644 index 0000000000..336f2a7f4c --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/applicationCommandLedgerWorkerTypes.ts @@ -0,0 +1,105 @@ +import { + type ApplicationCommandLedgerBeginRequest, + type ApplicationCommandLedgerBeginResult, + type ApplicationCommandLedgerRecord, +} from '@features/application-command-ledger/contracts'; + +export type AppCommandRecord = ApplicationCommandLedgerRecord; +export type AppCommandBeginRequest = ApplicationCommandLedgerBeginRequest; +export type AppCommandBeginResult = ApplicationCommandLedgerBeginResult; + +export interface DurableCommandRow { + commandId: string; + deploymentId: string; + stableActorId: string; + commandKind: string; + idempotencyKey: string; + descriptorId: string; + descriptorVersion: number; + inputSchemaVersion: number; + fingerprintVersion: string; + effectPlanVersion: number; + fingerprintKeyVersion: string; + fingerprintDigest: string; + attemptGeneration: number; + attemptId: string; + attemptOwnerId: string; + attemptLeaseToken: string; + attemptClaimedAt: string; + attemptLeaseExpiresAt: string; + state: string; + retentionClass: string; + auditSessionId: string | null; + coordinationAttributionJson: string; + outcomeJson: string | null; + errorCode: string | null; + errorJson: string | null; + createdAt: string; + updatedAt: string; + committedAt: string | null; +} + +export interface DurableEffectRow { + commandId: string; + ordinal: number; + effectId: string; + effectVersion: number; + recoveryClass: string; + evidenceSchemaVersion: number; + state: string; + updatedAt: string; +} + +export interface DurableEffectEvidenceRow { + commandId: string; + ordinal: number; + sequence: number; + outcome: string; + evidenceSchemaVersion: number; + evidenceJson: string; + recordedAt: string; +} + +export interface DurableOutboxRow { + sequence: number; + eventId: string; + commandId: string; + deploymentId: string; + eventType: string; + scopeKind: string; + scopeId: string; + schemaVersion: number; + semanticRevision: number; + payloadJson: string; + createdAt: string; + deliveryGeneration: number; + deliveryOwnerId: string | null; + deliveryLeaseToken: string | null; + deliveryClaimedAt: string | null; + deliveryLeaseExpiresAt: string | null; + deliveryAcknowledgedAt: string | null; +} + +export interface DurableConsumerApplicationRow { + consumerId: string; + eventId: string; + semanticRevision: number; + projectionKey: string; + stateJson: string; + appliedAt: string; +} + +export interface DurableConsumerProjectionRow { + consumerId: string; + projectionKey: string; + semanticRevision: number; + lastEventId: string; + stateJson: string; + applicationCount: number; + updatedAt: string; +} + +export const MAX_IDENTIFIER_LENGTH = 512; +export const MAX_IDEMPOTENCY_KEY_LENGTH = 1_024; +export const MAX_SAFE_JSON_BYTES = 64 * 1_024; +export const MAX_OUTBOX_PAGE_SIZE = 1_000; diff --git a/src/features/internal-storage/main/infrastructure/worker/applicationCommandMutationFenceOps.ts b/src/features/internal-storage/main/infrastructure/worker/applicationCommandMutationFenceOps.ts new file mode 100644 index 0000000000..351bcacca5 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/applicationCommandMutationFenceOps.ts @@ -0,0 +1,328 @@ +import { + ApplicationCommandBeginOutcome, + ApplicationCommandConflictReason, + type ApplicationCommandLedgerBeginRequest, + type ApplicationCommandLedgerBeginResult, + type ApplicationCommandLedgerRecord, + ApplicationCommandLedgerStatus, + type ApplicationCommandMutationFenceClaim, +} from '@features/application-command-ledger/contracts'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +type AppCommandRecord = ApplicationCommandLedgerRecord; +type AppCommandBeginRequest = ApplicationCommandLedgerBeginRequest; +type AppCommandBeginResult = ApplicationCommandLedgerBeginResult; + +interface MutationFenceRow { + namespace: string; + scopeKey: string; + laneId: string; + backend: string; + effectKind: string; + payloadHash: string; + operationId: string; + leaseToken: string; + leaseOwnerId: string; + leaseFence: number; + claimedAt: string; + expiresAt: string; + commandId: string; +} + +export type ApplicationCommandMutationFenceCommandReader = (input: { + namespace: string; + scopeKey: string; + commandId: string; +}) => AppCommandRecord | null; + +const MAX_IDENTIFIER_LENGTH = 512; + +export class ApplicationCommandMutationFenceOps { + constructor( + private readonly getDatabase: () => SqliteDatabase, + private readonly readCommandById: ApplicationCommandMutationFenceCommandReader + ) {} + + ensureSchema(): void { + this.getDatabase().exec(` + CREATE TABLE IF NOT EXISTS application_command_mutation_fences ( + namespace TEXT NOT NULL, + scope_key TEXT NOT NULL, + lane_id TEXT NOT NULL, + backend TEXT NOT NULL, + effect_kind TEXT NOT NULL, + payload_hash TEXT NOT NULL, + operation_id TEXT NOT NULL, + lease_token TEXT NOT NULL, + lease_owner_id TEXT NOT NULL, + lease_fence INTEGER NOT NULL CHECK (lease_fence > 0), + claimed_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + command_id TEXT NOT NULL, + PRIMARY KEY (namespace, scope_key, operation_id), + FOREIGN KEY (namespace, scope_key, command_id) + REFERENCES application_command_ledger(namespace, scope_key, command_id) + DEFERRABLE INITIALLY DEFERRED + ) WITHOUT ROWID; + CREATE UNIQUE INDEX IF NOT EXISTS idx_app_cmd_mutation_token + ON application_command_mutation_fences(namespace, scope_key, lease_token); + CREATE UNIQUE INDEX IF NOT EXISTS idx_app_cmd_mutation_lane_fence + ON application_command_mutation_fences(namespace, scope_key, lane_id, lease_fence); + CREATE INDEX IF NOT EXISTS idx_app_cmd_mutation_lane_max + ON application_command_mutation_fences(namespace, scope_key, lane_id, lease_fence DESC); + `); + } + + claim( + input: AppCommandBeginRequest, + current: AppCommandRecord | null + ): AppCommandBeginResult | null { + const claim = input.mutationFence; + if (!claim) { + if (!current) return null; + const storedClaim = this.readMutationFence( + `namespace = ? AND scope_key = ? AND command_id = ?`, + current.namespace, + current.scopeKey, + current.commandId + ); + return storedClaim + ? mutationConflict( + ApplicationCommandConflictReason.MutationOperationRebound, + current, + input + ) + : null; + } + validateMutationFenceClaim(claim, input); + + const existingOperation = this.readMutationFence( + `namespace = ? AND scope_key = ? AND operation_id = ?`, + input.namespace, + input.scopeKey, + claim.operationId + ); + if (existingOperation) { + if ( + current?.commandId === existingOperation.commandId && + sameMutationFence(existingOperation, input) + ) { + return null; + } + const reason = + existingOperation.payloadHash !== input.payloadHash + ? ApplicationCommandConflictReason.PayloadHashMismatch + : ApplicationCommandConflictReason.MutationOperationRebound; + return mutationConflict(reason, current, input); + } + + if (current) { + return mutationConflict( + ApplicationCommandConflictReason.MutationOperationRebound, + current, + input + ); + } + + const existingToken = this.readMutationFence( + `namespace = ? AND scope_key = ? AND lease_token = ?`, + input.namespace, + input.scopeKey, + claim.leaseToken + ); + if (existingToken) { + return mutationConflict( + ApplicationCommandConflictReason.MutationTokenRebound, + this.readCommandById({ + namespace: existingToken.namespace, + scopeKey: existingToken.scopeKey, + commandId: existingToken.commandId, + }), + input + ); + } + + const now = Date.parse(input.nowIso); + if (now < Date.parse(claim.claimedAtIso) || now >= Date.parse(claim.expiresAtIso)) { + return mutationConflict(ApplicationCommandConflictReason.MutationFenceExpired, null, input); + } + + const maximum = this.readMaximumMutationFence(input.namespace, input.scopeKey, claim.laneId); + if (maximum) { + const maximumCommand = this.readCommandById({ + namespace: maximum.namespace, + scopeKey: maximum.scopeKey, + commandId: maximum.commandId, + }); + if (!maximumCommand) { + throw new Error('Application command mutation fence refers to a missing command'); + } + if ( + maximumCommand.status === ApplicationCommandLedgerStatus.Started || + maximumCommand.status === ApplicationCommandLedgerStatus.UnknownAfterTimeout + ) { + return mutationConflict( + ApplicationCommandConflictReason.MutationSuccessorBlocked, + maximumCommand, + input + ); + } + if (claim.leaseFence <= maximum.leaseFence) { + return mutationConflict( + ApplicationCommandConflictReason.MutationFenceStale, + maximumCommand, + input + ); + } + } + + this.getDatabase() + .prepare( + `INSERT INTO application_command_mutation_fences ( + namespace, scope_key, lane_id, backend, effect_kind, payload_hash, + operation_id, lease_token, lease_owner_id, lease_fence, claimed_at, + expires_at, command_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + input.namespace, + input.scopeKey, + claim.laneId, + claim.backend, + claim.effectKind, + input.payloadHash, + claim.operationId, + claim.leaseToken, + claim.leaseOwnerId, + claim.leaseFence, + claim.claimedAtIso, + claim.expiresAtIso, + input.commandId + ); + return null; + } + + private readMaximumMutationFence( + namespace: string, + scopeKey: string, + laneId: string + ): MutationFenceRow | null { + return this.readMutationFence( + `namespace = ? AND scope_key = ? AND lane_id = ? + ORDER BY lease_fence DESC LIMIT 1`, + namespace, + scopeKey, + laneId + ); + } + + private readMutationFence(where: string, ...parameters: unknown[]): MutationFenceRow | null { + const row = this.getDatabase() + .prepare( + `SELECT + namespace, + scope_key AS scopeKey, + lane_id AS laneId, + backend, + effect_kind AS effectKind, + payload_hash AS payloadHash, + operation_id AS operationId, + lease_token AS leaseToken, + lease_owner_id AS leaseOwnerId, + lease_fence AS leaseFence, + claimed_at AS claimedAt, + expires_at AS expiresAt, + command_id AS commandId + FROM application_command_mutation_fences + WHERE ${where}` + ) + .get(...parameters) as MutationFenceRow | undefined; + return row ?? null; + } +} + +function validateMutationFenceClaim( + claim: ApplicationCommandMutationFenceClaim, + input: AppCommandBeginRequest +): void { + for (const [field, value] of Object.entries({ + laneId: claim.laneId, + backend: claim.backend, + effectKind: claim.effectKind, + operationId: claim.operationId, + leaseToken: claim.leaseToken, + leaseOwnerId: claim.leaseOwnerId, + })) { + assertIdentifier(`mutationFence.${field}`, value); + } + assertPositiveVersion('mutationFence.leaseFence', claim.leaseFence); + assertCanonicalIsoTimestamp('mutationFence.claimedAtIso', claim.claimedAtIso); + assertCanonicalIsoTimestamp('mutationFence.expiresAtIso', claim.expiresAtIso); + if (Date.parse(claim.expiresAtIso) <= Date.parse(claim.claimedAtIso)) { + throw new Error('Application command mutation fence must expire after it is claimed'); + } + if (claim.operationId !== input.commandId || claim.operationId !== input.idempotencyKey) { + throw new Error( + 'Application command mutation fence operationId must match command and idempotency identity' + ); + } +} + +function assertIdentifier(field: string, value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + value.includes('\0') + ) { + throw new Error(`Durable application command ${field} must be a bounded non-empty string`); + } +} + +function assertPositiveVersion(field: string, value: unknown): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new Error(`Durable application command ${field} must be a positive safe integer`); + } +} + +function assertCanonicalIsoTimestamp(field: string, value: string): void { + if (!Number.isFinite(Date.parse(value))) { + throw new Error(`Durable application command ${field} must be an ISO timestamp`); + } + if (new Date(value).toISOString() !== value) { + throw new Error(`Durable application command ${field} must be a canonical ISO timestamp`); + } +} + +function sameMutationFence(row: MutationFenceRow, input: AppCommandBeginRequest): boolean { + const claim = input.mutationFence; + return ( + claim !== undefined && + row.laneId === claim.laneId && + row.backend === claim.backend && + row.effectKind === claim.effectKind && + row.payloadHash === input.payloadHash && + row.operationId === claim.operationId && + row.leaseToken === claim.leaseToken && + row.leaseOwnerId === claim.leaseOwnerId && + row.leaseFence === claim.leaseFence && + row.claimedAt === claim.claimedAtIso && + row.expiresAt === claim.expiresAtIso && + row.commandId === input.commandId + ); +} + +function mutationConflict( + reason: ApplicationCommandConflictReason, + existing: AppCommandRecord | null, + requested: AppCommandBeginRequest +): AppCommandBeginResult { + return { + outcome: ApplicationCommandBeginOutcome.Conflict, + reason, + existing, + requested, + }; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/coordinationDurabilityState.ts b/src/features/internal-storage/main/infrastructure/worker/coordinationDurabilityState.ts new file mode 100644 index 0000000000..35fac5f3cb --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/coordinationDurabilityState.ts @@ -0,0 +1,581 @@ +import { createHash } from 'node:crypto'; + +import { assertBackupRunRecord, BACKUP_RUN_STATES } from '@features/coordination-backup'; +import { + assertCoordinationEventDraft, + COORDINATION_EVENT_SCOPE_KINDS, + type CoordinationEventDraft, + type CoordinationJsonValue, +} from '@features/coordination-events'; + +import type { + CoordinationDrainStorageEvidence, + StoredCommandCoordinationAttribution, + StoredCoordinationEventRow, + StoredEventJournalMetadata, +} from './internalStorageWorkerProtocol'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export interface EventMetadataRow { + deployment_id: string; + event_epoch: string; + retention_floor_sequence: number; + high_watermark_sequence: number; +} + +export interface EventRow { + deployment_id: string; + event_epoch: string; + event_sequence: number; + event_id: string; + body_json: string; +} + +export interface BackupRunRow { + backup_run_id: string; + deployment_id: string; + state: string; + revision: number; + record_json: string; +} + +export interface WriterFenceRow { + deployment_id: string; + generation: number; + admitted_run_id: string; + lease_id: string; + status: 'active' | 'released' | 'operator_required'; + disposition: 'committed' | 'aborted' | 'operator_required' | null; + acquired_at: string; + completed_at: string | null; +} + +const EVENT_EPOCH_PREFIX = 'epoch-initial-v1-'; +const ACTIVE_COMMAND_STATES = Object.freeze(['prepared', 'running', 'recovering'] as const); +const BACKUP_RUN_STATE_VALUES = new Set(BACKUP_RUN_STATES); + +export function assertCoordinationMutationAdmissionOpen( + db: SqliteDatabase, + deploymentId: string +): void { + const fence = readWriterFence(db, deploymentId); + if (fence?.status === 'active' || fence?.status === 'operator_required') { + throw new Error('coordination-mutation-admission-fenced'); + } +} + +/** + * Central source-database fence used by InternalStorageWorkerCore before every + * mutating operation. A backup-owned write may proceed only for the run that + * owns the one durable database-wide fence. + */ +export function assertInternalStorageMutationAdmissionOpen( + db: SqliteDatabase, + admittedBackupRunId: string | null +): void { + const fence = readBlockingWriterFence(db); + if (!fence) return; + if (admittedBackupRunId !== null && fence.admitted_run_id === admittedBackupRunId) return; + throw new Error('internal-storage-mutation-admission-fenced'); +} + +export function appendCommandOutboxEventToJournal( + db: SqliteDatabase, + input: { + readonly commandId: string; + readonly deploymentId: string; + readonly attribution: StoredCommandCoordinationAttribution; + readonly outbox: { + readonly eventId: string; + readonly eventType: string; + readonly scopeKind: string; + readonly scopeId: string; + readonly schemaVersion: number; + readonly payloadJson: string; + readonly createdAtIso: string; + }; + } +): void { + if (!COORDINATION_EVENT_SCOPE_KINDS.includes(input.outbox.scopeKind as never)) { + throw new Error('durable-command-outbox-scope-kind-invalid'); + } + if (input.outbox.schemaVersion !== 1) { + throw new Error('durable-command-outbox-schema-version-invalid'); + } + let payload: CoordinationJsonValue; + try { + payload = JSON.parse(input.outbox.payloadJson) as CoordinationJsonValue; + } catch { + throw new Error('durable-command-outbox-payload-json-invalid'); + } + const attribution = materializeCommandCoordinationAttribution(input.attribution); + const runId = + attribution.runId ?? (input.outbox.scopeKind === 'run' ? input.outbox.scopeId : undefined); + const draft: CoordinationEventDraft = { + schemaVersion: 1, + eventId: input.outbox.eventId, + scope: { + kind: input.outbox.scopeKind as CoordinationEventDraft['scope']['kind'], + scopeId: input.outbox.scopeId, + }, + ...(input.outbox.scopeKind === 'workspace' ? { workspaceId: input.outbox.scopeId } : {}), + ...(input.outbox.scopeKind === 'team' ? { teamId: input.outbox.scopeId } : {}), + ...(runId === undefined ? {} : { runId }), + actor: attribution.actor, + eventType: input.outbox.eventType, + emittedAt: input.outbox.createdAtIso, + payload, + }; + assertCoordinationEventDraft(draft); + const metadata = ensureEventMetadata( + db, + input.deploymentId, + undefined, + input.outbox.createdAtIso + ); + appendEventJournalRow( + db, + input.deploymentId, + metadata.event_epoch, + draft, + canonicalJson(draft), + input.commandId, + input.outbox.createdAtIso + ); +} + +export function createLegacyCommandCoordinationAttribution( + stableActorId: string +): StoredCommandCoordinationAttribution { + assertIdentifier(stableActorId, 'stableActorId'); + return Object.freeze({ + actor: Object.freeze({ + kind: 'recovery' as const, + actorRef: `legacy-command:${stableActorId}`, + }), + provenance: 'legacy_recovery_v1' as const, + }); +} + +export function materializeCommandCoordinationAttribution( + input: StoredCommandCoordinationAttribution +): StoredCommandCoordinationAttribution { + if (!input || typeof input !== 'object' || !input.actor || typeof input.actor !== 'object') { + throw new Error('durable-command-coordination-attribution-invalid'); + } + if (input.provenance !== 'trusted_context_v1' && input.provenance !== 'legacy_recovery_v1') { + throw new Error('durable-command-coordination-attribution-provenance-invalid'); + } + const actor = Object.freeze({ ...input.actor }); + const materialized = Object.freeze({ + actor, + ...(input.runId === undefined ? {} : { runId: input.runId }), + provenance: input.provenance, + }) as StoredCommandCoordinationAttribution; + const validationDraft: CoordinationEventDraft = { + schemaVersion: 1, + eventId: 'coordination-attribution-validation', + scope: { kind: 'instance', scopeId: 'coordination-attribution-validation' }, + ...(materialized.runId === undefined ? {} : { runId: materialized.runId }), + actor: materialized.actor, + eventType: 'coordination.attribution.validated', + emittedAt: new Date(0).toISOString(), + payload: null, + }; + assertCoordinationEventDraft(validationDraft); + if ( + materialized.provenance === 'legacy_recovery_v1' && + (materialized.actor.kind !== 'recovery' || + !materialized.actor.actorRef.startsWith('legacy-command:') || + materialized.runId !== undefined) + ) { + throw new Error('durable-command-legacy-attribution-invalid'); + } + return materialized; +} + +export function canonicalCoordinationStorageJson(value: unknown): string { + return canonicalJson(value); +} + +export function appendEventJournalRow( + db: SqliteDatabase, + deploymentId: string, + eventEpoch: string, + draft: CoordinationEventDraft, + bodyJson: string, + originCommandId: string | null, + nowIso: string +): { readonly row: StoredCoordinationEventRow; readonly watermark: StoredEventJournalMetadata } { + const metadata = requireEventMetadata(db, deploymentId); + requireEpoch(metadata, eventEpoch); + assertJournalContinuity(db, metadata); + const existing = db + .prepare( + `SELECT deployment_id, event_epoch, event_sequence, event_id, body_json + FROM coordination_event_journal WHERE event_id = ?` + ) + .get(draft.eventId) as EventRow | undefined; + if (existing) { + if ( + existing.deployment_id !== deploymentId || + existing.event_epoch !== eventEpoch || + existing.body_json !== bodyJson + ) { + throw new Error('coordination-event-journal-event-id-conflict'); + } + return Object.freeze({ row: mapEventRow(existing), watermark: mapMetadata(metadata) }); + } + const eventSequence = metadata.high_watermark_sequence + 1; + db.prepare( + `INSERT INTO coordination_event_journal ( + deployment_id, event_epoch, event_sequence, event_id, body_json, + emitted_at, origin_command_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + deploymentId, + eventEpoch, + eventSequence, + draft.eventId, + bodyJson, + draft.emittedAt, + originCommandId, + nowIso + ); + const update = db + .prepare( + `UPDATE coordination_event_journal_metadata + SET high_watermark_sequence = ?, updated_at = ? + WHERE deployment_id = ? AND event_epoch = ? AND high_watermark_sequence = ?` + ) + .run(eventSequence, nowIso, deploymentId, eventEpoch, metadata.high_watermark_sequence); + if (update.changes !== 1) throw new Error('coordination-event-journal-watermark-cas-failed'); + const updated = requireEventMetadata(db, deploymentId); + assertJournalContinuity(db, updated); + const row = db + .prepare( + `SELECT deployment_id, event_epoch, event_sequence, event_id, body_json + FROM coordination_event_journal + WHERE deployment_id = ? AND event_epoch = ? AND event_sequence = ?` + ) + .get(deploymentId, eventEpoch, eventSequence) as EventRow; + return Object.freeze({ row: mapEventRow(row), watermark: mapMetadata(updated) }); +} + +export function captureDrainEvidence( + db: SqliteDatabase, + input: { + readonly deploymentId: string; + readonly backupRunId: string; + readonly fenceGeneration: number; + } +): CoordinationDrainStorageEvidence { + assertIdentifier(input.deploymentId, 'deploymentId'); + assertIdentifier(input.backupRunId, 'backupRunId'); + assertPositiveInteger(input.fenceGeneration, 'fenceGeneration'); + const fence = readWriterFence(db, input.deploymentId); + if ( + fence?.status !== 'active' || + fence.admitted_run_id !== input.backupRunId || + fence.generation !== input.fenceGeneration + ) { + throw new Error('coordination-backup-drain-fence-mismatch'); + } + assertAcceptedCommandsDrained(db); + const outbox = db + .prepare( + `SELECT COALESCE(MAX(sequence), 0) AS sequence + FROM durable_application_command_outbox WHERE deployment_id = ?` + ) + .get(input.deploymentId) as { sequence: number }; + const metadata = ensureEventMetadata( + db, + input.deploymentId, + undefined, + new Date(0).toISOString() + ); + assertJournalContinuity(db, metadata); + const raw = Object.freeze({ + backupRunId: input.backupRunId, + fenceGeneration: input.fenceGeneration, + throughCommandSequence: outbox.sequence, + throughEventSequence: metadata.high_watermark_sequence, + eventEpoch: metadata.event_epoch, + }); + return Object.freeze({ ...raw, durableBarrier: encodeDrainEvidence(raw) }); +} + +export function ensureEventMetadata( + db: SqliteDatabase, + deploymentId: string, + requestedEpoch: string | undefined, + nowIso: string +): EventMetadataRow { + const current = readEventMetadata(db, deploymentId); + if (current) { + if (requestedEpoch !== undefined && requestedEpoch !== current.event_epoch) { + throw new Error('coordination-event-journal-epoch-mismatch'); + } + return current; + } + const eventEpoch = requestedEpoch ?? deterministicEventEpoch(deploymentId); + db.prepare( + `INSERT INTO coordination_event_journal_metadata ( + deployment_id, event_epoch, retention_floor_sequence, + high_watermark_sequence, created_at, updated_at + ) VALUES (?, ?, 0, 0, ?, ?)` + ).run(deploymentId, eventEpoch, nowIso, nowIso); + return requireEventMetadata(db, deploymentId); +} + +export function deterministicEventEpoch(deploymentId: string): string { + return `${EVENT_EPOCH_PREFIX}${createHash('sha256').update(deploymentId).digest('hex').slice(0, 24)}`; +} + +export function readEventMetadata( + db: SqliteDatabase, + deploymentId: string +): EventMetadataRow | undefined { + return db + .prepare( + `SELECT deployment_id, event_epoch, retention_floor_sequence, high_watermark_sequence + FROM coordination_event_journal_metadata WHERE deployment_id = ?` + ) + .get(deploymentId) as EventMetadataRow | undefined; +} + +export function requireEventMetadata(db: SqliteDatabase, deploymentId: string): EventMetadataRow { + const row = readEventMetadata(db, deploymentId); + if (!row) throw new Error('coordination-event-journal-not-initialized'); + return row; +} + +export function assertJournalContinuity(db: SqliteDatabase, metadata: EventMetadataRow): void { + if ( + !Number.isSafeInteger(metadata.retention_floor_sequence) || + !Number.isSafeInteger(metadata.high_watermark_sequence) || + metadata.retention_floor_sequence < 0 || + metadata.high_watermark_sequence < metadata.retention_floor_sequence + ) { + throw new Error('coordination-event-journal-watermark-corrupt'); + } + const observed = db + .prepare( + `SELECT COUNT(*) AS count, MIN(event_sequence) AS minimum, MAX(event_sequence) AS maximum + FROM coordination_event_journal + WHERE deployment_id = ? AND event_epoch = ?` + ) + .get(metadata.deployment_id, metadata.event_epoch) as { + count: number; + minimum: number | null; + maximum: number | null; + }; + const expected = metadata.high_watermark_sequence - metadata.retention_floor_sequence; + if ( + observed.count !== expected || + (expected === 0 && (observed.minimum !== null || observed.maximum !== null)) || + (expected > 0 && + (observed.minimum !== metadata.retention_floor_sequence + 1 || + observed.maximum !== metadata.high_watermark_sequence)) + ) { + throw new Error('coordination-event-journal-gap-detected'); + } +} + +export function assertContiguousEventRows( + rows: readonly EventRow[], + firstSequence: number, + expectedCount: number +): void { + if ( + rows.length !== expectedCount || + rows.some((row, index) => row.event_sequence !== firstSequence + index) + ) { + throw new Error('coordination-event-journal-gap-detected'); + } +} + +export function mapMetadata(row: EventMetadataRow): StoredEventJournalMetadata { + return Object.freeze({ + deploymentId: row.deployment_id, + eventEpoch: row.event_epoch, + retentionFloorSequence: row.retention_floor_sequence, + highWatermarkSequence: row.high_watermark_sequence, + }); +} + +export function mapEventRow(row: EventRow): StoredCoordinationEventRow { + return Object.freeze({ + deploymentId: row.deployment_id, + eventEpoch: row.event_epoch, + eventSequence: row.event_sequence, + eventId: row.event_id, + bodyJson: row.body_json, + }); +} + +export function readBackupRunRow( + db: SqliteDatabase, + backupRunId: string +): BackupRunRow | undefined { + return db + .prepare( + `SELECT backup_run_id, deployment_id, state, revision, record_json + FROM coordination_backup_runs WHERE backup_run_id = ?` + ) + .get(backupRunId) as BackupRunRow | undefined; +} + +export function requireBackupRunRow(db: SqliteDatabase, backupRunId: string): BackupRunRow { + const row = readBackupRunRow(db, backupRunId); + if (!row) throw new Error('backup-run-not-found'); + return row; +} + +export function parseBackupRun(row: BackupRunRow): unknown { + const record = JSON.parse(row.record_json) as { + backupRunId?: unknown; + deploymentId?: unknown; + state?: unknown; + revision?: unknown; + }; + if ( + record.backupRunId !== row.backup_run_id || + record.deploymentId !== row.deployment_id || + record.state !== row.state || + record.revision !== row.revision + ) { + throw new Error('backup-run-record-corrupt'); + } + assertBackupRunRecord(record as never); + return record; +} + +export function validateBackupRunStorageRecord(record: { + readonly backupRunId: string; + readonly deploymentId: string; + readonly state: string; + readonly revision: number; + readonly requestedAt: string; + readonly updatedAt: string; +}): void { + assertIdentifier(record.backupRunId, 'backupRunId'); + assertIdentifier(record.deploymentId, 'deploymentId'); + if (!BACKUP_RUN_STATE_VALUES.has(record.state)) { + throw new Error('coordination-storage-state-invalid'); + } + assertPositiveInteger(record.revision, 'revision'); + assertIsoTimestamp(record.requestedAt, 'requestedAt'); + assertIsoTimestamp(record.updatedAt, 'updatedAt'); + assertBackupRunRecord(record as never); +} + +export function readWriterFence( + db: SqliteDatabase, + deploymentId: string +): WriterFenceRow | undefined { + return db + .prepare('SELECT * FROM coordination_backup_writer_fences WHERE deployment_id = ?') + .get(deploymentId) as WriterFenceRow | undefined; +} + +export function readBlockingWriterFence(db: SqliteDatabase): WriterFenceRow | undefined { + const rows = db + .prepare( + `SELECT * FROM coordination_backup_writer_fences + WHERE status IN ('active', 'operator_required') + ORDER BY generation ASC, deployment_id ASC + LIMIT 2` + ) + .all() as WriterFenceRow[]; + if (rows.length > 1) throw new Error('coordination-backup-multiple-writer-fences'); + return rows[0]; +} + +export function assertAcceptedCommandsDrained(db: SqliteDatabase): void { + const placeholders = ACTIVE_COMMAND_STATES.map(() => '?').join(', '); + const active = db + .prepare( + `SELECT command_id + FROM durable_application_commands + WHERE state IN (${placeholders}) + ORDER BY created_at ASC LIMIT 1` + ) + .get(...ACTIVE_COMMAND_STATES) as { command_id: string } | undefined; + if (active) throw new Error('coordination-backup-command-drain-pending'); +} + +export function requireEpoch(metadata: EventMetadataRow, eventEpoch: string): void { + if (metadata.event_epoch !== eventEpoch) { + throw new Error('coordination-event-journal-epoch-mismatch'); + } +} + +export function encodeDrainEvidence(input: { + readonly backupRunId: string; + readonly fenceGeneration: number; + readonly throughCommandSequence: number; + readonly throughEventSequence: number; + readonly eventEpoch: string; +}): string { + return `coordination-drain-v1.${Buffer.from(canonicalJson(input), 'utf8').toString('base64url')}`; +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(normalizeCanonicalValue(value)); +} + +// Canonical bytes are persisted and byte-compared on later reads, so key order +// must not depend on the process locale or ICU version (localeCompare does). +export function compareCanonicalKeys(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function normalizeCanonicalValue(value: unknown): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('coordination-storage-json-number-invalid'); + return value; + } + if (Array.isArray(value)) return value.map(normalizeCanonicalValue); + if (typeof value !== 'object') { + throw new Error('coordination-storage-json-value-invalid'); + } + const record = value as Readonly>; + const normalized: Record = {}; + for (const key of Object.keys(record).sort(compareCanonicalKeys)) { + if (record[key] === undefined) continue; + normalized[key] = normalizeCanonicalValue(record[key]); + } + return normalized; +} + +export function assertIdentifier(value: string, field: string): void { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.trim() !== value + ) { + throw new Error(`coordination-storage-${field}-invalid`); + } +} + +export function assertIsoTimestamp(value: string, field: string): void { + assertIdentifier(value, field); + if (!Number.isFinite(Date.parse(value))) throw new Error(`coordination-storage-${field}-invalid`); +} + +export function assertNonNegativeInteger(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`coordination-storage-${field}-invalid`); + } +} + +export function assertPositiveInteger(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`coordination-storage-${field}-invalid`); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/coordinationDurabilityWorkerOps.ts b/src/features/internal-storage/main/infrastructure/worker/coordinationDurabilityWorkerOps.ts new file mode 100644 index 0000000000..3261176d5c --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/coordinationDurabilityWorkerOps.ts @@ -0,0 +1,535 @@ +import { + appendEventJournalRow, + assertAcceptedCommandsDrained, + assertContiguousEventRows, + assertCoordinationMutationAdmissionOpen, + assertIdentifier, + assertInternalStorageMutationAdmissionOpen, + assertIsoTimestamp, + assertJournalContinuity, + assertNonNegativeInteger, + assertPositiveInteger, + canonicalJson, + captureDrainEvidence, + ensureEventMetadata, + mapEventRow, + mapMetadata, + parseBackupRun, + readBackupRunRow, + readBlockingWriterFence, + readEventMetadata, + readWriterFence, + requireBackupRunRow, + requireEpoch, + requireEventMetadata, + validateBackupRunStorageRecord, +} from './coordinationDurabilityState'; +import { CoordinationSqliteSnapshotOps } from './coordinationSqliteSnapshotOps'; + +export { + appendCommandOutboxEventToJournal, + assertCoordinationMutationAdmissionOpen, + assertInternalStorageMutationAdmissionOpen, + canonicalCoordinationStorageJson, + createLegacyCommandCoordinationAttribution, + materializeCommandCoordinationAttribution, +} from './coordinationDurabilityState'; + +import { assertCoordinationEventDraft } from '@features/coordination-events'; + +import type { BackupRunRow, EventRow } from './coordinationDurabilityState'; +import type { + CoordinationDrainStorageEvidence, + CoordinationDurabilityWorkerPayloadByOp, + StoredCoordinationEventRow, + StoredEventJournalMetadata, +} from './internalStorageWorkerProtocol'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +const MAX_EVENT_PAGE_SIZE = 10_000; + +export class CoordinationDurabilityWorkerOps { + private readonly snapshotOps: CoordinationSqliteSnapshotOps; + + constructor( + private readonly getDb: () => SqliteDatabase, + createDatabase: ( + databasePath: string, + options?: { readonly?: boolean; fileMustExist?: boolean } + ) => SqliteDatabase, + databasePath: string + ) { + this.snapshotOps = new CoordinationSqliteSnapshotOps(this.getDb, createDatabase, databasePath); + } + + handle( + op: TOp, + payload: CoordinationDurabilityWorkerPayloadByOp[TOp] + ): unknown { + switch (op) { + case 'coordinationEvents.initialize': + return this.initializeEventJournal( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.initialize'] + ); + case 'coordinationEvents.getWatermark': + return this.getEventWatermark( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.getWatermark'] + ); + case 'coordinationEvents.read': + return this.readEvents( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.read'] + ); + case 'coordinationEvents.append': + return this.appendEvent( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.append'] + ); + case 'coordinationEvents.prune': + return this.pruneEvents( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.prune'] + ); + case 'coordinationBackupRuns.create': + return this.createBackupRun( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackupRuns.create'] + ); + case 'coordinationBackupRuns.get': + return this.getBackupRun( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackupRuns.get'] + ); + case 'coordinationBackupRuns.listRecoverable': + return this.listRecoverableBackupRuns(); + case 'coordinationBackupRuns.compareAndSet': + return this.compareAndSetBackupRun( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackupRuns.compareAndSet'] + ); + case 'coordinationBackupFence.acquire': + return this.acquireWriterFence( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFence.acquire'] + ); + case 'coordinationBackupFence.complete': + return this.completeWriterFence( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFence.complete'] + ); + case 'coordinationBackupFlush.drain': + return this.drainAcceptedCommands( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFlush.drain'] + ); + case 'coordinationBackupFlush.capture': + return this.captureCoordinationBarrier( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFlush.capture'] + ); + case 'coordinationBackup.sqlite.verify': + return this.snapshotOps.verifySqliteSnapshot( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.verify'] + ); + case 'coordinationBackup.sqlite.readChunk': + return this.snapshotOps.readSqliteSnapshotChunk( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.readChunk'] + ); + case 'coordinationBackup.sqlite.online': + throw new Error('SQLite Online Backup must be awaited through handleAsync'); + case 'coordinationBackup.sqlite.discard': + throw new Error('SQLite backup scratch discard must be awaited through handleAsync'); + default: + throw new Error(`Unknown coordination durability op: ${String(op)}`); + } + } + + async handleAsync( + op: TOp, + payload: CoordinationDurabilityWorkerPayloadByOp[TOp] + ): Promise { + if (op === 'coordinationBackup.sqlite.online') { + return this.snapshotOps.createOnlineBackup( + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.online'] + ); + } + if (op === 'coordinationBackup.sqlite.discard') { + const input = + payload as CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.discard']; + return this.snapshotOps.discard(input); + } + return this.handle(op, payload); + } + + private initializeEventJournal( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.initialize'] + ): StoredEventJournalMetadata { + assertIdentifier(input.deploymentId, 'deploymentId'); + assertIsoTimestamp(input.nowIso, 'nowIso'); + if (input.eventEpoch !== undefined) assertIdentifier(input.eventEpoch, 'eventEpoch'); + const db = this.getDb(); + return db + .transaction(() => { + if (!readEventMetadata(db, input.deploymentId)) { + assertInternalStorageMutationAdmissionOpen(db, null); + } + const metadata = ensureEventMetadata( + db, + input.deploymentId, + input.eventEpoch, + input.nowIso + ); + assertJournalContinuity(db, metadata); + return mapMetadata(metadata); + }) + .immediate(); + } + + private getEventWatermark( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.getWatermark'] + ): StoredEventJournalMetadata { + assertIdentifier(input.deploymentId, 'deploymentId'); + const db = this.getDb(); + return db.transaction(() => { + const metadata = requireEventMetadata(db, input.deploymentId); + assertJournalContinuity(db, metadata); + return mapMetadata(metadata); + })(); + } + + private readEvents(input: CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.read']): { + readonly rows: readonly StoredCoordinationEventRow[]; + readonly watermark: StoredEventJournalMetadata; + } { + assertIdentifier(input.deploymentId, 'deploymentId'); + assertNonNegativeInteger(input.afterSequence, 'afterSequence'); + assertNonNegativeInteger(input.throughSequence, 'throughSequence'); + if ( + !Number.isSafeInteger(input.limit) || + input.limit <= 0 || + input.limit > MAX_EVENT_PAGE_SIZE + ) { + throw new Error('coordination-event-journal-limit-invalid'); + } + const db = this.getDb(); + return db.transaction(() => { + const metadata = requireEventMetadata(db, input.deploymentId); + assertJournalContinuity(db, metadata); + if (input.afterSequence < metadata.retention_floor_sequence) { + throw new Error('coordination-event-journal-cursor-stale'); + } + if (input.afterSequence > metadata.high_watermark_sequence) { + throw new Error('coordination-event-journal-cursor-ahead'); + } + const target = Math.min(input.throughSequence, metadata.high_watermark_sequence); + if (target < input.afterSequence) { + throw new Error('coordination-event-journal-range-invalid'); + } + const rows = db + .prepare( + `SELECT deployment_id, event_epoch, event_sequence, event_id, body_json + FROM coordination_event_journal + WHERE deployment_id = ? AND event_epoch = ? + AND event_sequence > ? AND event_sequence <= ? + ORDER BY event_sequence ASC + LIMIT ?` + ) + .all( + input.deploymentId, + metadata.event_epoch, + input.afterSequence, + target, + input.limit + ) as EventRow[]; + const expectedCount = Math.min(input.limit, target - input.afterSequence); + assertContiguousEventRows(rows, input.afterSequence + 1, expectedCount); + return Object.freeze({ + rows: Object.freeze(rows.map(mapEventRow)), + watermark: mapMetadata(metadata), + }); + })(); + } + + private appendEvent( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.append'] + ): { readonly row: StoredCoordinationEventRow; readonly watermark: StoredEventJournalMetadata } { + assertIdentifier(input.deploymentId, 'deploymentId'); + assertIdentifier(input.eventEpoch, 'eventEpoch'); + assertIsoTimestamp(input.nowIso, 'nowIso'); + assertCoordinationEventDraft(input.draft); + const canonicalBody = canonicalJson(input.draft); + if (input.bodyJson !== canonicalBody) { + throw new Error('coordination-event-journal-body-not-canonical'); + } + const db = this.getDb(); + return db + .transaction(() => { + assertCoordinationMutationAdmissionOpen(db, input.deploymentId); + return appendEventJournalRow( + db, + input.deploymentId, + input.eventEpoch, + input.draft, + canonicalBody, + null, + input.nowIso + ); + }) + .immediate(); + } + + private pruneEvents( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationEvents.prune'] + ): StoredEventJournalMetadata { + assertIdentifier(input.deploymentId, 'deploymentId'); + assertIdentifier(input.eventEpoch, 'eventEpoch'); + assertNonNegativeInteger(input.throughSequence, 'throughSequence'); + assertIsoTimestamp(input.nowIso, 'nowIso'); + const db = this.getDb(); + return db + .transaction(() => { + const metadata = requireEventMetadata(db, input.deploymentId); + requireEpoch(metadata, input.eventEpoch); + assertJournalContinuity(db, metadata); + const requestedFloor = Math.min(input.throughSequence, metadata.high_watermark_sequence); + const nextFloor = Math.max(metadata.retention_floor_sequence, requestedFloor); + db.prepare( + `DELETE FROM coordination_event_journal + WHERE deployment_id = ? AND event_epoch = ? AND event_sequence <= ?` + ).run(input.deploymentId, input.eventEpoch, nextFloor); + db.prepare( + `UPDATE coordination_event_journal_metadata + SET retention_floor_sequence = ?, updated_at = ? + WHERE deployment_id = ? AND event_epoch = ?` + ).run(nextFloor, input.nowIso, input.deploymentId, input.eventEpoch); + const updated = requireEventMetadata(db, input.deploymentId); + assertJournalContinuity(db, updated); + return mapMetadata(updated); + }) + .immediate(); + } + + private createBackupRun( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackupRuns.create'] + ): unknown { + const record = input.record; + validateBackupRunStorageRecord(record); + const recordJson = canonicalJson(record); + const db = this.getDb(); + return db + .transaction(() => { + const existing = readBackupRunRow(db, record.backupRunId); + if (existing) { + if (existing.record_json !== recordJson) throw new Error('backup-run-create-conflict'); + return parseBackupRun(existing); + } + db.prepare( + `INSERT INTO coordination_backup_runs ( + backup_run_id, deployment_id, state, revision, fence_completion_status, + record_json, requested_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + record.backupRunId, + record.deploymentId, + record.state, + record.revision, + record.fenceCompletion?.status ?? null, + recordJson, + record.requestedAt, + record.updatedAt + ); + return parseBackupRun(requireBackupRunRow(db, record.backupRunId)); + }) + .immediate(); + } + + private getBackupRun( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackupRuns.get'] + ): unknown { + assertIdentifier(input.backupRunId, 'backupRunId'); + const row = readBackupRunRow(this.getDb(), input.backupRunId); + return row ? parseBackupRun(row) : null; + } + + private listRecoverableBackupRuns(): readonly unknown[] { + const rows = this.getDb() + .prepare( + `SELECT backup_run_id, deployment_id, state, revision, record_json + FROM coordination_backup_runs + WHERE state NOT IN ('committed', 'failed', 'operator_required', 'artifact_source') + OR fence_completion_status = 'pending' + ORDER BY requested_at ASC, backup_run_id ASC` + ) + .all() as BackupRunRow[]; + return Object.freeze(rows.map(parseBackupRun)); + } + + private compareAndSetBackupRun( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackupRuns.compareAndSet'] + ): unknown { + assertIdentifier(input.backupRunId, 'backupRunId'); + assertPositiveInteger(input.expectedRevision, 'expectedRevision'); + const record = input.record; + validateBackupRunStorageRecord(record); + if ( + record.backupRunId !== input.backupRunId || + record.revision !== input.expectedRevision + 1 + ) { + throw new Error('backup-run-compare-and-set-record-invalid'); + } + const db = this.getDb(); + return db + .transaction(() => { + const result = db + .prepare( + `UPDATE coordination_backup_runs + SET state = ?, revision = ?, fence_completion_status = ?, record_json = ?, updated_at = ? + WHERE backup_run_id = ? AND state = ? AND revision = ?` + ) + .run( + record.state, + record.revision, + record.fenceCompletion?.status ?? null, + canonicalJson(record), + record.updatedAt, + input.backupRunId, + input.expectedState, + input.expectedRevision + ); + if (result.changes !== 1) { + const observed = requireBackupRunRow(db, input.backupRunId); + if (observed.record_json === canonicalJson(record)) return parseBackupRun(observed); + throw new Error('backup-run-compare-and-set-failed'); + } + return parseBackupRun(requireBackupRunRow(db, input.backupRunId)); + }) + .immediate(); + } + + private acquireWriterFence( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFence.acquire'] + ): unknown { + assertIdentifier(input.deploymentId, 'deploymentId'); + assertIdentifier(input.backupRunId, 'backupRunId'); + assertIdentifier(input.leaseId, 'leaseId'); + assertIsoTimestamp(input.acquiredAt, 'acquiredAt'); + if (input.expectedGeneration !== null) { + assertPositiveInteger(input.expectedGeneration, 'expectedGeneration'); + } + const db = this.getDb(); + return db + .transaction(() => { + const run = requireBackupRunRow(db, input.backupRunId); + if (run.deployment_id !== input.deploymentId) throw new Error('backup-fence-run-mismatch'); + const activeFence = readBlockingWriterFence(db); + if (activeFence && activeFence.deployment_id !== input.deploymentId) { + return Object.freeze({ + status: 'busy' as const, + activeRunId: activeFence.admitted_run_id, + }); + } + const current = readWriterFence(db, input.deploymentId); + if (current?.status === 'active') { + if ( + current.admitted_run_id === input.backupRunId && + (input.expectedGeneration === null || input.expectedGeneration === current.generation) + ) { + return Object.freeze({ + status: 'acquired' as const, + generation: current.generation, + leaseId: current.lease_id, + }); + } + return Object.freeze({ + status: 'busy' as const, + activeRunId: current.admitted_run_id, + }); + } + if (current?.status === 'operator_required') { + return Object.freeze({ + status: 'busy' as const, + activeRunId: current.admitted_run_id, + }); + } + if (input.expectedGeneration !== null && input.expectedGeneration !== current?.generation) { + throw new Error('backup-fence-generation-stale'); + } + assertAcceptedCommandsDrained(db); + const generation = (current?.generation ?? 0) + 1; + db.prepare( + `INSERT INTO coordination_backup_writer_fences ( + deployment_id, generation, admitted_run_id, lease_id, status, + disposition, acquired_at, completed_at + ) VALUES (?, ?, ?, ?, 'active', NULL, ?, NULL) + ON CONFLICT(deployment_id) DO UPDATE SET + generation = excluded.generation, + admitted_run_id = excluded.admitted_run_id, + lease_id = excluded.lease_id, + status = 'active', + disposition = NULL, + acquired_at = excluded.acquired_at, + completed_at = NULL` + ).run(input.deploymentId, generation, input.backupRunId, input.leaseId, input.acquiredAt); + return Object.freeze({ status: 'acquired' as const, generation, leaseId: input.leaseId }); + }) + .immediate(); + } + + private completeWriterFence( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFence.complete'] + ): null { + assertIdentifier(input.deploymentId, 'deploymentId'); + assertIdentifier(input.backupRunId, 'backupRunId'); + assertIdentifier(input.leaseId, 'leaseId'); + assertPositiveInteger(input.generation, 'generation'); + assertIsoTimestamp(input.completedAt, 'completedAt'); + const db = this.getDb(); + db.transaction(() => { + const current = readWriterFence(db, input.deploymentId); + if (!current) throw new Error('backup-fence-not-found'); + if ( + current.generation !== input.generation || + current.admitted_run_id !== input.backupRunId || + current.lease_id !== input.leaseId + ) { + throw new Error('backup-fence-completion-fence-mismatch'); + } + const nextStatus = + input.disposition === 'operator_required' ? 'operator_required' : 'released'; + if (current.status !== 'active') { + if (current.status === nextStatus && current.disposition === input.disposition) return; + throw new Error('backup-fence-completion-conflict'); + } + db.prepare( + `UPDATE coordination_backup_writer_fences + SET status = ?, disposition = ?, completed_at = ? + WHERE deployment_id = ? AND generation = ? AND lease_id = ? AND status = 'active'` + ).run( + nextStatus, + input.disposition, + input.completedAt, + input.deploymentId, + input.generation, + input.leaseId + ); + }).immediate(); + return null; + } + + private drainAcceptedCommands( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFlush.drain'] + ): CoordinationDrainStorageEvidence { + const db = this.getDb(); + return db.transaction(() => captureDrainEvidence(db, input)).immediate(); + } + + private captureCoordinationBarrier( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackupFlush.capture'] + ): CoordinationDrainStorageEvidence { + const db = this.getDb(); + return db + .transaction(() => { + const current = captureDrainEvidence(db, { + deploymentId: input.deploymentId, + backupRunId: input.evidence.backupRunId, + fenceGeneration: input.evidence.fenceGeneration, + }); + if (canonicalJson(current) !== canonicalJson(input.evidence)) { + throw new Error('coordination-backup-drain-overtaken'); + } + return current; + }) + .immediate(); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/coordinationSqliteSnapshotOps.ts b/src/features/internal-storage/main/infrastructure/worker/coordinationSqliteSnapshotOps.ts new file mode 100644 index 0000000000..c5240f06e9 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/coordinationSqliteSnapshotOps.ts @@ -0,0 +1,455 @@ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { + assertIdentifier, + assertNonNegativeInteger, + assertPositiveInteger, + readBlockingWriterFence, + requireBackupRunRow, +} from './coordinationDurabilityState'; +import { + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES, + INTERNAL_STORAGE_SCHEMA_VERSION, +} from './internalStorageMigrations'; + +import type { + CoordinationDurabilityWorkerPayloadByOp, + SqliteBackupChunkStorageResult, + SqliteOnlineBackupStorageResult, + SqliteSnapshotVerificationStorageResult, +} from './internalStorageWorkerProtocol'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +type CreateDatabase = ( + databasePath: string, + options?: { readonly?: boolean; fileMustExist?: boolean } +) => SqliteDatabase; + +const MAX_BACKUP_CHUNK_BYTES = 1024 * 1024; +const BACKUP_SCRATCH_DIRECTORY_SUFFIX = '.coordination-backup-staging'; +const REQUIRED_IDENTITY_COMPONENT = 'team-identity'; + +export class CoordinationSqliteSnapshotOps { + private readonly backupScratchRoot: string; + + constructor( + private readonly getDb: () => SqliteDatabase, + private readonly createDatabase: CreateDatabase, + databasePath: string + ) { + validateSnapshotPath(databasePath); + this.backupScratchRoot = `${databasePath}${BACKUP_SCRATCH_DIRECTORY_SUFFIX}`; + } + + async discard( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.discard'] + ): Promise { + assertIdentifier(input.backupRunId, 'backupRunId'); + await removePartialSnapshot(this.snapshotScratchPath(input.backupRunId)); + return null; + } + + async createOnlineBackup( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.online'] + ): Promise { + validateOnlineBackupInput(input); + const source = this.getDb(); + const fence = readBlockingWriterFence(source); + if ( + fence?.status !== 'active' || + fence.admitted_run_id !== input.backupRunId || + requireBackupRunRow(source, input.backupRunId).state !== 'sqlite_snapshot' + ) { + throw new Error('coordination-backup-online-fence-mismatch'); + } + await ensurePrivateScratchRoot(this.backupScratchRoot); + const snapshotPath = this.snapshotScratchPath(input.backupRunId); + const existing = await inspectExistingSnapshot( + snapshotPath, + input.backupRunId, + this.createDatabase + ); + if (existing) return existing; + + for (;;) { + if (Date.now() >= input.deadlineAtMs) { + await removePartialSnapshot(snapshotPath); + return { status: 'deadline_exceeded' }; + } + await removePartialSnapshot(snapshotPath); + try { + await source.backup(snapshotPath, { + progress: () => { + if (Date.now() >= input.deadlineAtMs) throw new OnlineBackupDeadlineError(); + return input.pagesPerStep; + }, + }); + await fs.promises.chmod(snapshotPath, 0o600); + const verification = verifySnapshotFile( + snapshotPath, + input.backupRunId, + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_SCHEMA_VERSION, + requiredInternalStorageTables(), + this.createDatabase + ); + if (verification.status !== 'valid') { + await removePartialSnapshot(snapshotPath); + return { status: 'source_corrupt' }; + } + return measureCompletedSnapshot(snapshotPath, verification); + } catch (error) { + await removePartialSnapshot(snapshotPath); + if (error instanceof OnlineBackupDeadlineError || Date.now() >= input.deadlineAtMs) { + return { status: 'deadline_exceeded' }; + } + if (isSqliteCorruption(error)) return { status: 'source_corrupt' }; + if (!isSqliteBusy(error)) throw error; + const remaining = input.deadlineAtMs - Date.now(); + if (remaining <= input.busyRetryMs) return { status: 'busy_timeout' }; + await delay(Math.min(input.busyRetryMs, remaining), undefined, { ref: false }); + } + } + } + + verifySqliteSnapshot( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.verify'] + ): SqliteSnapshotVerificationStorageResult { + assertIdentifier(input.backupRunId, 'backupRunId'); + return verifySnapshotFile( + this.snapshotScratchPath(input.backupRunId), + input.backupRunId, + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_SCHEMA_VERSION, + requiredInternalStorageTables(), + this.createDatabase + ); + } + + readSqliteSnapshotChunk( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.readChunk'] + ): SqliteBackupChunkStorageResult { + assertIdentifier(input.backupRunId, 'backupRunId'); + assertNonNegativeInteger(input.offset, 'offset'); + if ( + !Number.isSafeInteger(input.maximumBytes) || + input.maximumBytes <= 0 || + input.maximumBytes > MAX_BACKUP_CHUNK_BYTES + ) { + throw new Error('coordination-backup-chunk-size-invalid'); + } + return readSnapshotChunk( + this.snapshotScratchPath(input.backupRunId), + input.offset, + input.maximumBytes + ); + } + + private snapshotScratchPath(backupRunId: string): string { + const name = `${createHash('sha256') + .update('coordination-backup-scratch-v1\0') + .update(backupRunId) + .digest('hex')}.sqlite`; + return path.join(this.backupScratchRoot, name); + } +} + +function validateOnlineBackupInput( + input: CoordinationDurabilityWorkerPayloadByOp['coordinationBackup.sqlite.online'] +): void { + assertIdentifier(input.backupRunId, 'backupRunId'); + assertPositiveInteger(input.deadlineAtMs, 'deadlineAtMs'); + assertPositiveInteger(input.busyRetryMs, 'busyRetryMs'); + assertPositiveInteger(input.pagesPerStep, 'pagesPerStep'); +} + +function validateSnapshotPath(snapshotPath: string): void { + if ( + typeof snapshotPath !== 'string' || + snapshotPath.length === 0 || + snapshotPath.length > 4_096 || + !path.isAbsolute(snapshotPath) || + path.resolve(snapshotPath) === path.parse(path.resolve(snapshotPath)).root + ) { + throw new Error('coordination-backup-snapshot-path-invalid'); + } +} + +async function inspectExistingSnapshot( + snapshotPath: string, + backupRunId: string, + createDatabase: CreateDatabase +): Promise { + let stat: fs.Stats; + try { + stat = await fs.promises.lstat(snapshotPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('coordination-backup-snapshot-target-not-regular'); + } + const verification = verifySnapshotFile( + snapshotPath, + backupRunId, + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_SCHEMA_VERSION, + requiredInternalStorageTables(), + createDatabase + ); + return verification.status === 'valid' + ? measureCompletedSnapshot(snapshotPath, verification) + : null; +} + +function verifySnapshotFile( + snapshotPath: string, + backupRunId: string, + expectedApplicationId: number, + expectedUserVersion: number, + requiredTables: readonly string[], + createDatabase: CreateDatabase +): SqliteSnapshotVerificationStorageResult { + let before: fs.Stats; + try { + before = fs.lstatSync(snapshotPath); + if (!before.isFile() || before.isSymbolicLink()) { + return { status: 'invalid', reason: 'integrity_check_failed' }; + } + } catch { + return { status: 'invalid', reason: 'integrity_check_failed' }; + } + let db: SqliteDatabase; + try { + db = createDatabase(snapshotPath, { readonly: true, fileMustExist: true }); + } catch { + return { status: 'invalid', reason: 'integrity_check_failed' }; + } + let verification: SqliteSnapshotVerificationStorageResult; + try { + verification = inspectSnapshotDatabase( + db, + backupRunId, + expectedApplicationId, + expectedUserVersion, + requiredTables + ); + } catch { + verification = { status: 'invalid', reason: 'integrity_check_failed' }; + } finally { + db.close(); + removeSnapshotSidecarsSync(snapshotPath); + } + let after: fs.Stats; + try { + after = fs.lstatSync(snapshotPath); + } catch (error) { + throw new Error('coordination-backup-snapshot-identity-race', { cause: error }); + } + if (!sameFileIdentity(before, after) || after.isSymbolicLink() || !after.isFile()) { + throw new Error('coordination-backup-snapshot-identity-race'); + } + return verification; +} + +function inspectSnapshotDatabase( + db: SqliteDatabase, + backupRunId: string, + expectedApplicationId: number, + expectedUserVersion: number, + requiredTables: readonly string[] +): SqliteSnapshotVerificationStorageResult { + db.pragma('query_only = ON'); + const integrity = db.pragma('integrity_check', { simple: true }); + if (integrity !== 'ok') return { status: 'invalid', reason: 'integrity_check_failed' }; + const applicationId = db.pragma('application_id', { simple: true }); + if (applicationId !== expectedApplicationId) { + return { status: 'invalid', reason: 'application_id_mismatch' }; + } + const userVersion = db.pragma('user_version', { simple: true }); + if (userVersion !== expectedUserVersion) { + return { + status: 'invalid', + reason: + typeof userVersion === 'number' && userVersion < expectedUserVersion + ? 'migration_incomplete' + : 'schema_mismatch', + }; + } + const tables = db + .prepare(`SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name ASC`) + .all() as { name: string }[]; + const names = new Set(tables.map((table) => table.name)); + if (requiredTables.some((table) => !names.has(table))) { + return { status: 'invalid', reason: 'migration_incomplete' }; + } + const identity = db + .prepare(`SELECT schema_version FROM team_identity_storage_metadata WHERE component = ?`) + .get(REQUIRED_IDENTITY_COMPONENT) as { schema_version: number } | undefined; + if (identity?.schema_version !== 1) { + return { status: 'invalid', reason: 'required_identity_missing' }; + } + const sourceRun = db + .prepare(`SELECT state, record_json FROM coordination_backup_runs WHERE backup_run_id = ?`) + .get(backupRunId) as { state: string; record_json: string } | undefined; + if (sourceRun?.state !== 'sqlite_snapshot') { + return { status: 'invalid', reason: 'required_identity_missing' }; + } + const record = JSON.parse(sourceRun.record_json) as { backupRunId?: unknown; state?: unknown }; + if (record.backupRunId !== backupRunId || record.state !== 'sqlite_snapshot') { + return { status: 'invalid', reason: 'required_identity_missing' }; + } + return Object.freeze({ + status: 'valid' as const, + applicationId, + userVersion, + requiredTables: Object.freeze([...requiredTables]), + }); +} + +async function measureCompletedSnapshot( + snapshotPath: string, + verification: Extract +): Promise { + const stat = await fs.promises.lstat(snapshotPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('coordination-backup-snapshot-target-not-regular'); + } + const hash = createHash('sha256'); + const handle = await fs.promises.open( + snapshotPath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + try { + for await (const chunk of handle.readableWebStream()) hash.update(Buffer.from(chunk)); + } finally { + await handle.close(); + } + return Object.freeze({ + status: 'completed' as const, + applicationId: verification.applicationId, + userVersion: verification.userVersion, + byteLength: stat.size, + mode: stat.mode & 0o777, + sha256: hash.digest('hex'), + }); +} + +async function removePartialSnapshot(snapshotPath: string): Promise { + for (const candidate of [snapshotPath, `${snapshotPath}-wal`, `${snapshotPath}-shm`]) { + try { + const stat = await fs.promises.lstat(candidate); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('coordination-backup-partial-target-not-regular'); + } + await fs.promises.unlink(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +} + +function removeSnapshotSidecarsSync(snapshotPath: string): void { + for (const candidate of [`${snapshotPath}-wal`, `${snapshotPath}-shm`]) { + try { + const stat = fs.lstatSync(candidate); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('coordination-backup-snapshot-sidecar-invalid'); + } + fs.unlinkSync(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +} + +async function ensurePrivateScratchRoot(scratchRoot: string): Promise { + const created = await fs.promises.mkdir(scratchRoot, { recursive: true, mode: 0o700 }); + if (created !== undefined) await fs.promises.chmod(scratchRoot, 0o700); + const before = await fs.promises.lstat(scratchRoot); + if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o700) { + throw new Error('coordination-backup-scratch-root-invalid'); + } + const realRoot = await fs.promises.realpath(scratchRoot); + const realParent = await fs.promises.realpath(path.dirname(scratchRoot)); + if (path.dirname(realRoot) !== realParent) { + throw new Error('coordination-backup-scratch-root-escape'); + } + const after = await fs.promises.lstat(scratchRoot); + if (!sameFileIdentity(before, after) || after.isSymbolicLink()) { + throw new Error('coordination-backup-scratch-root-race'); + } +} + +function readSnapshotChunk( + snapshotPath: string, + offset: number, + maximumBytes: number +): SqliteBackupChunkStorageResult { + const before = fs.lstatSync(snapshotPath); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('coordination-backup-snapshot-target-not-regular'); + } + const descriptor = fs.openSync( + snapshotPath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + try { + const opened = fs.fstatSync(descriptor); + if (!opened.isFile() || !sameFileIdentity(before, opened)) { + throw new Error('coordination-backup-snapshot-identity-race'); + } + if (offset > opened.size) throw new Error('coordination-backup-chunk-offset-invalid'); + const bytesToRead = Math.min(maximumBytes, opened.size - offset); + const buffer = Buffer.alloc(bytesToRead); + const bytesRead = + bytesToRead === 0 ? 0 : fs.readSync(descriptor, buffer, 0, bytesToRead, offset); + if (bytesRead !== bytesToRead) throw new Error('coordination-backup-snapshot-short-read'); + const afterDescriptor = fs.fstatSync(descriptor); + const afterPath = fs.lstatSync(snapshotPath); + if ( + !sameFileIdentity(opened, afterDescriptor) || + opened.size !== afterDescriptor.size || + !sameFileIdentity(afterDescriptor, afterPath) || + afterPath.isSymbolicLink() + ) { + throw new Error('coordination-backup-snapshot-changed-during-read'); + } + return Object.freeze({ + offset, + totalByteLength: opened.size, + bytes: Uint8Array.from(buffer), + eof: offset + bytesRead === opened.size, + }); + } finally { + fs.closeSync(descriptor); + } +} + +function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function requiredInternalStorageTables(): readonly string[] { + return INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES; +} + +function isSqliteBusy(error: unknown): boolean { + const code = error instanceof Error ? (error as Error & { code?: unknown }).code : undefined; + return code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED'; +} + +function isSqliteCorruption(error: unknown): boolean { + const code = error instanceof Error ? (error as Error & { code?: unknown }).code : undefined; + return ( + code === 'SQLITE_NOTADB' || (typeof code === 'string' && code.startsWith('SQLITE_CORRUPT')) + ); +} + +class OnlineBackupDeadlineError extends Error {} diff --git a/src/features/internal-storage/main/infrastructure/worker/durableApplicationCommandOutboxWorkerOps.ts b/src/features/internal-storage/main/infrastructure/worker/durableApplicationCommandOutboxWorkerOps.ts new file mode 100644 index 0000000000..c96930e22f --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/durableApplicationCommandOutboxWorkerOps.ts @@ -0,0 +1,326 @@ +import { and, asc, eq, gt, isNull, lt } from 'drizzle-orm'; + +import * as mapping from './applicationCommandLedgerMapping'; +import * as validation from './applicationCommandLedgerValidation'; +import { MAX_OUTBOX_PAGE_SIZE } from './applicationCommandLedgerWorkerTypes'; +import { + durableApplicationCommandConsumerApplications, + durableApplicationCommandConsumerProjections, + durableApplicationCommandOutbox, +} from './internalStorageSchema'; + +import type { ApplicationCommandLedgerRecordRepository } from './applicationCommandLedgerRecordRepository'; +import type { + DurableConsumerApplicationRow, + DurableConsumerProjectionRow, + DurableOutboxRow, +} from './applicationCommandLedgerWorkerTypes'; +import type { + DurableApplicationCommandConsumerApplyRequest, + DurableApplicationCommandConsumerApplyResult, + DurableApplicationCommandConsumerProjectionRecord, + DurableApplicationCommandConsumerProjectionRequest, + DurableApplicationCommandOutboxClaimRequest, + DurableApplicationCommandOutboxDeliveryAcknowledgementRequest, + DurableApplicationCommandOutboxListRequest, + DurableApplicationCommandOutboxRecord, +} from '@features/application-command-ledger'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +export class DurableApplicationCommandOutboxWorkerOps { + constructor( + private readonly getOrm: () => BetterSQLite3Database, + private readonly repository: ApplicationCommandLedgerRecordRepository + ) {} + + durableListOutbox( + input: DurableApplicationCommandOutboxListRequest + ): DurableApplicationCommandOutboxRecord[] { + if (!Number.isSafeInteger(input.afterSequence) || input.afterSequence < 0) { + throw new Error('Durable application command outbox afterSequence must be non-negative'); + } + if ( + !Number.isSafeInteger(input.limit) || + input.limit <= 0 || + input.limit > MAX_OUTBOX_PAGE_SIZE + ) { + throw new Error( + `Durable application command outbox limit must be between 1 and ${MAX_OUTBOX_PAGE_SIZE}` + ); + } + return this.getOrm() + .select() + .from(durableApplicationCommandOutbox) + .where(gt(durableApplicationCommandOutbox.sequence, input.afterSequence)) + .orderBy(asc(durableApplicationCommandOutbox.sequence)) + .limit(input.limit) + .all() + .map(mapping.mapOutboxRow); + } + + durableClaimOutbox( + input: DurableApplicationCommandOutboxClaimRequest + ): DurableApplicationCommandOutboxRecord[] { + validation.validateDurableOutboxClaim(input); + const orm = this.getOrm(); + return orm.transaction( + () => { + const rows = orm + .select() + .from(durableApplicationCommandOutbox) + .where(isNull(durableApplicationCommandOutbox.deliveryAcknowledgedAt)) + .orderBy(asc(durableApplicationCommandOutbox.sequence)) + .limit(input.limit) + .all() as DurableOutboxRow[]; + if (rows.length === 0) return []; + + const first = mapping.mapOutboxRow(rows[0]); + if (first.deliveryLease && !validation.sameOutboxDeliveryClaim(first, input)) { + if (Date.parse(input.claimedAtIso) < Date.parse(first.deliveryLease.leaseExpiresAt)) { + return []; + } + } + + if (first.deliveryLease && validation.sameOutboxDeliveryClaim(first, input)) { + const claimed: DurableApplicationCommandOutboxRecord[] = []; + for (const row of rows) { + const record = mapping.mapOutboxRow(row); + if (!validation.sameOutboxDeliveryClaim(record, input)) break; + claimed.push(record); + } + return claimed; + } + + for (const row of rows) { + orm + .update(durableApplicationCommandOutbox) + .set({ + deliveryGeneration: row.deliveryGeneration + 1, + deliveryOwnerId: input.ownerId, + deliveryLeaseToken: input.leaseToken, + deliveryClaimedAt: input.claimedAtIso, + deliveryLeaseExpiresAt: input.leaseExpiresAtIso, + }) + .where(eq(durableApplicationCommandOutbox.sequence, row.sequence)) + .run(); + } + + return orm + .select() + .from(durableApplicationCommandOutbox) + .where(isNull(durableApplicationCommandOutbox.deliveryAcknowledgedAt)) + .orderBy(asc(durableApplicationCommandOutbox.sequence)) + .limit(rows.length) + .all() + .map(mapping.mapOutboxRow); + }, + { behavior: 'immediate' } + ); + } + + durableAcknowledgeOutboxDelivery( + input: DurableApplicationCommandOutboxDeliveryAcknowledgementRequest + ): void { + validation.validateDurableOutboxDeliveryAcknowledgement(input); + const orm = this.getOrm(); + orm.transaction( + () => { + const rows = orm + .select() + .from(durableApplicationCommandOutbox) + .where(eq(durableApplicationCommandOutbox.eventId, input.eventId)) + .all(); + const row = rows[0]; + if (!row) { + throw new Error(`Durable application command outbox event not found: ${input.eventId}`); + } + const record = mapping.mapOutboxRow(row); + validation.assertOutboxDeliveryFence(record, input); + if (row.deliveryAcknowledgedAt !== null) return; + if ( + Date.parse(input.acknowledgedAtIso) < Date.parse(record.deliveryLease!.claimedAt) || + Date.parse(input.acknowledgedAtIso) >= Date.parse(record.deliveryLease!.leaseExpiresAt) + ) { + throw new Error( + `Durable application command outbox delivery lease expired: ${input.eventId}` + ); + } + const earlierUnacknowledged = orm + .select({ sequence: durableApplicationCommandOutbox.sequence }) + .from(durableApplicationCommandOutbox) + .where( + and( + isNull(durableApplicationCommandOutbox.deliveryAcknowledgedAt), + lt(durableApplicationCommandOutbox.sequence, record.sequence) + ) + ) + .limit(1) + .all(); + if (earlierUnacknowledged.length > 0) { + throw new Error( + `Durable application command outbox must acknowledge delivery in sequence order: ${input.eventId}` + ); + } + orm + .update(durableApplicationCommandOutbox) + .set({ deliveryAcknowledgedAt: input.acknowledgedAtIso }) + .where(eq(durableApplicationCommandOutbox.eventId, input.eventId)) + .run(); + }, + { behavior: 'immediate' } + ); + } + + durableApplyConsumerEvent( + input: DurableApplicationCommandConsumerApplyRequest + ): DurableApplicationCommandConsumerApplyResult { + validation.validateDurableConsumerApply(input); + const orm = this.getOrm(); + return orm.transaction( + () => { + const eventRow = orm + .select() + .from(durableApplicationCommandOutbox) + .where(eq(durableApplicationCommandOutbox.eventId, input.eventId)) + .get() as DurableOutboxRow | undefined; + if (!eventRow) { + throw new Error(`Durable application command consumer event not found: ${input.eventId}`); + } + const event = mapping.mapOutboxRow(eventRow); + if (event.semanticRevision !== input.semanticRevision) { + throw new Error( + `Durable application command consumer semantic revision mismatch: ${input.eventId} expected=${event.semanticRevision} actual=${input.semanticRevision}` + ); + } + + const existingRow = orm + .select() + .from(durableApplicationCommandConsumerApplications) + .where( + and( + eq(durableApplicationCommandConsumerApplications.consumerId, input.consumerId), + eq(durableApplicationCommandConsumerApplications.eventId, input.eventId) + ) + ) + .get() as DurableConsumerApplicationRow | undefined; + if (existingRow) { + const application = mapping.mapConsumerApplicationRow(existingRow); + if ( + application.semanticRevision !== input.semanticRevision || + application.projectionKey !== input.projectionKey || + application.stateJson !== input.stateJson + ) { + throw new Error( + `Durable application command consumer replay conflicts with the applied event: ${input.eventId}` + ); + } + return { + outcome: 'duplicate', + application, + projection: this.requireDurableConsumerProjection(input), + }; + } + + const current = this.readDurableConsumerProjection(input); + if (current && input.semanticRevision <= current.semanticRevision) { + throw new Error( + `Durable application command consumer semantic revision must advance: ${input.projectionKey} current=${current.semanticRevision} actual=${input.semanticRevision}` + ); + } + + orm + .insert(durableApplicationCommandConsumerApplications) + .values({ + consumerId: input.consumerId, + eventId: input.eventId, + semanticRevision: input.semanticRevision, + projectionKey: input.projectionKey, + stateJson: input.stateJson, + appliedAt: input.appliedAtIso, + }) + .run(); + + if (current) { + orm + .update(durableApplicationCommandConsumerProjections) + .set({ + semanticRevision: input.semanticRevision, + lastEventId: input.eventId, + stateJson: input.stateJson, + applicationCount: current.applicationCount + 1, + updatedAt: input.appliedAtIso, + }) + .where( + and( + eq(durableApplicationCommandConsumerProjections.consumerId, input.consumerId), + eq(durableApplicationCommandConsumerProjections.projectionKey, input.projectionKey) + ) + ) + .run(); + } else { + orm + .insert(durableApplicationCommandConsumerProjections) + .values({ + consumerId: input.consumerId, + projectionKey: input.projectionKey, + semanticRevision: input.semanticRevision, + lastEventId: input.eventId, + stateJson: input.stateJson, + applicationCount: 1, + updatedAt: input.appliedAtIso, + }) + .run(); + } + + return { + outcome: 'applied', + application: mapping.mapConsumerApplicationRow({ + consumerId: input.consumerId, + eventId: input.eventId, + semanticRevision: input.semanticRevision, + projectionKey: input.projectionKey, + stateJson: input.stateJson, + appliedAt: input.appliedAtIso, + }), + projection: this.requireDurableConsumerProjection(input), + }; + }, + { behavior: 'immediate' } + ); + } + + durableGetConsumerProjection( + input: DurableApplicationCommandConsumerProjectionRequest + ): DurableApplicationCommandConsumerProjectionRecord | null { + validation.validateDurableConsumerProjectionRequest(input); + return this.readDurableConsumerProjection(input); + } + + readDurableConsumerProjection( + input: DurableApplicationCommandConsumerProjectionRequest + ): DurableApplicationCommandConsumerProjectionRecord | null { + const row = this.getOrm() + .select() + .from(durableApplicationCommandConsumerProjections) + .where( + and( + eq(durableApplicationCommandConsumerProjections.consumerId, input.consumerId), + eq(durableApplicationCommandConsumerProjections.projectionKey, input.projectionKey) + ) + ) + .get() as DurableConsumerProjectionRow | undefined; + return row ? mapping.mapConsumerProjectionRow(row) : null; + } + + requireDurableConsumerProjection( + input: DurableApplicationCommandConsumerProjectionRequest + ): DurableApplicationCommandConsumerProjectionRecord { + const projection = this.readDurableConsumerProjection(input); + if (!projection) { + throw new Error( + `Durable application command consumer projection not found: ${input.consumerId}/${input.projectionKey}` + ); + } + return projection; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/durableApplicationCommandWorkerOps.ts b/src/features/internal-storage/main/infrastructure/worker/durableApplicationCommandWorkerOps.ts new file mode 100644 index 0000000000..8e1bb035f5 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/durableApplicationCommandWorkerOps.ts @@ -0,0 +1,548 @@ +import { + classifyAmbiguousEffect, + commitDurableCommand as assertDurableCommandCommit, + createCommandClaimScope, + resolveAmbiguousDurableEffect, + resolveCommandClaim, + retryDurableEffectAfterObservedAbsent, + transitionDurableCommandState, + transitionDurableEffectState, +} from '@features/application-command-ledger'; +import { and, eq } from 'drizzle-orm'; + +import * as mapping from './applicationCommandLedgerMapping'; +import * as validation from './applicationCommandLedgerValidation'; +import { + appendCommandOutboxEventToJournal, + assertCoordinationMutationAdmissionOpen, + canonicalCoordinationStorageJson, + createLegacyCommandCoordinationAttribution, + materializeCommandCoordinationAttribution, +} from './coordinationDurabilityWorkerOps'; +import { + durableApplicationCommandEffectEvidence, + durableApplicationCommandEffects, + durableApplicationCommandOutbox, + durableApplicationCommands, +} from './internalStorageSchema'; + +import type { StoredCommandCoordinationAttribution } from './internalStorageWorkerProtocol'; +import type { + DurableApplicationCommandAttemptClaim, + DurableApplicationCommandAttemptLeaseRequest, + DurableApplicationCommandClaimResult, + DurableApplicationCommandClaimStatusRequest, + DurableApplicationCommandCommitRequest, + DurableApplicationCommandEffectTransitionRequest, + DurableApplicationCommandPersistClaimRequest, + DurableApplicationCommandRecord, + DurableApplicationCommandStatusRequest, + DurableApplicationCommandTransitionRequest, +} from '@features/application-command-ledger'; +import type { + CommandClaimRecord, + DurableCommandState, + DurableEffectState, + EffectDescriptor, +} from '@features/application-command-ledger/contracts'; +import type DatabaseConstructor from 'better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +type SqliteDatabase = InstanceType; + +import type { ApplicationCommandLedgerRecordRepository } from './applicationCommandLedgerRecordRepository'; + +export class DurableApplicationCommandWorkerOps { + constructor( + private readonly getOrm: () => BetterSQLite3Database, + private readonly getDb: () => SqliteDatabase, + private readonly repository: ApplicationCommandLedgerRecordRepository + ) {} + + durableClaim( + input: DurableApplicationCommandPersistClaimRequest & { + readonly coordinationAttribution?: StoredCommandCoordinationAttribution; + } + ): DurableApplicationCommandClaimResult { + const validated = validation.validateDurableClaim(input); + const attribution = materializeCommandCoordinationAttribution( + input.coordinationAttribution ?? + createLegacyCommandCoordinationAttribution(validated.scope.stableActorId) + ); + const attributionJson = canonicalCoordinationStorageJson(attribution); + const orm = this.getOrm(); + + return orm.transaction( + () => { + const preexistingClaim = this.repository.readDurableRecordByClaim({ + scope: validated.scope, + }); + if (!preexistingClaim) { + assertCoordinationMutationAdmissionOpen(this.getDb(), validated.scope.deploymentId); + } + const insertResult = orm + .insert(durableApplicationCommands) + .values({ + commandId: validated.commandId, + deploymentId: validated.scope.deploymentId, + stableActorId: validated.scope.stableActorId, + commandKind: validated.scope.commandKind, + idempotencyKey: validated.scope.idempotencyKey, + descriptorId: validated.descriptor.descriptorId, + descriptorVersion: validated.descriptor.descriptorVersion, + inputSchemaVersion: validated.descriptor.inputSchemaVersion, + fingerprintVersion: validated.descriptor.fingerprintVersion, + effectPlanVersion: validated.descriptor.effectPlanVersion, + fingerprintKeyVersion: validated.fingerprint.keyVersion, + fingerprintDigest: validated.fingerprint.digest, + attemptGeneration: 1, + attemptId: validated.attempt.attemptId, + attemptOwnerId: validated.attempt.ownerId, + attemptLeaseToken: validated.attempt.leaseToken, + attemptClaimedAt: validated.attempt.claimedAtIso, + attemptLeaseExpiresAt: validated.attempt.leaseExpiresAtIso, + state: 'prepared', + retentionClass: validated.retentionClass, + auditSessionId: validated.auditSessionId, + coordinationAttributionJson: attributionJson, + outcomeJson: null, + errorCode: null, + errorJson: null, + createdAt: validated.createdAtIso, + updatedAt: validated.createdAtIso, + committedAt: null, + }) + .onConflictDoNothing() + .run(); + + const created = insertResult.changes === 1; + if (created) { + orm + .insert(durableApplicationCommandEffects) + .values( + validated.effectPlan.map((effect) => ({ + commandId: validated.commandId, + ordinal: effect.ordinal, + effectId: effect.effectId, + effectVersion: effect.effectVersion, + recoveryClass: effect.recoveryClass, + evidenceSchemaVersion: effect.evidenceSchemaVersion, + state: effect.state, + updatedAt: validated.createdAtIso, + })) + ) + .run(); + } + + const byCommandId = this.repository.readDurableRecord({ + deploymentId: validated.scope.deploymentId, + commandId: validated.commandId, + }); + const byClaim = this.repository.readDurableRecordByClaim({ scope: validated.scope }); + if (byCommandId && byClaim && byCommandId.commandId !== byClaim.commandId) { + throw new Error( + 'Durable application command claim conflicts with both an existing command id and claim scope' + ); + } + let command = byClaim ?? byCommandId; + if (!command) { + throw new Error('Durable application command claim did not converge to a stored record'); + } + if ( + this.repository.requireCoordinationAttributionJson(command.commandId) !== attributionJson + ) { + throw new Error('Durable application command coordination attribution conflicts'); + } + + const incoming: CommandClaimRecord = { + scope: validated.scope, + fingerprint: validated.fingerprint, + }; + if (!validation.sameClaimScope(command.claim, incoming)) { + throw new Error( + `Durable application command id is already in use: ${validated.commandId}` + ); + } + + const resolution = created + ? resolveCommandClaim(null, incoming) + : resolveCommandClaim(command.claim as CommandClaimRecord, incoming); + let attemptAcquired = + resolution.outcome !== 'idempotency_mismatch' && + !validation.isDurableCommandTerminal(command.state) && + (created || validation.sameAttemptClaim(command, validated.attempt)); + if (!created && resolution.outcome === 'same_intent' && !attemptAcquired) { + attemptAcquired = this.tryAcquireExpiredDurableAttempt(command, validated.attempt); + if (attemptAcquired) { + command = this.repository.requireDurableRecord({ + deploymentId: validated.scope.deploymentId, + commandId: command.commandId, + }); + } + } + return { + resolution, + attemptAcquired, + command: command as DurableApplicationCommandRecord, + }; + }, + { behavior: 'immediate' } + ); + } + + durableGetStatus( + input: DurableApplicationCommandStatusRequest + ): DurableApplicationCommandRecord | null { + validation.assertIdentifier('deploymentId', input.deploymentId); + validation.assertIdentifier('commandId', input.commandId); + const orm = this.getOrm(); + return orm.transaction( + () => + this.repository.readDurableRecord( + input + ) as DurableApplicationCommandRecord | null + ); + } + + durableGetByClaim( + input: DurableApplicationCommandClaimStatusRequest + ): DurableApplicationCommandRecord | null { + const scope = createCommandClaimScope(input.scope); + const orm = this.getOrm(); + return orm.transaction(() => this.repository.readDurableRecordByClaim({ scope })); + } + + durableRenewAttemptLease( + input: DurableApplicationCommandAttemptLeaseRequest + ): DurableApplicationCommandRecord { + validation.validateDurableAttemptLease(input); + const orm = this.getOrm(); + return orm.transaction( + () => { + const current = this.repository.requireDurableRecord(input); + validation.assertDurableAttemptFence(current, input.attempt, input.renewedAtIso); + if (validation.isDurableCommandTerminal(current.state)) { + throw new Error(`Durable application command attempt is terminal: ${current.commandId}`); + } + if (Date.parse(input.leaseExpiresAtIso) <= Date.parse(current.attempt.leaseExpiresAt)) { + throw new Error( + 'Durable application command lease renewal must extend the current lease' + ); + } + orm + .update(durableApplicationCommands) + .set({ + attemptLeaseExpiresAt: input.leaseExpiresAtIso, + updatedAt: input.renewedAtIso, + }) + .where(eq(durableApplicationCommands.commandId, current.commandId)) + .run(); + return this.repository.requireDurableRecord(input); + }, + { behavior: 'immediate' } + ); + } + + durableTransitionCommand( + input: DurableApplicationCommandTransitionRequest + ): DurableApplicationCommandRecord { + validation.validateDurableCommandTransition(input); + const orm = this.getOrm(); + return orm.transaction( + () => { + const current = this.repository.requireDurableRecord(input); + validation.assertDurableAttemptFence(current, input.attempt, input.transitionedAtIso); + if (current.state !== input.expectedState) { + throw validation.staleDurableCommandState(current, input.expectedState); + } + const nextState = transitionDurableCommandState(current.state, input.nextState); + const terminalError = nextState === 'failed' || nextState === 'operator_required'; + if (terminalError && !input.errorCode) { + throw new Error(`Durable application command ${nextState} requires a safe error code`); + } + if (!terminalError && (input.errorCode !== null || input.errorJson !== null)) { + throw new Error( + `Durable application command ${nextState} cannot record terminal error data` + ); + } + orm + .update(durableApplicationCommands) + .set({ + state: nextState, + errorCode: input.errorCode, + errorJson: input.errorJson, + updatedAt: input.transitionedAtIso, + }) + .where(eq(durableApplicationCommands.commandId, current.commandId)) + .run(); + return this.repository.requireDurableRecord(input); + }, + { behavior: 'immediate' } + ); + } + + durableTransitionEffect( + input: DurableApplicationCommandEffectTransitionRequest + ): DurableApplicationCommandRecord { + validation.validateDurableEffectTransition(input); + const orm = this.getOrm(); + return orm.transaction( + () => { + const current = this.repository.requireDurableRecord(input); + validation.assertDurableAttemptFence(current, input.attempt, input.transitionedAtIso); + if (current.state !== 'running' && current.state !== 'recovering') { + throw new Error( + `Durable application command effects cannot transition from command state ${current.state}` + ); + } + const effect = current.effects[input.ordinal]; + if (effect?.ordinal !== input.ordinal) { + throw new Error(`Durable application command effect ordinal not found: ${input.ordinal}`); + } + if (effect.state !== input.expectedState) { + throw new Error( + `Durable application command effect state is stale: ordinal=${input.ordinal} expected=${effect.state} actual=${input.expectedState}` + ); + } + + if (input.nextState === 'attempting') { + for (let ordinal = 0; ordinal < input.ordinal; ordinal += 1) { + if (current.effects[ordinal]?.state !== 'observed_succeeded') { + throw new Error( + `Durable application command effects must start in order: ordinal=${input.ordinal}` + ); + } + } + } + + const descriptor = validation.effectDescriptor(effect); + let nextState: DurableEffectState; + const isObserved = + input.nextState === 'observed_succeeded' || input.nextState === 'observed_absent'; + if (isObserved) { + if (!input.evidence || input.evidenceJson === null) { + throw new Error('Observed durable effect state requires validated evidence'); + } + validation.assertEvidenceMatchesEffect(input.evidence, descriptor, input.nextState); + nextState = + effect.state === 'ambiguous' + ? resolveAmbiguousDurableEffect(descriptor, effect.state, input.evidence) + : transitionDurableEffectState(descriptor, effect.state, input.nextState); + } else { + if (input.evidence !== null || input.evidenceJson !== null) { + throw new Error('Durable effect evidence is allowed only for an observed outcome'); + } + nextState = + effect.state === 'observed_absent' && input.nextState === 'attempting' + ? retryDurableEffectAfterObservedAbsent(descriptor, effect.state) + : transitionDurableEffectState(descriptor, effect.state, input.nextState); + } + + orm + .update(durableApplicationCommandEffects) + .set({ state: nextState, updatedAt: input.transitionedAtIso }) + .where( + and( + eq(durableApplicationCommandEffects.commandId, current.commandId), + eq(durableApplicationCommandEffects.ordinal, input.ordinal) + ) + ) + .run(); + + if (isObserved && input.evidence && input.evidenceJson !== null) { + const evidenceRows = this.repository.readEffectEvidence(current.commandId, input.ordinal); + orm + .insert(durableApplicationCommandEffectEvidence) + .values({ + commandId: current.commandId, + ordinal: input.ordinal, + sequence: (evidenceRows.at(-1)?.sequence ?? 0) + 1, + outcome: input.evidence.outcome, + evidenceSchemaVersion: input.evidence.evidenceSchemaVersion, + evidenceJson: input.evidenceJson, + recordedAt: input.transitionedAtIso, + }) + .run(); + } + + let commandState: DurableCommandState = current.state; + let errorCode = current.errorCode; + let errorJson = current.errorJson; + if (nextState === 'ambiguous') { + const disposition = classifyAmbiguousEffect(effect.recoveryClass); + if (commandState === 'running') { + commandState = transitionDurableCommandState(commandState, 'recovering'); + } + if (disposition.commandState === 'operator_required') { + commandState = transitionDurableCommandState(commandState, 'operator_required'); + errorCode = 'ambiguous_non_reconcilable_effect'; + errorJson = JSON.stringify({ effectId: effect.effectId, ordinal: effect.ordinal }); + } + } + orm + .update(durableApplicationCommands) + .set({ + state: commandState, + errorCode, + errorJson, + updatedAt: input.transitionedAtIso, + }) + .where(eq(durableApplicationCommands.commandId, current.commandId)) + .run(); + return this.repository.requireDurableRecord(input); + }, + { behavior: 'immediate' } + ); + } + + durableCommit(input: DurableApplicationCommandCommitRequest): DurableApplicationCommandRecord { + validation.validateDurableCommit(input); + const orm = this.getOrm(); + return orm.transaction( + () => { + const current = this.repository.requireDurableRecord(input); + validation.assertDurableAttemptFence(current, input.attempt, input.committedAtIso); + if (current.state === 'committed') { + const storedOutbox = this.repository.readOutboxByCommandId(current.commandId); + if ( + current.outcomeJson === input.outcomeJson && + storedOutbox && + mapping.sameOutboxInput(storedOutbox, input.outbox) + ) { + return current; + } + throw new Error(`Durable application command commit conflicts: ${current.commandId}`); + } + if (current.state !== input.expectedState) { + throw validation.staleDurableCommandState(current, input.expectedState); + } + + const descriptor = { + ...current.descriptor, + effects: current.effects.map(validation.effectDescriptor) as [ + EffectDescriptor, + ...EffectDescriptor[], + ], + }; + assertDurableCommandCommit( + current.state, + descriptor, + current.descriptor, + current.effects.map((effect) => ({ + effectId: effect.effectId, + effectVersion: effect.effectVersion, + recoveryClass: effect.recoveryClass, + evidenceSchemaVersion: effect.evidenceSchemaVersion, + ordinal: effect.ordinal, + state: effect.state, + })) + ); + + orm + .insert(durableApplicationCommandOutbox) + .values({ + eventId: input.outbox.eventId, + commandId: current.commandId, + deploymentId: current.claim.scope.deploymentId, + eventType: input.outbox.eventType, + scopeKind: input.outbox.scopeKind, + scopeId: input.outbox.scopeId, + schemaVersion: input.outbox.schemaVersion, + semanticRevision: input.outbox.semanticRevision, + payloadJson: input.outbox.payloadJson, + createdAt: input.outbox.createdAtIso, + deliveryGeneration: 0, + deliveryOwnerId: null, + deliveryLeaseToken: null, + deliveryClaimedAt: null, + deliveryLeaseExpiresAt: null, + deliveryAcknowledgedAt: null, + }) + .run(); + appendCommandOutboxEventToJournal(this.getDb(), { + commandId: current.commandId, + deploymentId: current.claim.scope.deploymentId, + attribution: this.repository.readCoordinationAttribution(current.commandId), + outbox: input.outbox, + }); + orm + .update(durableApplicationCommands) + .set({ + state: 'committed', + outcomeJson: input.outcomeJson, + errorCode: null, + errorJson: null, + updatedAt: input.committedAtIso, + committedAt: input.committedAtIso, + }) + .where(eq(durableApplicationCommands.commandId, current.commandId)) + .run(); + return this.repository.requireDurableRecord(input); + }, + { behavior: 'immediate' } + ); + } + + tryAcquireExpiredDurableAttempt( + current: DurableApplicationCommandRecord, + attempt: DurableApplicationCommandAttemptClaim + ): boolean { + if ( + validation.isDurableCommandTerminal(current.state) || + Date.parse(attempt.claimedAtIso) < Date.parse(current.attempt.leaseExpiresAt) + ) { + return false; + } + + const orm = this.getOrm(); + let commandState = current.state; + let errorCode = current.errorCode; + let errorJson = current.errorJson; + for (const effect of current.effects) { + if (effect.state !== 'attempting' && effect.state !== 'compensating') continue; + transitionDurableEffectState(validation.effectDescriptor(effect), effect.state, 'ambiguous'); + orm + .update(durableApplicationCommandEffects) + .set({ state: 'ambiguous', updatedAt: attempt.claimedAtIso }) + .where( + and( + eq(durableApplicationCommandEffects.commandId, current.commandId), + eq(durableApplicationCommandEffects.ordinal, effect.ordinal) + ) + ) + .run(); + if (commandState === 'running') { + commandState = transitionDurableCommandState(commandState, 'recovering'); + } + if (classifyAmbiguousEffect(effect.recoveryClass).commandState === 'operator_required') { + if (commandState !== 'recovering') { + throw new Error('Non-reconcilable effect takeover requires a recovering command'); + } + commandState = transitionDurableCommandState(commandState, 'operator_required'); + errorCode = 'ambiguous_non_reconcilable_effect'; + errorJson = JSON.stringify({ effectId: effect.effectId, ordinal: effect.ordinal }); + } + } + if (commandState === 'running') { + commandState = transitionDurableCommandState(commandState, 'recovering'); + } + + orm + .update(durableApplicationCommands) + .set({ + attemptGeneration: current.attempt.generation + 1, + attemptId: attempt.attemptId, + attemptOwnerId: attempt.ownerId, + attemptLeaseToken: attempt.leaseToken, + attemptClaimedAt: attempt.claimedAtIso, + attemptLeaseExpiresAt: attempt.leaseExpiresAtIso, + state: commandState, + errorCode, + errorJson, + updatedAt: attempt.claimedAtIso, + }) + .where(eq(durableApplicationCommands.commandId, current.commandId)) + .run(); + return true; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationCheckpointSupport.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationCheckpointSupport.ts new file mode 100644 index 0000000000..0c068b8a69 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationCheckpointSupport.ts @@ -0,0 +1,205 @@ +import { + EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION, + FileObservationState, + type FileObservationStateCheckpoint, +} from '@features/external-writer-coordination'; +import { parseDeploymentId } from '@shared/contracts/hosted'; + +import type { + ExternalWriterObservationCheckpointIdentity, + ExternalWriterObservationCheckpointSaveRequest, +} from '../../../contracts/externalWriterObservationStorageContracts'; + +export interface ExternalWriterStoredCheckpointRow { + revision: number; + checkpoint_json: string; +} +export const MAX_CHECKPOINT_JSON_BYTES = 64 * 1024 * 1024; +const OBSERVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const LIMITS = Object.freeze({ + maxPendingObservations: 1_024, + maxSelfWriteIntents: 1_024, + maxObservationAttempts: 3, + maxScopes: 1_024, + maxObservedFiles: 100_000, +}); + +export function compareExternalWriterText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function exactObject( + value: unknown, + keys: readonly string[], + code: string +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(code); + const record = value as Record; + const actual = Object.keys(record); + if (actual.length !== keys.length || keys.some((key) => !Object.hasOwn(record, key))) + throw new TypeError(code); + return record; +} + +export function parseExternalWriterObservationIdentity( + value: unknown +): ExternalWriterObservationCheckpointIdentity { + const record = exactObject( + value, + ['deploymentId', 'observerId'], + 'external-writer-observation-identity-invalid' + ); + if (typeof record.observerId !== 'string' || !OBSERVER_ID_PATTERN.test(record.observerId)) + throw new TypeError('external-writer-observer-id-invalid'); + return { deploymentId: parseDeploymentId(record.deploymentId), observerId: record.observerId }; +} + +export function parseExternalWriterObservationCheckpoint( + value: unknown +): FileObservationStateCheckpoint { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('external-writer-observation-checkpoint-invalid'); + let json: string; + try { + json = JSON.stringify(value); + } catch { + throw new TypeError('external-writer-observation-checkpoint-invalid'); + } + if ( + Buffer.byteLength(json, 'utf8') > MAX_CHECKPOINT_JSON_BYTES || + (value as { schemaVersion?: unknown }).schemaVersion !== + EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION + ) + throw new TypeError('external-writer-observation-checkpoint-invalid'); + try { + const checkpoint = FileObservationState.restore( + value as FileObservationStateCheckpoint, + LIMITS + ).snapshot(); + return { + ...checkpoint, + fileWriterEpochs: [...checkpoint.fileWriterEpochs].sort((a, b) => + compareExternalWriterText(a.teamId, b.teamId) + ), + teamObservationWatermarks: [...checkpoint.teamObservationWatermarks].sort((a, b) => + compareExternalWriterText(a.teamId, b.teamId) + ), + pendingObservations: [...checkpoint.pendingObservations].sort((a, b) => + compareExternalWriterText(a.id, b.id) + ), + dirtyScopes: [...checkpoint.dirtyScopes].sort( + (a, b) => + compareExternalWriterText(a.scope.teamId, b.scope.teamId) || + compareExternalWriterText(a.scope.featureKey, b.scope.featureKey) + ), + selfWriteIntents: [...checkpoint.selfWriteIntents].sort((a, b) => + compareExternalWriterText(a.intentId, b.intentId) + ), + observedFiles: [...checkpoint.observedFiles].sort( + (a, b) => + compareExternalWriterText(a.scope.teamId, b.scope.teamId) || + compareExternalWriterText(a.scope.featureKey, b.scope.featureKey) || + compareExternalWriterText(a.fileKey, b.fileKey) + ), + }; + } catch { + throw new TypeError('external-writer-observation-checkpoint-invalid'); + } +} + +export function parseExternalWriterObservationCheckpointRecord(value: unknown): { + readonly revision: number; + readonly checkpoint: FileObservationStateCheckpoint; +} { + const record = exactObject( + value, + ['revision', 'checkpoint'], + 'external-writer-observation-record-invalid' + ); + if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 0) { + throw new TypeError('external-writer-observation-record-invalid'); + } + return { + revision: record.revision as number, + checkpoint: parseExternalWriterObservationCheckpoint(record.checkpoint), + }; +} + +export function parseSaveRequest(value: unknown): ExternalWriterObservationCheckpointSaveRequest { + const record = exactObject( + value, + ['deploymentId', 'observerId', 'expectedRevision', 'checkpoint'], + 'external-writer-observation-save-invalid' + ); + const identity = parseExternalWriterObservationIdentity({ + deploymentId: record.deploymentId, + observerId: record.observerId, + }); + if ( + record.expectedRevision !== null && + (!Number.isSafeInteger(record.expectedRevision) || (record.expectedRevision as number) <= 0) + ) + throw new TypeError('external-writer-observation-revision-invalid'); + return { + ...identity, + expectedRevision: record.expectedRevision as number | null, + checkpoint: parseExternalWriterObservationCheckpoint(record.checkpoint), + }; +} + +export function checkpointTeamIds(checkpoint: FileObservationStateCheckpoint): Set { + return new Set( + [ + ...checkpoint.fileWriterEpochs, + ...checkpoint.teamObservationWatermarks, + ...checkpoint.pendingObservations, + ...checkpoint.dirtyScopes, + ...checkpoint.selfWriteIntents, + ...checkpoint.observedFiles, + ].map((entry) => ('teamId' in entry ? entry.teamId : entry.scope.teamId)) + ); +} + +export function assertNonRegressing( + previous: FileObservationStateCheckpoint, + next: FileObservationStateCheckpoint +): void { + if ( + next.lastObservationSequence < previous.lastObservationSequence || + next.observationWatermark < previous.observationWatermark + ) + throw new Error('external-writer-observation-checkpoint-regression'); + const epochs = new Map(next.fileWriterEpochs.map((entry) => [entry.teamId, entry.epoch])); + for (const entry of previous.fileWriterEpochs) + if ((epochs.get(entry.teamId) ?? -1) < entry.epoch) + throw new Error('external-writer-observation-checkpoint-regression'); + const watermarks = new Map( + next.teamObservationWatermarks.map((entry) => [entry.teamId, entry] as const) + ); + for (const entry of previous.teamObservationWatermarks) { + const nextEntry = watermarks.get(entry.teamId); + if ( + !nextEntry || + nextEntry.lastObservationSequence < entry.lastObservationSequence || + nextEntry.observationWatermark < entry.observationWatermark + ) + throw new Error('external-writer-observation-checkpoint-regression'); + } +} + +export function checkpointFromRow( + row: ExternalWriterStoredCheckpointRow +): FileObservationStateCheckpoint { + if ( + !Number.isSafeInteger(row.revision) || + row.revision <= 0 || + Buffer.byteLength(row.checkpoint_json, 'utf8') > MAX_CHECKPOINT_JSON_BYTES + ) + throw new TypeError('external-writer-observation-checkpoint-invalid'); + try { + return parseExternalWriterObservationCheckpoint(JSON.parse(row.checkpoint_json) as unknown); + } catch { + throw new TypeError('external-writer-observation-checkpoint-invalid'); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationConsumeReceiptMigration.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationConsumeReceiptMigration.ts new file mode 100644 index 0000000000..913d7db474 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationConsumeReceiptMigration.ts @@ -0,0 +1,23 @@ +export const EXTERNAL_WRITER_OBSERVATION_CONSUME_RECEIPT_MIGRATION_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS external_writer_observation_consume_receipts ( + deployment_id TEXT NOT NULL, + observer_id TEXT NOT NULL, + consume_attempt_id TEXT NOT NULL CHECK ( + length(consume_attempt_id) BETWEEN 1 AND 128 + AND consume_attempt_id NOT GLOB '*[^A-Za-z0-9._:-]*' + ), + result_revision INTEGER NOT NULL CHECK (result_revision > 0), + schema_version INTEGER NOT NULL CHECK (schema_version = 2), + checkpoint_json TEXT NOT NULL CHECK (json_valid(checkpoint_json)), + checkpoint_sha256 TEXT NOT NULL CHECK ( + length(checkpoint_sha256) = 64 + AND checkpoint_sha256 NOT GLOB '*[^0-9a-f]*' + ), + PRIMARY KEY (deployment_id, observer_id) + )`, +] as const; + +export const EXTERNAL_WRITER_OBSERVATION_CONSUME_RECEIPT_MIGRATION = Object.freeze({ + version: 26, + statements: [...EXTERNAL_WRITER_OBSERVATION_CONSUME_RECEIPT_MIGRATION_STATEMENTS], +}); diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationConsumeReceiptStore.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationConsumeReceiptStore.ts new file mode 100644 index 0000000000..1e9dd95690 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationConsumeReceiptStore.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto'; + +import { EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION } from '@features/external-writer-coordination'; + +import { parseExternalWriterObservationCheckpoint } from './externalWriterObservationCheckpointSupport'; + +import type { + ExternalWriterObservationCheckpointIdentity, + ExternalWriterObservationCheckpointRecord, +} from '../../../contracts/externalWriterObservationStorageContracts'; +import type { FileObservationStateCheckpoint } from '@features/external-writer-coordination'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +const hash = (checkpoint: FileObservationStateCheckpoint): string => + createHash('sha256').update(JSON.stringify(checkpoint)).digest('hex'); + +export function readConsumeReceipt( + db: SqliteDatabase, + identity: ExternalWriterObservationCheckpointIdentity, + attemptId: string +): ExternalWriterObservationCheckpointRecord | null { + const row = db + .prepare( + `SELECT consume_attempt_id, result_revision, checkpoint_json, checkpoint_sha256 FROM external_writer_observation_consume_receipts WHERE deployment_id = ? AND observer_id = ?` + ) + .get(identity.deploymentId, identity.observerId) as + | { + consume_attempt_id: string; + result_revision: number; + checkpoint_json: string; + checkpoint_sha256: string; + } + | undefined; + if (!row || row.consume_attempt_id !== attemptId) return null; + let checkpoint: FileObservationStateCheckpoint; + try { + checkpoint = parseExternalWriterObservationCheckpoint( + JSON.parse(row.checkpoint_json) as unknown + ); + } catch { + throw new Error('external-writer-observation-consume-receipt-invalid'); + } + if ( + !Number.isSafeInteger(row.result_revision) || + row.result_revision <= 0 || + hash(checkpoint) !== row.checkpoint_sha256 + ) + throw new Error('external-writer-observation-consume-receipt-invalid'); + return { revision: row.result_revision, checkpoint }; +} + +export function replaceConsumeReceipt( + db: SqliteDatabase, + identity: ExternalWriterObservationCheckpointIdentity, + attemptId: string, + result: ExternalWriterObservationCheckpointRecord +): void { + db.prepare( + `INSERT INTO external_writer_observation_consume_receipts (deployment_id, observer_id, consume_attempt_id, result_revision, schema_version, checkpoint_json, checkpoint_sha256) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(deployment_id, observer_id) DO UPDATE SET consume_attempt_id = excluded.consume_attempt_id, result_revision = excluded.result_revision, schema_version = excluded.schema_version, checkpoint_json = excluded.checkpoint_json, checkpoint_sha256 = excluded.checkpoint_sha256` + ).run( + identity.deploymentId, + identity.observerId, + attemptId, + result.revision, + EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION, + JSON.stringify(result.checkpoint), + hash(result.checkpoint) + ); +} diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationSchema.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationSchema.ts new file mode 100644 index 0000000000..0b0586898f --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationSchema.ts @@ -0,0 +1,156 @@ +import { sql } from 'drizzle-orm'; +import { check, foreignKey, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +import { teamIdentityRecords } from './teamIdentityStorageSchema'; + +export const externalWriterObservationCheckpoints = sqliteTable( + 'external_writer_observation_checkpoints', + { + deploymentId: text('deployment_id').notNull(), + observerId: text('observer_id').notNull(), + revision: integer('revision').notNull(), + schemaVersion: integer('schema_version').notNull(), + checkpointJson: text('checkpoint_json').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.deploymentId, table.observerId] }), + check('ck_external_writer_observation_revision', sql`${table.revision} > 0`), + check('ck_external_writer_observation_schema', sql`${table.schemaVersion} = 2`), + check('ck_external_writer_observation_json', sql`json_valid(${table.checkpointJson})`), + ] +); + +export const externalWriterObservationConsumeReceipts = sqliteTable( + 'external_writer_observation_consume_receipts', + { + deploymentId: text('deployment_id').notNull(), + observerId: text('observer_id').notNull(), + consumeAttemptId: text('consume_attempt_id').notNull(), + resultRevision: integer('result_revision').notNull(), + schemaVersion: integer('schema_version').notNull(), + checkpointJson: text('checkpoint_json').notNull(), + checkpointSha256: text('checkpoint_sha256').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.deploymentId, table.observerId] }), + check( + 'ck_external_writer_consume_attempt_id', + sql`length(${table.consumeAttemptId}) BETWEEN 1 AND 128 AND ${table.consumeAttemptId} NOT GLOB '*[^A-Za-z0-9._:-]*'` + ), + check('ck_external_writer_consume_result_revision', sql`${table.resultRevision} > 0`), + check('ck_external_writer_consume_schema', sql`${table.schemaVersion} = 2`), + check('ck_external_writer_consume_checkpoint_json', sql`json_valid(${table.checkpointJson})`), + check( + 'ck_external_writer_consume_checkpoint_hash', + sql`length(${table.checkpointSha256}) = 64 AND ${table.checkpointSha256} NOT GLOB '*[^0-9a-f]*'` + ), + ] +); + +export const externalWriterObservationRetiredTeamFloors = sqliteTable( + 'external_writer_observation_retired_team_floors', + { + deploymentId: text('deployment_id').notNull(), + observerId: text('observer_id').notNull(), + teamId: text('team_id') + .notNull() + .references(() => teamIdentityRecords.teamId, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + identityChecksum: text('identity_checksum').notNull(), + tombstonedAt: text('tombstoned_at').notNull(), + writerEpoch: integer('writer_epoch'), + lastObservationSequence: integer('last_observation_sequence').notNull(), + observationWatermark: integer('observation_watermark').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.deploymentId, table.observerId, table.teamId] }), + check( + 'ck_external_writer_retired_epoch', + sql`${table.writerEpoch} IS NULL OR ${table.writerEpoch} >= 1` + ), + check('ck_external_writer_retired_sequence', sql`${table.lastObservationSequence} >= 0`), + check('ck_external_writer_retired_watermark', sql`${table.observationWatermark} >= 0`), + check( + 'ck_external_writer_retired_watermark_sequence', + sql`${table.observationWatermark} <= ${table.lastObservationSequence}` + ), + ] +); + +export const externalWriterObservationHandoffEligibility = sqliteTable( + 'external_writer_observation_handoff_eligibility', + { + deploymentId: text('deployment_id').notNull(), + observerId: text('observer_id').notNull(), + expectedCheckpointRevision: integer('expected_checkpoint_revision').notNull(), + handoffId: text('handoff_id').notNull(), + protocolVersion: integer('protocol_version').notNull(), + checkpointSha256: text('checkpoint_sha256').notNull(), + capturedSequence: integer('captured_sequence').notNull(), + persistedWatermark: integer('persisted_watermark').notNull(), + oldCatalogToken: text('old_catalog_token').notNull(), + targetCatalogToken: text('target_catalog_token').notNull(), + nextRegistrationDigest: text('next_registration_digest').notNull(), + candidateDigest: text('candidate_digest').notNull(), + candidatesJson: text('candidates_json').notNull(), + retainedRegistrationsJson: text('retained_registrations_json').notNull(), + removedRegistrationsJson: text('removed_registrations_json').notNull(), + createdAt: text('created_at').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.deploymentId, table.observerId] }), + foreignKey({ + columns: [table.deploymentId, table.observerId], + foreignColumns: [ + externalWriterObservationCheckpoints.deploymentId, + externalWriterObservationCheckpoints.observerId, + ], + }) + .onDelete('cascade') + .onUpdate('restrict'), + check('ck_external_writer_handoff_revision', sql`${table.expectedCheckpointRevision} > 0`), + check('ck_external_writer_handoff_protocol', sql`${table.protocolVersion} = 1`), + check( + 'ck_external_writer_handoff_id', + sql`length(${table.handoffId}) BETWEEN 1 AND 128 AND ${table.handoffId} NOT GLOB '*[^A-Za-z0-9._:-]*'` + ), + check( + 'ck_external_writer_handoff_checkpoint_hash', + sql`length(${table.checkpointSha256}) = 64 AND ${table.checkpointSha256} NOT GLOB '*[^0-9a-f]*'` + ), + check( + 'ck_external_writer_handoff_captured_watermark', + sql`${table.capturedSequence} >= 0 AND ${table.capturedSequence} = ${table.persistedWatermark}` + ), + check( + 'ck_external_writer_handoff_old_token', + sql`length(${table.oldCatalogToken}) = 64 AND ${table.oldCatalogToken} NOT GLOB '*[^0-9a-f]*'` + ), + check( + 'ck_external_writer_handoff_target_token', + sql`length(${table.targetCatalogToken}) = 64 AND ${table.targetCatalogToken} NOT GLOB '*[^0-9a-f]*'` + ), + check( + 'ck_external_writer_handoff_registration_digest', + sql`length(${table.nextRegistrationDigest}) = 64 AND ${table.nextRegistrationDigest} NOT GLOB '*[^0-9a-f]*'` + ), + check( + 'ck_external_writer_handoff_candidate_digest', + sql`length(${table.candidateDigest}) = 64 AND ${table.candidateDigest} NOT GLOB '*[^0-9a-f]*'` + ), + check( + 'ck_external_writer_handoff_candidates_json', + sql`json_valid(${table.candidatesJson}) AND json_type(${table.candidatesJson}) = 'array' AND json_array_length(${table.candidatesJson}) <= 1024 AND length(CAST(${table.candidatesJson} AS BLOB)) <= 67108864` + ), + check( + 'ck_external_writer_handoff_retained_json', + sql`json_valid(${table.retainedRegistrationsJson}) AND json_type(${table.retainedRegistrationsJson}) = 'array' AND json_array_length(${table.retainedRegistrationsJson}) <= 100000 AND length(CAST(${table.retainedRegistrationsJson} AS BLOB)) <= 67108864` + ), + check( + 'ck_external_writer_handoff_removed_json', + sql`json_valid(${table.removedRegistrationsJson}) AND json_type(${table.removedRegistrationsJson}) = 'array' AND json_array_length(${table.removedRegistrationsJson}) <= 100000 AND length(CAST(${table.removedRegistrationsJson} AS BLOB)) <= 67108864` + ), + ] +); diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationSqlSupport.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationSqlSupport.ts new file mode 100644 index 0000000000..22a527651e --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationSqlSupport.ts @@ -0,0 +1,47 @@ +import { EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION, type FileObservationStateCheckpoint } from '@features/external-writer-coordination'; + +import { checkpointTeamIds, type ExternalWriterStoredCheckpointRow } from './externalWriterObservationCheckpointSupport'; + +import type { ExternalWriterObservationCheckpointIdentity, ExternalWriterObservationCheckpointRecord, ExternalWriterObservationCheckpointSaveRequest, ExternalWriterObservationRetirementProof } from '../../../contracts/externalWriterObservationStorageContracts'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export function assertNoRetiredTeamReappears(db: SqliteDatabase, identity: ExternalWriterObservationCheckpointIdentity, checkpoint: FileObservationStateCheckpoint): void { + const ids = [...checkpointTeamIds(checkpoint)]; + for (let offset = 0; offset < ids.length; offset += 400) { + const batch = ids.slice(offset, offset + 400); + if (db.prepare(`SELECT 1 FROM external_writer_observation_retired_team_floors WHERE deployment_id = ? AND observer_id = ? AND team_id IN (${batch.map(() => '?').join(', ')}) LIMIT 1`).get(identity.deploymentId, identity.observerId, ...batch)) throw new Error('external-writer-observation-retired-team-reappeared'); + } +} + +export function verifyTombstoneProof(db: SqliteDatabase, proof: ExternalWriterObservationRetirementProof): void { + const row = db.prepare(`SELECT identity.state, identity.identity_checksum, identity.tombstoned_at, + reservation.team_id reservation_team_id, reservation.state reservation_state, + reservation.tombstoned_at reservation_tombstoned_at, adoption.state adoption_state, + adoption.expected_identity_checksum, adoption.published_identity_checksum, + adoption.committed_identity_checksum + FROM team_identity_records identity JOIN legacy_team_key_reservations reservation ON reservation.legacy_key = identity.legacy_key + JOIN team_adoption_intents adoption ON adoption.intent_id = identity.adoption_intent_id AND adoption.team_id = identity.team_id WHERE identity.team_id = ?`).get(proof.teamId) as Record | undefined; + if (!row || row.state !== 'tombstoned' || row.identity_checksum !== proof.identityChecksum || row.tombstoned_at !== proof.tombstonedAt || row.reservation_team_id !== proof.teamId || row.reservation_state !== 'tombstoned' || row.reservation_tombstoned_at !== proof.tombstonedAt || row.adoption_state !== 'committed' || row.expected_identity_checksum !== proof.identityChecksum || row.published_identity_checksum !== proof.identityChecksum || row.committed_identity_checksum !== proof.identityChecksum) throw new Error('external-writer-observation-retirement-proof-mismatch'); +} + +export function insertRetiredFloor(db: SqliteDatabase, identity: ExternalWriterObservationCheckpointIdentity, proof: ExternalWriterObservationRetirementProof, previous: FileObservationStateCheckpoint): void { + const epoch = previous.fileWriterEpochs.find((entry) => entry.teamId === proof.teamId)?.epoch ?? null; + const watermark = previous.teamObservationWatermarks.find((entry) => entry.teamId === proof.teamId); + db.prepare(`INSERT INTO external_writer_observation_retired_team_floors (deployment_id, observer_id, team_id, identity_checksum, tombstoned_at, writer_epoch, last_observation_sequence, observation_watermark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(identity.deploymentId, identity.observerId, proof.teamId, proof.identityChecksum, proof.tombstonedAt, epoch, watermark?.lastObservationSequence ?? 0, watermark?.observationWatermark ?? 0); +} + +export function writeCheckpoint(db: SqliteDatabase, request: ExternalWriterObservationCheckpointSaveRequest, previous: ExternalWriterStoredCheckpointRow | undefined): ExternalWriterObservationCheckpointRecord { + const revision = (previous?.revision ?? 0) + 1; + db.prepare(`INSERT INTO external_writer_observation_checkpoints (deployment_id, observer_id, revision, schema_version, checkpoint_json) VALUES (?, ?, ?, ?, ?) ON CONFLICT(deployment_id, observer_id) DO UPDATE SET revision = excluded.revision, schema_version = excluded.schema_version, checkpoint_json = excluded.checkpoint_json`).run(request.deploymentId, request.observerId, revision, EXTERNAL_WRITER_OBSERVATION_SCHEMA_VERSION, JSON.stringify(request.checkpoint)); + return { revision, checkpoint: request.checkpoint }; +} + +export function hasHandoffEligibility(db: SqliteDatabase, identity: ExternalWriterObservationCheckpointIdentity): boolean { + return Boolean(db.prepare(`SELECT 1 FROM external_writer_observation_handoff_eligibility WHERE deployment_id = ? AND observer_id = ?`).get(identity.deploymentId, identity.observerId)); +} + +export function readCheckpoint(db: SqliteDatabase, identity: ExternalWriterObservationCheckpointIdentity): ExternalWriterStoredCheckpointRow | undefined { + return db.prepare(`SELECT revision, checkpoint_json FROM external_writer_observation_checkpoints WHERE deployment_id = ? AND observer_id = ?`).get(identity.deploymentId, identity.observerId) as ExternalWriterStoredCheckpointRow | undefined; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationStorageOps.ts new file mode 100644 index 0000000000..e9974f3c25 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterObservationStorageOps.ts @@ -0,0 +1,647 @@ +import { createHash } from 'node:crypto'; + +import { parseTeamId } from '@shared/contracts/hosted'; + +import { parseTeamIdentityChecksum } from '../../../contracts/teamIdentityStorageContracts'; + +import type { ExternalWriterObservationCheckpointRecord } from '../../../contracts/externalWriterObservationStorageContracts'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +import { + assertNonRegressing, + checkpointFromRow, + checkpointTeamIds, + compareExternalWriterText, + exactObject, + parseExternalWriterObservationCheckpoint, + parseExternalWriterObservationIdentity, + parseSaveRequest, +} from './externalWriterObservationCheckpointSupport'; +import { + readConsumeReceipt, + replaceConsumeReceipt, +} from './externalWriterObservationConsumeReceiptStore'; +import { + assertNoRetiredTeamReappears, + hasHandoffEligibility, + insertRetiredFloor, + readCheckpoint, + verifyTombstoneProof, + writeCheckpoint, +} from './externalWriterObservationSqlSupport'; + +export { parseExternalWriterObservationCheckpoint } from './externalWriterObservationCheckpointSupport'; + +const MAX_HANDOFF_JSON_BYTES = 64 * 1024 * 1024; +const MAX_HANDOFF_REGISTRATIONS = 100_000; +const MAX_HANDOFF_RETIREMENTS = 1_024; +const MAX_HANDOFF_KEY_BYTES = 1_024; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const HANDOFF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const CONSUME_ATTEMPT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const HANDOFF_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,1023}$/; +function exactDataObject( + value: unknown, + keys: readonly string[], + code: string +): Record { + const record = exactObject(value, keys, code); + const prototype = Object.getPrototypeOf(record) as unknown; + if ( + (prototype !== Object.prototype && prototype !== null) || + Reflect.ownKeys(record).length !== keys.length + ) { + throw new TypeError(code); + } + const descriptors = Object.getOwnPropertyDescriptors(record); + if ( + keys.some((key) => { + const descriptor = descriptors[key]; + return !descriptor || !Object.hasOwn(descriptor, 'value') || descriptor.get || descriptor.set; + }) + ) { + throw new TypeError(code); + } + return record; +} + +function exactDenseArray(value: unknown, maxLength: number, code: string): readonly unknown[] { + if ( + !Array.isArray(value) || + value.length > maxLength || + Reflect.ownKeys(value).length !== value.length + 1 + ) { + throw new TypeError(code); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + for (let index = 0; index < value.length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor || !Object.hasOwn(descriptor, 'value') || descriptor.get || descriptor.set) { + throw new TypeError(code); + } + } + return value; +} + +function boundedHandoffKey(value: unknown, code: string): string { + if ( + typeof value !== 'string' || + value.length < 1 || + Buffer.byteLength(value, 'utf8') > MAX_HANDOFF_KEY_BYTES || + !HANDOFF_KEY_PATTERN.test(value) + ) { + throw new TypeError(code); + } + return value; +} + +function canonicalJson(value: unknown, code: string): string { + const json = JSON.stringify(value); + if (Buffer.byteLength(json, 'utf8') > MAX_HANDOFF_JSON_BYTES) throw new TypeError(code); + return json; +} + +function canonicalSha256(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +export class ExternalWriterObservationStorageOps { + constructor(private readonly getDb: () => SqliteDatabase) {} + + load(payload: unknown): ExternalWriterObservationCheckpointRecord | null { + const identity = parseExternalWriterObservationIdentity(payload); + const row = readCheckpoint(this.getDb(), identity); + return row ? { revision: row.revision, checkpoint: checkpointFromRow(row) } : null; + } + + save(payload: unknown): ExternalWriterObservationCheckpointRecord { + const request = parseSaveRequest(payload); + return this.getDb().transaction(() => { + const previous = readCheckpoint(this.getDb(), request); + if ((previous?.revision ?? null) !== request.expectedRevision) { + throw new Error('external-writer-observation-checkpoint-conflict'); + } + assertNoRetiredTeamReappears(this.getDb(), request, request.checkpoint); + if (previous) assertNonRegressing(checkpointFromRow(previous), request.checkpoint); + if (hasHandoffEligibility(this.getDb(), request)) { + throw new Error('external-writer-observation-handoff-eligibility-active'); + } + return writeCheckpoint(this.getDb(), request, previous); + })(); + } + + saveCleanHandoff(payload: unknown): ExternalWriterObservationCheckpointRecord { + const record = exactObject( + payload, + ['deploymentId', 'observerId', 'expectedRevision', 'checkpoint', 'plan'], + 'external-writer-observation-handoff-save-invalid' + ); + const request = parseSaveRequest({ + deploymentId: record.deploymentId, + observerId: record.observerId, + expectedRevision: record.expectedRevision, + checkpoint: record.checkpoint, + }); + const plan = exactDataObject( + record.plan, + [ + 'handoffId', + 'oldCatalogToken', + 'nextCatalogToken', + 'retainedRegistrations', + 'retirementProofs', + 'createdAt', + ], + 'external-writer-observation-handoff-plan-invalid' + ); + const retirementInputs = exactDenseArray( + plan.retirementProofs, + MAX_HANDOFF_RETIREMENTS, + 'external-writer-observation-handoff-plan-invalid' + ); + const retirementTeamIds = new Set(); + const retirementProofs = retirementInputs.map((proof) => { + const parsed = exactDataObject( + proof, + ['teamId', 'identityChecksum', 'tombstonedAt'], + 'external-writer-observation-handoff-plan-invalid' + ); + if ( + typeof parsed.tombstonedAt !== 'string' || + !Number.isFinite(Date.parse(parsed.tombstonedAt)) || + new Date(parsed.tombstonedAt).toISOString() !== parsed.tombstonedAt + ) { + throw new TypeError('external-writer-observation-handoff-plan-invalid'); + } + const teamId = parseTeamId(parsed.teamId); + if (retirementTeamIds.has(teamId)) { + throw new TypeError('external-writer-observation-handoff-plan-invalid'); + } + retirementTeamIds.add(teamId); + return { + teamId, + identityChecksum: parseTeamIdentityChecksum(parsed.identityChecksum), + tombstonedAt: parsed.tombstonedAt, + }; + }); + const hotTeamIds = checkpointTeamIds(request.checkpoint); + if (retirementProofs.some((proof) => !hotTeamIds.has(proof.teamId))) { + throw new Error('external-writer-observation-handoff-retirement-not-hot'); + } + for (const token of [plan.oldCatalogToken, plan.nextCatalogToken]) { + if (typeof token !== 'string' || !SHA256_PATTERN.test(token)) { + throw new TypeError('external-writer-observation-handoff-plan-invalid'); + } + } + if (typeof plan.handoffId !== 'string' || !HANDOFF_ID_PATTERN.test(plan.handoffId)) { + throw new TypeError('external-writer-observation-handoff-plan-invalid'); + } + const retainedInputs = exactDenseArray( + plan.retainedRegistrations, + MAX_HANDOFF_REGISTRATIONS, + 'external-writer-observation-handoff-plan-invalid' + ); + const retainedKeys = new Set(); + const retained = retainedInputs + .map((value) => { + const entry = exactDataObject( + value, + ['teamId', 'featureKey', 'fileKey'], + 'external-writer-observation-handoff-plan-invalid' + ); + const parsed = { + teamId: parseTeamId(entry.teamId), + featureKey: boundedHandoffKey( + entry.featureKey, + 'external-writer-observation-handoff-plan-invalid' + ), + fileKey: boundedHandoffKey( + entry.fileKey, + 'external-writer-observation-handoff-plan-invalid' + ), + }; + const key = `${parsed.teamId}\0${parsed.featureKey}\0${parsed.fileKey}`; + if (retainedKeys.has(key)) { + throw new TypeError('external-writer-observation-handoff-plan-invalid'); + } + retainedKeys.add(key); + return parsed; + }) + .sort( + (a, b) => + compareExternalWriterText(a.teamId, b.teamId) || + compareExternalWriterText(a.featureKey, b.featureKey) || + compareExternalWriterText(a.fileKey, b.fileKey) + ); + const retainedJson = canonicalJson( + retained, + 'external-writer-observation-handoff-plan-invalid' + ); + if (retained.some((entry) => retirementTeamIds.has(entry.teamId))) { + throw new Error('external-writer-observation-handoff-not-clean'); + } + const removed = request.checkpoint.observedFiles + .map((entry) => ({ + teamId: entry.scope.teamId, + featureKey: entry.scope.featureKey, + fileKey: entry.fileKey, + })) + .filter( + (entry) => !retainedKeys.has(`${entry.teamId}\0${entry.featureKey}\0${entry.fileKey}`) + ) + .sort( + (a, b) => + compareExternalWriterText(a.teamId, b.teamId) || + compareExternalWriterText(a.featureKey, b.featureKey) || + compareExternalWriterText(a.fileKey, b.fileKey) + ); + const removedJson = canonicalJson(removed, 'external-writer-observation-handoff-plan-invalid'); + const nextRegistrationDigest = canonicalSha256(retained); + const requestedCandidateCoordinates = retirementProofs + .map((proof) => ({ + teamId: proof.teamId, + identityChecksum: proof.identityChecksum, + tombstonedAt: proof.tombstonedAt, + epoch: + request.checkpoint.fileWriterEpochs.find((entry) => entry.teamId === proof.teamId) + ?.epoch ?? null, + lastObservationSequence: + request.checkpoint.teamObservationWatermarks.find( + (entry) => entry.teamId === proof.teamId + )?.lastObservationSequence ?? null, + observationWatermark: + request.checkpoint.teamObservationWatermarks.find( + (entry) => entry.teamId === proof.teamId + )?.observationWatermark ?? null, + })) + .sort((a, b) => compareExternalWriterText(a.teamId, b.teamId)); + const requestedCandidatesJson = canonicalJson( + requestedCandidateCoordinates, + 'external-writer-observation-handoff-plan-invalid' + ); + const everySelfWriteRetained = request.checkpoint.selfWriteIntents.every( + (intent) => + !retirementTeamIds.has(intent.scope.teamId) && + retainedKeys.has(`${intent.scope.teamId}\0${intent.scope.featureKey}\0${intent.fileKey}`) + ); + if ( + typeof plan.createdAt !== 'string' || + !Number.isFinite(Date.parse(plan.createdAt)) || + new Date(plan.createdAt).toISOString() !== plan.createdAt || + request.checkpoint.lastObservationSequence !== request.checkpoint.observationWatermark || + request.checkpoint.pendingObservations.length > 0 || + request.checkpoint.dirtyScopes.length > 0 || + !everySelfWriteRetained + ) { + throw new Error('external-writer-observation-handoff-not-clean'); + } + return this.getDb().transaction(() => { + const previous = readCheckpoint(this.getDb(), request); + const revision = + (previous?.revision ?? null) === request.expectedRevision + ? (previous?.revision ?? 0) + 1 + : null; + if (revision === null) { + const existing = this.getDb() + .prepare( + `SELECT expected_checkpoint_revision, handoff_id, protocol_version, checkpoint_sha256, + old_catalog_token, target_catalog_token, next_registration_digest, + candidate_digest, candidates_json, retained_registrations_json, + removed_registrations_json, created_at + FROM external_writer_observation_handoff_eligibility + WHERE deployment_id = ? AND observer_id = ?` + ) + .get(request.deploymentId, request.observerId) as Record | undefined; + const current = previous ? checkpointFromRow(previous) : null; + if ( + existing && + request.expectedRevision !== null && + previous?.revision === request.expectedRevision + 1 && + existing.expected_checkpoint_revision === previous.revision && + existing.protocol_version === 1 && + existing.handoff_id === plan.handoffId && + current && + existing.checkpoint_sha256 === canonicalSha256(current) && + canonicalSha256(request.checkpoint) === existing.checkpoint_sha256 && + existing.old_catalog_token === plan.oldCatalogToken && + existing.target_catalog_token === plan.nextCatalogToken && + existing.next_registration_digest === nextRegistrationDigest && + existing.retained_registrations_json === retainedJson && + existing.removed_registrations_json === removedJson && + existing.candidates_json === requestedCandidatesJson && + existing.candidate_digest === canonicalSha256(requestedCandidateCoordinates) && + existing.created_at === plan.createdAt + ) { + return { revision: previous.revision, checkpoint: current }; + } + throw new Error('external-writer-observation-checkpoint-conflict'); + } + if (previous) assertNonRegressing(checkpointFromRow(previous), request.checkpoint); + assertNoRetiredTeamReappears(this.getDb(), request, request.checkpoint); + for (const proof of retirementProofs) verifyTombstoneProof(this.getDb(), proof); + const candidateRecords = retirementProofs + .map((proof) => { + const epoch = + request.checkpoint.fileWriterEpochs.find((entry) => entry.teamId === proof.teamId) + ?.epoch ?? null; + const watermark = request.checkpoint.teamObservationWatermarks.find( + (entry) => entry.teamId === proof.teamId + ); + return { + teamId: proof.teamId, + identityChecksum: proof.identityChecksum, + tombstonedAt: proof.tombstonedAt, + epoch, + lastObservationSequence: watermark?.lastObservationSequence ?? null, + observationWatermark: watermark?.observationWatermark ?? null, + }; + }) + .sort((a, b) => compareExternalWriterText(a.teamId, b.teamId)); + const candidatesJson = canonicalJson( + candidateRecords, + 'external-writer-observation-handoff-plan-invalid' + ); + const checkpointSha256 = canonicalSha256(request.checkpoint); + const candidateDigest = canonicalSha256(candidateRecords); + const existing = this.getDb() + .prepare( + `SELECT checkpoint_sha256, old_catalog_token, target_catalog_token, + handoff_id, next_registration_digest, candidate_digest, candidates_json, created_at + FROM external_writer_observation_handoff_eligibility + WHERE deployment_id = ? AND observer_id = ?` + ) + .get(request.deploymentId, request.observerId) as Record | undefined; + if (existing) throw new Error('external-writer-observation-handoff-conflict'); + const result = writeCheckpoint(this.getDb(), request, previous); + this.getDb() + .prepare( + `INSERT INTO external_writer_observation_handoff_eligibility ( + deployment_id, observer_id, expected_checkpoint_revision, handoff_id, protocol_version, + checkpoint_sha256, captured_sequence, persisted_watermark, + old_catalog_token, target_catalog_token, next_registration_digest, + candidate_digest, candidates_json, retained_registrations_json, + removed_registrations_json, created_at + ) VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + request.deploymentId, + request.observerId, + result.revision, + plan.handoffId, + checkpointSha256, + request.checkpoint.lastObservationSequence, + request.checkpoint.observationWatermark, + plan.oldCatalogToken, + plan.nextCatalogToken, + nextRegistrationDigest, + candidateDigest, + candidatesJson, + retainedJson, + removedJson, + plan.createdAt + ); + return result; + })(); + } + + consumeCleanHandoff(payload: unknown): ExternalWriterObservationCheckpointRecord | null { + const record = exactObject( + payload, + ['deploymentId', 'observerId', 'consumeAttemptId'], + 'external-writer-observation-handoff-consume-invalid' + ); + const identity = parseExternalWriterObservationIdentity({ + deploymentId: record.deploymentId, + observerId: record.observerId, + }); + if ( + typeof record.consumeAttemptId !== 'string' || + !CONSUME_ATTEMPT_ID_PATTERN.test(record.consumeAttemptId) + ) { + throw new TypeError('external-writer-observation-consume-attempt-id-invalid'); + } + const consumeAttemptId = record.consumeAttemptId; + return this.getDb().transaction(() => { + const receipt = readConsumeReceipt(this.getDb(), identity, consumeAttemptId); + if (receipt) return receipt; + const marker = this.getDb() + .prepare( + `SELECT expected_checkpoint_revision, protocol_version, checkpoint_sha256, + captured_sequence, persisted_watermark, next_registration_digest, + candidate_digest, candidates_json, retained_registrations_json, + removed_registrations_json + FROM external_writer_observation_handoff_eligibility + WHERE deployment_id = ? AND observer_id = ?` + ) + .get(identity.deploymentId, identity.observerId) as + | { + expected_checkpoint_revision: number; + protocol_version: number; + checkpoint_sha256: string; + captured_sequence: number; + persisted_watermark: number; + next_registration_digest: string; + candidate_digest: string; + candidates_json: string; + retained_registrations_json: string; + removed_registrations_json: string; + } + | undefined; + if (!marker) return null; + const previousRow = readCheckpoint(this.getDb(), identity); + if (!previousRow || previousRow.revision !== marker.expected_checkpoint_revision) + throw new Error('external-writer-observation-checkpoint-conflict'); + const previous = checkpointFromRow(previousRow); + if ( + marker.protocol_version !== 1 || + canonicalSha256(previous) !== marker.checkpoint_sha256 || + marker.captured_sequence !== previous.lastObservationSequence || + marker.persisted_watermark !== previous.observationWatermark || + marker.captured_sequence !== marker.persisted_watermark + ) { + throw new Error('external-writer-observation-handoff-checkpoint-mismatch'); + } + const parseStoredRegistrations = (json: string) => { + if (Buffer.byteLength(json, 'utf8') > MAX_HANDOFF_JSON_BYTES) { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + let parsed: unknown; + try { + parsed = JSON.parse(json) as unknown; + } catch { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + const inputs = exactDenseArray( + parsed, + MAX_HANDOFF_REGISTRATIONS, + 'external-writer-observation-handoff-marker-invalid' + ); + const seen = new Set(); + const registrations = inputs.map((value) => { + const entry = exactDataObject( + value, + ['teamId', 'featureKey', 'fileKey'], + 'external-writer-observation-handoff-marker-invalid' + ); + const registration = { + teamId: parseTeamId(entry.teamId), + featureKey: boundedHandoffKey( + entry.featureKey, + 'external-writer-observation-handoff-marker-invalid' + ), + fileKey: boundedHandoffKey( + entry.fileKey, + 'external-writer-observation-handoff-marker-invalid' + ), + }; + const key = `${registration.teamId}\0${registration.featureKey}\0${registration.fileKey}`; + if (seen.has(key)) throw new Error('external-writer-observation-handoff-marker-invalid'); + seen.add(key); + return registration; + }); + if ( + canonicalJson(registrations, 'external-writer-observation-handoff-marker-invalid') !== + json + ) { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + return registrations; + }; + const retained = parseStoredRegistrations(marker.retained_registrations_json); + const recordedRemoved = parseStoredRegistrations(marker.removed_registrations_json); + if (canonicalSha256(retained) !== marker.next_registration_digest) { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + let candidateValue: unknown; + try { + candidateValue = JSON.parse(marker.candidates_json) as unknown; + } catch { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + const candidateInputs = exactDenseArray( + candidateValue, + MAX_HANDOFF_RETIREMENTS, + 'external-writer-observation-handoff-marker-invalid' + ); + const candidateTeams = new Set(); + const candidates = candidateInputs.map((value) => { + const entry = exactDataObject( + value, + [ + 'teamId', + 'identityChecksum', + 'tombstonedAt', + 'epoch', + 'lastObservationSequence', + 'observationWatermark', + ], + 'external-writer-observation-handoff-marker-invalid' + ); + const teamId = parseTeamId(entry.teamId); + const epoch = entry.epoch; + const last = entry.lastObservationSequence; + const watermark = entry.observationWatermark; + const priorEpoch = + previous.fileWriterEpochs.find((entry) => entry.teamId === teamId)?.epoch ?? null; + const priorWatermark = previous.teamObservationWatermarks.find( + (entry) => entry.teamId === teamId + ); + if ( + candidateTeams.has(teamId) || + (epoch !== null && (!Number.isSafeInteger(epoch) || (epoch as number) < 1)) || + (last === null) !== (watermark === null) || + (last !== null && + (!Number.isSafeInteger(last) || + (last as number) < 0 || + !Number.isSafeInteger(watermark) || + (watermark as number) < 0 || + (watermark as number) > (last as number))) || + epoch !== priorEpoch || + last !== (priorWatermark?.lastObservationSequence ?? null) || + watermark !== (priorWatermark?.observationWatermark ?? null) + ) { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + candidateTeams.add(teamId); + return { + teamId, + identityChecksum: parseTeamIdentityChecksum(entry.identityChecksum), + tombstonedAt: + typeof entry.tombstonedAt === 'string' && + Number.isFinite(Date.parse(entry.tombstonedAt)) && + new Date(entry.tombstonedAt).toISOString() === entry.tombstonedAt + ? entry.tombstonedAt + : (() => { + throw new Error('external-writer-observation-handoff-marker-invalid'); + })(), + epoch: epoch as number | null, + lastObservationSequence: last as number | null, + observationWatermark: watermark as number | null, + }; + }); + if ( + canonicalJson(candidates, 'external-writer-observation-handoff-marker-invalid') !== + marker.candidates_json || + canonicalSha256(candidates) !== marker.candidate_digest + ) { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + for (const proof of candidates) verifyTombstoneProof(this.getDb(), proof); + const removed = new Set(candidates.map((entry) => entry.teamId)); + const retainedKeys = new Set( + retained.map((entry) => `${entry.teamId}\0${entry.featureKey}\0${entry.fileKey}`) + ); + const removedKeys = new Set( + recordedRemoved.map((entry) => `${entry.teamId}\0${entry.featureKey}\0${entry.fileKey}`) + ); + const expectedRemoved = previous.observedFiles + .map((entry) => ({ + teamId: entry.scope.teamId, + featureKey: entry.scope.featureKey, + fileKey: entry.fileKey, + })) + .filter( + (entry) => !retainedKeys.has(`${entry.teamId}\0${entry.featureKey}\0${entry.fileKey}`) + ) + .sort( + (a, b) => + compareExternalWriterText(a.teamId, b.teamId) || + compareExternalWriterText(a.featureKey, b.featureKey) || + compareExternalWriterText(a.fileKey, b.fileKey) + ); + if ( + canonicalJson(expectedRemoved, 'external-writer-observation-handoff-marker-invalid') !== + marker.removed_registrations_json + ) { + throw new Error('external-writer-observation-handoff-marker-invalid'); + } + const next = parseExternalWriterObservationCheckpoint({ + ...previous, + fileWriterEpochs: previous.fileWriterEpochs.filter((entry) => !removed.has(entry.teamId)), + teamObservationWatermarks: previous.teamObservationWatermarks.filter( + (entry) => !removed.has(entry.teamId) + ), + observedFiles: previous.observedFiles.filter((entry) => { + const key = `${entry.scope.teamId}\0${entry.scope.featureKey}\0${entry.fileKey}`; + if (removedKeys.has(key)) return false; + return retainedKeys.has(key); + }), + }); + for (const proof of candidates) + insertRetiredFloor(this.getDb(), identity, proof as never, previous); + const result = writeCheckpoint( + this.getDb(), + { ...identity, expectedRevision: previousRow.revision, checkpoint: next }, + previousRow + ); + this.getDb() + .prepare( + `DELETE FROM external_writer_observation_handoff_eligibility WHERE deployment_id = ? AND observer_id = ?` + ) + .run(identity.deploymentId, identity.observerId); + replaceConsumeReceipt(this.getDb(), identity, consumeAttemptId, result); + return result; + })(); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterReconciliationMigration.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterReconciliationMigration.ts new file mode 100644 index 0000000000..0c9c76e936 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterReconciliationMigration.ts @@ -0,0 +1,24 @@ +export const EXTERNAL_WRITER_RECONCILIATION_MIGRATION_STATEMENTS = Object.freeze([ + `CREATE TABLE IF NOT EXISTS external_writer_reconciliation_receipts ( + deployment_id TEXT NOT NULL, + reconciliation_id TEXT NOT NULL, + input_sha256 TEXT NOT NULL CHECK (length(input_sha256) = 64 AND input_sha256 NOT GLOB '*[^0-9a-f]*'), + event_id TEXT NOT NULL UNIQUE, + source_generation INTEGER NOT NULL CHECK (source_generation >= 0), + feature_revision INTEGER NOT NULL CHECK (feature_revision >= 0), + event_body_json TEXT NOT NULL CHECK (json_valid(event_body_json)), + committed_at TEXT NOT NULL, + PRIMARY KEY (deployment_id, reconciliation_id) + )`, + `CREATE TRIGGER IF NOT EXISTS external_writer_reconciliation_no_update + BEFORE UPDATE ON external_writer_reconciliation_receipts + BEGIN SELECT RAISE(ABORT, 'external-writer-reconciliation-receipt-immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS external_writer_reconciliation_no_delete + BEFORE DELETE ON external_writer_reconciliation_receipts + BEGIN SELECT RAISE(ABORT, 'external-writer-reconciliation-receipt-immutable'); END`, +]); + +export const EXTERNAL_WRITER_RECONCILIATION_MIGRATION = Object.freeze({ + version: 27, + statements: [...EXTERNAL_WRITER_RECONCILIATION_MIGRATION_STATEMENTS], +}); diff --git a/src/features/internal-storage/main/infrastructure/worker/externalWriterReconciliationStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/externalWriterReconciliationStorageOps.ts new file mode 100644 index 0000000000..f5cbd913dc --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/externalWriterReconciliationStorageOps.ts @@ -0,0 +1,119 @@ +import { + appendEventJournalRow, + canonicalCoordinationStorageJson, + ensureEventMetadata, +} from './coordinationDurabilityState'; + +import type { + ExternalWriterReconciliationCommitRequest, + ExternalWriterReconciliationReceipt, +} from '../../../contracts/externalWriterReconciliationStorageContracts'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +interface ReceiptRow { + readonly reconciliation_id: string; + readonly input_sha256: string; + readonly event_id: string; + readonly source_generation: number; + readonly feature_revision: number; + readonly event_body_json: string; + readonly committed_at: string; +} + +const receipt = (row: ReceiptRow): ExternalWriterReconciliationReceipt => + Object.freeze({ + reconciliationId: row.reconciliation_id, + inputSha256: row.input_sha256, + eventId: row.event_id, + sourceGeneration: row.source_generation, + featureRevision: row.feature_revision, + eventBodyJson: row.event_body_json, + committedAt: row.committed_at, + }); + +export class ExternalWriterReconciliationStorageOps { + constructor(private readonly getDb: () => SqliteDatabase) {} + + get(input: { + readonly deploymentId: string; + readonly reconciliationId: string; + }): ExternalWriterReconciliationReceipt | null { + const row = this.getDb() + .prepare( + `SELECT reconciliation_id, input_sha256, event_id, source_generation, + feature_revision, event_body_json, committed_at + FROM external_writer_reconciliation_receipts + WHERE deployment_id = ? AND reconciliation_id = ?` + ) + .get(input.deploymentId, input.reconciliationId) as ReceiptRow | undefined; + return row ? receipt(row) : null; + } + + commit(input: ExternalWriterReconciliationCommitRequest): { + readonly outcome: 'committed' | 'idempotent_replay' | 'input_conflict'; + readonly receipt: ExternalWriterReconciliationReceipt | null; + } { + const db = this.getDb(); + return db + .transaction(() => { + const existing = this.get({ + deploymentId: input.deploymentId, + reconciliationId: input.receipt.reconciliationId, + }); + if (existing) { + return Object.freeze({ + outcome: + existing.inputSha256 === input.receipt.inputSha256 + ? ('idempotent_replay' as const) + : ('input_conflict' as const), + receipt: existing.inputSha256 === input.receipt.inputSha256 ? existing : null, + }); + } + const bodyJson = canonicalCoordinationStorageJson(input.event); + if ( + input.receipt.eventId !== input.event.eventId || + (input.receipt.eventBodyJson !== '' && input.receipt.eventBodyJson !== bodyJson) || + input.receipt.committedAt !== input.event.emittedAt + ) { + throw new Error('external-writer-reconciliation-event-binding-invalid'); + } + const metadata = ensureEventMetadata( + db, + input.deploymentId, + undefined, + input.receipt.committedAt + ); + appendEventJournalRow( + db, + input.deploymentId, + metadata.event_epoch, + input.event, + bodyJson, + null, + input.receipt.committedAt + ); + db.prepare( + `INSERT INTO external_writer_reconciliation_receipts ( + deployment_id, reconciliation_id, input_sha256, event_id, source_generation, + feature_revision, event_body_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + input.deploymentId, + input.receipt.reconciliationId, + input.receipt.inputSha256, + input.receipt.eventId, + input.receipt.sourceGeneration, + input.receipt.featureRevision, + bodyJson, + input.receipt.committedAt + ); + return Object.freeze({ + outcome: 'committed' as const, + receipt: Object.freeze({ ...input.receipt, eventBodyJson: bodyJson }), + }); + }) + .immediate(); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedAuthStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/hostedAuthStorageOps.ts new file mode 100644 index 0000000000..ce64227b2e --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedAuthStorageOps.ts @@ -0,0 +1,668 @@ +import { HostedAuthModeStorageOps, HostedWorkspaceStorageOps } from './internalStorageBackupTables'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +interface HostedAuthOperationInput { + readonly operation: string; + readonly payload: Record; +} + +function record(value: unknown, code: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(code); + } + return value as Record; +} + +function text(value: unknown, code: string, maximum = 16_384): string { + if (typeof value !== 'string' || value.length === 0 || value.length > maximum) { + throw new TypeError(code); + } + return value; +} + +function integer(value: unknown, code: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) throw new TypeError(code); + return Number(value); +} + +function nullableText(value: unknown, code: string): string | null { + return value === null ? null : text(value, code); +} + +export class HostedAuthStorageOps { + private readonly modeOps: HostedAuthModeStorageOps; + private readonly workspaceOps: HostedWorkspaceStorageOps; + + constructor(private readonly database: () => SqliteDatabase) { + this.modeOps = new HostedAuthModeStorageOps(database); + this.workspaceOps = new HostedWorkspaceStorageOps(database); + } + + handle(inputValue: unknown): unknown { + const input = record( + inputValue, + 'hosted-auth-storage-input-invalid' + ) as unknown as HostedAuthOperationInput; + const operation = text(input.operation, 'hosted-auth-storage-operation-invalid', 128); + const payload = record(input.payload, 'hosted-auth-storage-payload-invalid'); + if (operation === 'configuration.read') return this.modeOps.readConfiguration(); + switch (operation) { + case 'configuration.claimMode': + return this.claimMode(payload); + case 'configuration.resetMode': + return this.modeOps.resetMode(payload); + case 'configuration.markSecretsRotated': + return this.modeOps.markSecretsRotated(payload); + case 'authority.load': + return this.loadAuthority(); + case 'authority.initialize': + return this.initializeAuthority(payload); + case 'authority.compareAndSwap': + return this.compareAndSwapAuthority(payload); + case 'oidcAttempt.create': + return this.createOidcAttempt(payload); + case 'oidcAttempt.consume': + return this.consumeOidcAttempt(payload); + case 'identity.bind': + return this.bindIdentity(payload); + case 'personal.ensureOwner': + return this.ensurePersonalOwner(payload); + case 'session.create': + return this.createSession(payload); + case 'session.findByHash': + return this.findSessionByHash(payload); + case 'session.touch': + return this.touchSession(payload); + case 'session.revoke': + return this.revokeSession(payload); + case 'backchannel.apply': + return this.applyBackchannelLogout(payload); + case 'user.get': + return this.getUser(payload); + case 'user.list': + return this.listUsers(); + case 'user.setStatus': + return this.setUserStatus(payload); + case 'role.getLocal': + return this.getLocalRole(payload); + case 'role.setLocal': + return this.setLocalRole(payload); + case 'role.clearLocal': + return this.clearLocalRole(payload); + case 'workspace.isRegistered': + return this.workspaceOps.isWorkspaceRegistered(payload); + case 'workspace.seed': + return this.workspaceOps.seedWorkspace(payload); + case 'workspace.list': + return this.workspaceOps.listWorkspaces(); + case 'workspace.register': + return this.workspaceOps.registerWorkspace(payload); + case 'workspace.disable': + return this.workspaceOps.disableWorkspace(payload); + case 'workspace.grant.list': + return this.workspaceOps.listWorkspaceGrants(payload); + case 'workspace.grant.set': + return this.workspaceOps.setWorkspaceGrant(payload); + case 'workspace.grant.revoke': + return this.workspaceOps.revokeWorkspaceGrant(payload); + case 'audit.append': + return this.appendAudit(payload); + default: + throw new Error('hosted-auth-storage-operation-unknown'); + } + } + + private claimMode(payload: Record): boolean { + const mode = text(payload.mode, 'hosted-auth-mode-invalid'); + if (mode !== 'personal' && mode !== 'oidc') throw new TypeError('hosted-auth-mode-invalid'); + return this.database().transaction(() => { + this.database() + .prepare( + `INSERT OR IGNORE INTO hosted_auth_configuration (singleton, auth_mode, configured_at) + VALUES (1, ?, ?)` + ) + .run(mode, integer(payload.configuredAt, 'hosted-auth-configured-at-invalid')); + const row = this.database() + .prepare(`SELECT auth_mode AS authMode FROM hosted_auth_configuration WHERE singleton = 1`) + .get() as { authMode?: unknown } | undefined; + return row?.authMode === mode; + })(); + } + + private loadAuthority(): unknown { + return ( + this.database() + .prepare( + `SELECT state_json AS stateJson, revision, rollback_fence_revision AS rollbackFenceRevision + FROM hosted_access_authority WHERE singleton = 1` + ) + .get() ?? null + ); + } + + private initializeAuthority(payload: Record): string { + const stateJson = text(payload.stateJson, 'hosted-auth-authority-state-invalid', 4_000_000); + const revision = integer(payload.revision, 'hosted-auth-authority-revision-invalid'); + const parsedState = record( + JSON.parse(stateJson) as unknown, + 'hosted-auth-authority-state-invalid' + ); + if (integer(parsedState.revision, 'hosted-auth-authority-revision-invalid') !== revision) { + throw new TypeError('hosted-auth-authority-revision-mismatch'); + } + const result = this.database() + .prepare( + `INSERT OR IGNORE INTO hosted_access_authority + (singleton, state_json, revision, rollback_fence_revision) + VALUES (1, ?, ?, ?)` + ) + .run(stateJson, revision, revision); + return result.changes === 1 ? 'committed' : 'conflict'; + } + + private compareAndSwapAuthority(payload: Record): string { + const expectedRevision = integer( + payload.expectedRevision, + 'hosted-auth-authority-expected-revision-invalid' + ); + const expectedFence = integer( + payload.expectedRollbackFenceRevision, + 'hosted-auth-authority-fence-invalid' + ); + const nextRevision = integer( + payload.nextRollbackFenceRevision, + 'hosted-auth-authority-next-fence-invalid' + ); + if (nextRevision !== expectedRevision + 1 || nextRevision !== expectedFence + 1) { + throw new TypeError('hosted-auth-authority-cas-sequence-invalid'); + } + const stateJson = text(payload.stateJson, 'hosted-auth-authority-state-invalid', 4_000_000); + const parsedState = record( + JSON.parse(stateJson) as unknown, + 'hosted-auth-authority-state-invalid' + ); + if (integer(parsedState.revision, 'hosted-auth-authority-revision-invalid') !== nextRevision) { + throw new TypeError('hosted-auth-authority-revision-mismatch'); + } + const result = this.database() + .prepare( + `UPDATE hosted_access_authority + SET state_json = ?, revision = ?, rollback_fence_revision = ? + WHERE singleton = 1 AND revision = ? AND rollback_fence_revision = ?` + ) + .run(stateJson, nextRevision, nextRevision, expectedRevision, expectedFence); + return result.changes === 1 ? 'committed' : 'conflict'; + } + + private createOidcAttempt(payload: Record): string { + const createdAt = integer(payload.createdAt, 'oidc-created-at-invalid'); + const expiresAt = integer(payload.expiresAt, 'oidc-expires-at-invalid'); + if (expiresAt <= createdAt) throw new TypeError('oidc-expires-at-invalid'); + return this.database().transaction(() => { + this.database() + .prepare(`DELETE FROM oidc_login_attempts WHERE expires_at <= ?`) + .run(createdAt); + const retainedAttempts = this.database() + .prepare( + `SELECT COUNT(*) AS count FROM oidc_login_attempts + WHERE expires_at > ?` + ) + .get(createdAt) as { count: number }; + if (retainedAttempts.count >= 512) return 'capacity'; + const result = this.database() + .prepare( + `INSERT OR IGNORE INTO oidc_login_attempts + (attempt_id, provider_id, state_hash, nonce, pkce_verifier_ciphertext, + return_to, created_at, expires_at, consumed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)` + ) + .run( + text(payload.attemptId, 'oidc-attempt-id-invalid'), + text(payload.providerId, 'oidc-provider-id-invalid'), + text(payload.stateHash, 'oidc-state-hash-invalid'), + text(payload.nonce, 'oidc-nonce-invalid'), + text(payload.pkceVerifierCiphertext, 'oidc-pkce-invalid'), + text(payload.returnTo, 'oidc-return-to-invalid', 2048), + createdAt, + expiresAt + ); + return result.changes === 1 ? 'created' : 'conflict'; + })(); + } + + private consumeOidcAttempt(payload: Record): unknown { + const attemptId = text(payload.attemptId, 'oidc-attempt-id-invalid'); + const providerId = text(payload.providerId, 'oidc-provider-id-invalid'); + const stateHash = text(payload.stateHash, 'oidc-state-hash-invalid'); + const now = integer(payload.now, 'oidc-consumed-at-invalid'); + return this.database().transaction(() => { + const result = this.database() + .prepare( + `UPDATE oidc_login_attempts SET consumed_at = ? + WHERE attempt_id = ? AND provider_id = ? AND state_hash = ? + AND consumed_at IS NULL AND expires_at > ?` + ) + .run(now, attemptId, providerId, stateHash, now); + if (result.changes !== 1) return null; + return this.database() + .prepare( + `SELECT attempt_id AS attemptId, provider_id AS providerId, state_hash AS stateHash, + nonce, pkce_verifier_ciphertext AS pkceVerifierCiphertext, + return_to AS returnTo, created_at AS createdAt, expires_at AS expiresAt, + consumed_at AS consumedAt + FROM oidc_login_attempts WHERE attempt_id = ?` + ) + .get(attemptId); + })(); + } + + private bindIdentity(payload: Record): unknown { + const identity = record(payload.identity, 'external-identity-invalid'); + const proposed = record(payload.proposedUser, 'hosted-user-invalid'); + const issuer = text(identity.issuer, 'external-identity-issuer-invalid'); + const subject = text(identity.subject, 'external-identity-subject-invalid'); + return this.database().transaction(() => { + const existing = this.database() + .prepare( + `SELECT u.user_id AS userId, u.display_name AS displayName, u.status, + u.created_at AS createdAt, u.updated_at AS updatedAt + FROM external_identities e JOIN users u ON u.user_id = e.user_id + WHERE e.issuer = ? AND e.subject = ?` + ) + .get(issuer, subject) as Record | undefined; + if (existing) { + this.database() + .prepare( + `UPDATE external_identities SET last_authenticated_at = ? + WHERE issuer = ? AND subject = ?` + ) + .run( + integer(identity.lastAuthenticatedAt, 'external-identity-auth-at-invalid'), + issuer, + subject + ); + return { + user: existing, + identity: { + ...identity, + issuer, + subject, + userId: existing.userId, + }, + }; + } + this.database() + .prepare( + `INSERT INTO users (user_id, display_name, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ) + .run( + text(proposed.userId, 'hosted-user-id-invalid'), + text(proposed.displayName, 'hosted-user-display-name-invalid', 256), + text(proposed.status, 'hosted-user-status-invalid'), + integer(proposed.createdAt, 'hosted-user-created-at-invalid'), + integer(proposed.updatedAt, 'hosted-user-updated-at-invalid') + ); + this.database() + .prepare( + `INSERT INTO external_identities + (issuer, subject, user_id, provider_id, created_at, last_authenticated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + issuer, + subject, + proposed.userId, + text(identity.providerId, 'oidc-provider-id-invalid'), + integer(identity.createdAt, 'external-identity-created-at-invalid'), + integer(identity.lastAuthenticatedAt, 'external-identity-auth-at-invalid') + ); + return { + user: proposed, + identity: { ...identity, issuer, subject, userId: proposed.userId }, + }; + })(); + } + + private ensurePersonalOwner(payload: Record): unknown { + const user = record(payload.user, 'hosted-user-invalid'); + const operatorId = text(payload.operatorId, 'personal-operator-id-invalid'); + return this.database().transaction(() => { + const existing = this.database() + .prepare( + `SELECT p.operator_id AS operatorId, u.user_id AS userId, + u.display_name AS displayName, u.status, + u.created_at AS createdAt, u.updated_at AS updatedAt + FROM personal_owners p JOIN users u ON u.user_id = p.user_id WHERE p.singleton = 1` + ) + .get() as Record | undefined; + if (existing) { + return { + operatorId: existing.operatorId, + user: { + userId: existing.userId, + displayName: existing.displayName, + status: existing.status, + createdAt: existing.createdAt, + updatedAt: existing.updatedAt, + }, + }; + } + this.database() + .prepare( + `INSERT INTO users (user_id, display_name, status, created_at, updated_at) + VALUES (?, ?, 'active', ?, ?)` + ) + .run( + text(user.userId, 'hosted-user-id-invalid'), + text(user.displayName, 'hosted-user-display-name-invalid', 256), + integer(user.createdAt, 'hosted-user-created-at-invalid'), + integer(user.updatedAt, 'hosted-user-updated-at-invalid') + ); + this.database() + .prepare( + `INSERT INTO personal_owners (singleton, operator_id, user_id, created_at) + VALUES (1, ?, ?, ?)` + ) + .run(operatorId, user.userId, user.createdAt); + return { operatorId, user }; + })(); + } + + private createSession(payload: Record): null { + const session = record(payload.session, 'hosted-session-invalid'); + this.database().transaction(() => { + this.database() + .prepare( + `INSERT INTO operator_sessions + (session_id, user_id, secret_hash, authentication_method, provider_id, + provider_issuer, provider_subject, provider_session_id, issued_at, last_used_at, + idle_expires_at, absolute_expires_at, status, revoked_at, revocation_reason) + VALUES (?, ?, ?, 'oidc', ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL)` + ) + .run( + text(session.sessionId, 'hosted-session-id-invalid'), + text(session.userId, 'hosted-user-id-invalid'), + text(session.secretHash, 'hosted-session-secret-hash-invalid'), + text(session.providerId, 'oidc-provider-id-invalid'), + text(session.providerIssuer, 'oidc-provider-issuer-invalid'), + text(session.providerSubject, 'oidc-provider-subject-invalid'), + nullableText(session.providerSessionId, 'oidc-provider-session-invalid'), + integer(session.issuedAt, 'hosted-session-issued-at-invalid'), + integer(session.lastUsedAt, 'hosted-session-last-used-at-invalid'), + integer(session.idleExpiresAt, 'hosted-session-idle-invalid'), + integer(session.absoluteExpiresAt, 'hosted-session-absolute-invalid') + ); + const snapshot = record(session.roleSnapshot, 'hosted-role-snapshot-invalid'); + this.database() + .prepare( + `INSERT INTO role_snapshots (session_id, role, source, captured_at) + VALUES (?, ?, ?, ?)` + ) + .run( + session.sessionId, + text(snapshot.role, 'hosted-role-invalid'), + text(snapshot.source, 'hosted-role-source-invalid'), + integer(snapshot.capturedAt, 'hosted-role-captured-at-invalid') + ); + })(); + return null; + } + + private findSessionByHash(payload: Record): unknown { + return ( + this.database() + .prepare( + `SELECT s.session_id AS sessionId, s.user_id AS userId, s.secret_hash AS secretHash, + s.authentication_method AS authenticationMethod, s.provider_id AS providerId, + s.provider_issuer AS providerIssuer, s.provider_subject AS providerSubject, + s.provider_session_id AS providerSessionId, s.issued_at AS issuedAt, + s.last_used_at AS lastUsedAt, s.idle_expires_at AS idleExpiresAt, + s.absolute_expires_at AS absoluteExpiresAt, s.status, s.revoked_at AS revokedAt, + s.revocation_reason AS revocationReason, r.role, r.source AS roleSource, + r.captured_at AS roleCapturedAt + FROM operator_sessions s JOIN role_snapshots r ON r.session_id = s.session_id + WHERE s.secret_hash = ?` + ) + .get(text(payload.secretHash, 'hosted-session-secret-hash-invalid')) ?? null + ); + } + + private touchSession(payload: Record): boolean { + const lastUsedAt = integer(payload.lastUsedAt, 'hosted-session-last-used-at-invalid'); + const idleExpiresAt = integer(payload.idleExpiresAt, 'hosted-session-idle-invalid'); + const sessionId = text(payload.sessionId, 'hosted-session-id-invalid'); + const expectedLastUsedAt = integer( + payload.expectedLastUsedAt, + 'hosted-session-expected-last-used-invalid' + ); + return this.database().transaction(() => { + const result = this.database() + .prepare( + `UPDATE operator_sessions SET last_used_at = ?, idle_expires_at = ? + WHERE session_id = ? AND last_used_at = ? AND status = 'active'` + ) + .run(lastUsedAt, idleExpiresAt, sessionId, expectedLastUsedAt); + if (result.changes === 1) return true; + + // Parallel requests may both authenticate the same still-active bearer + // from one snapshot. A newer successful touch is admissible; logout, + // user disable, back-channel revocation, and expiry all change status + // first and therefore remain fail closed. + const current = this.database() + .prepare( + `SELECT status, last_used_at AS lastUsedAt + FROM operator_sessions WHERE session_id = ?` + ) + .get(sessionId) as { readonly status?: unknown; readonly lastUsedAt?: unknown } | undefined; + return ( + current?.status === 'active' && + typeof current.lastUsedAt === 'number' && + current.lastUsedAt >= expectedLastUsedAt + ); + })(); + } + + private revokeSession(payload: Record): null { + this.database() + .prepare( + `UPDATE operator_sessions SET status = 'revoked', revoked_at = ?, revocation_reason = ? + WHERE session_id = ? AND status = 'active'` + ) + .run( + integer(payload.now, 'hosted-session-revoked-at-invalid'), + text(payload.reason, 'hosted-session-reason-invalid', 256), + text(payload.sessionId, 'hosted-session-id-invalid') + ); + return null; + } + + private revokeProviderSessions(payload: Record): number { + const subject = + payload.subject === undefined ? null : text(payload.subject, 'oidc-sub-invalid'); + const sid = + payload.providerSessionId === undefined + ? null + : text(payload.providerSessionId, 'oidc-sid-invalid'); + if (subject === null && sid === null) throw new TypeError('oidc-logout-selector-invalid'); + const result = this.database() + .prepare( + `UPDATE operator_sessions SET status = 'revoked', revoked_at = ?, revocation_reason = ? + WHERE status = 'active' AND provider_issuer = ? + AND (? IS NULL OR provider_subject = ?) AND (? IS NULL OR provider_session_id = ?)` + ) + .run( + integer(payload.now, 'hosted-session-revoked-at-invalid'), + text(payload.reason, 'hosted-session-reason-invalid', 256), + text(payload.issuer, 'oidc-issuer-invalid'), + subject, + subject, + sid, + sid + ); + return result.changes; + } + + private consumeBackchannelId(payload: Record): boolean { + const db = this.database(); + const consumedAt = integer(payload.consumedAt, 'oidc-logout-consumed-at-invalid'); + const expiresAt = integer(payload.expiresAt, 'oidc-logout-expires-at-invalid'); + if (expiresAt <= consumedAt || expiresAt > consumedAt + 25 * 60 * 60 * 1_000) { + throw new TypeError('oidc-logout-expires-at-invalid'); + } + db.prepare(`DELETE FROM oidc_logout_replay WHERE expires_at <= ?`).run(consumedAt); + const issuer = text(payload.issuer, 'oidc-issuer-invalid'); + const jti = text(payload.jti, 'oidc-jti-invalid'); + const replayed = db + .prepare(`SELECT 1 FROM oidc_logout_replay WHERE issuer = ? AND jti = ?`) + .get(issuer, jti); + if (replayed !== undefined) return false; + const result = db + .prepare( + `INSERT OR IGNORE INTO oidc_logout_replay + (provider_id, issuer, jti, expires_at, consumed_at) VALUES (?, ?, ?, ?, ?)` + ) + .run( + text(payload.providerId, 'oidc-provider-id-invalid'), + issuer, + jti, + expiresAt, + consumedAt + ); + return result.changes === 1; + } + + private applyBackchannelLogout(payload: Record): unknown { + return this.database().transaction(() => { + const consumed = this.consumeBackchannelId(payload); + if (!consumed) return { consumed: false, revoked: 0 }; + return { + consumed: true, + revoked: this.revokeProviderSessions({ + ...payload, + now: payload.consumedAt, + }), + }; + })(); + } + + private getUser(payload: Record): unknown { + return ( + this.database() + .prepare( + `SELECT user_id AS userId, display_name AS displayName, status, + created_at AS createdAt, updated_at AS updatedAt + FROM users WHERE user_id = ?` + ) + .get(text(payload.userId, 'hosted-user-id-invalid')) ?? null + ); + } + + private listUsers(): unknown { + return this.database() + .prepare( + `SELECT user_id AS userId, display_name AS displayName, status, + created_at AS createdAt, updated_at AS updatedAt + FROM users ORDER BY created_at, user_id` + ) + .all(); + } + + private setUserStatus(payload: Record): boolean { + const userId = text(payload.userId, 'hosted-user-id-invalid'); + const status = text(payload.status, 'hosted-user-status-invalid'); + if (status !== 'active' && status !== 'disabled') { + throw new TypeError('hosted-user-status-invalid'); + } + const now = integer(payload.now, 'hosted-user-updated-at-invalid'); + return this.database().transaction(() => { + const result = this.database() + .prepare(`UPDATE users SET status = ?, updated_at = ? WHERE user_id = ?`) + .run(status, now, userId); + if (result.changes !== 1) return false; + if (status === 'disabled') { + this.database() + .prepare( + `UPDATE operator_sessions + SET status = 'revoked', revoked_at = ?, revocation_reason = 'user_disabled' + WHERE user_id = ? AND status = 'active'` + ) + .run(now, userId); + } + return true; + })(); + } + + private getLocalRole(payload: Record): unknown { + return ( + this.database() + .prepare( + `SELECT user_id AS userId, role, assigned_at AS assignedAt, assigned_by AS assignedBy + FROM local_role_assignments WHERE user_id = ?` + ) + .get(text(payload.userId, 'hosted-user-id-invalid')) ?? null + ); + } + + private setLocalRole(payload: Record): null { + const assignment = record(payload.assignment, 'hosted-local-role-assignment-invalid'); + const role = text(assignment.role, 'hosted-role-invalid'); + if (!['owner', 'admin', 'member', 'viewer'].includes(role)) { + throw new TypeError('hosted-role-invalid'); + } + const assignedBy = text(assignment.assignedBy, 'hosted-role-assigned-by-invalid'); + if (assignedBy !== 'local-cli') throw new TypeError('hosted-role-assigned-by-invalid'); + this.database() + .prepare( + `INSERT INTO local_role_assignments (user_id, role, assigned_at, assigned_by) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + role = excluded.role, + assigned_at = excluded.assigned_at, + assigned_by = excluded.assigned_by` + ) + .run( + text(assignment.userId, 'hosted-user-id-invalid'), + role, + integer(assignment.assignedAt, 'hosted-role-assigned-at-invalid'), + assignedBy + ); + return null; + } + + private clearLocalRole(payload: Record): boolean { + return ( + this.database() + .prepare(`DELETE FROM local_role_assignments WHERE user_id = ?`) + .run(text(payload.userId, 'hosted-user-id-invalid')).changes === 1 + ); + } + + private appendAudit(payload: Record): null { + const event = record(payload.event, 'hosted-audit-event-invalid'); + const outcome = text(event.outcome, 'hosted-audit-outcome-invalid'); + if (outcome !== 'success' && outcome !== 'denied' && outcome !== 'failure') { + throw new TypeError('hosted-audit-outcome-invalid'); + } + this.database() + .prepare( + `INSERT INTO auth_audit_events + (event_id, occurred_at, user_id, session_id, action, outcome, source_ip_hash, details_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + text(event.eventId, 'hosted-audit-id-invalid'), + integer(event.occurredAt, 'hosted-audit-occurred-at-invalid'), + nullableText(event.userId, 'hosted-user-id-invalid'), + nullableText(event.sessionId, 'hosted-session-id-invalid'), + text(event.action, 'hosted-audit-action-invalid', 256), + outcome, + nullableText(event.sourceIpHash, 'hosted-audit-source-ip-invalid'), + text(event.detailsJson, 'hosted-audit-details-invalid', 65_536) + ); + return null; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedAuthorityProjectionStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/hostedAuthorityProjectionStorageOps.ts new file mode 100644 index 0000000000..17a709ad3d --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedAuthorityProjectionStorageOps.ts @@ -0,0 +1,598 @@ +import { resolveCommandClaim, stableJsonStringify } from '@features/application-command-ledger'; + +import { + encodeHostedAuthorityProjectionReceiptEnvelope, + HOSTED_AUTHORITY_PROJECTION_RECEIPT_CODEC_VERSION, + parseHostedAuthorityProjectionPersistRequest, + parseHostedAuthorityProjectionReadRequest, + parseHostedAuthorityProjectionReceiptEnvelope, + parseHostedAuthorityProjectionRecord, +} from '../../application/hostedAuthorityProjectionStorage'; + +import * as validation from './applicationCommandLedgerValidation'; +import { + appendCommandOutboxEventToJournal, + assertInternalStorageMutationAdmissionOpen, + canonicalCoordinationStorageJson, + materializeCommandCoordinationAttribution, +} from './coordinationDurabilityWorkerOps'; + +import type { ApplicationCommandLedgerRecordRepository } from './applicationCommandLedgerRecordRepository'; +import type { + DurableApplicationCommandPersistClaimRequest, + HostedAuthorityProjectionCommitResult, + HostedAuthorityProjectionPersistRequest, + HostedAuthorityProjectionReadRequest, + HostedAuthorityProjectionReceiptRecord, + HostedAuthorityProjectionRecord, +} from '@features/application-command-ledger'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +interface ProjectionRow { + readonly deploymentId: string; + readonly projectionKind: string; + readonly projectionKey: string; + readonly generation: number; + readonly revision: number; + readonly stateJson: string; + readonly lastCommandId: string; + readonly updatedAt: string; + readonly commandState: string; + readonly commandDeploymentId: string; + readonly commandOutcomeJson: string | null; + readonly commandCommittedAt: string | null; +} + +interface JournalBindingRow { + readonly deploymentId: string; + readonly eventId: string; + readonly bodyJson: string; + readonly emittedAt: string; + readonly originCommandId: string | null; + readonly createdAt: string; +} + +const ATOMIC_ATTEMPT_OWNER = 'hosted-authority-projection-atomic-v1'; +const ATOMIC_ATTEMPT_EXPIRY = '9999-12-31T23:59:59.999Z'; + +export class HostedAuthorityProjectionStorageOps { + constructor( + private readonly getDb: () => SqliteDatabase, + private readonly repository: ApplicationCommandLedgerRecordRepository + ) {} + + commit(value: unknown): HostedAuthorityProjectionCommitResult { + const input = parseHostedAuthorityProjectionPersistRequest(value); + const attempt = { + attemptId: `atomic:${input.commandId}`, + ownerId: ATOMIC_ATTEMPT_OWNER, + leaseToken: `atomic:${input.commandId}`, + claimedAtIso: input.committedAtIso, + leaseExpiresAtIso: ATOMIC_ATTEMPT_EXPIRY, + }; + const validated = validation.validateDurableClaim({ + ...input, + createdAtIso: input.committedAtIso, + attempt, + }); + if (validated.effectPlan.some((effect) => effect.recoveryClass !== 'transactional_local')) { + throw new Error('hosted-authority-projection-effect-must-be-transactional-local'); + } + const envelopeJson = encodeHostedAuthorityProjectionReceiptEnvelope({ + codecVersion: HOSTED_AUTHORITY_PROJECTION_RECEIPT_CODEC_VERSION, + deploymentId: validated.scope.deploymentId, + commandId: input.commandId, + projectionKind: input.projection.projectionKind, + projectionKey: input.projection.projectionKey, + expectedGeneration: input.projection.expectedGeneration, + expectedRevision: input.projection.expectedRevision, + generation: input.projection.nextGeneration, + revision: input.projection.nextRevision, + stateJson: input.projection.stateJson, + eventId: input.outbox.eventId, + receiptJson: input.receiptJson, + committedAt: input.committedAtIso, + }); + validation.validateDurableCommit({ + deploymentId: validated.scope.deploymentId, + commandId: validated.commandId, + attempt: { + generation: 1, + attemptId: attempt.attemptId, + ownerId: attempt.ownerId, + leaseToken: attempt.leaseToken, + }, + expectedState: 'running', + outcomeJson: envelopeJson, + committedAtIso: input.committedAtIso, + outbox: input.outbox, + }); + if (Date.parse(input.committedAtIso) > input.deadlineAtMs) { + throw new Error('hosted-authority-projection-deadline-invalid'); + } + + const attribution = materializeCommandCoordinationAttribution({ + actor: input.attribution.actor, + ...(input.attribution.actor.kind === 'verified_runtime' + ? { runId: input.attribution.actor.runId } + : {}), + provenance: 'trusted_context_v1', + }); + const attributionJson = canonicalCoordinationStorageJson(attribution); + const db = this.getDb(); + + // better-sqlite3's immediate() issues BEGIN IMMEDIATE before the first read. + return db + .transaction(() => { + assertDeadlineOpen(input.deadlineAtMs); + assertInternalStorageMutationAdmissionOpen(db, null); + const current = this.readProjectionRow({ + deploymentId: validated.scope.deploymentId, + projectionKind: input.projection.projectionKind, + projectionKey: input.projection.projectionKey, + deadlineAtMs: input.deadlineAtMs, + }); + + // Generation is the authority-incarnation fence and must win even over + // an otherwise matching idempotency receipt from an older incarnation. + if (current && current.generation !== input.projection.expectedGeneration) { + return staleResult('stale_generation', current); + } + + const byClaim = this.repository.readDurableRecordByClaim({ scope: validated.scope }); + const byCommandId = this.repository.readDurableRecord({ + deploymentId: validated.scope.deploymentId, + commandId: validated.commandId, + }); + if (byCommandId && byClaim && byCommandId.commandId !== byClaim.commandId) { + throw new Error('hosted-authority-projection-command-identity-ambiguous'); + } + if (byCommandId && !byClaim) { + throw new Error('hosted-authority-projection-command-id-reused'); + } + if (byClaim) { + const resolution = resolveCommandClaim(byClaim.claim, { + scope: validated.scope, + fingerprint: validated.fingerprint, + }); + if (resolution.outcome === 'idempotency_mismatch') { + return Object.freeze({ outcome: 'fingerprint_conflict' as const }); + } + if (!current) { + throw new Error('hosted-authority-projection-replay-projection-missing'); + } + return this.replay(input, validated, attributionJson, byClaim.commandId); + } + + const currentGeneration = current?.generation ?? input.projection.expectedGeneration; + const currentRevision = current?.revision ?? 0; + if (currentRevision !== input.projection.expectedRevision) { + return { + outcome: 'stale_revision' as const, + currentGeneration, + currentRevision, + }; + } + + this.insertCommittedCommand(input, validated, attributionJson, envelopeJson, attempt); + this.writeProjection(input, validated.scope.deploymentId, current); + this.insertOutbox(input, validated.scope.deploymentId); + appendCommandOutboxEventToJournal(db, { + commandId: validated.commandId, + deploymentId: validated.scope.deploymentId, + attribution, + outbox: input.outbox, + }); + assertDeadlineOpen(input.deadlineAtMs); + + const projection = this.requireProjection({ + deploymentId: validated.scope.deploymentId, + projectionKind: input.projection.projectionKind, + projectionKey: input.projection.projectionKey, + deadlineAtMs: input.deadlineAtMs, + }); + return Object.freeze({ + outcome: 'committed' as const, + projection, + receipt: receiptFromEnvelope(validated.scope.deploymentId, envelopeJson), + }); + }) + .immediate(); + } + + get(value: unknown): HostedAuthorityProjectionRecord | null { + const input = parseHostedAuthorityProjectionReadRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDb(); + return db.transaction(() => { + assertDeadlineOpen(input.deadlineAtMs); + return this.readProjectionRow(input); + })(); + } + + private replay( + input: HostedAuthorityProjectionPersistRequest, + validated: DurableApplicationCommandPersistClaimRequest, + attributionJson: string, + existingCommandId: string + ): HostedAuthorityProjectionCommitResult { + const command = this.repository.requireDurableRecord({ + deploymentId: validated.scope.deploymentId, + commandId: existingCommandId, + }); + if ( + command.state !== 'committed' || + command.retentionClass !== validated.retentionClass || + command.descriptor.descriptorId !== validated.descriptor.descriptorId || + command.descriptor.descriptorVersion !== validated.descriptor.descriptorVersion || + command.descriptor.commandKind !== validated.descriptor.commandKind || + command.descriptor.inputSchemaVersion !== validated.descriptor.inputSchemaVersion || + command.descriptor.fingerprintVersion !== validated.descriptor.fingerprintVersion || + command.descriptor.effectPlanVersion !== validated.descriptor.effectPlanVersion || + command.effects.length !== validated.effectPlan.length || + command.effects.some( + (effect, ordinal) => + effect.state !== 'observed_succeeded' || + effect.effectId !== validated.effectPlan[ordinal]?.effectId || + effect.effectVersion !== validated.effectPlan[ordinal]?.effectVersion || + effect.recoveryClass !== 'transactional_local' || + effect.evidenceSchemaVersion !== validated.effectPlan[ordinal]?.evidenceSchemaVersion + ) || + this.repository.requireCoordinationAttributionJson(existingCommandId) !== attributionJson || + command.outcomeJson === null || + command.committedAt === null + ) { + throw new Error('hosted-authority-projection-replay-command-invalid'); + } + const envelope = parseHostedAuthorityProjectionReceiptEnvelope(command.outcomeJson); + const storedOutbox = this.repository.readOutboxByCommandId(existingCommandId); + if ( + envelope.deploymentId !== validated.scope.deploymentId || + envelope.commandId !== existingCommandId || + envelope.projectionKind !== input.projection.projectionKind || + envelope.projectionKey !== input.projection.projectionKey || + envelope.expectedGeneration !== input.projection.expectedGeneration || + envelope.expectedRevision !== input.projection.expectedRevision || + envelope.committedAt !== command.committedAt || + !storedOutbox || + storedOutbox.commandId !== envelope.commandId || + storedOutbox.deploymentId !== envelope.deploymentId || + storedOutbox.eventId !== envelope.eventId || + storedOutbox.semanticRevision !== envelope.revision || + storedOutbox.createdAt !== envelope.committedAt + ) { + throw new Error('hosted-authority-projection-replay-binding-conflict'); + } + const journal = this.getDb() + .prepare( + `SELECT deployment_id AS deploymentId, event_id AS eventId, body_json AS bodyJson, + emitted_at AS emittedAt, origin_command_id AS originCommandId, + created_at AS createdAt + FROM coordination_event_journal + WHERE origin_command_id = ? AND event_id = ?` + ) + .all(existingCommandId, envelope.eventId) as JournalBindingRow[]; + const journalRow = journal[0]; + if ( + journal.length !== 1 || + !journalRow || + journalRow.deploymentId !== envelope.deploymentId || + journalRow.eventId !== envelope.eventId || + journalRow.originCommandId !== envelope.commandId || + journalRow.emittedAt !== envelope.committedAt || + journalRow.createdAt !== envelope.committedAt || + journalRow.bodyJson !== + expectedJournalBodyJson( + storedOutbox, + this.repository.readCoordinationAttribution(existingCommandId) + ) + ) { + throw new Error('hosted-authority-projection-replay-journal-binding-invalid'); + } + return Object.freeze({ + outcome: 'idempotent_replay' as const, + projection: projectionFromEnvelope(command.outcomeJson), + receipt: receiptFromEnvelope(validated.scope.deploymentId, command.outcomeJson), + }); + } + + private insertCommittedCommand( + input: HostedAuthorityProjectionPersistRequest, + validated: DurableApplicationCommandPersistClaimRequest, + attributionJson: string, + envelopeJson: string, + attempt: { + readonly attemptId: string; + readonly ownerId: string; + readonly leaseToken: string; + readonly claimedAtIso: string; + readonly leaseExpiresAtIso: string; + } + ): void { + const db = this.getDb(); + db.prepare( + `INSERT INTO durable_application_commands ( + command_id, deployment_id, stable_actor_id, command_kind, idempotency_key, + descriptor_id, descriptor_version, input_schema_version, fingerprint_version, + effect_plan_version, fingerprint_key_version, fingerprint_digest, + attempt_generation, attempt_id, attempt_owner_id, attempt_lease_token, + attempt_claimed_at, attempt_lease_expires_at, state, retention_class, + audit_session_id, coordination_attribution_json, outcome_json, error_code, error_json, + created_at, updated_at, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, 'committed', ?, ?, ?, ?, NULL, NULL, ?, ?, ?)` + ).run( + validated.commandId, + validated.scope.deploymentId, + validated.scope.stableActorId, + validated.scope.commandKind, + validated.scope.idempotencyKey, + validated.descriptor.descriptorId, + validated.descriptor.descriptorVersion, + validated.descriptor.inputSchemaVersion, + validated.descriptor.fingerprintVersion, + validated.descriptor.effectPlanVersion, + validated.fingerprint.keyVersion, + validated.fingerprint.digest, + attempt.attemptId, + attempt.ownerId, + attempt.leaseToken, + attempt.claimedAtIso, + attempt.leaseExpiresAtIso, + validated.retentionClass, + validated.auditSessionId, + attributionJson, + envelopeJson, + input.committedAtIso, + input.committedAtIso, + input.committedAtIso + ); + for (const effect of validated.effectPlan) { + db.prepare( + `INSERT INTO durable_application_command_effects ( + command_id, ordinal, effect_id, effect_version, recovery_class, + evidence_schema_version, state, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'observed_succeeded', ?)` + ).run( + validated.commandId, + effect.ordinal, + effect.effectId, + effect.effectVersion, + effect.recoveryClass, + effect.evidenceSchemaVersion, + input.committedAtIso + ); + db.prepare( + `INSERT INTO durable_application_command_effect_evidence ( + command_id, ordinal, sequence, outcome, evidence_schema_version, + evidence_json, recorded_at + ) VALUES (?, ?, 1, 'observed_succeeded', ?, ?, ?)` + ).run( + validated.commandId, + effect.ordinal, + effect.evidenceSchemaVersion, + stableJsonStringify({ + kind: 'atomic_hosted_authority_projection', + projectionKey: input.projection.projectionKey, + projectionKind: input.projection.projectionKind, + revision: input.projection.nextRevision, + }), + input.committedAtIso + ); + } + } + + private writeProjection( + input: HostedAuthorityProjectionPersistRequest, + deploymentId: string, + current: HostedAuthorityProjectionRecord | null + ): void { + const db = this.getDb(); + if (!current) { + db.prepare( + `INSERT INTO hosted_authority_projections ( + deployment_id, projection_kind, projection_key, generation, revision, + state_json, last_command_id, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + deploymentId, + input.projection.projectionKind, + input.projection.projectionKey, + input.projection.nextGeneration, + input.projection.nextRevision, + input.projection.stateJson, + input.commandId, + input.committedAtIso + ); + return; + } + const changed = db + .prepare( + `UPDATE hosted_authority_projections + SET generation = ?, revision = ?, state_json = ?, last_command_id = ?, updated_at = ? + WHERE deployment_id = ? AND projection_kind = ? AND projection_key = ? + AND generation = ? AND revision = ?` + ) + .run( + input.projection.nextGeneration, + input.projection.nextRevision, + input.projection.stateJson, + input.commandId, + input.committedAtIso, + deploymentId, + input.projection.projectionKind, + input.projection.projectionKey, + input.projection.expectedGeneration, + input.projection.expectedRevision + ); + if (changed.changes !== 1) { + throw new Error('hosted-authority-projection-cas-did-not-converge'); + } + } + + private insertOutbox(input: HostedAuthorityProjectionPersistRequest, deploymentId: string): void { + this.getDb() + .prepare( + `INSERT INTO durable_application_command_outbox ( + event_id, command_id, deployment_id, event_type, scope_kind, scope_id, + schema_version, semantic_revision, payload_json, created_at, + delivery_generation, delivery_owner_id, delivery_lease_token, + delivery_claimed_at, delivery_lease_expires_at, delivery_acknowledged_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, NULL, NULL, NULL)` + ) + .run( + input.outbox.eventId, + input.commandId, + deploymentId, + input.outbox.eventType, + input.outbox.scopeKind, + input.outbox.scopeId, + input.outbox.schemaVersion, + input.outbox.semanticRevision, + input.outbox.payloadJson, + input.outbox.createdAtIso + ); + } + + private requireProjection( + input: HostedAuthorityProjectionReadRequest + ): HostedAuthorityProjectionRecord { + const projection = this.readProjectionRow(input); + if (!projection) throw new Error('hosted-authority-projection-commit-missing'); + return projection; + } + + private readProjectionRow( + input: HostedAuthorityProjectionReadRequest + ): HostedAuthorityProjectionRecord | null { + const row = this.getDb() + .prepare( + `SELECT p.deployment_id AS deploymentId, p.projection_kind AS projectionKind, + p.projection_key AS projectionKey, p.generation, p.revision, + p.state_json AS stateJson, p.last_command_id AS lastCommandId, + p.updated_at AS updatedAt, c.state AS commandState, + c.deployment_id AS commandDeploymentId, c.outcome_json AS commandOutcomeJson, + c.committed_at AS commandCommittedAt + FROM hosted_authority_projections AS p + JOIN durable_application_commands AS c ON c.command_id = p.last_command_id + WHERE p.deployment_id = ? AND p.projection_kind = ? AND p.projection_key = ?` + ) + .get(input.deploymentId, input.projectionKind, input.projectionKey) as + | ProjectionRow + | undefined; + if (!row) return null; + if ( + row.commandState !== 'committed' || + row.commandDeploymentId !== row.deploymentId || + row.commandOutcomeJson === null || + row.commandCommittedAt === null + ) { + throw new Error('hosted-authority-projection-command-not-committed'); + } + const envelope = parseHostedAuthorityProjectionReceiptEnvelope(row.commandOutcomeJson); + if ( + envelope.commandId !== row.lastCommandId || + envelope.deploymentId !== row.deploymentId || + envelope.projectionKind !== row.projectionKind || + envelope.projectionKey !== row.projectionKey || + envelope.generation !== row.generation || + envelope.revision !== row.revision || + envelope.stateJson !== row.stateJson || + envelope.committedAt !== row.updatedAt || + envelope.committedAt !== row.commandCommittedAt + ) { + throw new Error('hosted-authority-projection-command-binding-invalid'); + } + const { + commandState: _commandState, + commandDeploymentId: _commandDeploymentId, + commandOutcomeJson: _commandOutcomeJson, + commandCommittedAt: _commandCommittedAt, + ...projection + } = row; + return parseHostedAuthorityProjectionRecord(projection); + } +} + +function staleResult( + outcome: 'stale_generation' | 'stale_revision', + current: HostedAuthorityProjectionRecord +): HostedAuthorityProjectionCommitResult { + return { + outcome, + currentGeneration: current.generation, + currentRevision: current.revision, + }; +} + +function receiptFromEnvelope( + deploymentId: string, + envelopeJson: string +): HostedAuthorityProjectionReceiptRecord { + const envelope = parseHostedAuthorityProjectionReceiptEnvelope(envelopeJson); + if (deploymentId !== envelope.deploymentId) { + throw new Error('hosted-authority-projection-receipt-deployment-binding-invalid'); + } + return Object.freeze({ + deploymentId, + projectionKind: envelope.projectionKind, + projectionKey: envelope.projectionKey, + commandId: envelope.commandId, + generation: envelope.generation, + revision: envelope.revision, + eventId: envelope.eventId, + receiptJson: envelope.receiptJson, + committedAt: envelope.committedAt, + }); +} + +function projectionFromEnvelope(envelopeJson: string): HostedAuthorityProjectionRecord { + const envelope = parseHostedAuthorityProjectionReceiptEnvelope(envelopeJson); + return parseHostedAuthorityProjectionRecord({ + deploymentId: envelope.deploymentId, + projectionKind: envelope.projectionKind, + projectionKey: envelope.projectionKey, + generation: envelope.generation, + revision: envelope.revision, + stateJson: envelope.stateJson, + lastCommandId: envelope.commandId, + updatedAt: envelope.committedAt, + }); +} + +function expectedJournalBodyJson( + outbox: { + readonly eventId: string; + readonly eventType: string; + readonly scopeKind: string; + readonly scopeId: string; + readonly schemaVersion: number; + readonly payloadJson: string; + readonly createdAt: string; + }, + attribution: { + readonly actor: unknown; + readonly runId?: string; + } +): string { + const runId = attribution.runId ?? (outbox.scopeKind === 'run' ? outbox.scopeId : undefined); + return canonicalCoordinationStorageJson({ + schemaVersion: outbox.schemaVersion, + eventId: outbox.eventId, + scope: { kind: outbox.scopeKind, scopeId: outbox.scopeId }, + ...(outbox.scopeKind === 'workspace' ? { workspaceId: outbox.scopeId } : {}), + ...(outbox.scopeKind === 'team' ? { teamId: outbox.scopeId } : {}), + ...(runId === undefined ? {} : { runId }), + actor: attribution.actor, + eventType: outbox.eventType, + emittedAt: outbox.createdAt, + payload: JSON.parse(outbox.payloadJson) as unknown, + }); +} + +function assertDeadlineOpen(deadlineAtMs: number): void { + if (!Number.isSafeInteger(deadlineAtMs) || deadlineAtMs < 1 || Date.now() >= deadlineAtMs) { + throw new Error('hosted-authority-projection-deadline-expired'); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageMigration.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageMigration.ts new file mode 100644 index 0000000000..7ef4c20df8 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageMigration.ts @@ -0,0 +1,162 @@ +/** Additive v18 schema for provider-neutral hosted approval durability. */ +export const HOSTED_TEAM_APPROVAL_AUTHORITY_STORAGE_MIGRATION_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS hosted_team_approval_records ( + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + team_id TEXT NOT NULL, + authority_generation TEXT NOT NULL, + restore_generation INTEGER NOT NULL CHECK (restore_generation >= 0), + approval_id TEXT NOT NULL, + approval_generation TEXT NOT NULL, + category TEXT NOT NULL, + summary TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER, + preview_ref TEXT, + preview_content TEXT, + preview_byte_length INTEGER, + preview_truncated INTEGER, + preview_is_binary INTEGER, + delivery_ref TEXT NOT NULL, + state TEXT NOT NULL, + decision TEXT, + revision INTEGER NOT NULL CHECK (revision > 0), + observed_at_ms INTEGER NOT NULL, + resolved_at_ms INTEGER, + last_idempotency_key TEXT, + payload_hash TEXT, + PRIMARY KEY ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ), + CHECK (requested_at_ms >= 0 AND observed_at_ms >= requested_at_ms + AND (expires_at_ms IS NULL OR expires_at_ms > requested_at_ms)), + CHECK (state IN ('pending', 'superseded', 'resolved') + AND (state = 'resolved') = (decision IS NOT NULL) + AND (state = 'pending') = (resolved_at_ms IS NULL)), + CHECK ( + (preview_ref IS NULL AND preview_content IS NULL AND preview_byte_length IS NULL + AND preview_truncated IS NULL AND preview_is_binary IS NULL) + OR (preview_ref IS NOT NULL AND preview_content IS NOT NULL + AND preview_byte_length IS NOT NULL AND preview_truncated IS NOT NULL + AND preview_is_binary IS NOT NULL) + ) + )`, + `CREATE INDEX IF NOT EXISTS idx_hosted_team_approval_pending_page + ON hosted_team_approval_records ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + state, approval_id + )`, + `CREATE INDEX IF NOT EXISTS idx_hosted_team_approval_current_generation + ON hosted_team_approval_records ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, observed_at_ms + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_hosted_team_approval_one_pending_generation + ON hosted_team_approval_records ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, approval_id + ) WHERE state = 'pending'`, + `CREATE TABLE IF NOT EXISTS hosted_team_approval_idempotency ( + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + team_id TEXT NOT NULL, + authority_generation TEXT NOT NULL, + restore_generation INTEGER NOT NULL CHECK (restore_generation >= 0), + idempotency_key TEXT NOT NULL, + approval_id TEXT NOT NULL, + approval_generation TEXT NOT NULL, + decision TEXT NOT NULL, + payload_hash TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + audit_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + idempotency_key + ), + UNIQUE (audit_id), + UNIQUE (delivery_id), + FOREIGN KEY ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ) REFERENCES hosted_team_approval_records ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CHECK (created_at_ms >= 0) + )`, + `CREATE TABLE IF NOT EXISTS hosted_team_approval_audit ( + audit_id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + team_id TEXT NOT NULL, + authority_generation TEXT NOT NULL, + restore_generation INTEGER NOT NULL CHECK (restore_generation >= 0), + approval_id TEXT NOT NULL, + approval_generation TEXT NOT NULL, + decision TEXT NOT NULL, + payload_hash TEXT NOT NULL, + actor_id TEXT NOT NULL, + session_id TEXT NOT NULL, + occurred_at_ms INTEGER NOT NULL, + FOREIGN KEY ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ) REFERENCES hosted_team_approval_records ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CHECK (occurred_at_ms >= 0) + )`, + `CREATE INDEX IF NOT EXISTS idx_hosted_team_approval_audit_scope + ON hosted_team_approval_audit ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + occurred_at_ms + )`, + `CREATE TABLE IF NOT EXISTS hosted_team_approval_delivery_outbox ( + delivery_id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + team_id TEXT NOT NULL, + authority_generation TEXT NOT NULL, + restore_generation INTEGER NOT NULL CHECK (restore_generation >= 0), + approval_id TEXT NOT NULL, + approval_generation TEXT NOT NULL, + decision TEXT NOT NULL, + payload_hash TEXT NOT NULL, + delivery_ref TEXT NOT NULL, + intent_json TEXT NOT NULL CHECK (json_valid(intent_json)), + state TEXT NOT NULL, + delivery_generation INTEGER NOT NULL CHECK (delivery_generation >= 0), + delivery_owner_id TEXT, + delivery_lease_token TEXT, + delivery_claimed_at_ms INTEGER, + delivery_lease_expires_at_ms INTEGER, + delivered_at_ms INTEGER, + created_at_ms INTEGER NOT NULL, + UNIQUE ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ), + FOREIGN KEY ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ) REFERENCES hosted_team_approval_records ( + principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CHECK (created_at_ms >= 0), + CHECK (state IN ('pending', 'delivered') AND (state = 'delivered') = (delivered_at_ms IS NOT NULL)), + CHECK ( + (delivery_owner_id IS NULL AND delivery_lease_token IS NULL + AND delivery_claimed_at_ms IS NULL AND delivery_lease_expires_at_ms IS NULL) + OR (delivery_owner_id IS NOT NULL AND delivery_lease_token IS NOT NULL + AND delivery_claimed_at_ms IS NOT NULL AND delivery_lease_expires_at_ms > delivery_claimed_at_ms) + ) + )`, + `CREATE INDEX IF NOT EXISTS idx_hosted_team_approval_delivery_pending + ON hosted_team_approval_delivery_outbox ( + state, delivery_lease_expires_at_ms, created_at_ms, delivery_id + )`, +] as const; diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageOps.ts new file mode 100644 index 0000000000..0bb1d9d02b --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageOps.ts @@ -0,0 +1,757 @@ +import { + hashHostedTeamApprovalDecision, + hashHostedTeamApprovalGeneration, + hashHostedTeamApprovalIdentity, + parseHostedTeamApprovalDecisionStorageRequest, + parseHostedTeamApprovalDeliveryAcknowledgeRequest, + parseHostedTeamApprovalDeliveryClaimRequest, + parseHostedTeamApprovalPendingReadRequest, + parseHostedTeamApprovalPendingReadResult, + parseHostedTeamApprovalPendingStorageRecord, + parseHostedTeamApprovalPreviewReadRequest, + parseHostedTeamApprovalTimeoutAuditRequest, + serializeHostedTeamApprovalDeliveryIntent, +} from '../../application/hostedTeamApprovalAuthorityStorage'; + +import { assertInternalStorageMutationAdmissionOpen } from './coordinationDurabilityWorkerOps'; +import { + assertDeadlineOpen, + DELIVERY_COLUMNS, + deliveryRecordFromRow, + isDecision, + isNonNegativeInteger, + isPositiveInteger, + nextAuditTime, + pendingRecordFromRow, + previewFromRow, + readRecord, + readReplacementGeneration, + RECORD_COLUMNS, + scopeParameters, + type SqliteDatabase, + type UnknownRow, +} from './hostedTeamApprovalAuthorityStorageSupport'; +import { + markHostedTeamApprovalDeliveryOperatorRequired, + readHostedTeamApprovalDeliveryReconciliation, + settleHostedTeamApprovalDeliveryReconciliation, +} from './hostedTeamApprovalDeliveryReconciliationStorageOps'; +import { expireHostedTeamApprovals } from './hostedTeamApprovalTimeoutStorageOps'; + +import type { + HostedTeamApprovalDecisionStorageResult, + HostedTeamApprovalDeliveryRecord, + HostedTeamApprovalPendingReadRecord, + HostedTeamApprovalPreviewReadResult, +} from '../../../contracts/hostedTeamApprovalAuthorityStorageContracts'; + +export type HostedTeamApprovalAuthorityWorkerOp = + | 'hostedTeamApprovalAuthority.observe' + | 'hostedTeamApprovalAuthority.readPending' + | 'hostedTeamApprovalAuthority.readPreview' + | 'hostedTeamApprovalAuthority.decide' + | 'hostedTeamApprovalAuthority.claimDeliveries' + | 'hostedTeamApprovalAuthority.acknowledgeDelivery' + | 'hostedTeamApprovalAuthority.markDeliveryOperatorRequired' + | 'hostedTeamApprovalAuthority.readDeliveryReconciliation' + | 'hostedTeamApprovalAuthority.settleDeliveryReconciliation' + | 'hostedTeamApprovalAuthority.auditTimeouts'; +/** External orchestrator invokes durable ingress/outbox; this never launches, owns, or invokes a runtime. */ +export class HostedTeamApprovalAuthorityStorageOps { + constructor( + private readonly getDatabase: () => SqliteDatabase, + private readonly storageNow: () => number = Date.now + ) {} + + handle(op: HostedTeamApprovalAuthorityWorkerOp, payload: unknown): unknown { + switch (op) { + case 'hostedTeamApprovalAuthority.observe': + return this.observe(payload); + case 'hostedTeamApprovalAuthority.readPending': + return this.readPending(payload); + case 'hostedTeamApprovalAuthority.readPreview': + return this.readPreview(payload); + case 'hostedTeamApprovalAuthority.decide': + return this.decide(payload); + case 'hostedTeamApprovalAuthority.claimDeliveries': + return this.claimDeliveries(payload); + case 'hostedTeamApprovalAuthority.acknowledgeDelivery': + this.acknowledgeDelivery(payload); + return undefined; + case 'hostedTeamApprovalAuthority.markDeliveryOperatorRequired': + markHostedTeamApprovalDeliveryOperatorRequired( + this.getDatabase(), + payload, + this.storageNow + ); + return undefined; + case 'hostedTeamApprovalAuthority.readDeliveryReconciliation': + return readHostedTeamApprovalDeliveryReconciliation( + this.getDatabase(), + payload, + this.storageNow + ); + case 'hostedTeamApprovalAuthority.settleDeliveryReconciliation': + settleHostedTeamApprovalDeliveryReconciliation( + this.getDatabase(), + payload, + this.storageNow + ); + return undefined; + case 'hostedTeamApprovalAuthority.auditTimeouts': + return this.auditTimeouts(payload); + } + } + + private observe(value: unknown): HostedTeamApprovalPendingReadRecord { + const input = parseHostedTeamApprovalPendingStorageRecord(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDatabase(); + return db + .transaction(() => { + assertDeadlineOpen(input.deadlineAtMs); + assertInternalStorageMutationAdmissionOpen(db, null); + const observedAtMs = this.nowMs(); + if (input.observedAtMs > observedAtMs) { + throw new Error('hosted-team-approval-storage-observation-time-invalid'); + } + const identityHash = hashHostedTeamApprovalIdentity(input); + const existing = db + .prepare( + `SELECT ${RECORD_COLUMNS} FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND request_id = ?` + ) + .get(...scopeParameters(input.scope), input.runId, input.requestId) as + | UnknownRow + | undefined; + if (existing) { + if ( + existing.approval_id !== input.approvalId || + existing.approval_generation !== input.approvalGeneration + ) { + throw new Error('hosted-team-approval-storage-observation-identity-conflict'); + } + if (existing.state !== 'pending') { + throw new Error('hosted-team-approval-storage-observation-generation-stale'); + } + if (existing.payload_hash !== identityHash) { + throw new Error('hosted-team-approval-storage-observation-identity-conflict'); + } + return pendingRecordFromRow(existing); + } + const derivedIdentity = db + .prepare( + `SELECT request_id, approval_generation + FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND approval_id = ?` + ) + .get(...scopeParameters(input.scope), input.runId, input.approvalId) as + | UnknownRow + | undefined; + if (derivedIdentity) { + throw new Error('hosted-team-approval-storage-observation-identity-conflict'); + } + db.prepare( + `INSERT INTO hosted_team_approval_records ( + workspace_id, team_id, authority_generation, restore_generation, + run_id, request_id, approval_id, approval_generation, + category, summary, requested_at_ms, expires_at_ms, + preview_ref, preview_content, preview_byte_length, preview_truncated, preview_is_binary, + delivery_ref, state, decision, revision, observed_at_ms, resolved_at_ms, + last_idempotency_key, payload_hash + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + 'pending', NULL, 1, ?, NULL, NULL, ?)` + ).run( + ...scopeParameters(input.scope), + input.runId, + input.requestId, + input.approvalId, + input.approvalGeneration, + input.category, + input.summary, + input.requestedAtMs, + input.expiresAtMs, + input.preview?.previewRef ?? null, + input.preview?.content ?? null, + input.preview?.byteLength ?? null, + input.preview === null ? null : input.preview.truncated ? 1 : 0, + input.preview === null ? null : input.preview.isBinary ? 1 : 0, + input.deliveryRef, + input.observedAtMs, + identityHash + ); + assertDeadlineOpen(input.deadlineAtMs); + const row = db + .prepare( + `SELECT ${RECORD_COLUMNS} + FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND request_id = ?` + ) + .get(...scopeParameters(input.scope), input.runId, input.requestId) as + | UnknownRow + | undefined; + if (!row) throw new Error('hosted-team-approval-storage-observe-missing'); + return pendingRecordFromRow(row); + }) + .immediate(); + } + + private readPending(value: unknown): ReturnType { + const input = parseHostedTeamApprovalPendingReadRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDatabase(); + return db + .transaction(() => { + assertInternalStorageMutationAdmissionOpen(db, null); + expireHostedTeamApprovals({ + db, + scope: input.scope, + expectedRunId: input.expectedRunId, + nowMs: this.nowMs(), + }); + if (input.afterApprovalId !== null && input.afterApprovalGenerationHash !== null) { + const cursor = db + .prepare( + `SELECT approval_generation + FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND approval_id = ? AND state = 'pending'` + ) + .get(...scopeParameters(input.scope), input.expectedRunId, input.afterApprovalId) as + | { readonly approval_generation: unknown } + | undefined; + if ( + typeof cursor?.approval_generation !== 'string' || + hashHostedTeamApprovalGeneration(cursor.approval_generation) !== + input.afterApprovalGenerationHash + ) { + throw new Error('hosted-team-approval-storage-pending-cursor-stale'); + } + } + const rows = db + .prepare( + `SELECT ${RECORD_COLUMNS} + FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND state = 'pending' + AND (expires_at_ms IS NULL OR expires_at_ms > ?) + AND (? IS NULL OR approval_id > ?) + ORDER BY approval_id ASC + LIMIT ?` + ) + .all( + ...scopeParameters(input.scope), + input.expectedRunId, + this.nowMs(), + input.afterApprovalId, + input.afterApprovalId, + input.limit + 1 + ) as UnknownRow[]; + assertDeadlineOpen(input.deadlineAtMs); + return parseHostedTeamApprovalPendingReadResult({ + records: rows.slice(0, input.limit).map(pendingRecordFromRow), + hasMore: rows.length > input.limit, + }); + }) + .immediate(); + } + + private readPreview(value: unknown): HostedTeamApprovalPreviewReadResult { + const input = parseHostedTeamApprovalPreviewReadRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDatabase(); + db.transaction(() => { + assertInternalStorageMutationAdmissionOpen(db, null); + expireHostedTeamApprovals({ + db, + scope: input.scope, + expectedRunId: input.expectedRunId, + nowMs: this.nowMs(), + approvalId: input.approvalId, + approvalGeneration: input.expectedApprovalGeneration, + }); + }).immediate(); + const current = db + .prepare( + `SELECT ${RECORD_COLUMNS} + FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? + AND approval_id = ? AND approval_generation = ? AND state = 'pending'` + ) + .get( + ...scopeParameters(input.scope), + input.expectedRunId, + input.approvalId, + input.expectedApprovalGeneration + ) as UnknownRow | undefined; + if (current) { + assertDeadlineOpen(input.deadlineAtMs); + return current.preview_ref === input.previewRef + ? previewFromRow(current) + : Object.freeze({ kind: 'not_found' }); + } + const replacement = readReplacementGeneration( + db, + input.scope, + input.expectedRunId, + input.approvalId, + input.expectedApprovalGeneration + ); + assertDeadlineOpen(input.deadlineAtMs); + if (replacement !== null) { + return { + kind: 'stale_generation', + currentApprovalGeneration: replacement, + }; + } + return Object.freeze({ kind: 'not_found' }); + } + + private decide(value: unknown): HostedTeamApprovalDecisionStorageResult { + const input = parseHostedTeamApprovalDecisionStorageRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDatabase(); + return db + .transaction(() => { + assertDeadlineOpen(input.deadlineAtMs); + assertInternalStorageMutationAdmissionOpen(db, null); + expireHostedTeamApprovals({ + db, + scope: input.scope, + expectedRunId: input.expectedRunId, + nowMs: this.nowMs(), + approvalId: input.approvalId, + approvalGeneration: input.expectedApprovalGeneration, + }); + const record = readRecord( + db, + input.scope, + input.expectedRunId, + input.approvalId, + input.expectedApprovalGeneration + ); + if (!record) { + const replacement = readReplacementGeneration( + db, + input.scope, + input.expectedRunId, + input.approvalId, + input.expectedApprovalGeneration + ); + return replacement === null + ? Object.freeze({ kind: 'not_found' as const }) + : Object.freeze({ + kind: 'stale_generation' as const, + currentApprovalGeneration: replacement, + }); + } + if ( + typeof record.payload_hash !== 'string' || + !/^[a-f0-9]{64}$/.test(record.payload_hash) + ) { + throw new Error('hosted-team-approval-storage-approval-identity-invalid'); + } + const payloadHash = hashHostedTeamApprovalDecision(input.payloadHash, record.payload_hash); + + const idempotency = db + .prepare( + `SELECT approval_id, approval_generation, decision, payload_hash, revision + FROM hosted_team_approval_idempotency + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND idempotency_key = ?` + ) + .get(...scopeParameters(input.scope), input.expectedRunId, input.idempotencyKey) as + | UnknownRow + | undefined; + if (idempotency) { + if ( + idempotency.approval_id !== input.approvalId || + idempotency.approval_generation !== input.expectedApprovalGeneration || + idempotency.decision !== input.decision || + idempotency.payload_hash !== payloadHash + ) { + return Object.freeze({ + kind: 'conflict' as const, + reason: 'idempotency_mismatch' as const, + }); + } + if (!isPositiveInteger(idempotency.revision)) { + throw new Error('hosted-team-approval-storage-revision-invalid'); + } + return Object.freeze({ + kind: 'idempotent_replay' as const, + receipt: { + approvalGeneration: input.expectedApprovalGeneration, + decision: input.decision, + revision: idempotency.revision, + }, + }); + } + + if (record.state === 'resolved' && record.decision === 'timeout') { + return Object.freeze({ kind: 'expired' as const }); + } + if ( + record.state !== 'pending' || + (typeof record.decision !== 'undefined' && record.decision !== null) + ) { + if (typeof record.approval_generation !== 'string' || !isDecision(record.decision)) { + throw new Error('hosted-team-approval-storage-resolved-record-invalid'); + } + return Object.freeze({ + kind: 'already_resolved' as const, + approvalGeneration: record.approval_generation, + decision: record.decision, + }); + } + const revision = record.revision; + if (!isPositiveInteger(revision)) { + throw new Error('hosted-team-approval-storage-revision-invalid'); + } + const nextRevision = revision + 1; + const occurredAtMs = nextAuditTime(db, input.scope, record, this.nowMs()); + const update = db + .prepare( + `UPDATE hosted_team_approval_records + SET state = 'resolved', decision = ?, revision = ?, resolved_at_ms = ?, + last_idempotency_key = ? + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? + AND approval_id = ? AND approval_generation = ? + AND state = 'pending' AND revision = ?` + ) + .run( + input.decision, + nextRevision, + occurredAtMs, + input.idempotencyKey, + ...scopeParameters(input.scope), + input.expectedRunId, + input.approvalId, + input.expectedApprovalGeneration, + revision + ); + if (update.changes !== 1) { + throw new Error('hosted-team-approval-storage-decision-cas-lost'); + } + + const deliveryRef = record.delivery_ref; + if (typeof deliveryRef !== 'string') { + throw new Error('hosted-team-approval-storage-delivery-reference-invalid'); + } + const intentJson = serializeHostedTeamApprovalDeliveryIntent({ + partition: { teamId: input.scope.teamId, runId: input.expectedRunId }, + requestId: record.request_id as string, + approvalId: input.approvalId, + approvalGeneration: input.expectedApprovalGeneration, + decision: input.decision, + payloadHash, + deliveryId: input.delivery.deliveryId, + principal: { kind: 'operator', actorId: input.audit.principalId }, + deliveryRef, + }); + db.prepare( + `INSERT INTO hosted_team_approval_audit ( + audit_id, workspace_id, team_id, authority_generation, restore_generation, + run_id, request_id, approval_id, approval_generation, + decision, payload_hash, actor_id, session_id, occurred_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + input.audit.auditId, + ...scopeParameters(input.scope), + input.expectedRunId, + record.request_id, + input.approvalId, + input.expectedApprovalGeneration, + input.decision, + payloadHash, + input.audit.principalId, + input.audit.sessionId, + occurredAtMs + ); + db.prepare( + `INSERT INTO hosted_team_approval_delivery_outbox ( + delivery_id, principal_id, workspace_id, team_id, authority_generation, + restore_generation, run_id, request_id, approval_id, approval_generation, + decision, payload_hash, delivery_ref, intent_json, + state, delivery_generation, delivery_owner_id, delivery_lease_token, delivery_claimed_at_ms, + delivery_lease_expires_at_ms, delivered_at_ms, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, NULL, NULL, NULL, ?)` + ).run( + input.delivery.deliveryId, + JSON.stringify({ kind: 'operator', actorId: input.audit.principalId }), + ...scopeParameters(input.scope), + input.expectedRunId, + record.request_id, + input.approvalId, + input.expectedApprovalGeneration, + input.decision, + payloadHash, + deliveryRef, + intentJson, + occurredAtMs + ); + db.prepare( + `INSERT INTO hosted_team_approval_idempotency ( + workspace_id, team_id, authority_generation, restore_generation, + run_id, idempotency_key, request_id, approval_id, + approval_generation, decision, payload_hash, revision, + audit_id, delivery_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + ...scopeParameters(input.scope), + input.expectedRunId, + input.idempotencyKey, + record.request_id, + input.approvalId, + input.expectedApprovalGeneration, + input.decision, + payloadHash, + nextRevision, + input.audit.auditId, + input.delivery.deliveryId, + occurredAtMs + ); + assertDeadlineOpen(input.deadlineAtMs); + return Object.freeze({ + kind: 'committed' as const, + receipt: { + approvalGeneration: input.expectedApprovalGeneration, + decision: input.decision, + revision: nextRevision, + }, + }); + }) + .immediate(); + } + + private claimDeliveries(value: unknown): readonly HostedTeamApprovalDeliveryRecord[] { + const input = parseHostedTeamApprovalDeliveryClaimRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDatabase(); + return db + .transaction(() => { + assertDeadlineOpen(input.deadlineAtMs); + assertInternalStorageMutationAdmissionOpen(db, null); + const claimedAtMs = this.nowMs(); + expireHostedTeamApprovals({ db, nowMs: claimedAtMs }); + const leaseExpiresAtMs = claimedAtMs + input.leaseDurationMs; + if (!Number.isSafeInteger(leaseExpiresAtMs)) { + throw new Error('hosted-team-approval-storage-delivery-lease-invalid'); + } + const candidates = db + .prepare( + `SELECT ${DELIVERY_COLUMNS} + FROM hosted_team_approval_delivery_outbox + WHERE workspace_id = ? AND team_id = ? + AND authority_generation = ? AND restore_generation = ? + AND state = 'pending' + AND ( + (delivery_owner_id IS NULL AND delivery_lease_token IS NULL + AND delivery_lease_expires_at_ms IS NULL) + OR delivery_lease_expires_at_ms <= ? + ) + ORDER BY created_at_ms ASC, delivery_id ASC + LIMIT ?` + ) + .all( + input.workspaceId, + input.teamId, + input.authorityGeneration, + input.restoreGeneration, + claimedAtMs, + input.limit + ) as UnknownRow[]; + const claimed: HostedTeamApprovalDeliveryRecord[] = []; + for (const candidate of candidates) { + const update = db + .prepare( + `UPDATE hosted_team_approval_delivery_outbox + SET delivery_generation = delivery_generation + 1, + delivery_owner_id = ?, delivery_lease_token = ?, + delivery_claimed_at_ms = ?, delivery_lease_expires_at_ms = ? + WHERE delivery_id = ? + AND state = 'pending' + AND ( + (delivery_owner_id IS NULL AND delivery_lease_token IS NULL + AND delivery_lease_expires_at_ms IS NULL) + OR delivery_lease_expires_at_ms <= ? + )` + ) + .run( + input.ownerId, + input.leaseToken, + claimedAtMs, + leaseExpiresAtMs, + candidate.delivery_id, + claimedAtMs + ); + if (update.changes !== 1) { + throw new Error('hosted-team-approval-storage-delivery-claim-lost'); + } + const row = db + .prepare( + `SELECT ${DELIVERY_COLUMNS} + FROM hosted_team_approval_delivery_outbox + WHERE delivery_id = ? AND workspace_id = ? AND authority_generation = ? + AND restore_generation = ?` + ) + .get( + candidate.delivery_id, + input.workspaceId, + input.authorityGeneration, + input.restoreGeneration + ) as UnknownRow | undefined; + if (!row) throw new Error('hosted-team-approval-storage-delivery-missing'); + claimed.push(deliveryRecordFromRow(row, input)); + } + assertDeadlineOpen(input.deadlineAtMs); + return Object.freeze(claimed); + }) + .immediate(); + } + + private acknowledgeDelivery(value: unknown): void { + const input = parseHostedTeamApprovalDeliveryAcknowledgeRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDatabase(); + db.transaction(() => { + assertDeadlineOpen(input.deadlineAtMs); + assertInternalStorageMutationAdmissionOpen(db, null); + const acknowledgedAtMs = this.nowMs(); + const row = db + .prepare( + `SELECT ${DELIVERY_COLUMNS} + FROM hosted_team_approval_delivery_outbox + WHERE delivery_id = ? AND workspace_id = ? AND authority_generation = ? + AND restore_generation = ? AND team_id = ? AND run_id = ?` + ) + .get( + input.deliveryId, + input.workspaceId, + input.authorityGeneration, + input.restoreGeneration, + input.partition.teamId, + input.partition.runId + ) as UnknownRow | undefined; + if (!row) throw new Error('hosted-team-approval-storage-delivery-not-found'); + const leaseMatches = + row.delivery_generation === input.deliveryGeneration && + row.delivery_owner_id === input.ownerId && + row.delivery_lease_token === input.leaseToken; + if (row.state === 'delivered') { + if (!leaseMatches) throw new Error('hosted-team-approval-storage-delivery-ack-conflict'); + return; + } + if ( + row.state !== 'pending' || + !leaseMatches || + typeof row.delivery_lease_expires_at_ms !== 'number' || + row.delivery_lease_expires_at_ms <= acknowledgedAtMs + ) { + throw new Error('hosted-team-approval-storage-delivery-ack-conflict'); + } + const update = db + .prepare( + `UPDATE hosted_team_approval_delivery_outbox + SET state = 'delivered', delivered_at_ms = ? + WHERE delivery_id = ? + AND workspace_id = ? AND authority_generation = ? AND restore_generation = ? + AND team_id = ? AND run_id = ? + AND state = 'pending' AND delivery_generation = ? + AND delivery_owner_id = ? AND delivery_lease_token = ? + AND delivery_lease_expires_at_ms > ?` + ) + .run( + acknowledgedAtMs, + input.deliveryId, + input.workspaceId, + input.authorityGeneration, + input.restoreGeneration, + input.partition.teamId, + input.partition.runId, + input.deliveryGeneration, + input.ownerId, + input.leaseToken, + acknowledgedAtMs + ); + if (update.changes !== 1) { + throw new Error('hosted-team-approval-storage-delivery-ack-conflict'); + } + assertDeadlineOpen(input.deadlineAtMs); + }).immediate(); + } + + private auditTimeouts(value: unknown): { + readonly resolvedCount: number; + readonly nextAuditTimeMs: number | null; + } { + const input = parseHostedTeamApprovalTimeoutAuditRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + const db = this.getDatabase(); + return db + .transaction(() => { + assertDeadlineOpen(input.deadlineAtMs); + assertInternalStorageMutationAdmissionOpen(db, null); + const highWater = db + .prepare( + `SELECT MAX(value) AS value FROM ( + SELECT MAX(observed_at_ms) AS value FROM hosted_team_approval_records + UNION ALL SELECT MAX(resolved_at_ms) FROM hosted_team_approval_records + UNION ALL SELECT MAX(occurred_at_ms) FROM hosted_team_approval_audit + UNION ALL SELECT MAX(created_at_ms) FROM hosted_team_approval_delivery_outbox + UNION ALL SELECT MAX(delivered_at_ms) FROM hosted_team_approval_delivery_outbox + UNION ALL SELECT MAX(operator_required_at_ms) FROM hosted_team_approval_delivery_outbox + )` + ) + .get() as { readonly value: unknown } | undefined; + const previous = highWater?.value; + if (previous !== null && previous !== undefined && !isNonNegativeInteger(previous)) { + throw new Error('hosted-team-approval-storage-chronology-invalid'); + } + const auditTimeMs = Math.max( + input.nextAuditTimeMs, + this.nowMs(), + previous === null || previous === undefined ? 0 : previous + ); + if (!Number.isSafeInteger(auditTimeMs)) { + throw new Error('hosted-team-approval-storage-chronology-invalid'); + } + const resolvedCount = expireHostedTeamApprovals({ db, nowMs: auditTimeMs }); + const next = db + .prepare( + `SELECT MIN(expires_at_ms) AS expires_at_ms + FROM hosted_team_approval_records + WHERE state = 'pending' AND expires_at_ms IS NOT NULL` + ) + .get() as { readonly expires_at_ms: unknown } | undefined; + const nextAuditTimeMs = next?.expires_at_ms; + if ( + nextAuditTimeMs !== null && + nextAuditTimeMs !== undefined && + (!isNonNegativeInteger(nextAuditTimeMs) || nextAuditTimeMs <= auditTimeMs) + ) { + throw new Error('hosted-team-approval-storage-next-audit-time-invalid'); + } + assertDeadlineOpen(input.deadlineAtMs); + return Object.freeze({ + resolvedCount, + nextAuditTimeMs: + nextAuditTimeMs === null || nextAuditTimeMs === undefined ? null : nextAuditTimeMs, + }); + }) + .immediate(); + } + + private nowMs(): number { + const value = this.storageNow(); + if (!isNonNegativeInteger(value)) { + throw new Error('hosted-team-approval-storage-clock-invalid'); + } + return value; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageSupport.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageSupport.ts new file mode 100644 index 0000000000..788e89b070 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalAuthorityStorageSupport.ts @@ -0,0 +1,209 @@ +import { + parseHostedTeamApprovalDeliveryRecord, + parseHostedTeamApprovalPendingReadRecord, + parseHostedTeamApprovalPreviewStorageRecord, +} from '../../application/hostedTeamApprovalAuthorityStorage'; + +import type { + HostedTeamApprovalAuthorityScope, + HostedTeamApprovalDeliveryRecord, + HostedTeamApprovalPendingReadRecord, + HostedTeamApprovalPreviewReadResult, + HostedTeamApprovalStorageDecision, +} from '../../../contracts/hostedTeamApprovalAuthorityStorageContracts'; +import type DatabaseConstructor from 'better-sqlite3'; + +export type SqliteDatabase = InstanceType; +export type UnknownRow = Record; + +export const RECORD_COLUMNS = ` + workspace_id, team_id, authority_generation, restore_generation, + run_id, request_id, approval_id, approval_generation, + category, summary, requested_at_ms, expires_at_ms, + preview_ref, preview_content, preview_byte_length, preview_truncated, preview_is_binary, + delivery_ref, state, decision, revision, observed_at_ms, resolved_at_ms, + last_idempotency_key, payload_hash`; + +export const DELIVERY_COLUMNS = ` + delivery_id, principal_id, workspace_id, team_id, authority_generation, restore_generation, + run_id, request_id, approval_id, approval_generation, + decision, payload_hash, delivery_ref, state, + delivery_generation, delivery_owner_id, delivery_lease_token, delivery_claimed_at_ms, + delivery_lease_expires_at_ms, delivered_at_ms, created_at_ms`; + +export function assertDeadlineOpen(deadlineAtMs: number): void { + if (!Number.isSafeInteger(deadlineAtMs) || deadlineAtMs < 1 || Date.now() >= deadlineAtMs) { + throw new Error('hosted-team-approval-storage-deadline-expired'); + } +} + +export function isNonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +export function isPositiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +export function scopeParameters(scope: HostedTeamApprovalAuthorityScope): readonly unknown[] { + return [scope.workspaceId, scope.teamId, scope.authorityGeneration, scope.restoreGeneration]; +} + +function partitionFromRow(row: UnknownRow): { readonly teamId: string; readonly runId: string } { + return { + teamId: row.team_id as string, + runId: row.run_id as string, + }; +} + +export function pendingRecordFromRow(row: UnknownRow): HostedTeamApprovalPendingReadRecord { + return parseHostedTeamApprovalPendingReadRecord({ + runId: row.run_id, + requestId: row.request_id, + approvalId: row.approval_id, + approvalGeneration: row.approval_generation, + category: row.category, + summary: row.summary, + requestedAtMs: row.requested_at_ms, + expiresAtMs: row.expires_at_ms, + previewRef: row.preview_ref, + }); +} + +export function previewFromRow(row: UnknownRow): HostedTeamApprovalPreviewReadResult { + if ( + typeof row.preview_ref !== 'string' || + typeof row.preview_content !== 'string' || + !Number.isSafeInteger(row.preview_byte_length) || + (row.preview_truncated !== 0 && row.preview_truncated !== 1) || + (row.preview_is_binary !== 0 && row.preview_is_binary !== 1) + ) { + return Object.freeze({ kind: 'not_found' }); + } + return Object.freeze({ + kind: 'found', + preview: parseHostedTeamApprovalPreviewStorageRecord({ + previewRef: row.preview_ref, + content: row.preview_content, + byteLength: row.preview_byte_length, + truncated: row.preview_truncated === 1, + isBinary: row.preview_is_binary === 1, + }), + }); +} + +export function deliveryRecordFromRow( + row: UnknownRow, + expected: { readonly ownerId: string; readonly leaseToken: string } +): HostedTeamApprovalDeliveryRecord { + if ( + row.delivery_owner_id !== expected.ownerId || + row.delivery_lease_token !== expected.leaseToken || + row.state !== 'pending' + ) { + throw new Error('hosted-team-approval-storage-delivery-claim-lost'); + } + return parseHostedTeamApprovalDeliveryRecord({ + deliveryId: row.delivery_id, + principal: JSON.parse(row.principal_id as string) as unknown, + workspaceId: row.workspace_id, + authorityGeneration: row.authority_generation, + restoreGeneration: row.restore_generation, + partition: partitionFromRow(row), + requestId: row.request_id, + approvalId: row.approval_id, + approvalGeneration: row.approval_generation, + decision: row.decision, + payloadHash: row.payload_hash, + deliveryRef: row.delivery_ref, + deliveryGeneration: row.delivery_generation, + ownerId: row.delivery_owner_id, + leaseToken: row.delivery_lease_token, + claimedAtMs: row.delivery_claimed_at_ms, + leaseExpiresAtMs: row.delivery_lease_expires_at_ms, + createdAtMs: row.created_at_ms, + }); +} + +export function readRecord( + db: SqliteDatabase, + scope: HostedTeamApprovalAuthorityScope, + expectedRunId: string, + approvalId: string, + approvalGeneration: string +): UnknownRow | undefined { + return db + .prepare( + `SELECT ${RECORD_COLUMNS} + FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? + AND approval_id = ? AND approval_generation = ?` + ) + .get(...scopeParameters(scope), expectedRunId, approvalId, approvalGeneration) as + | UnknownRow + | undefined; +} + +export function readReplacementGeneration( + db: SqliteDatabase, + scope: HostedTeamApprovalAuthorityScope, + expectedRunId: string, + approvalId: string, + expectedApprovalGeneration: string +): string | null { + const row = db + .prepare( + `SELECT approval_generation + FROM hosted_team_approval_records + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? + AND approval_id = ? AND approval_generation <> ? + ORDER BY CASE state WHEN 'pending' THEN 0 WHEN 'resolved' THEN 1 ELSE 2 END, + COALESCE(resolved_at_ms, observed_at_ms) DESC + LIMIT 1` + ) + .get(...scopeParameters(scope), expectedRunId, approvalId, expectedApprovalGeneration) as + | { readonly approval_generation: unknown } + | undefined; + return row && typeof row.approval_generation === 'string' ? row.approval_generation : null; +} + +export function nextAuditTime( + db: SqliteDatabase, + scope: HostedTeamApprovalAuthorityScope, + record: UnknownRow, + nowMs: number +): number { + if ( + !isNonNegativeInteger(record.requested_at_ms) || + !isNonNegativeInteger(record.observed_at_ms) + ) { + throw new Error('hosted-team-approval-storage-chronology-invalid'); + } + const latest = db + .prepare( + `SELECT MAX(occurred_at_ms) AS occurred_at_ms + FROM hosted_team_approval_audit + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ?` + ) + .get(...scopeParameters(scope)) as { readonly occurred_at_ms: unknown } | undefined; + const previous = latest?.occurred_at_ms; + if (previous !== null && previous !== undefined && !isNonNegativeInteger(previous)) { + throw new Error('hosted-team-approval-storage-chronology-invalid'); + } + const occurredAtMs = Math.max( + nowMs, + record.requested_at_ms, + record.observed_at_ms, + previous === null || previous === undefined ? 0 : previous + 1 + ); + if (!Number.isSafeInteger(occurredAtMs)) { + throw new Error('hosted-team-approval-storage-chronology-invalid'); + } + return occurredAtMs; +} + +export const isDecision = (value: unknown): value is HostedTeamApprovalStorageDecision => + value === 'allow' || value === 'deny' || value === 'timeout'; diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalCanonicalIdentityStorageMigration.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalCanonicalIdentityStorageMigration.ts new file mode 100644 index 0000000000..698bb78835 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalCanonicalIdentityStorageMigration.ts @@ -0,0 +1,142 @@ +/** + * Forward-only v21 migration from the released v18 approval schema. + * + * v18 did not retain a provider request id or run id. A temporary mapping gives + * every historical approval row a stable, collision-free legacy run partition; + * child rows join through the complete released v18 foreign-key identity. + */ +export const HOSTED_TEAM_APPROVAL_CANONICAL_IDENTITY_STORAGE_MIGRATION_STATEMENTS = [ + `CREATE TEMP TABLE hosted_team_approval_v21_identity_map AS + SELECT principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation, printf('run_%032x', rowid) AS run_id, + approval_id AS request_id + FROM hosted_team_approval_records`, + `CREATE TABLE hosted_team_approval_records_v21 ( + workspace_id TEXT NOT NULL, team_id TEXT NOT NULL, authority_generation TEXT NOT NULL, + restore_generation INTEGER NOT NULL, run_id TEXT NOT NULL, request_id TEXT NOT NULL, + approval_id TEXT NOT NULL, approval_generation TEXT NOT NULL, + category TEXT NOT NULL, summary TEXT NOT NULL, requested_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER, preview_ref TEXT, preview_content TEXT, + preview_byte_length INTEGER, preview_truncated INTEGER, preview_is_binary INTEGER, + delivery_ref TEXT NOT NULL, state TEXT NOT NULL, decision TEXT, + revision INTEGER NOT NULL CHECK (revision > 0), observed_at_ms INTEGER NOT NULL, + resolved_at_ms INTEGER, last_idempotency_key TEXT, payload_hash TEXT, + PRIMARY KEY (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id), + CHECK (requested_at_ms >= 0 AND observed_at_ms >= requested_at_ms + AND (expires_at_ms IS NULL OR expires_at_ms > requested_at_ms)), + CHECK (state IN ('pending', 'superseded', 'resolved') + AND (state = 'resolved') = (decision IS NOT NULL) + AND (state = 'pending') = (resolved_at_ms IS NULL)), + CHECK ((preview_ref IS NULL AND preview_content IS NULL AND preview_byte_length IS NULL + AND preview_truncated IS NULL AND preview_is_binary IS NULL) + OR (preview_ref IS NOT NULL AND preview_content IS NOT NULL + AND preview_byte_length IS NOT NULL AND preview_truncated IS NOT NULL + AND preview_is_binary IS NOT NULL)) + )`, + `INSERT INTO hosted_team_approval_records_v21 + SELECT legacy.workspace_id, legacy.team_id, legacy.authority_generation, + legacy.restore_generation, identity_map.run_id, identity_map.request_id, + legacy.approval_id, legacy.approval_generation, legacy.category, legacy.summary, + legacy.requested_at_ms, legacy.expires_at_ms, legacy.preview_ref, legacy.preview_content, + legacy.preview_byte_length, legacy.preview_truncated, legacy.preview_is_binary, + legacy.delivery_ref, legacy.state, legacy.decision, legacy.revision, + legacy.observed_at_ms, legacy.resolved_at_ms, legacy.last_idempotency_key, + legacy.payload_hash + FROM hosted_team_approval_records AS legacy + JOIN hosted_team_approval_v21_identity_map AS identity_map + USING (principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation)`, + `CREATE TABLE hosted_team_approval_idempotency_v21 ( + workspace_id TEXT NOT NULL, team_id TEXT NOT NULL, authority_generation TEXT NOT NULL, + restore_generation INTEGER NOT NULL, run_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, + request_id TEXT NOT NULL, approval_id TEXT NOT NULL, approval_generation TEXT NOT NULL, + decision TEXT NOT NULL, payload_hash TEXT NOT NULL, revision INTEGER NOT NULL CHECK (revision > 0), + audit_id TEXT NOT NULL UNIQUE, delivery_id TEXT NOT NULL UNIQUE, + created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0), + PRIMARY KEY (workspace_id, team_id, authority_generation, restore_generation, run_id, idempotency_key), + FOREIGN KEY (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + REFERENCES hosted_team_approval_records_v21 (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + ON DELETE RESTRICT ON UPDATE RESTRICT + )`, + `INSERT INTO hosted_team_approval_idempotency_v21 + SELECT legacy.workspace_id, legacy.team_id, legacy.authority_generation, + legacy.restore_generation, identity_map.run_id, legacy.idempotency_key, + identity_map.request_id, legacy.approval_id, legacy.approval_generation, + legacy.decision, legacy.payload_hash, legacy.revision, legacy.audit_id, + legacy.delivery_id, legacy.created_at_ms + FROM hosted_team_approval_idempotency AS legacy + JOIN hosted_team_approval_v21_identity_map AS identity_map + USING (principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation)`, + `CREATE TABLE hosted_team_approval_audit_v21 ( + audit_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, team_id TEXT NOT NULL, + authority_generation TEXT NOT NULL, restore_generation INTEGER NOT NULL, run_id TEXT NOT NULL, + request_id TEXT NOT NULL, approval_id TEXT NOT NULL, approval_generation TEXT NOT NULL, + decision TEXT NOT NULL, payload_hash TEXT NOT NULL, actor_id TEXT NOT NULL, + session_id TEXT NOT NULL, occurred_at_ms INTEGER NOT NULL CHECK (occurred_at_ms >= 0), + FOREIGN KEY (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + REFERENCES hosted_team_approval_records_v21 (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + ON DELETE RESTRICT ON UPDATE RESTRICT + )`, + `INSERT INTO hosted_team_approval_audit_v21 + SELECT legacy.audit_id, legacy.workspace_id, legacy.team_id, legacy.authority_generation, + legacy.restore_generation, identity_map.run_id, identity_map.request_id, + legacy.approval_id, legacy.approval_generation, legacy.decision, legacy.payload_hash, + legacy.actor_id, legacy.session_id, legacy.occurred_at_ms + FROM hosted_team_approval_audit AS legacy + JOIN hosted_team_approval_v21_identity_map AS identity_map + USING (principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation)`, + `CREATE TABLE hosted_team_approval_delivery_outbox_v21 ( + delivery_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, team_id TEXT NOT NULL, + authority_generation TEXT NOT NULL, restore_generation INTEGER NOT NULL, run_id TEXT NOT NULL, + request_id TEXT NOT NULL, approval_id TEXT NOT NULL, approval_generation TEXT NOT NULL, + decision TEXT NOT NULL, payload_hash TEXT NOT NULL, delivery_ref TEXT NOT NULL, + intent_json TEXT NOT NULL CHECK (json_valid(intent_json)), state TEXT NOT NULL, + delivery_generation INTEGER NOT NULL CHECK (delivery_generation >= 0), + delivery_owner_id TEXT, delivery_lease_token TEXT, delivery_claimed_at_ms INTEGER, + delivery_lease_expires_at_ms INTEGER, delivered_at_ms INTEGER, + created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0), + UNIQUE (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id), + FOREIGN KEY (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + REFERENCES hosted_team_approval_records_v21 (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + ON DELETE RESTRICT ON UPDATE RESTRICT, + CHECK (state IN ('pending', 'delivered') AND (state = 'delivered') = (delivered_at_ms IS NOT NULL)), + CHECK ((delivery_owner_id IS NULL AND delivery_lease_token IS NULL + AND delivery_claimed_at_ms IS NULL AND delivery_lease_expires_at_ms IS NULL) + OR (delivery_owner_id IS NOT NULL AND delivery_lease_token IS NOT NULL + AND delivery_claimed_at_ms IS NOT NULL AND delivery_lease_expires_at_ms > delivery_claimed_at_ms)) + )`, + `INSERT INTO hosted_team_approval_delivery_outbox_v21 + SELECT legacy.delivery_id, legacy.workspace_id, legacy.team_id, legacy.authority_generation, + legacy.restore_generation, identity_map.run_id, identity_map.request_id, + legacy.approval_id, legacy.approval_generation, legacy.decision, legacy.payload_hash, + legacy.delivery_ref, legacy.intent_json, legacy.state, legacy.delivery_generation, + legacy.delivery_owner_id, legacy.delivery_lease_token, legacy.delivery_claimed_at_ms, + legacy.delivery_lease_expires_at_ms, legacy.delivered_at_ms, legacy.created_at_ms + FROM hosted_team_approval_delivery_outbox AS legacy + JOIN hosted_team_approval_v21_identity_map AS identity_map + USING (principal_id, workspace_id, team_id, authority_generation, restore_generation, + approval_id, approval_generation)`, + `DROP TABLE hosted_team_approval_delivery_outbox`, + `DROP TABLE hosted_team_approval_audit`, + `DROP TABLE hosted_team_approval_idempotency`, + `DROP TABLE hosted_team_approval_records`, + `ALTER TABLE hosted_team_approval_records_v21 RENAME TO hosted_team_approval_records`, + `ALTER TABLE hosted_team_approval_idempotency_v21 RENAME TO hosted_team_approval_idempotency`, + `ALTER TABLE hosted_team_approval_audit_v21 RENAME TO hosted_team_approval_audit`, + `ALTER TABLE hosted_team_approval_delivery_outbox_v21 RENAME TO hosted_team_approval_delivery_outbox`, + `DROP TABLE hosted_team_approval_v21_identity_map`, + `CREATE UNIQUE INDEX idx_hosted_team_approval_identity + ON hosted_team_approval_records + (workspace_id, team_id, authority_generation, restore_generation, run_id, approval_id)`, + `CREATE INDEX idx_hosted_team_approval_pending_page + ON hosted_team_approval_records (team_id, state, approval_id)`, + `CREATE INDEX idx_hosted_team_approval_pending_partition + ON hosted_team_approval_records (team_id, run_id, state, approval_id)`, + `CREATE INDEX idx_hosted_team_approval_audit_partition + ON hosted_team_approval_audit (team_id, run_id, occurred_at_ms)`, + `CREATE INDEX idx_hosted_team_approval_delivery_pending + ON hosted_team_approval_delivery_outbox + (state, delivery_owner_id, delivery_lease_expires_at_ms, created_at_ms, delivery_id)`, +] as const; diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalDeliveryReconciliationStorageMigration.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalDeliveryReconciliationStorageMigration.ts new file mode 100644 index 0000000000..f081d1e500 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalDeliveryReconciliationStorageMigration.ts @@ -0,0 +1,56 @@ +/** + * Durable quarantine for ambiguous provider delivery. The table rebuild is + * required because SQLite cannot extend the released state CHECK in place. + */ +export const HOSTED_TEAM_APPROVAL_DELIVERY_RECONCILIATION_STORAGE_MIGRATION_STATEMENTS = [ + `CREATE TABLE hosted_team_approval_delivery_outbox_v24 ( + delivery_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, team_id TEXT NOT NULL, + authority_generation TEXT NOT NULL, restore_generation INTEGER NOT NULL, run_id TEXT NOT NULL, + request_id TEXT NOT NULL, approval_id TEXT NOT NULL, approval_generation TEXT NOT NULL, + decision TEXT NOT NULL, payload_hash TEXT NOT NULL, delivery_ref TEXT NOT NULL, + intent_json TEXT NOT NULL CHECK (json_valid(intent_json)), state TEXT NOT NULL, + delivery_generation INTEGER NOT NULL CHECK (delivery_generation >= 0), + delivery_owner_id TEXT, delivery_lease_token TEXT, delivery_claimed_at_ms INTEGER, + delivery_lease_expires_at_ms INTEGER, delivered_at_ms INTEGER, + created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0), principal_id TEXT, + reconciliation_ref TEXT, operator_required_at_ms INTEGER, + UNIQUE (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id), + FOREIGN KEY (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + REFERENCES hosted_team_approval_records (workspace_id, team_id, authority_generation, restore_generation, run_id, request_id) + ON DELETE RESTRICT ON UPDATE RESTRICT, + CHECK ( + (state = 'pending' AND delivered_at_ms IS NULL AND reconciliation_ref IS NULL + AND operator_required_at_ms IS NULL) + OR (state = 'operator_required' AND delivered_at_ms IS NULL + AND reconciliation_ref IS NOT NULL AND operator_required_at_ms IS NOT NULL) + OR (state = 'delivered' AND delivered_at_ms IS NOT NULL + AND ((reconciliation_ref IS NULL AND operator_required_at_ms IS NULL) + OR (reconciliation_ref IS NOT NULL AND operator_required_at_ms IS NOT NULL))) + ), + CHECK ((delivery_owner_id IS NULL AND delivery_lease_token IS NULL + AND delivery_claimed_at_ms IS NULL AND delivery_lease_expires_at_ms IS NULL) + OR (delivery_owner_id IS NOT NULL AND delivery_lease_token IS NOT NULL + AND delivery_claimed_at_ms IS NOT NULL AND delivery_lease_expires_at_ms > delivery_claimed_at_ms)), + CHECK (state <> 'operator_required' OR (delivery_owner_id IS NOT NULL + AND delivery_lease_token IS NOT NULL AND delivery_claimed_at_ms IS NOT NULL + AND delivery_lease_expires_at_ms IS NOT NULL)) + )`, + `INSERT INTO hosted_team_approval_delivery_outbox_v24 ( + delivery_id, workspace_id, team_id, authority_generation, restore_generation, run_id, + request_id, approval_id, approval_generation, decision, payload_hash, delivery_ref, + intent_json, state, delivery_generation, delivery_owner_id, delivery_lease_token, + delivery_claimed_at_ms, delivery_lease_expires_at_ms, delivered_at_ms, created_at_ms, + principal_id, reconciliation_ref, operator_required_at_ms + ) SELECT delivery_id, workspace_id, team_id, authority_generation, restore_generation, run_id, + request_id, approval_id, approval_generation, decision, payload_hash, delivery_ref, + intent_json, state, delivery_generation, delivery_owner_id, delivery_lease_token, + delivery_claimed_at_ms, delivery_lease_expires_at_ms, delivered_at_ms, created_at_ms, + principal_id, NULL, NULL + FROM hosted_team_approval_delivery_outbox`, + `DROP TABLE hosted_team_approval_delivery_outbox`, + `ALTER TABLE hosted_team_approval_delivery_outbox_v24 + RENAME TO hosted_team_approval_delivery_outbox`, + `CREATE INDEX idx_hosted_team_approval_delivery_pending + ON hosted_team_approval_delivery_outbox + (state, delivery_owner_id, delivery_lease_expires_at_ms, created_at_ms, delivery_id)`, +] as const; diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalDeliveryReconciliationStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalDeliveryReconciliationStorageOps.ts new file mode 100644 index 0000000000..d2c499e333 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalDeliveryReconciliationStorageOps.ts @@ -0,0 +1,248 @@ +import { + parseHostedTeamApprovalDeliveryOperatorRequiredRequest, + parseHostedTeamApprovalDeliveryReconciliationRequest, + parseHostedTeamApprovalDeliveryReconciliationSettleRequest, +} from '../../application/hostedTeamApprovalAuthorityStorage'; + +import { assertInternalStorageMutationAdmissionOpen } from './coordinationDurabilityWorkerOps'; +import { + assertDeadlineOpen, + isNonNegativeInteger, + isPositiveInteger, + type SqliteDatabase, + type UnknownRow, +} from './hostedTeamApprovalAuthorityStorageSupport'; + +import type { HostedTeamApprovalDeliveryReconciliationReadResult } from '../../../contracts/hostedTeamApprovalAuthorityStorageContracts'; + +function nowMs(storageNow: () => number): number { + const value = storageNow(); + if (!isNonNegativeInteger(value)) throw new Error('hosted-team-approval-storage-clock-invalid'); + return value; +} + +export function markHostedTeamApprovalDeliveryOperatorRequired( + db: SqliteDatabase, + value: unknown, + storageNow: () => number +): void { + const input = parseHostedTeamApprovalDeliveryOperatorRequiredRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + db.transaction(() => { + assertInternalStorageMutationAdmissionOpen(db, null); + const row = db + .prepare( + `SELECT state, delivery_generation, delivery_owner_id, delivery_lease_token, + delivery_lease_expires_at_ms, approval_generation, reconciliation_ref + FROM hosted_team_approval_delivery_outbox + WHERE delivery_id = ? AND workspace_id = ? AND authority_generation = ? + AND restore_generation = ? AND team_id = ? AND run_id = ?` + ) + .get( + input.deliveryId, + input.workspaceId, + input.authorityGeneration, + input.restoreGeneration, + input.partition.teamId, + input.partition.runId + ) as UnknownRow | undefined; + if (!row) throw new Error('hosted-team-approval-storage-delivery-not-found'); + if ( + row.state === 'operator_required' && + row.delivery_generation === input.deliveryGeneration + 1 && + row.approval_generation === input.approvalGeneration && + row.reconciliation_ref === input.reconciliationRef && + row.delivery_owner_id === input.ownerId && + row.delivery_lease_token === input.leaseToken + ) + return; + const quarantinedAtMs = nowMs(storageNow); + const boundaryLeaseExpiresAtMs = quarantinedAtMs + input.boundaryLeaseDurationMs; + if (!Number.isSafeInteger(boundaryLeaseExpiresAtMs)) { + throw new Error('hosted-team-approval-storage-delivery-boundary-lease-invalid'); + } + if ( + row.state !== 'pending' || + row.delivery_generation !== input.deliveryGeneration || + row.approval_generation !== input.approvalGeneration || + row.delivery_owner_id !== input.ownerId || + row.delivery_lease_token !== input.leaseToken || + !isPositiveInteger(row.delivery_lease_expires_at_ms) || + row.delivery_lease_expires_at_ms <= quarantinedAtMs + ) + throw new Error('hosted-team-approval-storage-delivery-operator-required-conflict'); + const update = db + .prepare( + `UPDATE hosted_team_approval_delivery_outbox + SET state = 'operator_required', reconciliation_ref = ?, operator_required_at_ms = ?, + delivery_generation = delivery_generation + 1, + delivery_lease_expires_at_ms = ? + WHERE delivery_id = ? AND workspace_id = ? AND authority_generation = ? + AND restore_generation = ? AND team_id = ? AND run_id = ? AND approval_generation = ? + AND state = 'pending' AND delivery_generation = ? AND delivery_owner_id = ? + AND delivery_lease_token = ? AND delivery_lease_expires_at_ms > ?` + ) + .run( + input.reconciliationRef, + quarantinedAtMs, + boundaryLeaseExpiresAtMs, + input.deliveryId, + input.workspaceId, + input.authorityGeneration, + input.restoreGeneration, + input.partition.teamId, + input.partition.runId, + input.approvalGeneration, + input.deliveryGeneration, + input.ownerId, + input.leaseToken, + quarantinedAtMs + ); + if (update.changes !== 1) { + throw new Error('hosted-team-approval-storage-delivery-operator-required-conflict'); + } + assertDeadlineOpen(input.deadlineAtMs); + }).immediate(); +} + +export function readHostedTeamApprovalDeliveryReconciliation( + db: SqliteDatabase, + value: unknown, + storageNow: () => number +): HostedTeamApprovalDeliveryReconciliationReadResult { + const input = parseHostedTeamApprovalDeliveryReconciliationRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + return db + .transaction(() => { + assertInternalStorageMutationAdmissionOpen(db, null); + const row = db + .prepare( + `SELECT workspace_id, authority_generation, restore_generation, team_id, run_id, + approval_generation, delivery_generation, state, reconciliation_ref, + delivery_owner_id, delivery_lease_token, delivery_lease_expires_at_ms + FROM hosted_team_approval_delivery_outbox WHERE delivery_id = ?` + ) + .get(input.deliveryId) as UnknownRow | undefined; + if (!row) return Object.freeze({ kind: 'not_found' }); + const exactBase = + row.workspace_id === input.workspaceId && + row.authority_generation === input.authorityGeneration && + row.restore_generation === input.restoreGeneration && + row.team_id === input.partition.teamId && + row.run_id === input.partition.runId && + row.approval_generation === input.approvalGeneration && + row.state === 'operator_required' && + row.reconciliation_ref === input.reconciliationRef; + assertDeadlineOpen(input.deadlineAtMs); + if (!exactBase) return Object.freeze({ kind: 'stale_binding' }); + const claimedAtMs = nowMs(storageNow); + const sameOperation = + row.delivery_owner_id === input.ownerId && row.delivery_lease_token === input.leaseToken; + if ( + !isPositiveInteger(row.delivery_generation) || + input.deliveryGeneration > row.delivery_generation + ) + return Object.freeze({ kind: 'stale_binding' }); + if ( + isPositiveInteger(row.delivery_lease_expires_at_ms) && + row.delivery_lease_expires_at_ms > claimedAtMs + ) { + return sameOperation + ? Object.freeze({ kind: 'claimed', deliveryGeneration: row.delivery_generation }) + : Object.freeze({ kind: 'unavailable' }); + } + if ( + !isPositiveInteger(row.delivery_lease_expires_at_ms) || + row.delivery_lease_expires_at_ms > claimedAtMs + ) { + return Object.freeze({ kind: 'unavailable' }); + } + const leaseExpiresAtMs = claimedAtMs + input.leaseDurationMs; + if (!Number.isSafeInteger(leaseExpiresAtMs)) + throw new Error('hosted-team-approval-storage-reconciliation-lease-invalid'); + const update = db + .prepare( + `UPDATE hosted_team_approval_delivery_outbox + SET delivery_generation = delivery_generation + 1, + delivery_owner_id = ?, delivery_lease_token = ?, delivery_claimed_at_ms = ?, + delivery_lease_expires_at_ms = ? + WHERE delivery_id = ? AND workspace_id = ? AND authority_generation = ? + AND restore_generation = ? AND team_id = ? AND run_id = ? AND approval_generation = ? + AND state = 'operator_required' AND delivery_generation = ? AND reconciliation_ref = ? + AND delivery_lease_expires_at_ms <= ?` + ) + .run( + input.ownerId, + input.leaseToken, + claimedAtMs, + leaseExpiresAtMs, + input.deliveryId, + input.workspaceId, + input.authorityGeneration, + input.restoreGeneration, + input.partition.teamId, + input.partition.runId, + input.approvalGeneration, + row.delivery_generation, + input.reconciliationRef, + claimedAtMs + ); + if (update.changes !== 1) return Object.freeze({ kind: 'unavailable' }); + assertDeadlineOpen(input.deadlineAtMs); + return Object.freeze({ + kind: 'claimed', + deliveryGeneration: row.delivery_generation + 1, + }); + }) + .immediate(); +} + +export function settleHostedTeamApprovalDeliveryReconciliation( + db: SqliteDatabase, + value: unknown, + storageNow: () => number +): void { + const input = parseHostedTeamApprovalDeliveryReconciliationSettleRequest(value); + assertDeadlineOpen(input.deadlineAtMs); + db.transaction(() => { + assertInternalStorageMutationAdmissionOpen(db, null); + const stateSql = + input.outcome === 'delivered' + ? `state = 'delivered', delivered_at_ms = ?` + : `state = 'pending', delivered_at_ms = NULL, reconciliation_ref = NULL, + operator_required_at_ms = NULL`; + const settledAtMs = nowMs(storageNow); + const parameters = input.outcome === 'delivered' ? [settledAtMs] : []; + const update = db + .prepare( + `UPDATE hosted_team_approval_delivery_outbox SET ${stateSql}, + delivery_generation = delivery_generation + 1, delivery_owner_id = NULL, + delivery_lease_token = NULL, delivery_claimed_at_ms = NULL, + delivery_lease_expires_at_ms = NULL + WHERE delivery_id = ? AND workspace_id = ? AND authority_generation = ? + AND restore_generation = ? AND team_id = ? AND run_id = ? AND approval_generation = ? + AND state = 'operator_required' AND delivery_generation = ? AND reconciliation_ref = ? + AND delivery_owner_id = ? AND delivery_lease_token = ? + AND delivery_lease_expires_at_ms > ?` + ) + .run( + ...parameters, + input.deliveryId, + input.workspaceId, + input.authorityGeneration, + input.restoreGeneration, + input.partition.teamId, + input.partition.runId, + input.approvalGeneration, + input.deliveryGeneration, + input.reconciliationRef, + input.ownerId, + input.leaseToken, + settledAtMs + ); + if (update.changes !== 1) { + throw new Error('hosted-team-approval-storage-delivery-reconciliation-conflict'); + } + assertDeadlineOpen(input.deadlineAtMs); + }).immediate(); +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalIdentityStorageMigrations.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalIdentityStorageMigrations.ts new file mode 100644 index 0000000000..103c9840bb --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalIdentityStorageMigrations.ts @@ -0,0 +1,43 @@ +import { HOSTED_TEAM_APPROVAL_CANONICAL_IDENTITY_STORAGE_MIGRATION_STATEMENTS } from './hostedTeamApprovalCanonicalIdentityStorageMigration'; +import { HOSTED_TEAM_APPROVAL_DELIVERY_RECONCILIATION_STORAGE_MIGRATION_STATEMENTS } from './hostedTeamApprovalDeliveryReconciliationStorageMigration'; + +/** + * Ordered approval identity migrations. Keep these entries append-only so the + * parent storage registry can preserve its forward-only transaction semantics. + */ +export const HOSTED_TEAM_APPROVAL_IDENTITY_STORAGE_MIGRATIONS = [ + { + version: 21, + statements: [...HOSTED_TEAM_APPROVAL_CANONICAL_IDENTITY_STORAGE_MIGRATION_STATEMENTS], + }, + { + version: 22, + statements: [ + `ALTER TABLE hosted_team_approval_delivery_outbox ADD COLUMN principal_id TEXT`, + `UPDATE hosted_team_approval_delivery_outbox + SET principal_id = COALESCE(( + SELECT actor_id FROM hosted_team_approval_audit AS audit + WHERE audit.approval_id = hosted_team_approval_delivery_outbox.approval_id + AND audit.approval_generation = hosted_team_approval_delivery_outbox.approval_generation + AND audit.team_id = hosted_team_approval_delivery_outbox.team_id + AND audit.run_id = hosted_team_approval_delivery_outbox.run_id + ORDER BY audit.occurred_at_ms DESC LIMIT 1 + ), 'actor_approval-timeout-system') + WHERE principal_id IS NULL`, + ], + }, + { + version: 23, + statements: [ + `UPDATE hosted_team_approval_delivery_outbox + SET principal_id = CASE + WHEN decision = 'timeout' THEN '{"kind":"system_timeout"}' + ELSE json_object('kind', 'operator', 'actorId', principal_id) + END`, + ], + }, + { + version: 24, + statements: [...HOSTED_TEAM_APPROVAL_DELIVERY_RECONCILIATION_STORAGE_MIGRATION_STATEMENTS], + }, +]; diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalMigrationRepair.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalMigrationRepair.ts new file mode 100644 index 0000000000..8e187abb69 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalMigrationRepair.ts @@ -0,0 +1,798 @@ +import { parseActorId } from '@shared/contracts/hosted'; + +import { HOSTED_TEAM_APPROVAL_AUTHORITY_STORAGE_MIGRATION_STATEMENTS } from './hostedTeamApprovalAuthorityStorageMigration'; +import { HOSTED_TEAM_APPROVAL_CANONICAL_IDENTITY_STORAGE_MIGRATION_STATEMENTS } from './hostedTeamApprovalCanonicalIdentityStorageMigration'; +import { HOSTED_TEAM_APPROVAL_DELIVERY_RECONCILIATION_STORAGE_MIGRATION_STATEMENTS } from './hostedTeamApprovalDeliveryReconciliationStorageMigration'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +type Shape = 'absent' | 'legacy' | 'canonical-v21' | 'canonical-v22' | 'canonical-v24' | 'invalid'; + +interface ColumnSpec { + readonly name: string; + readonly type: string; + readonly notnull: number; + readonly pk: number; +} + +interface TableSpec { + readonly columns: readonly string[]; + readonly foreignKeys: readonly string[]; + readonly indexes: readonly string[]; +} + +interface ForeignKeyRow { + readonly id: number; + readonly seq: number; + readonly table: string; + readonly from: string; + readonly to: string; + readonly on_update: string; + readonly on_delete: string; + readonly match: string; +} + +interface SchemaSqlSpec { + readonly legacy: readonly string[]; + readonly canonicalV21: readonly string[]; + readonly canonicalV22: readonly string[]; + readonly canonicalV24: readonly string[]; +} + +interface SchemaSqlRow { + readonly type: string; + readonly name: string; + readonly tbl_name: string; + readonly sql: string | null; +} + +const TABLES = [ + 'hosted_team_approval_records', + 'hosted_team_approval_idempotency', + 'hosted_team_approval_audit', + 'hosted_team_approval_delivery_outbox', +] as const; + +const APPROVAL_AUTO_INDEXES = [ + 'sqlite_autoindex_hosted_team_approval_audit_1', + 'sqlite_autoindex_hosted_team_approval_delivery_outbox_1', + 'sqlite_autoindex_hosted_team_approval_delivery_outbox_2', + 'sqlite_autoindex_hosted_team_approval_idempotency_1', + 'sqlite_autoindex_hosted_team_approval_idempotency_2', + 'sqlite_autoindex_hosted_team_approval_idempotency_3', + 'sqlite_autoindex_hosted_team_approval_records_1', +] as const; + +const SCHEMA_SQL = buildSchemaSqlSpec(); + +function buildSchemaSqlSpec(): SchemaSqlSpec { + const legacy = schemaDefinitions(HOSTED_TEAM_APPROVAL_AUTHORITY_STORAGE_MIGRATION_STATEMENTS); + const canonicalV21 = schemaDefinitions( + HOSTED_TEAM_APPROVAL_CANONICAL_IDENTITY_STORAGE_MIGRATION_STATEMENTS, + true + ); + const canonicalV22 = canonicalV21.map((definition) => + definition.includes(':create table hosted_team_approval_delivery_outbox(') + ? definition.replace(',unique(', ',principal_id text,unique(') + : definition + ); + const v24OutboxDefinitions = schemaDefinitions( + HOSTED_TEAM_APPROVAL_DELIVERY_RECONCILIATION_STORAGE_MIGRATION_STATEMENTS, + true + ).filter( + (definition) => + definition.startsWith('table:hosted_team_approval_delivery_outbox:') || + definition.startsWith('index:idx_hosted_team_approval_delivery_pending:') + ); + const canonicalV24 = [ + ...canonicalV22.filter( + (definition) => + !definition.startsWith('table:hosted_team_approval_delivery_outbox:') && + !definition.startsWith('index:idx_hosted_team_approval_delivery_pending:') + ), + ...v24OutboxDefinitions, + ].sort((left, right) => left.localeCompare(right)); + return { legacy, canonicalV21, canonicalV22, canonicalV24 }; +} + +function schemaDefinitions(statements: readonly string[], canonical = false): readonly string[] { + const explicit = statements + .filter((statement) => /^\s*CREATE (?:UNIQUE )?(?:TABLE|INDEX)\b/i.test(statement)) + .filter((statement) => !/\bTEMP\b/i.test(statement)) + .map(normalizeSchemaSql) + .map((definition) => + canonical + ? definition + .replace(/^create table ([^(]+)_v(?:21|24)\(/, 'create table $1(') + .replaceAll( + 'references hosted_team_approval_records_v21', + 'references hosted_team_approval_records' + ) + : definition + ) + .map(schemaObjectFingerprint); + const automatic = APPROVAL_AUTO_INDEXES.map((name) => { + const table = name.replace(/^sqlite_autoindex_/, '').replace(/_\d+$/, ''); + return `index:${name}:${table}:`; + }); + return [...explicit, ...automatic].sort((left, right) => left.localeCompare(right)); +} + +function normalizeSchemaSql(sql: string): string { + const literals: string[] = []; + const masked = sql.replace(/'(?:''|[^'])*'/g, (literal) => { + const index = literals.push(literal) - 1; + return `__APPROVAL_SQL_LITERAL_${index}__`; + }); + const normalized = masked + .replace(/"((?:""|[^"])*)"/g, (_match, identifier: string) => identifier.replaceAll('""', '"')) + .replace(/\bIF NOT EXISTS\b/gi, '') + .replace(/\s+/g, ' ') + .replace(/\s*([(),])\s*/g, '$1') + .trim() + .toLowerCase(); + return normalized.replace(/__approval_sql_literal_(\d+)__/g, (_match, index: string) => { + const literal = literals[Number(index)]; + if (literal === undefined) throw new Error('internal-storage-approval-schema-literal-invalid'); + return literal; + }); +} + +function schemaObjectFingerprint(sql: string): string { + const table = /^create table ([^(]+)/.exec(sql); + if (table?.[1]) return `table:${table[1]}:${table[1]}:${sql}`; + const index = /^create (?:unique )?index ([^ ]+) on ([^(]+)/.exec(sql); + if (index?.[1] && index[2]) return `index:${index[1]}:${index[2]}:${sql}`; + throw new Error('internal-storage-approval-schema-definition-invalid'); +} + +const column = (name: string, type: 'TEXT' | 'INTEGER', notnull = 1, pk = 0): string => + `${name}:${type}:${notnull}:${pk}`; + +const LEGACY: Readonly> = { + hosted_team_approval_records: { + columns: [ + column('principal_id', 'TEXT', 1, 1), + column('workspace_id', 'TEXT', 1, 2), + column('team_id', 'TEXT', 1, 3), + column('authority_generation', 'TEXT', 1, 4), + column('restore_generation', 'INTEGER', 1, 5), + column('approval_id', 'TEXT', 1, 6), + column('approval_generation', 'TEXT', 1, 7), + column('category', 'TEXT'), + column('summary', 'TEXT'), + column('requested_at_ms', 'INTEGER'), + column('expires_at_ms', 'INTEGER', 0), + column('preview_ref', 'TEXT', 0), + column('preview_content', 'TEXT', 0), + column('preview_byte_length', 'INTEGER', 0), + column('preview_truncated', 'INTEGER', 0), + column('preview_is_binary', 'INTEGER', 0), + column('delivery_ref', 'TEXT'), + column('state', 'TEXT'), + column('decision', 'TEXT', 0), + column('revision', 'INTEGER'), + column('observed_at_ms', 'INTEGER'), + column('resolved_at_ms', 'INTEGER', 0), + column('last_idempotency_key', 'TEXT', 0), + column('payload_hash', 'TEXT', 0), + ], + foreignKeys: [], + indexes: [ + 'idx_hosted_team_approval_current_generation:0:c:principal_id,workspace_id,team_id,authority_generation,restore_generation,approval_id,observed_at_ms', + 'idx_hosted_team_approval_one_pending_generation:1:c:principal_id,workspace_id,team_id,authority_generation,restore_generation,approval_id', + 'idx_hosted_team_approval_pending_page:0:c:principal_id,workspace_id,team_id,authority_generation,restore_generation,state,approval_id', + ], + }, + hosted_team_approval_idempotency: { + columns: [ + column('principal_id', 'TEXT', 1, 1), + column('workspace_id', 'TEXT', 1, 2), + column('team_id', 'TEXT', 1, 3), + column('authority_generation', 'TEXT', 1, 4), + column('restore_generation', 'INTEGER', 1, 5), + column('idempotency_key', 'TEXT', 1, 6), + column('approval_id', 'TEXT'), + column('approval_generation', 'TEXT'), + column('decision', 'TEXT'), + column('payload_hash', 'TEXT'), + column('revision', 'INTEGER'), + column('audit_id', 'TEXT'), + column('delivery_id', 'TEXT'), + column('created_at_ms', 'INTEGER'), + ], + foreignKeys: legacyForeignKeys(), + indexes: [], + }, + hosted_team_approval_audit: { + columns: [ + column('audit_id', 'TEXT', 0, 1), + column('principal_id', 'TEXT'), + column('workspace_id', 'TEXT'), + column('team_id', 'TEXT'), + column('authority_generation', 'TEXT'), + column('restore_generation', 'INTEGER'), + column('approval_id', 'TEXT'), + column('approval_generation', 'TEXT'), + column('decision', 'TEXT'), + column('payload_hash', 'TEXT'), + column('actor_id', 'TEXT'), + column('session_id', 'TEXT'), + column('occurred_at_ms', 'INTEGER'), + ], + foreignKeys: legacyForeignKeys(), + indexes: [ + 'idx_hosted_team_approval_audit_scope:0:c:principal_id,workspace_id,team_id,authority_generation,restore_generation,occurred_at_ms', + ], + }, + hosted_team_approval_delivery_outbox: { + columns: legacyDeliveryColumns(), + foreignKeys: legacyForeignKeys(), + indexes: [ + 'idx_hosted_team_approval_delivery_pending:0:c:state,delivery_lease_expires_at_ms,created_at_ms,delivery_id', + ], + }, +}; + +function legacyForeignKeys(): readonly string[] { + const names = [ + 'principal_id', + 'workspace_id', + 'team_id', + 'authority_generation', + 'restore_generation', + 'approval_id', + 'approval_generation', + ]; + return names.map( + (name, sequence) => + `0:${sequence}:hosted_team_approval_records:${name}:${name}:RESTRICT:RESTRICT:NONE` + ); +} + +function legacyDeliveryColumns(): readonly string[] { + return [ + column('delivery_id', 'TEXT', 0, 1), + column('principal_id', 'TEXT'), + column('workspace_id', 'TEXT'), + column('team_id', 'TEXT'), + column('authority_generation', 'TEXT'), + column('restore_generation', 'INTEGER'), + column('approval_id', 'TEXT'), + column('approval_generation', 'TEXT'), + column('decision', 'TEXT'), + column('payload_hash', 'TEXT'), + column('delivery_ref', 'TEXT'), + column('intent_json', 'TEXT'), + column('state', 'TEXT'), + column('delivery_generation', 'INTEGER'), + column('delivery_owner_id', 'TEXT', 0), + column('delivery_lease_token', 'TEXT', 0), + column('delivery_claimed_at_ms', 'INTEGER', 0), + column('delivery_lease_expires_at_ms', 'INTEGER', 0), + column('delivered_at_ms', 'INTEGER', 0), + column('created_at_ms', 'INTEGER'), + ]; +} + +const CANONICAL: Readonly> = { + hosted_team_approval_records: { + columns: [ + column('workspace_id', 'TEXT', 1, 1), + column('team_id', 'TEXT', 1, 2), + column('authority_generation', 'TEXT', 1, 3), + column('restore_generation', 'INTEGER', 1, 4), + column('run_id', 'TEXT', 1, 5), + column('request_id', 'TEXT', 1, 6), + column('approval_id', 'TEXT'), + column('approval_generation', 'TEXT'), + column('category', 'TEXT'), + column('summary', 'TEXT'), + column('requested_at_ms', 'INTEGER'), + column('expires_at_ms', 'INTEGER', 0), + column('preview_ref', 'TEXT', 0), + column('preview_content', 'TEXT', 0), + column('preview_byte_length', 'INTEGER', 0), + column('preview_truncated', 'INTEGER', 0), + column('preview_is_binary', 'INTEGER', 0), + column('delivery_ref', 'TEXT'), + column('state', 'TEXT'), + column('decision', 'TEXT', 0), + column('revision', 'INTEGER'), + column('observed_at_ms', 'INTEGER'), + column('resolved_at_ms', 'INTEGER', 0), + column('last_idempotency_key', 'TEXT', 0), + column('payload_hash', 'TEXT', 0), + ], + foreignKeys: [], + indexes: [ + 'idx_hosted_team_approval_identity:1:c:workspace_id,team_id,authority_generation,restore_generation,run_id,approval_id', + 'idx_hosted_team_approval_pending_page:0:c:team_id,state,approval_id', + 'idx_hosted_team_approval_pending_partition:0:c:team_id,run_id,state,approval_id', + ], + }, + hosted_team_approval_idempotency: { + columns: [ + column('workspace_id', 'TEXT', 1, 1), + column('team_id', 'TEXT', 1, 2), + column('authority_generation', 'TEXT', 1, 3), + column('restore_generation', 'INTEGER', 1, 4), + column('run_id', 'TEXT', 1, 5), + column('idempotency_key', 'TEXT', 1, 6), + column('request_id', 'TEXT'), + column('approval_id', 'TEXT'), + column('approval_generation', 'TEXT'), + column('decision', 'TEXT'), + column('payload_hash', 'TEXT'), + column('revision', 'INTEGER'), + column('audit_id', 'TEXT'), + column('delivery_id', 'TEXT'), + column('created_at_ms', 'INTEGER'), + ], + foreignKeys: canonicalForeignKeys(), + indexes: [], + }, + hosted_team_approval_audit: { + columns: [ + column('audit_id', 'TEXT', 0, 1), + column('workspace_id', 'TEXT'), + column('team_id', 'TEXT'), + column('authority_generation', 'TEXT'), + column('restore_generation', 'INTEGER'), + column('run_id', 'TEXT'), + column('request_id', 'TEXT'), + column('approval_id', 'TEXT'), + column('approval_generation', 'TEXT'), + column('decision', 'TEXT'), + column('payload_hash', 'TEXT'), + column('actor_id', 'TEXT'), + column('session_id', 'TEXT'), + column('occurred_at_ms', 'INTEGER'), + ], + foreignKeys: canonicalForeignKeys(), + indexes: ['idx_hosted_team_approval_audit_partition:0:c:team_id,run_id,occurred_at_ms'], + }, + hosted_team_approval_delivery_outbox: { + columns: canonicalDeliveryColumns(false), + foreignKeys: canonicalForeignKeys(), + indexes: [ + 'idx_hosted_team_approval_delivery_pending:0:c:state,delivery_owner_id,delivery_lease_expires_at_ms,created_at_ms,delivery_id', + ], + }, +}; + +function canonicalForeignKeys(): readonly string[] { + const names = [ + 'workspace_id', + 'team_id', + 'authority_generation', + 'restore_generation', + 'run_id', + 'request_id', + ]; + return names.map( + (name, sequence) => + `0:${sequence}:hosted_team_approval_records:${name}:${name}:RESTRICT:RESTRICT:NONE` + ); +} + +function canonicalDeliveryColumns(withPrincipal: boolean): readonly string[] { + const columns = [ + column('delivery_id', 'TEXT', 0, 1), + column('workspace_id', 'TEXT'), + column('team_id', 'TEXT'), + column('authority_generation', 'TEXT'), + column('restore_generation', 'INTEGER'), + column('run_id', 'TEXT'), + column('request_id', 'TEXT'), + column('approval_id', 'TEXT'), + column('approval_generation', 'TEXT'), + column('decision', 'TEXT'), + column('payload_hash', 'TEXT'), + column('delivery_ref', 'TEXT'), + column('intent_json', 'TEXT'), + column('state', 'TEXT'), + column('delivery_generation', 'INTEGER'), + column('delivery_owner_id', 'TEXT', 0), + column('delivery_lease_token', 'TEXT', 0), + column('delivery_claimed_at_ms', 'INTEGER', 0), + column('delivery_lease_expires_at_ms', 'INTEGER', 0), + column('delivered_at_ms', 'INTEGER', 0), + column('created_at_ms', 'INTEGER'), + ]; + return withPrincipal ? [...columns, column('principal_id', 'TEXT', 0)] : columns; +} + +export function runHostedTeamApprovalMigrationRepair(db: SqliteDatabase, version: number): boolean { + if (version === 18) { + const shape = detectShape(db); + if (shape === 'absent') { + assertNoApprovalTempShadows(db, 'internal-storage-v18-approval-temp-shadow'); + execute(db, HOSTED_TEAM_APPROVAL_AUTHORITY_STORAGE_MIGRATION_STATEMENTS); + assertShape(db, 'legacy'); + } else if (!['legacy', 'canonical-v21', 'canonical-v22', 'canonical-v24'].includes(shape)) { + throw new Error('internal-storage-v18-approval-schema-invalid'); + } + return true; + } + if (version === 21) { + const shape = detectShape(db); + if (shape === 'legacy') { + assertNoApprovalTempShadows(db, 'internal-storage-v21-approval-temp-shadow'); + migrateCanonicalIdentity(db); + } else if (!['canonical-v21', 'canonical-v22', 'canonical-v24'].includes(shape)) { + throw new Error('internal-storage-v21-approval-schema-invalid'); + } + return true; + } + if (version === 22) { + migratePrincipalIdentity(db); + return true; + } + if (version === 23) { + migratePrincipalJson(db); + return true; + } + if (version === 24) { + migrateDeliveryReconciliation(db); + return true; + } + return false; +} + +function migrateCanonicalIdentity(db: SqliteDatabase): void { + const before = rowCounts(db); + execute(db, HOSTED_TEAM_APPROVAL_CANONICAL_IDENTITY_STORAGE_MIGRATION_STATEMENTS); + assertShape(db, 'canonical-v21'); + assertRowCounts(db, before, 'internal-storage-v21-approval-row-count-mismatch'); + assertForeignKeys(db, 'internal-storage-v21-approval-foreign-key-invalid'); +} + +function assertNoApprovalTempShadows(db: SqliteDatabase, error: string): void { + const shadow = db + .prepare( + `SELECT 1 FROM temp.sqlite_schema + WHERE lower(name) LIKE 'hosted_team_approval_%' + OR lower(tbl_name) LIKE 'hosted_team_approval_%' + OR lower(name) LIKE 'idx_hosted_team_approval_%' + LIMIT 1` + ) + .get(); + if (shadow) throw new Error(error); +} + +function migratePrincipalIdentity(db: SqliteDatabase): void { + const before = rowCounts(db); + const shape = detectShape(db); + if (!['canonical-v21', 'canonical-v22', 'canonical-v24'].includes(shape)) { + throw new Error('internal-storage-v22-approval-schema-invalid'); + } + if (shape === 'canonical-v21') { + db.exec(`ALTER TABLE main.hosted_team_approval_delivery_outbox ADD COLUMN principal_id TEXT`); + } + db.exec( + `UPDATE main.hosted_team_approval_delivery_outbox AS delivery + SET principal_id = CASE + WHEN delivery.decision = 'timeout' THEN 'actor_approval-timeout-system' + ELSE ( + SELECT audit.actor_id FROM main.hosted_team_approval_audit AS audit + WHERE audit.workspace_id = delivery.workspace_id + AND audit.team_id = delivery.team_id + AND audit.authority_generation = delivery.authority_generation + AND audit.restore_generation = delivery.restore_generation + AND audit.run_id = delivery.run_id + AND audit.request_id = delivery.request_id + AND audit.approval_id = delivery.approval_id + AND audit.approval_generation = delivery.approval_generation + AND audit.decision = delivery.decision + AND length(trim(audit.actor_id)) > 0 + ORDER BY audit.occurred_at_ms DESC, audit.audit_id DESC LIMIT 1 + ) + END + WHERE principal_id IS NULL` + ); + assertV22Principals(db); + assertShape(db, shape === 'canonical-v24' ? 'canonical-v24' : 'canonical-v22'); + assertRowCounts(db, before, 'internal-storage-v22-approval-row-count-mismatch'); +} + +function assertV22Principals(db: SqliteDatabase): void { + const rows = db + .prepare( + `SELECT delivery.delivery_id AS deliveryId, delivery.decision, + delivery.principal_id AS principalId, delivery.workspace_id AS workspaceId, + delivery.team_id AS teamId, delivery.authority_generation AS authorityGeneration, + delivery.restore_generation AS restoreGeneration, delivery.run_id AS runId, + delivery.request_id AS requestId, delivery.approval_id AS approvalId, + delivery.approval_generation AS approvalGeneration + FROM main.hosted_team_approval_delivery_outbox AS delivery` + ) + .all() as readonly { + deliveryId: string; + decision: string; + principalId: string | null; + workspaceId: string; + teamId: string; + authorityGeneration: string; + restoreGeneration: number; + runId: string; + requestId: string; + approvalId: string; + approvalGeneration: string; + }[]; + for (const row of rows) { + const actorId = readV22ActorId(row.principalId, row.decision); + if (actorId === null) continue; + const audit = db + .prepare( + `SELECT 1 FROM main.hosted_team_approval_audit + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND request_id = ? + AND approval_id = ? AND approval_generation = ? AND decision = ? + AND actor_id = ? AND length(trim(actor_id)) > 0 LIMIT 1` + ) + .get( + row.workspaceId, + row.teamId, + row.authorityGeneration, + row.restoreGeneration, + row.runId, + row.requestId, + row.approvalId, + row.approvalGeneration, + row.decision, + actorId + ); + if (!audit) throw new Error('internal-storage-v22-approval-principal-invalid'); + } +} + +function readV22ActorId(value: string | null, decision: string): string | null { + if (!value?.trim()) throw new Error('internal-storage-v22-approval-principal-invalid'); + if (!value.startsWith('{') && !value.startsWith('[')) { + if (decision === 'timeout') { + if (value !== 'actor_approval-timeout-system') { + throw new Error('internal-storage-v22-approval-principal-invalid'); + } + return null; + } + return validateActorId(value, 'internal-storage-v22-approval-principal-invalid'); + } + try { + const parsed = JSON.parse(value) as unknown; + if (!isExactPrincipal(parsed, decision)) throw new Error('invalid'); + return decision === 'timeout' + ? null + : validateActorId( + (parsed as { actorId: string }).actorId, + 'internal-storage-v22-approval-principal-invalid' + ); + } catch { + throw new Error('internal-storage-v22-approval-principal-invalid'); + } +} + +function migratePrincipalJson(db: SqliteDatabase): void { + const shape = detectShape(db); + if (shape !== 'canonical-v22' && shape !== 'canonical-v24') { + throw new Error('internal-storage-v23-approval-schema-invalid'); + } + const before = rowCounts(db); + const rows = db + .prepare( + `SELECT delivery_id AS deliveryId, decision, principal_id AS principalId + FROM main.hosted_team_approval_delivery_outbox` + ) + .all() as readonly { deliveryId: string; decision: string; principalId: string | null }[]; + const update = db.prepare( + `UPDATE main.hosted_team_approval_delivery_outbox SET principal_id = ? WHERE delivery_id = ?` + ); + for (const row of rows) { + const normalized = normalizePrincipal(row.principalId, row.decision); + if (normalized !== row.principalId) update.run(normalized, row.deliveryId); + } + const invalid = db + .prepare( + `SELECT 1 FROM main.hosted_team_approval_delivery_outbox + WHERE principal_id IS NULL OR json_valid(principal_id) = 0 + OR (decision = 'timeout' AND NOT ( + json_type(principal_id, '$.kind') = 'text' + AND json_extract(principal_id, '$.kind') = 'system_timeout' + AND (SELECT COUNT(*) FROM json_each(principal_id)) = 1)) + OR (decision <> 'timeout' AND NOT ( + json_type(principal_id, '$.kind') = 'text' + AND json_extract(principal_id, '$.kind') = 'operator' + AND json_type(principal_id, '$.actorId') = 'text' + AND length(trim(json_extract(principal_id, '$.actorId'))) > 0 + AND (SELECT COUNT(*) FROM json_each(principal_id)) = 2)) LIMIT 1` + ) + .get(); + if (invalid) throw new Error('internal-storage-v23-approval-principal-invalid'); + assertRowCounts(db, before, 'internal-storage-v23-approval-row-count-mismatch'); +} + +function migrateDeliveryReconciliation(db: SqliteDatabase): void { + const shape = detectShape(db); + if (shape === 'canonical-v24') return; + if (shape !== 'canonical-v22') { + throw new Error('internal-storage-v24-approval-schema-invalid'); + } + const before = rowCounts(db); + execute(db, HOSTED_TEAM_APPROVAL_DELIVERY_RECONCILIATION_STORAGE_MIGRATION_STATEMENTS); + assertShape(db, 'canonical-v24'); + assertRowCounts(db, before, 'internal-storage-v24-approval-row-count-mismatch'); + assertForeignKeys(db, 'internal-storage-v24-approval-foreign-key-invalid'); +} + +function normalizePrincipal(value: string | null, decision: string): string { + if (!value) throw new Error('internal-storage-v23-approval-principal-invalid'); + if (value.startsWith('{') || value.startsWith('[')) { + try { + const parsed = JSON.parse(value) as unknown; + if (!isExactPrincipal(parsed, decision)) throw new Error('invalid'); + return value; + } catch { + throw new Error('internal-storage-v23-approval-principal-invalid'); + } + } + if (decision === 'timeout') { + if (value !== 'actor_approval-timeout-system') { + throw new Error('internal-storage-v23-approval-principal-invalid'); + } + return '{"kind":"system_timeout"}'; + } + const actorId = validateActorId(value, 'internal-storage-v23-approval-principal-invalid'); + return JSON.stringify({ kind: 'operator', actorId }); +} + +function isExactPrincipal(value: unknown, decision: string): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const keys = Object.keys(record).sort((left, right) => left.localeCompare(right)); + if (decision === 'timeout') { + return keys.length === 1 && keys[0] === 'kind' && record.kind === 'system_timeout'; + } + if (keys.join(',') !== 'actorId,kind' || record.kind !== 'operator') return false; + try { + parseActorId(record.actorId); + return true; + } catch { + return false; + } +} + +function detectShape(db: SqliteDatabase): Shape { + const present = TABLES.filter((table) => tableExists(db, table)); + if (present.length === 0) return 'absent'; + if (present.length !== TABLES.length) return 'invalid'; + if (matches(db, LEGACY) && matchesSchemaSql(db, SCHEMA_SQL.legacy)) return 'legacy'; + if (matches(db, CANONICAL) && matchesSchemaSql(db, SCHEMA_SQL.canonicalV21)) { + return 'canonical-v21'; + } + const canonicalV22 = { + ...CANONICAL, + hosted_team_approval_delivery_outbox: { + ...CANONICAL.hosted_team_approval_delivery_outbox, + columns: canonicalDeliveryColumns(true), + }, + }; + if (matches(db, canonicalV22) && matchesSchemaSql(db, SCHEMA_SQL.canonicalV22)) { + return 'canonical-v22'; + } + const canonicalV24 = { + ...CANONICAL, + hosted_team_approval_delivery_outbox: { + ...CANONICAL.hosted_team_approval_delivery_outbox, + columns: [ + ...canonicalDeliveryColumns(true), + column('reconciliation_ref', 'TEXT', 0), + column('operator_required_at_ms', 'INTEGER', 0), + ], + }, + }; + return matches(db, canonicalV24) && matchesSchemaSql(db, SCHEMA_SQL.canonicalV24) + ? 'canonical-v24' + : 'invalid'; +} + +function matches( + db: SqliteDatabase, + expected: Readonly> +): boolean { + return TABLES.every((table) => { + const columns = db.pragma(`main.table_info('${table}')`) as readonly ColumnSpec[]; + const actualColumns = columns.map( + ({ name, type, notnull, pk }) => `${name}:${type}:${notnull}:${pk}` + ); + return ( + equal(actualColumns, expected[table].columns) && + equal(readForeignKeys(db, table), expected[table].foreignKeys) && + equal(readCriticalIndexes(db, table), expected[table].indexes) + ); + }); +} + +function matchesSchemaSql(db: SqliteDatabase, expected: readonly string[]): boolean { + const rows = db + .prepare( + `SELECT type, name, tbl_name, sql FROM main.sqlite_schema + WHERE tbl_name LIKE 'hosted_team_approval_%' + OR name LIKE 'hosted_team_approval_%' + OR name LIKE 'sqlite_autoindex_hosted_team_approval_%'` + ) + .all() as readonly SchemaSqlRow[]; + const actual = rows + .map( + ({ type, name, tbl_name: table, sql }) => + `${type}:${name}:${table}:${sql === null ? '' : normalizeSchemaSql(sql)}` + ) + .sort((left, right) => left.localeCompare(right)); + return equal(actual, expected); +} + +function readForeignKeys(db: SqliteDatabase, table: string): readonly string[] { + const rows = db.pragma(`main.foreign_key_list('${table}')`) as readonly ForeignKeyRow[]; + return rows.map( + (row) => + `${row.id}:${row.seq}:${row.table}:${row.from}:${row.to}:${row.on_update}:${row.on_delete}:${row.match}` + ); +} + +function readCriticalIndexes(db: SqliteDatabase, table: string): readonly string[] { + const rows = db.pragma(`main.index_list('${table}')`) as readonly { + name: string; + unique: number; + origin: string; + }[]; + return rows + .filter(({ origin }) => origin === 'c') + .map(({ name, unique, origin }) => { + const columns = db.pragma(`main.index_info('${name}')`) as readonly { name: string }[]; + return `${name}:${unique}:${origin}:${columns.map((entry) => entry.name).join(',')}`; + }) + .sort((left, right) => left.localeCompare(right)); +} + +function rowCounts(db: SqliteDatabase): readonly number[] { + return TABLES.map( + (table) => + (db.prepare(`SELECT COUNT(*) AS count FROM main.${table}`).get() as { count: number }).count + ); +} + +function assertRowCounts(db: SqliteDatabase, expected: readonly number[], error: string): void { + if (!equal(rowCounts(db), expected)) throw new Error(error); +} + +function assertForeignKeys(db: SqliteDatabase, error: string): void { + if ((db.pragma('main.foreign_key_check') as readonly unknown[]).length > 0) + throw new Error(error); +} + +function assertShape(db: SqliteDatabase, expected: Shape): void { + if (detectShape(db) !== expected) + throw new Error(`internal-storage-approval-schema-postcondition-${expected}`); +} + +function tableExists(db: SqliteDatabase, table: string): boolean { + return Boolean( + db.prepare(`SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = ?`).get(table) + ); +} + +function execute(db: SqliteDatabase, statements: readonly string[]): void { + for (const statement of statements) db.exec(statement); +} + +function equal(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function validateActorId(value: unknown, error: string): string { + try { + return parseActorId(value); + } catch { + throw new Error(error); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalTimeoutStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalTimeoutStorageOps.ts new file mode 100644 index 0000000000..311119e0ed --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamApprovalTimeoutStorageOps.ts @@ -0,0 +1,189 @@ +import { createHash } from 'node:crypto'; + +import { + hashHostedTeamApprovalTimeout, + serializeHostedTeamApprovalDeliveryIntent, +} from '../../application/hostedTeamApprovalAuthorityStorage'; + +import type { HostedTeamApprovalAuthorityScope } from '../../../contracts/hostedTeamApprovalAuthorityStorageContracts'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +type UnknownRow = Record; + +function timeoutIdentity(row: UnknownRow): string { + return createHash('sha256') + .update( + JSON.stringify({ + schemaVersion: 1, + teamId: row.team_id, + runId: row.run_id, + requestId: row.request_id, + approvalId: row.approval_id, + approvalGeneration: row.approval_generation, + expiresAtMs: row.expires_at_ms, + deliveryRef: row.delivery_ref, + }) + ) + .digest('hex'); +} + +/** Resolves every due approval in the caller-owned transaction. */ +export function expireHostedTeamApprovals(input: { + readonly db: SqliteDatabase; + readonly scope?: HostedTeamApprovalAuthorityScope; + readonly expectedRunId?: string; + readonly nowMs: number; + readonly approvalId?: string; + readonly approvalGeneration?: string; +}): number { + const rows = input.db + .prepare( + `SELECT workspace_id, team_id, authority_generation, restore_generation, + run_id, request_id, approval_id, approval_generation, + delivery_ref, payload_hash, revision, expires_at_ms + FROM hosted_team_approval_records + WHERE ${input.scope === undefined ? '' : 'workspace_id = ? AND team_id = ? AND authority_generation = ? AND restore_generation = ? AND'} + ${input.expectedRunId === undefined ? '' : 'run_id = ? AND'} + state = 'pending' AND expires_at_ms IS NOT NULL AND expires_at_ms <= ? + ${input.approvalId === undefined ? '' : 'AND approval_id = ? AND approval_generation = ?'} + ORDER BY expires_at_ms ASC, approval_id ASC` + ) + .all( + ...(input.scope === undefined + ? [] + : [ + input.scope.workspaceId, + input.scope.teamId, + input.scope.authorityGeneration, + input.scope.restoreGeneration, + ]), + ...(input.expectedRunId === undefined ? [] : [input.expectedRunId]), + input.nowMs, + ...(input.approvalId === undefined ? [] : [input.approvalId, input.approvalGeneration]) + ) as UnknownRow[]; + let expired = 0; + for (const row of rows) { + if ( + typeof row.team_id !== 'string' || + typeof row.run_id !== 'string' || + typeof row.request_id !== 'string' || + typeof row.approval_id !== 'string' || + typeof row.approval_generation !== 'string' || + typeof row.delivery_ref !== 'string' || + typeof row.payload_hash !== 'string' || + !Number.isSafeInteger(row.revision) || + !Number.isSafeInteger(row.expires_at_ms) + ) { + throw new Error('hosted-team-approval-storage-timeout-record-invalid'); + } + const identity = timeoutIdentity(row); + const payloadHash = hashHostedTeamApprovalTimeout(row.payload_hash); + const latest = input.db + .prepare( + `SELECT MAX(occurred_at_ms) AS occurred_at_ms + FROM hosted_team_approval_audit + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ?` + ) + .get( + row.workspace_id, + row.team_id, + row.authority_generation, + row.restore_generation, + row.run_id + ) as { readonly occurred_at_ms: unknown } | undefined; + const previous = latest?.occurred_at_ms; + if (previous !== null && previous !== undefined && !Number.isSafeInteger(previous)) { + throw new Error('hosted-team-approval-storage-timeout-chronology-invalid'); + } + const occurredAtMs = Math.max( + input.nowMs, + row.expires_at_ms as number, + previous === null || previous === undefined ? 0 : (previous as number) + 1 + ); + const update = input.db + .prepare( + `UPDATE hosted_team_approval_records + SET state = 'resolved', decision = 'timeout', revision = revision + 1, + resolved_at_ms = ?, last_idempotency_key = NULL + WHERE workspace_id = ? AND team_id = ? AND authority_generation = ? + AND restore_generation = ? AND run_id = ? AND request_id = ? + AND state = 'pending' AND revision = ? AND expires_at_ms <= ?` + ) + .run( + occurredAtMs, + row.workspace_id, + row.team_id, + row.authority_generation, + row.restore_generation, + row.run_id, + row.request_id, + row.revision, + occurredAtMs + ); + if (update.changes !== 1) continue; + const auditId = `approval_audit_timeout-${identity}`; + const deliveryId = `approval_delivery_timeout-${identity}`; + input.db + .prepare( + `INSERT INTO hosted_team_approval_audit ( + audit_id, workspace_id, team_id, authority_generation, restore_generation, + run_id, request_id, approval_id, approval_generation, + decision, payload_hash, actor_id, session_id, occurred_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'timeout', ?, 'system:approval-timeout', ?, ?)` + ) + .run( + auditId, + row.workspace_id, + row.team_id, + row.authority_generation, + row.restore_generation, + row.run_id, + row.request_id, + row.approval_id, + row.approval_generation, + payloadHash, + `session_approval-timeout-${identity}`, + occurredAtMs + ); + input.db + .prepare( + `INSERT INTO hosted_team_approval_delivery_outbox ( + delivery_id, principal_id, workspace_id, team_id, authority_generation, + restore_generation, run_id, request_id, approval_id, approval_generation, + decision, payload_hash, delivery_ref, intent_json, state, delivery_generation, + delivery_owner_id, delivery_lease_token, delivery_claimed_at_ms, + delivery_lease_expires_at_ms, delivered_at_ms, created_at_ms + ) VALUES (?, '{"kind":"system_timeout"}', ?, ?, ?, ?, ?, ?, ?, ?, 'timeout', ?, ?, ?, 'pending', 0, + NULL, NULL, NULL, NULL, NULL, ?)` + ) + .run( + deliveryId, + row.workspace_id, + row.team_id, + row.authority_generation, + row.restore_generation, + row.run_id, + row.request_id, + row.approval_id, + row.approval_generation, + payloadHash, + row.delivery_ref, + serializeHostedTeamApprovalDeliveryIntent({ + partition: { teamId: row.team_id, runId: row.run_id }, + requestId: row.request_id, + approvalId: row.approval_id, + approvalGeneration: row.approval_generation, + decision: 'timeout', + payloadHash, + deliveryId, + principal: { kind: 'system_timeout' }, + deliveryRef: row.delivery_ref, + }), + occurredAtMs + ); + expired += 1; + } + return expired; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedTeamConfigurationStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/hostedTeamConfigurationStorageOps.ts new file mode 100644 index 0000000000..8bcf288b11 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedTeamConfigurationStorageOps.ts @@ -0,0 +1,303 @@ +import { randomBytes } from 'node:crypto'; + +import { sql } from 'drizzle-orm'; +import { + check, + foreignKey, + integer, + primaryKey, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; + +import { + type HostedTeamConfigurationStorageCreateResult, + type HostedTeamConfigurationStorageDeleteResult, + type HostedTeamConfigurationStorageReadResult, + type HostedTeamConfigurationStorageUpdateResult, + parseHostedTeamConfigurationStorageCreateRequest, + parseHostedTeamConfigurationStorageDeleteRequest, + parseHostedTeamConfigurationStorageDraft, + parseHostedTeamConfigurationStorageIdentity, + parseHostedTeamConfigurationStorageUpdateRequest, +} from '../../../contracts/hostedTeamConfigurationStorageContracts'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export const hostedTeamConfigurationDrafts = sqliteTable( + 'hosted_team_configuration_drafts', + { + workspaceId: text('workspace_id').notNull(), + teamId: text('team_id').notNull(), + state: text('state').notNull(), + revisionOrdinal: integer('revision_ordinal').notNull(), + revisionToken: text('revision_token').notNull(), + metadataJson: text('metadata_json').notNull(), + membersJson: text('members_json').notNull(), + createdAtMs: integer('created_at_ms').notNull(), + updatedAtMs: integer('updated_at_ms').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.workspaceId, table.teamId] }), + uniqueIndex('idx_hosted_team_configuration_team_id').on(table.teamId), + uniqueIndex('idx_hosted_team_configuration_revision_token').on(table.revisionToken), + check('ck_hosted_team_configuration_state', sql`${table.state} IN ('active', 'deleted')`), + check('ck_hosted_team_configuration_revision', sql`${table.revisionOrdinal} > 0`), + check('ck_hosted_team_configuration_metadata', sql`json_valid(${table.metadataJson})`), + check('ck_hosted_team_configuration_members', sql`json_valid(${table.membersJson})`), + ] +); + +export const hostedTeamConfigurationCreateKeys = sqliteTable( + 'hosted_team_configuration_create_keys', + { + workspaceId: text('workspace_id').notNull(), + idempotencyKey: text('idempotency_key').notNull(), + payloadHash: text('payload_hash').notNull(), + teamId: text('team_id').notNull(), + initialRevision: text('initial_revision').notNull(), + createdAtMs: integer('created_at_ms').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.workspaceId, table.idempotencyKey] }), + foreignKey({ + columns: [table.workspaceId, table.teamId], + foreignColumns: [ + hostedTeamConfigurationDrafts.workspaceId, + hostedTeamConfigurationDrafts.teamId, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + ] +); + +interface DraftRow { + workspace_id: string; + team_id: string; + revision_token: string; + metadata_json: string; + members_json: string; + state: 'active' | 'deleted'; + revision_ordinal: number; +} + +function teamId(): string { + return `team_${randomBytes(16).toString('hex')}`; +} + +function revision(): string { + return `revision_${randomBytes(24).toString('hex')}`; +} + +function draft(row: DraftRow) { + return parseHostedTeamConfigurationStorageDraft({ + workspaceId: row.workspace_id, + teamId: row.team_id, + revision: row.revision_token, + metadata: JSON.parse(row.metadata_json), + members: JSON.parse(row.members_json), + }); +} + +/** All mutations execute under one SQLite IMMEDIATE transaction. */ +export class HostedTeamConfigurationStorageOps { + constructor( + private readonly getDatabase: () => SqliteDatabase, + private readonly now: () => number + ) {} + + handle(op: string, payload: unknown): unknown { + switch (op) { + case 'hostedTeamConfiguration.create': + return this.create(payload); + case 'hostedTeamConfiguration.read': + return this.read(payload); + case 'hostedTeamConfiguration.update': + return this.update(payload); + case 'hostedTeamConfiguration.delete': + return this.delete(payload); + default: + throw new Error(`Unknown hosted team configuration storage op: ${op}`); + } + } + + private create(payload: unknown): HostedTeamConfigurationStorageCreateResult { + const input = parseHostedTeamConfigurationStorageCreateRequest(payload); + const db = this.getDatabase(); + return db + .transaction((): HostedTeamConfigurationStorageCreateResult => { + const admittedAtMs = this.requireMutationAdmission(input.deadlineAtMs); + const replay = db + .prepare( + `SELECT payload_hash, team_id, initial_revision + FROM hosted_team_configuration_create_keys + WHERE workspace_id = ? AND idempotency_key = ?` + ) + .get(input.workspaceId, input.idempotencyKey) as + | { payload_hash: string; team_id: string; initial_revision: string } + | undefined; + if (replay) { + return replay.payload_hash === input.payloadHash + ? { + kind: 'created', + teamId: replay.team_id as never, + revision: replay.initial_revision as never, + outcome: 'idempotent_replay', + } + : { kind: 'conflict', reason: 'idempotency_mismatch' }; + } + + let reservedTeamId = teamId(); + while ( + db + .prepare('SELECT 1 FROM hosted_team_configuration_drafts WHERE team_id = ?') + .get(reservedTeamId) + ) { + reservedTeamId = teamId(); + } + const initialRevision = revision(); + db.prepare( + `INSERT INTO hosted_team_configuration_drafts + (workspace_id, team_id, state, revision_ordinal, revision_token, + metadata_json, members_json, created_at_ms, updated_at_ms) + VALUES (?, ?, 'active', 1, ?, ?, ?, ?, ?)` + ).run( + input.workspaceId, + reservedTeamId, + initialRevision, + JSON.stringify(input.metadata), + JSON.stringify(input.members), + admittedAtMs, + admittedAtMs + ); + db.prepare( + `INSERT INTO hosted_team_configuration_create_keys + (workspace_id, idempotency_key, payload_hash, team_id, initial_revision, created_at_ms) + VALUES (?, ?, ?, ?, ?, ?)` + ).run( + input.workspaceId, + input.idempotencyKey, + input.payloadHash, + reservedTeamId, + initialRevision, + admittedAtMs + ); + return { + kind: 'created', + teamId: reservedTeamId as never, + revision: initialRevision as never, + outcome: 'created', + }; + }) + .immediate(); + } + + private read(payload: unknown): HostedTeamConfigurationStorageReadResult { + const input = parseHostedTeamConfigurationStorageIdentity(payload); + const row = this.getDatabase() + .prepare( + `SELECT workspace_id, team_id, revision_token, revision_ordinal, + metadata_json, members_json, state + FROM hosted_team_configuration_drafts + WHERE workspace_id = ? AND team_id = ? AND state = 'active'` + ) + .get(input.workspaceId, input.teamId) as DraftRow | undefined; + return row ? { kind: 'found', draft: draft(row) } : { kind: 'not_found' }; + } + + private update(payload: unknown): HostedTeamConfigurationStorageUpdateResult { + const input = parseHostedTeamConfigurationStorageUpdateRequest(payload); + const db = this.getDatabase(); + return db + .transaction((): HostedTeamConfigurationStorageUpdateResult => { + const admittedAtMs = this.requireMutationAdmission(input.deadlineAtMs); + const row = db + .prepare( + `SELECT workspace_id, team_id, revision_token, revision_ordinal, + metadata_json, members_json, state + FROM hosted_team_configuration_drafts + WHERE workspace_id = ? AND team_id = ?` + ) + .get(input.workspaceId, input.teamId) as DraftRow | undefined; + if (!row || row.state !== 'active') return { kind: 'not_found' }; + if (row.revision_token !== input.expectedRevision) { + return { kind: 'conflict', reason: 'revision_mismatch' }; + } + const nextRevision = revision(); + const nextMetadata = { ...(JSON.parse(row.metadata_json) as object), ...input.updates }; + const changed = db + .prepare( + `UPDATE hosted_team_configuration_drafts + SET revision_ordinal = revision_ordinal + 1, + revision_token = ?, metadata_json = ?, updated_at_ms = ? + WHERE workspace_id = ? AND team_id = ? AND state = 'active' AND revision_token = ?` + ) + .run( + nextRevision, + JSON.stringify(nextMetadata), + admittedAtMs, + input.workspaceId, + input.teamId, + input.expectedRevision + ); + if (changed.changes !== 1) return { kind: 'conflict', reason: 'revision_mismatch' }; + return { + kind: 'updated', + draft: draft({ + ...row, + revision_ordinal: row.revision_ordinal + 1, + revision_token: nextRevision, + metadata_json: JSON.stringify(nextMetadata), + }), + }; + }) + .immediate(); + } + + private delete(payload: unknown): HostedTeamConfigurationStorageDeleteResult { + const input = parseHostedTeamConfigurationStorageDeleteRequest(payload); + const db = this.getDatabase(); + return db + .transaction((): HostedTeamConfigurationStorageDeleteResult => { + const admittedAtMs = this.requireMutationAdmission(input.deadlineAtMs); + const row = db + .prepare( + `SELECT state, revision_token, revision_ordinal + FROM hosted_team_configuration_drafts + WHERE workspace_id = ? AND team_id = ?` + ) + .get(input.workspaceId, input.teamId) as + | { state: 'active' | 'deleted'; revision_token: string; revision_ordinal: number } + | undefined; + // Absence and tombstones deliberately have the same response, including wrong-workspace IDs. + if (!row || row.state === 'deleted') return { kind: 'deleted', outcome: 'already_absent' }; + if (row.revision_token !== input.expectedRevision) { + return { kind: 'conflict', reason: 'revision_mismatch' }; + } + const changed = db + .prepare( + `UPDATE hosted_team_configuration_drafts + SET state = 'deleted', revision_ordinal = revision_ordinal + 1, + revision_token = ?, updated_at_ms = ? + WHERE workspace_id = ? AND team_id = ? AND state = 'active' AND revision_token = ?` + ) + .run(revision(), admittedAtMs, input.workspaceId, input.teamId, input.expectedRevision); + return changed.changes === 1 + ? { kind: 'deleted', outcome: 'deleted' } + : { kind: 'conflict', reason: 'revision_mismatch' }; + }) + .immediate(); + } + + private requireMutationAdmission(deadlineAtMs: number): number { + const now = this.now(); + if (now >= deadlineAtMs) { + throw new Error('hosted-team-configuration-mutation-deadline-expired'); + } + return now; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/hostedWorkspaceGrantRevisionStorageMigration.ts b/src/features/internal-storage/main/infrastructure/worker/hostedWorkspaceGrantRevisionStorageMigration.ts new file mode 100644 index 0000000000..23b350b2c0 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/hostedWorkspaceGrantRevisionStorageMigration.ts @@ -0,0 +1,24 @@ +/** Append-only v20 migration: every grant mutation receives an unguessable ABA revision. */ +export const HOSTED_WORKSPACE_GRANT_REVISION_STORAGE_MIGRATION_STATEMENTS = Object.freeze([ + `ALTER TABLE hosted_workspace_grants RENAME TO hosted_workspace_grants_v19`, + `CREATE TABLE hosted_workspace_grants ( + user_id TEXT NOT NULL, + runtime_workspace_id TEXT NOT NULL, + grant_generation INTEGER NOT NULL CHECK (grant_generation >= 0), + grant_revision TEXT NOT NULL CHECK (length(grant_revision) = 64), + granted_at INTEGER NOT NULL, + granted_by TEXT NOT NULL CHECK (granted_by = 'local-cli'), + PRIMARY KEY (user_id, runtime_workspace_id), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT, + FOREIGN KEY (runtime_workspace_id) + REFERENCES hosted_workspaces(runtime_workspace_id) ON DELETE CASCADE + )`, + `INSERT INTO hosted_workspace_grants + (user_id, runtime_workspace_id, grant_generation, grant_revision, granted_at, granted_by) + SELECT user_id, runtime_workspace_id, grant_generation, lower(hex(randomblob(32))), + granted_at, granted_by + FROM hosted_workspace_grants_v19`, + `DROP TABLE hosted_workspace_grants_v19`, + `CREATE INDEX idx_hosted_workspace_grants_generation + ON hosted_workspace_grants (user_id, grant_generation, runtime_workspace_id)`, +]); diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageBackupTables.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageBackupTables.ts new file mode 100644 index 0000000000..57f873945f --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageBackupTables.ts @@ -0,0 +1,690 @@ +import { INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES } from '../../application/internalStorageBackupContract'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export { INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES }; + +export const HOSTED_WORKSPACE_ACCESS_MIGRATION_STATEMENTS = Object.freeze([ + `CREATE TABLE hosted_workspaces_v16 ( + runtime_workspace_id TEXT PRIMARY KEY, + public_workspace_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), + registered_at INTEGER NOT NULL, + registered_by TEXT, + FOREIGN KEY (registered_by) REFERENCES users(user_id) ON DELETE RESTRICT + )`, + `INSERT INTO hosted_workspaces_v16 + (runtime_workspace_id, public_workspace_id, display_name, status, registered_at, registered_by) + SELECT workspace_id, 'workspace_' || lower(hex(randomblob(16))), + display_name, status, registered_at, registered_by + FROM hosted_workspaces`, + `DROP TABLE hosted_workspaces`, + `ALTER TABLE hosted_workspaces_v16 RENAME TO hosted_workspaces`, + `CREATE TABLE IF NOT EXISTS hosted_workspace_grants ( + user_id TEXT NOT NULL, + runtime_workspace_id TEXT NOT NULL, + grant_generation INTEGER NOT NULL CHECK (grant_generation >= 0), + granted_at INTEGER NOT NULL, + granted_by TEXT NOT NULL CHECK (granted_by = 'local-cli'), + PRIMARY KEY (user_id, runtime_workspace_id), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT, + FOREIGN KEY (runtime_workspace_id) + REFERENCES hosted_workspaces(runtime_workspace_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_hosted_workspace_grants_generation + ON hosted_workspace_grants (user_id, grant_generation, runtime_workspace_id)`, +] as const); + +type TableColumnShape = readonly [ + name: string, + type: string, + notnull: number, + primaryKeyPosition: number, +]; + +const LEGACY_HOSTED_WORKSPACE_COLUMNS: readonly TableColumnShape[] = Object.freeze([ + ['workspace_id', 'TEXT', 0, 1], + ['display_name', 'TEXT', 1, 0], + ['status', 'TEXT', 1, 0], + ['registered_at', 'INTEGER', 1, 0], + ['registered_by', 'TEXT', 0, 0], +]); +const CURRENT_HOSTED_WORKSPACE_COLUMNS: readonly TableColumnShape[] = Object.freeze([ + ['runtime_workspace_id', 'TEXT', 0, 1], + ['public_workspace_id', 'TEXT', 1, 0], + ['display_name', 'TEXT', 1, 0], + ['status', 'TEXT', 1, 0], + ['registered_at', 'INTEGER', 1, 0], + ['registered_by', 'TEXT', 0, 0], +]); +const V16_HOSTED_WORKSPACE_GRANT_COLUMNS: readonly TableColumnShape[] = Object.freeze([ + ['user_id', 'TEXT', 1, 1], + ['runtime_workspace_id', 'TEXT', 1, 2], + ['grant_generation', 'INTEGER', 1, 0], + ['granted_at', 'INTEGER', 1, 0], + ['granted_by', 'TEXT', 1, 0], +]); +const V20_HOSTED_WORKSPACE_GRANT_COLUMNS: readonly TableColumnShape[] = Object.freeze([ + ['user_id', 'TEXT', 1, 1], + ['runtime_workspace_id', 'TEXT', 1, 2], + ['grant_generation', 'INTEGER', 1, 0], + ['grant_revision', 'TEXT', 1, 0], + ['granted_at', 'INTEGER', 1, 0], + ['granted_by', 'TEXT', 1, 0], +]); + +/** + * A restored database can have a current table shape with an older user_version. + * Do not destructively rebuild it as though it still exposed the v13 workspace_id column. + */ +export function migrateHostedWorkspaceAccess(db: SqliteDatabase): void { + const workspaceColumns = readTableColumns(db, 'hosted_workspaces'); + const legacy = sameColumns(workspaceColumns, LEGACY_HOSTED_WORKSPACE_COLUMNS); + const current = sameColumns(workspaceColumns, CURRENT_HOSTED_WORKSPACE_COLUMNS); + if (!legacy && !current) { + throw new Error('hosted-workspace-access-migration-metadata-invalid'); + } + if (legacy && readTableColumns(db, 'hosted_workspace_grants').length > 0) { + throw new Error('hosted-workspace-access-migration-metadata-invalid'); + } + const statements = legacy + ? HOSTED_WORKSPACE_ACCESS_MIGRATION_STATEMENTS + : HOSTED_WORKSPACE_ACCESS_MIGRATION_STATEMENTS.slice(4); + for (const statement of statements) db.exec(statement); + const grantColumns = readTableColumns(db, 'hosted_workspace_grants'); + if ( + !sameColumns(readTableColumns(db, 'hosted_workspaces'), CURRENT_HOSTED_WORKSPACE_COLUMNS) || + (!sameColumns(grantColumns, V16_HOSTED_WORKSPACE_GRANT_COLUMNS) && + !sameColumns(grantColumns, V20_HOSTED_WORKSPACE_GRANT_COLUMNS)) || + !hasExactIndex(db, 'hosted_workspaces', ['public_workspace_id'], true) || + !hasExactIndex( + db, + 'hosted_workspace_grants', + ['user_id', 'grant_generation', 'runtime_workspace_id'], + false + ) + ) { + throw new Error('hosted-workspace-access-migration-metadata-invalid'); + } +} + +interface TableColumn { + readonly name: string; + readonly type: string; + readonly notnull: number; + readonly dflt_value: string | null; + readonly pk: number; +} + +function readTableColumns(db: SqliteDatabase, tableName: string): readonly TableColumn[] { + return db.pragma(`table_info(${tableName})`) as TableColumn[]; +} + +function sameColumns( + actual: readonly TableColumn[], + expected: readonly TableColumnShape[] +): boolean { + return ( + actual.length === expected.length && + actual.every((column, index) => { + const shape = expected[index]; + return ( + column.name === shape[0] && + column.type === shape[1] && + column.notnull === shape[2] && + column.dflt_value === null && + column.pk === shape[3] + ); + }) + ); +} + +function hasExactIndex( + db: SqliteDatabase, + tableName: string, + columnNames: readonly string[], + unique: boolean +): boolean { + const indexes = db.pragma(`index_list(${tableName})`) as { + readonly name: string; + readonly unique: number; + }[]; + return indexes.some((index) => { + if (index.unique !== Number(unique)) return false; + const quotedIndexName = `"${index.name.replaceAll('"', '""')}"`; + const indexedColumns = db.pragma(`index_info(${quotedIndexName})`) as { + readonly name: string; + }[]; + return ( + indexedColumns.length === columnNames.length && + indexedColumns.every((column, position) => column.name === columnNames[position]) + ); + }); +} + +export function ensureHostedAuthResetColumns(db: SqliteDatabase): void { + const columns = new Set(readHostedAuthConfigurationColumns(db).map((column) => column.name)); + if (!columns.has('reset_generation')) { + db.exec( + `ALTER TABLE hosted_auth_configuration + ADD COLUMN reset_generation INTEGER NOT NULL DEFAULT 0 + CHECK (reset_generation >= 0)` + ); + } + if (!columns.has('secrets_rotated_generation')) { + db.exec( + `ALTER TABLE hosted_auth_configuration + ADD COLUMN secrets_rotated_generation INTEGER NOT NULL DEFAULT 0 + CHECK ( + secrets_rotated_generation >= 0 + AND secrets_rotated_generation <= reset_generation + )` + ); + } + if (!columns.has('pending_personal_keyring_id')) { + db.exec(`ALTER TABLE hosted_auth_configuration ADD COLUMN pending_personal_keyring_id TEXT`); + } + const migratedColumns = new Map( + readHostedAuthConfigurationColumns(db).map((column) => [column.name, column]) + ); + const resetGeneration = migratedColumns.get('reset_generation'); + const secretsRotatedGeneration = migratedColumns.get('secrets_rotated_generation'); + const pendingPersonalKeyringId = migratedColumns.get('pending_personal_keyring_id'); + if ( + resetGeneration?.type !== 'INTEGER' || + resetGeneration.notnull !== 1 || + resetGeneration.dflt_value !== '0' || + resetGeneration.pk !== 0 || + secretsRotatedGeneration?.type !== 'INTEGER' || + secretsRotatedGeneration.notnull !== 1 || + secretsRotatedGeneration.dflt_value !== '0' || + secretsRotatedGeneration.pk !== 0 || + pendingPersonalKeyringId?.type !== 'TEXT' || + pendingPersonalKeyringId.notnull !== 0 || + pendingPersonalKeyringId.dflt_value !== null || + pendingPersonalKeyringId.pk !== 0 + ) { + throw new Error('hosted-auth-reset-migration-metadata-invalid'); + } +} + +function readHostedAuthConfigurationColumns(db: SqliteDatabase): readonly { + readonly name: string; + readonly type: string; + readonly notnull: number; + readonly dflt_value: string | null; + readonly pk: number; +}[] { + return db.pragma('table_info(hosted_auth_configuration)') as { + readonly name: string; + readonly type: string; + readonly notnull: number; + readonly dflt_value: string | null; + readonly pk: number; + }[]; +} + +function modeResetRecord(value: unknown, code: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(code); + } + return value as Record; +} + +function modeResetText(value: unknown, code: string, maximum = 16_384): string { + if (typeof value !== 'string' || value.length === 0 || value.length > maximum) { + throw new TypeError(code); + } + return value; +} + +function modeResetInteger(value: unknown, code: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) throw new TypeError(code); + return Number(value); +} + +function modeResetNullableText(value: unknown, code: string): string | null { + return value === null ? null : modeResetText(value, code); +} + +interface ModeResetAuditEvent { + readonly eventId: string; + readonly occurredAt: number; + readonly userId: string | null; + readonly sessionId: string | null; + readonly action: string; + readonly outcome: string; + readonly sourceIpHash: string | null; + readonly detailsJson: string; +} + +/** + * Owns the one cross-table hosted-auth transition. It lives beside the + * authoritative backup inventory because every table it mutates is part of + * the same coordinated durability domain. + */ +export class HostedAuthModeStorageOps { + constructor(private readonly database: () => SqliteDatabase) {} + + readConfiguration(): unknown { + return ( + this.database() + .prepare( + `SELECT auth_mode AS authMode, configured_at AS configuredAt, + reset_generation AS resetGeneration, + secrets_rotated_generation AS secretsRotatedGeneration, + pending_personal_keyring_id AS pendingPersonalKeyringId + FROM hosted_auth_configuration WHERE singleton = 1` + ) + .get() ?? null + ); + } + + resetMode(payload: Record): string { + const currentMode = modeResetText(payload.currentMode, 'hosted-auth-current-mode-invalid'); + const targetMode = modeResetText(payload.targetMode, 'hosted-auth-target-mode-invalid'); + if ( + (currentMode !== 'personal' && currentMode !== 'oidc') || + (targetMode !== 'personal' && targetMode !== 'oidc') || + currentMode === targetMode + ) { + throw new TypeError('hosted-auth-mode-transition-invalid'); + } + const resetGeneration = modeResetInteger( + payload.resetGeneration, + 'hosted-auth-reset-generation-invalid' + ); + if (resetGeneration === 0) throw new TypeError('hosted-auth-reset-generation-invalid'); + const resetAt = modeResetInteger(payload.resetAt, 'hosted-auth-reset-at-invalid'); + const pendingPersonalKeyringId = modeResetText( + payload.pendingPersonalKeyringId, + 'hosted-auth-pending-keyring-invalid', + 128 + ); + const expectedAuthorityRevision = + payload.expectedAuthorityRevision === null + ? null + : modeResetInteger( + payload.expectedAuthorityRevision, + 'hosted-auth-authority-revision-invalid' + ); + const nextAuthorityStateJson = modeResetText( + payload.nextAuthorityStateJson, + 'hosted-auth-authority-state-invalid', + 4_000_000 + ); + const parsedState = modeResetRecord( + JSON.parse(nextAuthorityStateJson) as unknown, + 'hosted-auth-authority-state-invalid' + ); + const nextAuthorityRevision = modeResetInteger( + parsedState.revision, + 'hosted-auth-authority-revision-invalid' + ); + if ( + parsedState.expectedKeyringId !== pendingPersonalKeyringId || + parsedState.consumedResetGeneration !== resetGeneration || + !Array.isArray(parsedState.pairingChallenges) || + parsedState.pairingChallenges.length !== 0 || + !Array.isArray(parsedState.deviceFamilies) || + parsedState.deviceFamilies.length !== 0 || + !Array.isArray(parsedState.deviceGrants) || + parsedState.deviceGrants.length !== 0 || + !Array.isArray(parsedState.sessions) || + parsedState.sessions.length !== 0 || + parsedState.resetIntent !== null + ) { + throw new TypeError('hosted-auth-authority-reset-state-invalid'); + } + if ( + (expectedAuthorityRevision === null && nextAuthorityRevision !== 0) || + (expectedAuthorityRevision !== null && + nextAuthorityRevision !== expectedAuthorityRevision + 1) + ) { + throw new TypeError('hosted-auth-authority-reset-sequence-invalid'); + } + const event = this.parseAuditEvent(payload.auditEvent); + + return this.database().transaction(() => { + const configuration = this.database() + .prepare( + `SELECT auth_mode AS authMode, reset_generation AS resetGeneration + FROM hosted_auth_configuration WHERE singleton = 1` + ) + .get() as { readonly authMode: string; readonly resetGeneration: number } | undefined; + if (configuration?.authMode !== currentMode) return 'mode_mismatch'; + if (resetGeneration <= configuration.resetGeneration) return 'generation_not_newer'; + const authority = this.database() + .prepare( + `SELECT revision, rollback_fence_revision AS rollbackFenceRevision + FROM hosted_access_authority WHERE singleton = 1` + ) + .get() as { readonly revision: number; readonly rollbackFenceRevision: number } | undefined; + if (expectedAuthorityRevision === null) { + if (authority !== undefined) return 'authority_conflict'; + const inserted = this.database() + .prepare( + `INSERT INTO hosted_access_authority + (singleton, state_json, revision, rollback_fence_revision) + VALUES (1, ?, 0, 0)` + ) + .run(nextAuthorityStateJson); + if (inserted.changes !== 1) throw new Error('hosted-auth-mode-reset-atomicity-failed'); + } else { + if ( + authority?.revision !== expectedAuthorityRevision || + authority.rollbackFenceRevision !== expectedAuthorityRevision + ) { + return 'authority_conflict'; + } + const updated = this.database() + .prepare( + `UPDATE hosted_access_authority + SET state_json = ?, revision = ?, rollback_fence_revision = ? + WHERE singleton = 1 AND revision = ? AND rollback_fence_revision = ?` + ) + .run( + nextAuthorityStateJson, + nextAuthorityRevision, + nextAuthorityRevision, + expectedAuthorityRevision, + expectedAuthorityRevision + ); + if (updated.changes !== 1) throw new Error('hosted-auth-mode-reset-atomicity-failed'); + } + const changed = this.database() + .prepare( + `UPDATE hosted_auth_configuration + SET auth_mode = ?, configured_at = ?, reset_generation = ?, + pending_personal_keyring_id = ? + WHERE singleton = 1 AND auth_mode = ? AND reset_generation < ?` + ) + .run( + targetMode, + resetAt, + resetGeneration, + pendingPersonalKeyringId, + currentMode, + resetGeneration + ); + if (changed.changes !== 1) throw new Error('hosted-auth-mode-reset-atomicity-failed'); + this.database() + .prepare( + `UPDATE operator_sessions + SET status = 'revoked', revoked_at = ?, revocation_reason = 'auth_mode_reset' + WHERE status = 'active'` + ) + .run(resetAt); + this.database().prepare(`DELETE FROM oidc_login_attempts`).run(); + this.insertAuditEvent(event); + return 'committed'; + })(); + } + + markSecretsRotated(payload: Record): boolean { + const mode = modeResetText(payload.mode, 'hosted-auth-mode-invalid'); + if (mode !== 'personal' && mode !== 'oidc') throw new TypeError('hosted-auth-mode-invalid'); + const resetGeneration = modeResetInteger( + payload.resetGeneration, + 'hosted-auth-reset-generation-invalid' + ); + const pendingPersonalKeyringId = modeResetText( + payload.pendingPersonalKeyringId, + 'hosted-auth-pending-keyring-invalid', + 128 + ); + return ( + this.database() + .prepare( + `UPDATE hosted_auth_configuration + SET secrets_rotated_generation = reset_generation, + pending_personal_keyring_id = NULL + WHERE singleton = 1 AND auth_mode = ? AND reset_generation = ? + AND secrets_rotated_generation < reset_generation + AND pending_personal_keyring_id = ?` + ) + .run(mode, resetGeneration, pendingPersonalKeyringId).changes === 1 + ); + } + + private parseAuditEvent(value: unknown): ModeResetAuditEvent { + const event = modeResetRecord(value, 'hosted-audit-event-invalid'); + const outcome = modeResetText(event.outcome, 'hosted-audit-outcome-invalid'); + if (outcome !== 'success' && outcome !== 'denied' && outcome !== 'failure') { + throw new TypeError('hosted-audit-outcome-invalid'); + } + return Object.freeze({ + eventId: modeResetText(event.eventId, 'hosted-audit-id-invalid'), + occurredAt: modeResetInteger(event.occurredAt, 'hosted-audit-occurred-at-invalid'), + userId: modeResetNullableText(event.userId, 'hosted-user-id-invalid'), + sessionId: modeResetNullableText(event.sessionId, 'hosted-session-id-invalid'), + action: modeResetText(event.action, 'hosted-audit-action-invalid', 256), + outcome, + sourceIpHash: modeResetNullableText(event.sourceIpHash, 'hosted-audit-source-ip-invalid'), + detailsJson: modeResetText(event.detailsJson, 'hosted-audit-details-invalid', 65_536), + }); + } + + private insertAuditEvent(event: ModeResetAuditEvent): void { + this.database() + .prepare( + `INSERT INTO auth_audit_events + (event_id, occurred_at, user_id, session_id, action, outcome, source_ip_hash, details_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + event.eventId, + event.occurredAt, + event.userId, + event.sessionId, + event.action, + event.outcome, + event.sourceIpHash, + event.detailsJson + ); + } +} + +/** + * Keeps workspace registration and per-principal grants in one SQLite + * durability boundary. Grant reads always include the active restore + * generation supplied by the hosted-access application service. + */ +export class HostedWorkspaceStorageOps { + constructor(private readonly database: () => SqliteDatabase) {} + + isWorkspaceRegistered(payload: Record): boolean { + return Boolean( + this.database() + .prepare( + `SELECT 1 FROM hosted_workspaces + WHERE runtime_workspace_id = ? AND status = 'active'` + ) + .get(modeResetText(payload.runtimeWorkspaceId, 'hosted-workspace-id-invalid', 1024)) + ); + } + + seedWorkspace(payload: Record): null { + this.database() + .prepare( + `INSERT INTO hosted_workspaces + (runtime_workspace_id, public_workspace_id, display_name, status, + registered_at, registered_by) + VALUES (?, ?, ?, 'active', ?, NULL) + ON CONFLICT(runtime_workspace_id) DO UPDATE SET display_name = excluded.display_name` + ) + .run( + modeResetText(payload.runtimeWorkspaceId, 'hosted-workspace-id-invalid', 1024), + modeResetText(payload.workspaceId, 'hosted-public-workspace-id-invalid', 128), + modeResetText(payload.displayName, 'hosted-workspace-name-invalid', 256), + modeResetInteger(payload.registeredAt, 'hosted-workspace-created-at-invalid') + ); + return null; + } + + listWorkspaces(): unknown { + return this.database() + .prepare( + `SELECT runtime_workspace_id AS runtimeWorkspaceId, + public_workspace_id AS workspaceId, display_name AS displayName, status, + registered_at AS registeredAt, registered_by AS registeredBy + FROM hosted_workspaces ORDER BY registered_at, runtime_workspace_id` + ) + .all(); + } + + registerWorkspace(payload: Record): unknown { + const registeredBy = + payload.registeredBy === null + ? null + : modeResetText(payload.registeredBy, 'hosted-workspace-registered-by-invalid'); + const runtimeWorkspaceId = modeResetText( + payload.runtimeWorkspaceId, + 'hosted-workspace-id-invalid', + 256 + ); + this.database() + .prepare( + `INSERT INTO hosted_workspaces + (runtime_workspace_id, public_workspace_id, display_name, status, + registered_at, registered_by) + VALUES (?, ?, ?, 'active', ?, ?) + ON CONFLICT(runtime_workspace_id) DO UPDATE SET + display_name = excluded.display_name, + status = 'active', + registered_at = excluded.registered_at, + registered_by = excluded.registered_by` + ) + .run( + runtimeWorkspaceId, + modeResetText(payload.workspaceId, 'hosted-public-workspace-id-invalid', 128), + modeResetText(payload.displayName, 'hosted-workspace-name-invalid', 256), + modeResetInteger(payload.registeredAt, 'hosted-workspace-created-at-invalid'), + registeredBy + ); + return this.database() + .prepare( + `SELECT runtime_workspace_id AS runtimeWorkspaceId, + public_workspace_id AS workspaceId, display_name AS displayName, status, + registered_at AS registeredAt, registered_by AS registeredBy + FROM hosted_workspaces WHERE runtime_workspace_id = ?` + ) + .get(runtimeWorkspaceId); + } + + disableWorkspace(payload: Record): boolean { + const runtimeWorkspaceId = modeResetText( + payload.runtimeWorkspaceId, + 'hosted-workspace-id-invalid', + 256 + ); + return this.database().transaction(() => { + const changed = this.database() + .prepare( + `UPDATE hosted_workspaces SET status = 'disabled' + WHERE runtime_workspace_id = ? AND status = 'active'` + ) + .run(runtimeWorkspaceId).changes; + if (changed !== 1) return false; + this.database() + .prepare(`DELETE FROM hosted_workspace_grants WHERE runtime_workspace_id = ?`) + .run(runtimeWorkspaceId); + return true; + })(); + } + + listWorkspaceGrants(payload: Record): unknown { + return this.database() + .prepare( + `SELECT grants.user_id AS userId, + workspaces.public_workspace_id AS workspaceId, + grants.runtime_workspace_id AS runtimeWorkspaceId, + workspaces.display_name AS displayName, + grants.grant_generation AS grantGeneration, + grants.grant_revision AS grantRevision, + grants.granted_at AS grantedAt, + grants.granted_by AS grantedBy + FROM hosted_workspace_grants AS grants + INNER JOIN hosted_workspaces AS workspaces + ON workspaces.runtime_workspace_id = grants.runtime_workspace_id + INNER JOIN users ON users.user_id = grants.user_id + WHERE grants.user_id = ? + AND grants.grant_generation = ? + AND workspaces.status = 'active' + AND users.status = 'active' + ORDER BY grants.granted_at, grants.runtime_workspace_id` + ) + .all( + modeResetText(payload.userId, 'hosted-user-id-invalid'), + modeResetInteger(payload.grantGeneration, 'hosted-workspace-grant-generation-invalid') + ); + } + + setWorkspaceGrant(payload: Record): unknown { + const userId = modeResetText(payload.userId, 'hosted-user-id-invalid'); + const runtimeWorkspaceId = modeResetText( + payload.runtimeWorkspaceId, + 'hosted-workspace-id-invalid', + 256 + ); + const grantGeneration = modeResetInteger( + payload.grantGeneration, + 'hosted-workspace-grant-generation-invalid' + ); + const grantedAt = modeResetInteger(payload.grantedAt, 'hosted-workspace-granted-at-invalid'); + const grantedBy = modeResetText(payload.grantedBy, 'hosted-workspace-granted-by-invalid'); + if (grantedBy !== 'local-cli') { + throw new TypeError('hosted-workspace-granted-by-invalid'); + } + return this.database().transaction(() => { + const changed = this.database() + .prepare( + `INSERT INTO hosted_workspace_grants + (user_id, runtime_workspace_id, grant_generation, grant_revision, granted_at, granted_by) + SELECT ?, runtime_workspace_id, ?, lower(hex(randomblob(32))), ?, ? + FROM hosted_workspaces + WHERE runtime_workspace_id = ? AND status = 'active' + ON CONFLICT(user_id, runtime_workspace_id) DO UPDATE SET + grant_generation = excluded.grant_generation, + grant_revision = excluded.grant_revision, + granted_at = excluded.granted_at, + granted_by = excluded.granted_by` + ) + .run(userId, grantGeneration, grantedAt, grantedBy, runtimeWorkspaceId).changes; + if (changed !== 1) throw new Error('hosted-workspace-not-registered'); + return this.database() + .prepare( + `SELECT grants.user_id AS userId, + workspaces.public_workspace_id AS workspaceId, + grants.runtime_workspace_id AS runtimeWorkspaceId, + workspaces.display_name AS displayName, + grants.grant_generation AS grantGeneration, + grants.grant_revision AS grantRevision, + grants.granted_at AS grantedAt, + grants.granted_by AS grantedBy + FROM hosted_workspace_grants AS grants + INNER JOIN hosted_workspaces AS workspaces + ON workspaces.runtime_workspace_id = grants.runtime_workspace_id + WHERE grants.user_id = ? AND grants.runtime_workspace_id = ?` + ) + .get(userId, runtimeWorkspaceId); + })(); + } + + revokeWorkspaceGrant(payload: Record): boolean { + return ( + this.database() + .prepare( + `DELETE FROM hosted_workspace_grants + WHERE user_id = ? AND runtime_workspace_id = ?` + ) + .run( + modeResetText(payload.userId, 'hosted-user-id-invalid'), + modeResetText(payload.runtimeWorkspaceId, 'hosted-workspace-id-invalid', 256) + ).changes === 1 + ); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageLegacyDurabilityMigration.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageLegacyDurabilityMigration.ts new file mode 100644 index 0000000000..e50b2fb78a --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageLegacyDurabilityMigration.ts @@ -0,0 +1,59 @@ +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export function ensureHistoricalV6DurabilityTables( + db: SqliteDatabase, + statements: readonly string[] +): void { + for (const statement of statements) db.exec(statement); + const crossDeployment = db + .prepare( + `SELECT command_id FROM durable_application_command_outbox + GROUP BY command_id HAVING COUNT(DISTINCT deployment_id) <> 1 LIMIT 1` + ) + .get(); + if (crossDeployment) throw new Error('internal-storage-legacy-command-deployment-ambiguous'); + db.exec(`INSERT INTO durable_application_commands ( + command_id, deployment_id, stable_actor_id, command_kind, idempotency_key, + descriptor_id, descriptor_version, input_schema_version, fingerprint_version, + effect_plan_version, fingerprint_key_version, fingerprint_digest, + attempt_generation, attempt_id, attempt_owner_id, attempt_lease_token, + attempt_claimed_at, attempt_lease_expires_at, state, retention_class, + audit_session_id, outcome_json, error_code, error_json, + created_at, updated_at, committed_at) + SELECT outbox.command_id, MIN(outbox.deployment_id), + 'legacy-unattributed:' || outbox.command_id, 'legacy_recovery', + 'legacy-event:' || outbox.command_id, 'legacy-recovery-v1', 1, 1, + 'hmac-sha256-ld-v1', 1, 'legacy-unavailable', + '0000000000000000000000000000000000000000000000000000000000000000', + 1, 'legacy-attempt:' || outbox.command_id, 'legacy-recovery', + 'legacy-lease:' || outbox.command_id, MIN(outbox.created_at), + '9999-12-31T23:59:59.999Z', 'committed', 'legacy_recovery', NULL, + json_object('provenance', 'legacy_recovery_v1'), NULL, NULL, + MIN(outbox.created_at), MAX(outbox.created_at), MAX(outbox.created_at) + FROM durable_application_command_outbox AS outbox + WHERE NOT EXISTS (SELECT 1 FROM durable_application_commands AS commands + WHERE commands.command_id = outbox.command_id) + GROUP BY outbox.command_id`); + db.exec(`INSERT INTO durable_application_command_effects ( + command_id, ordinal, effect_id, effect_version, recovery_class, + evidence_schema_version, state, updated_at) + SELECT commands.command_id, 0, 'legacy-recovery:' || commands.command_id, + 1, 'transactional_local', 1, 'observed_succeeded', commands.updated_at + FROM durable_application_commands AS commands + WHERE commands.descriptor_id = 'legacy-recovery-v1' + AND commands.stable_actor_id = 'legacy-unattributed:' || commands.command_id + AND NOT EXISTS (SELECT 1 FROM durable_application_command_effects AS effects + WHERE effects.command_id = commands.command_id)`); + db.exec(`INSERT INTO durable_application_command_effect_evidence ( + command_id, ordinal, sequence, outcome, evidence_schema_version, + evidence_json, recorded_at) + SELECT commands.command_id, 0, 1, 'observed_succeeded', 1, + json_object('provenance', 'legacy_recovery_v1'), commands.updated_at + FROM durable_application_commands AS commands + WHERE commands.descriptor_id = 'legacy-recovery-v1' + AND commands.stable_actor_id = 'legacy-unattributed:' || commands.command_id + AND NOT EXISTS (SELECT 1 FROM durable_application_command_effect_evidence AS evidence + WHERE evidence.command_id = commands.command_id AND evidence.ordinal = 0)`); +} diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrationBackfills.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrationBackfills.ts new file mode 100644 index 0000000000..abf740691f --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrationBackfills.ts @@ -0,0 +1,279 @@ +import { normalizeMemberWorkSyncTeamKey } from '../../../contracts/memberWorkSyncTeamIdentity'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +const MEMBER_WORK_SYNC_TEAM_KEY_TABLES = [ + 'member_work_sync_status', + 'member_work_sync_report_intents', + 'member_work_sync_outbox', + 'member_work_sync_metric_events', +] as const; +/** Runs inside the v9 migration transaction and deliberately uses the shared JS contract. */ +export function backfillMemberWorkSyncTeamKeys(db: SqliteDatabase): void { + for (const tableName of MEMBER_WORK_SYNC_TEAM_KEY_TABLES) { + const rows = db.prepare(`SELECT rowid, team_name FROM ${tableName}`).all() as { + readonly rowid: number; + readonly team_name: string; + }[]; + const update = db.prepare(`UPDATE ${tableName} SET team_key = ? WHERE rowid = ?`); + for (const row of rows) { + update.run(normalizeMemberWorkSyncTeamKey(row.team_name), row.rowid); + } + } +} +export function ensureMemberWorkSyncTeamKeyIndexes(db: SqliteDatabase): void { + db.exec(`CREATE INDEX IF NOT EXISTS idx_mws_status_team_key + ON member_work_sync_status (team_key)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mws_report_intents_team_key + ON member_work_sync_report_intents (team_key)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mws_outbox_team_key + ON member_work_sync_outbox (team_key)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mws_metric_events_team_key + ON member_work_sync_metric_events (team_key)`); +} + +export function ensureCommandCoordinationAttribution(db: SqliteDatabase): void { + const columns = db.pragma('table_info(durable_application_commands)') as { + readonly name: string; + }[]; + if (!columns.some((column) => column.name === 'coordination_attribution_json')) { + db.exec( + `ALTER TABLE durable_application_commands + ADD COLUMN coordination_attribution_json TEXT NOT NULL + DEFAULT '{"actor":{"actorRef":"legacy-command:unknown","kind":"recovery"},"provenance":"legacy_recovery_v1"}' + CHECK (json_valid(coordination_attribution_json))` + ); + } + db.exec( + `UPDATE durable_application_commands + SET coordination_attribution_json = json_object( + 'actor', json_object( + 'actorRef', 'legacy-command:' || stable_actor_id, + 'kind', 'recovery' + ), + 'provenance', 'legacy_recovery_v1' + ) + WHERE json_extract(coordination_attribution_json, '$.provenance') = 'legacy_recovery_v1'` + ); +} + +interface LegacyOutboxEventRow { + readonly deployment_id: string; + readonly sequence: number; + readonly event_id: string; + readonly command_id: string; + readonly event_type: string; + readonly scope_kind: string; + readonly scope_id: string; + readonly schema_version: number; + readonly payload_json: string; + readonly created_at: string; + readonly coordination_attribution_json: string; +} + +const LEGACY_EVENT_SCOPE_KINDS = new Set([ + 'instance', + 'catalog', + 'workspace', + 'team', + 'run', + 'session', +]); + +/** Imports the v6/v7 outbox into the one journal using runtime-identical canonical JSON. */ +export function backfillCoordinationEventJournal(db: SqliteDatabase): void { + const mismatchedCommand = db + .prepare( + `SELECT outbox.command_id + FROM durable_application_command_outbox AS outbox + JOIN durable_application_commands AS commands ON commands.command_id = outbox.command_id + WHERE commands.deployment_id <> outbox.deployment_id + LIMIT 1` + ) + .get() as { readonly command_id: string } | undefined; + if (mismatchedCommand) throw new Error('internal-storage-command-outbox-deployment-mismatch'); + + const rows = db + .prepare( + `SELECT + outbox.deployment_id, + outbox.sequence, + outbox.event_id, + outbox.command_id, + outbox.event_type, + outbox.scope_kind, + outbox.scope_id, + outbox.schema_version, + outbox.payload_json, + outbox.created_at, + commands.coordination_attribution_json + FROM durable_application_command_outbox AS outbox + JOIN durable_application_commands AS commands ON commands.command_id = outbox.command_id + ORDER BY outbox.deployment_id ASC, outbox.sequence ASC, outbox.event_id ASC` + ) + .all() as LegacyOutboxEventRow[]; + + const deployments = db + .prepare( + `SELECT + deployment_id, + COUNT(*) AS event_count, + MIN(created_at) AS created_at, + MAX(created_at) AS updated_at + FROM durable_application_command_outbox + GROUP BY deployment_id + ORDER BY deployment_id ASC` + ) + .all() as { + readonly deployment_id: string; + readonly event_count: number; + readonly created_at: string; + readonly updated_at: string; + }[]; + for (const deployment of deployments) { + const result = db + .prepare( + `INSERT INTO coordination_event_journal_metadata ( + deployment_id, event_epoch, retention_floor_sequence, + high_watermark_sequence, created_at, updated_at + ) VALUES (?, 'epoch-initial-v1', 0, ?, ?, ?) + ON CONFLICT(deployment_id) DO NOTHING` + ) + .run( + deployment.deployment_id, + deployment.event_count, + deployment.created_at, + deployment.updated_at + ); + if (result.changes === 0) { + const existing = db + .prepare( + `SELECT event_epoch, retention_floor_sequence, high_watermark_sequence + FROM coordination_event_journal_metadata WHERE deployment_id = ?` + ) + .get(deployment.deployment_id) as { + readonly event_epoch: string; + readonly retention_floor_sequence: number; + readonly high_watermark_sequence: number; + }; + if ( + existing.event_epoch !== 'epoch-initial-v1' || + existing.retention_floor_sequence !== 0 || + existing.high_watermark_sequence !== deployment.event_count + ) { + throw new Error('internal-storage-event-journal-metadata-backfill-conflict'); + } + } + } + + let deploymentId: string | null = null; + let eventSequence = 0; + for (const row of rows) { + if (row.deployment_id !== deploymentId) { + deploymentId = row.deployment_id; + eventSequence = 0; + } + eventSequence += 1; + const bodyJson = legacyOutboxEventBodyJson(row); + const existing = db + .prepare( + `SELECT deployment_id, event_epoch, event_sequence, body_json + FROM coordination_event_journal + WHERE event_id = ?` + ) + .get(row.event_id) as + | { + readonly deployment_id: string; + readonly event_epoch: string; + readonly event_sequence: number; + readonly body_json: string; + } + | undefined; + if (existing) { + if ( + existing.deployment_id !== row.deployment_id || + existing.event_epoch !== 'epoch-initial-v1' || + existing.event_sequence !== eventSequence || + existing.body_json !== bodyJson + ) { + throw new Error('internal-storage-event-journal-backfill-conflict'); + } + continue; + } + db.prepare( + `INSERT INTO coordination_event_journal ( + deployment_id, event_epoch, event_sequence, event_id, body_json, + emitted_at, origin_command_id, created_at + ) VALUES (?, 'epoch-initial-v1', ?, ?, ?, ?, ?, ?)` + ).run( + row.deployment_id, + eventSequence, + row.event_id, + bodyJson, + row.created_at, + row.command_id, + row.created_at + ); + } +} + +function legacyOutboxEventBodyJson(row: LegacyOutboxEventRow): string { + if (row.schema_version !== 1 || !LEGACY_EVENT_SCOPE_KINDS.has(row.scope_kind)) { + throw new Error('internal-storage-legacy-outbox-event-contract-invalid'); + } + const attribution = parseMigrationJsonObject(row.coordination_attribution_json); + const actor = attribution.actor; + if (!actor || typeof actor !== 'object' || Array.isArray(actor)) { + throw new Error('internal-storage-legacy-outbox-attribution-invalid'); + } + const runId = row.scope_kind === 'run' ? row.scope_id : attribution.runId; + if (runId !== undefined && typeof runId !== 'string') { + throw new Error('internal-storage-legacy-outbox-run-id-invalid'); + } + return canonicalMigrationJson({ + actor, + eventId: row.event_id, + emittedAt: row.created_at, + eventType: row.event_type, + payload: JSON.parse(row.payload_json) as unknown, + ...(runId === undefined ? {} : { runId }), + schemaVersion: row.schema_version, + scope: { kind: row.scope_kind, scopeId: row.scope_id }, + ...(row.scope_kind === 'team' ? { teamId: row.scope_id } : {}), + ...(row.scope_kind === 'workspace' ? { workspaceId: row.scope_id } : {}), + }); +} + +function parseMigrationJsonObject(value: string): Readonly> { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('internal-storage-migration-json-object-invalid'); + } + return parsed as Readonly>; +} + +function canonicalMigrationJson(value: unknown): string { + return JSON.stringify(normalizeMigrationJson(value)); +} + +function normalizeMigrationJson(value: unknown): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('internal-storage-migration-json-number-invalid'); + return value; + } + if (Array.isArray(value)) return value.map(normalizeMigrationJson); + if (typeof value !== 'object') throw new Error('internal-storage-migration-json-value-invalid'); + const record = value as Readonly>; + const normalized: Record = {}; + // Code-unit ordering: must produce the same canonical bytes as the event + // journal writer regardless of process locale or ICU version. + for (const key of Object.keys(record).sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + )) { + if (record[key] !== undefined) normalized[key] = normalizeMigrationJson(record[key]); + } + return normalized; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrationGuards.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrationGuards.ts new file mode 100644 index 0000000000..ecdec8de54 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrationGuards.ts @@ -0,0 +1,15 @@ +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export function assertNoActiveBackupFenceForMigration( + db: SqliteDatabase, + migrationVersion: number +): void { + const activeFence = db + .prepare("SELECT 1 FROM coordination_backup_writer_fences WHERE status = 'active' LIMIT 1") + .get(); + if (activeFence) { + throw new Error(`internal-storage-v${migrationVersion}-migration-backup-fenced`); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts index 018433ce9e..fe0ed4c166 100644 --- a/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageMigrations.ts @@ -1,16 +1,49 @@ +import { + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_SCHEMA_VERSION, +} from '../../application/internalStorageBackupContract'; + +import { EXTERNAL_WRITER_OBSERVATION_CONSUME_RECEIPT_MIGRATION } from './externalWriterObservationConsumeReceiptMigration'; +import { EXTERNAL_WRITER_RECONCILIATION_MIGRATION } from './externalWriterReconciliationMigration'; +import { HOSTED_TEAM_APPROVAL_AUTHORITY_STORAGE_MIGRATION_STATEMENTS } from './hostedTeamApprovalAuthorityStorageMigration'; +import { HOSTED_TEAM_APPROVAL_IDENTITY_STORAGE_MIGRATIONS } from './hostedTeamApprovalIdentityStorageMigrations'; +import { runHostedTeamApprovalMigrationRepair } from './hostedTeamApprovalMigrationRepair'; +import { HOSTED_WORKSPACE_GRANT_REVISION_STORAGE_MIGRATION_STATEMENTS } from './hostedWorkspaceGrantRevisionStorageMigration'; +import { + ensureHostedAuthResetColumns, + migrateHostedWorkspaceAccess, +} from './internalStorageBackupTables'; +import { ensureHistoricalV6DurabilityTables } from './internalStorageLegacyDurabilityMigration'; +import { + backfillCoordinationEventJournal, + backfillMemberWorkSyncTeamKeys, + ensureCommandCoordinationAttribution, + ensureMemberWorkSyncTeamKeyIndexes, +} from './internalStorageMigrationBackfills'; +import { assertNoActiveBackupFenceForMigration } from './internalStorageMigrationGuards'; +import { PROCESS_OWNERSHIP_STORAGE_MIGRATION_STATEMENTS } from './processOwnershipStorageOps'; +import { TEAM_IDENTITY_STORAGE_MIGRATION_STATEMENTS } from './teamIdentityStorageSchema'; +import { + TEAM_ROSTER_STORAGE_MIGRATION_STATEMENTS, + verifyTeamRosterStorageMigration, +} from './teamRosterStorageSchema'; + import type DatabaseConstructor from 'better-sqlite3'; +export { + INTERNAL_STORAGE_APPLICATION_ID, + INTERNAL_STORAGE_REQUIRED_BACKUP_TABLES, + INTERNAL_STORAGE_SCHEMA_VERSION, +} from '../../application/internalStorageBackupContract'; type SqliteDatabase = InstanceType; - interface InternalStorageMigration { version: number; statements: string[]; } - /** - * Versioned via PRAGMA user_version. Statements must stay append-only and - * idempotent (IF NOT EXISTS) — released versions are never edited, new schema - * changes get a new version entry. Keep in sync with internalStorageSchema.ts. + * Versioned via PRAGMA user_version. Released versions are append-only and never edited. + * CREATE statements stay idempotent where recovery replays them; ALTER statements require the + * historical source schema selected by user_version. Keep the latest result in sync with internalStorageSchema.ts. */ const MIGRATIONS: InternalStorageMigration[] = [ { @@ -165,27 +198,602 @@ const MIGRATIONS: InternalStorageMigration[] = [ ON application_command_ledger (namespace, scope_key, operation)`, ], }, + { + version: 5, + statements: [...TEAM_IDENTITY_STORAGE_MIGRATION_STATEMENTS], + }, + { + version: 6, + statements: [ + `CREATE TABLE IF NOT EXISTS durable_application_commands ( + command_id TEXT PRIMARY KEY, + deployment_id TEXT NOT NULL, + stable_actor_id TEXT NOT NULL, + command_kind TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + descriptor_id TEXT NOT NULL, + descriptor_version INTEGER NOT NULL, + input_schema_version INTEGER NOT NULL, + fingerprint_version TEXT NOT NULL, + effect_plan_version INTEGER NOT NULL, + fingerprint_key_version TEXT NOT NULL, + fingerprint_digest TEXT NOT NULL, + attempt_generation INTEGER NOT NULL, + attempt_id TEXT NOT NULL, + attempt_owner_id TEXT NOT NULL, + attempt_lease_token TEXT NOT NULL, + attempt_claimed_at TEXT NOT NULL, + attempt_lease_expires_at TEXT NOT NULL, + state TEXT NOT NULL, + retention_class TEXT NOT NULL, + audit_session_id TEXT, + outcome_json TEXT, + error_code TEXT, + error_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + committed_at TEXT + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_durable_app_cmd_claim + ON durable_application_commands ( + deployment_id, stable_actor_id, command_kind, idempotency_key + )`, + `CREATE INDEX IF NOT EXISTS idx_durable_app_cmd_state + ON durable_application_commands (deployment_id, state, updated_at)`, + `CREATE TABLE IF NOT EXISTS durable_application_command_effects ( + command_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + effect_id TEXT NOT NULL, + effect_version INTEGER NOT NULL, + recovery_class TEXT NOT NULL, + evidence_schema_version INTEGER NOT NULL, + state TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (command_id, ordinal), + FOREIGN KEY (command_id) REFERENCES durable_application_commands(command_id) + ON DELETE RESTRICT + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_durable_app_cmd_effect_id + ON durable_application_command_effects (command_id, effect_id)`, + `CREATE TABLE IF NOT EXISTS durable_application_command_effect_evidence ( + command_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + sequence INTEGER NOT NULL, + outcome TEXT NOT NULL, + evidence_schema_version INTEGER NOT NULL, + evidence_json TEXT NOT NULL, + recorded_at TEXT NOT NULL, + PRIMARY KEY (command_id, ordinal, sequence), + FOREIGN KEY (command_id, ordinal) + REFERENCES durable_application_command_effects(command_id, ordinal) + ON DELETE RESTRICT + )`, + `CREATE INDEX IF NOT EXISTS idx_durable_app_cmd_evidence_order + ON durable_application_command_effect_evidence (command_id, ordinal, sequence)`, + `CREATE TABLE IF NOT EXISTS durable_application_command_outbox ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + command_id TEXT NOT NULL, + deployment_id TEXT NOT NULL, + event_type TEXT NOT NULL, + scope_kind TEXT NOT NULL, + scope_id TEXT NOT NULL, + schema_version INTEGER NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + -- Version 6 used publication terminology for delivery bookkeeping. + -- Version 7 renames these physical columns without changing behavior. + publication_generation INTEGER NOT NULL, + publication_publisher_id TEXT, + publication_lease_token TEXT, + publication_claimed_at TEXT, + publication_lease_expires_at TEXT, + published_at TEXT, + FOREIGN KEY (command_id) REFERENCES durable_application_commands(command_id) + ON DELETE RESTRICT + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_durable_app_cmd_outbox_event + ON durable_application_command_outbox (event_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_durable_app_cmd_outbox_command + ON durable_application_command_outbox (command_id)`, + `CREATE INDEX IF NOT EXISTS idx_durable_app_cmd_outbox_sequence + ON durable_application_command_outbox (sequence)`, + ], + }, + { + version: 7, + statements: [ + `ALTER TABLE durable_application_command_outbox + RENAME COLUMN publication_generation TO delivery_generation`, + `ALTER TABLE durable_application_command_outbox + RENAME COLUMN publication_publisher_id TO delivery_owner_id`, + `ALTER TABLE durable_application_command_outbox + RENAME COLUMN publication_lease_token TO delivery_lease_token`, + `ALTER TABLE durable_application_command_outbox + RENAME COLUMN publication_claimed_at TO delivery_claimed_at`, + `ALTER TABLE durable_application_command_outbox + RENAME COLUMN publication_lease_expires_at TO delivery_lease_expires_at`, + `ALTER TABLE durable_application_command_outbox + RENAME COLUMN published_at TO delivery_acknowledged_at`, + // Version 6 events had no typed revision. Start with a valid value so + // ALTER TABLE remains legal for populated databases, then deterministically + // rank every legacy projection's events in durable replay order. The + // projection key is (deployment_id, scope_kind, scope_id); sequence is + // canonical replay order and event_id is its deterministic tie-breaker. + `ALTER TABLE durable_application_command_outbox + ADD COLUMN semantic_revision INTEGER NOT NULL DEFAULT 1`, + `WITH ranked_legacy_events AS ( + SELECT + sequence, + event_id, + ROW_NUMBER() OVER ( + PARTITION BY deployment_id, scope_kind, scope_id + ORDER BY sequence ASC, event_id ASC + ) AS semantic_revision + FROM durable_application_command_outbox + ) + UPDATE durable_application_command_outbox + SET semantic_revision = ( + SELECT ranked_legacy_events.semantic_revision + FROM ranked_legacy_events + WHERE ranked_legacy_events.sequence = durable_application_command_outbox.sequence + AND ranked_legacy_events.event_id = durable_application_command_outbox.event_id + )`, + `CREATE TABLE IF NOT EXISTS durable_application_command_consumer_applications ( + consumer_id TEXT NOT NULL, + event_id TEXT NOT NULL, + semantic_revision INTEGER NOT NULL, + projection_key TEXT NOT NULL, + state_json TEXT NOT NULL, + applied_at TEXT NOT NULL, + PRIMARY KEY (consumer_id, event_id), + FOREIGN KEY (event_id) REFERENCES durable_application_command_outbox(event_id) + ON DELETE RESTRICT + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_durable_app_cmd_consumer_revision + ON durable_application_command_consumer_applications ( + consumer_id, projection_key, semantic_revision + )`, + `CREATE TABLE IF NOT EXISTS durable_application_command_consumer_projections ( + consumer_id TEXT NOT NULL, + projection_key TEXT NOT NULL, + semantic_revision INTEGER NOT NULL, + last_event_id TEXT NOT NULL, + state_json TEXT NOT NULL, + application_count INTEGER NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (consumer_id, projection_key), + FOREIGN KEY (consumer_id, last_event_id) + REFERENCES durable_application_command_consumer_applications(consumer_id, event_id) + ON DELETE RESTRICT + )`, + ], + }, + { + version: 8, + statements: [ + `PRAGMA application_id = ${INTERNAL_STORAGE_APPLICATION_ID}`, + `CREATE TABLE IF NOT EXISTS coordination_event_journal_metadata ( + deployment_id TEXT PRIMARY KEY, + event_epoch TEXT NOT NULL, + retention_floor_sequence INTEGER NOT NULL DEFAULT 0 + CHECK (retention_floor_sequence >= 0), + high_watermark_sequence INTEGER NOT NULL DEFAULT 0 + CHECK (high_watermark_sequence >= retention_floor_sequence), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (deployment_id, event_epoch) + )`, + `CREATE TABLE IF NOT EXISTS coordination_event_journal ( + deployment_id TEXT NOT NULL, + event_epoch TEXT NOT NULL, + event_sequence INTEGER NOT NULL CHECK (event_sequence > 0), + event_id TEXT NOT NULL UNIQUE, + body_json TEXT NOT NULL CHECK (json_valid(body_json)), + emitted_at TEXT NOT NULL, + origin_command_id TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (deployment_id, event_epoch, event_sequence), + FOREIGN KEY (deployment_id, event_epoch) + REFERENCES coordination_event_journal_metadata(deployment_id, event_epoch) + ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY (origin_command_id) + REFERENCES durable_application_commands(command_id) + ON DELETE RESTRICT ON UPDATE RESTRICT + )`, + `CREATE INDEX IF NOT EXISTS idx_coordination_event_journal_replay + ON coordination_event_journal (deployment_id, event_epoch, event_sequence)`, + `CREATE TABLE IF NOT EXISTS snapshot_retention_leases ( + lease_id TEXT PRIMARY KEY, + deployment_id TEXT NOT NULL, + event_epoch TEXT NOT NULL, + scope_kind TEXT NOT NULL, + scope_id TEXT NOT NULL, + retention_floor_sequence INTEGER NOT NULL CHECK (retention_floor_sequence >= 0), + high_watermark_sequence INTEGER NOT NULL + CHECK (high_watermark_sequence >= retention_floor_sequence), + expires_at_ms INTEGER NOT NULL CHECK (expires_at_ms > 0), + use_token TEXT, + use_deadline_at_ms INTEGER, + release_requested INTEGER NOT NULL DEFAULT 0 CHECK (release_requested IN (0, 1)), + created_at_ms INTEGER NOT NULL CHECK (created_at_ms > 0), + FOREIGN KEY (deployment_id, event_epoch) + REFERENCES coordination_event_journal_metadata(deployment_id, event_epoch) + ON DELETE RESTRICT ON UPDATE RESTRICT, + CHECK ((use_token IS NULL AND use_deadline_at_ms IS NULL) + OR (use_token IS NOT NULL AND use_deadline_at_ms IS NOT NULL)) + )`, + `CREATE INDEX IF NOT EXISTS idx_snapshot_retention_lease_floor + ON snapshot_retention_leases ( + deployment_id, event_epoch, release_requested, expires_at_ms, high_watermark_sequence + )`, + `CREATE TABLE IF NOT EXISTS coordination_backup_runs ( + backup_run_id TEXT PRIMARY KEY, + deployment_id TEXT NOT NULL, + state TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + fence_completion_status TEXT, + record_json TEXT NOT NULL CHECK (json_valid(record_json)), + requested_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_coordination_backup_runs_recoverable + ON coordination_backup_runs (state, fence_completion_status, updated_at)`, + `CREATE TABLE IF NOT EXISTS coordination_backup_writer_fences ( + deployment_id TEXT PRIMARY KEY, + generation INTEGER NOT NULL CHECK (generation > 0), + admitted_run_id TEXT NOT NULL, + lease_id TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('active', 'released', 'operator_required')), + disposition TEXT CHECK (disposition IN ('committed', 'aborted', 'operator_required')), + acquired_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (admitted_run_id) REFERENCES coordination_backup_runs(backup_run_id) + ON DELETE RESTRICT ON UPDATE RESTRICT, + CHECK ((status = 'active' AND disposition IS NULL AND completed_at IS NULL) + OR (status <> 'active' AND disposition IS NOT NULL AND completed_at IS NOT NULL)) + )`, + ], + }, + { + version: 9, + statements: [ + `ALTER TABLE member_work_sync_status + ADD COLUMN team_key TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE member_work_sync_report_intents + ADD COLUMN team_key TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE member_work_sync_outbox + ADD COLUMN team_key TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE member_work_sync_metric_events + ADD COLUMN team_key TEXT NOT NULL DEFAULT ''`, + `CREATE INDEX IF NOT EXISTS idx_mws_status_team_key + ON member_work_sync_status (team_key)`, + `CREATE INDEX IF NOT EXISTS idx_mws_report_intents_team_key + ON member_work_sync_report_intents (team_key)`, + `CREATE INDEX IF NOT EXISTS idx_mws_outbox_team_key + ON member_work_sync_outbox (team_key)`, + `CREATE INDEX IF NOT EXISTS idx_mws_metric_events_team_key + ON member_work_sync_metric_events (team_key)`, + ], + }, + { + version: 10, + statements: [...TEAM_ROSTER_STORAGE_MIGRATION_STATEMENTS], + }, + { version: 11, statements: [...PROCESS_OWNERSHIP_STORAGE_MIGRATION_STATEMENTS] }, + { + version: 12, + statements: ['DROP TABLE IF EXISTS snapshot_retention_leases'], + }, + { + version: 13, + statements: [ + `CREATE TABLE IF NOT EXISTS hosted_auth_configuration ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + auth_mode TEXT NOT NULL CHECK (auth_mode IN ('personal', 'oidc')), + configured_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS hosted_access_authority ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + state_json TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 0), + rollback_fence_revision INTEGER NOT NULL CHECK (rollback_fence_revision >= revision) + )`, + `CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS external_identities ( + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_authenticated_at INTEGER NOT NULL, + PRIMARY KEY (issuer, subject), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT + )`, + `CREATE INDEX IF NOT EXISTS idx_external_identities_user + ON external_identities (user_id)`, + `CREATE TABLE IF NOT EXISTS personal_owners ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + operator_id TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT + )`, + `CREATE TABLE IF NOT EXISTS operator_sessions ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + secret_hash TEXT NOT NULL UNIQUE, + authentication_method TEXT NOT NULL CHECK (authentication_method = 'oidc'), + provider_id TEXT NOT NULL, + provider_issuer TEXT NOT NULL, + provider_subject TEXT NOT NULL, + provider_session_id TEXT, + issued_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL, + idle_expires_at INTEGER NOT NULL, + absolute_expires_at INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + revoked_at INTEGER, + revocation_reason TEXT, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT + )`, + `CREATE INDEX IF NOT EXISTS idx_operator_sessions_provider + ON operator_sessions (provider_id, provider_issuer, provider_subject, provider_session_id)`, + `CREATE TABLE IF NOT EXISTS role_snapshots ( + session_id TEXT PRIMARY KEY, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + source TEXT NOT NULL CHECK (source IN ('personal-owner', 'oidc-claim', 'local-cli')), + captured_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES operator_sessions(session_id) ON DELETE RESTRICT + )`, + `CREATE TABLE IF NOT EXISTS oidc_login_attempts ( + attempt_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + state_hash TEXT NOT NULL UNIQUE, + nonce TEXT NOT NULL, + pkce_verifier_ciphertext TEXT NOT NULL, + return_to TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER + )`, + `CREATE INDEX IF NOT EXISTS idx_oidc_login_attempts_expiry + ON oidc_login_attempts (expires_at, consumed_at)`, + `CREATE TABLE IF NOT EXISTS oidc_logout_replay ( + provider_id TEXT NOT NULL, + issuer TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER NOT NULL, + PRIMARY KEY (provider_id, issuer, jti) + )`, + `CREATE TABLE IF NOT EXISTS hosted_workspaces ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), + registered_at INTEGER NOT NULL, + registered_by TEXT, + FOREIGN KEY (registered_by) REFERENCES users(user_id) ON DELETE RESTRICT + )`, + `CREATE TABLE IF NOT EXISTS auth_audit_events ( + event_id TEXT PRIMARY KEY, + occurred_at INTEGER NOT NULL, + user_id TEXT, + session_id TEXT, + action TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('success', 'denied', 'failure')), + source_ip_hash TEXT, + details_json TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT + )`, + `CREATE INDEX IF NOT EXISTS idx_auth_audit_occurred + ON auth_audit_events (occurred_at, event_id)`, + ], + }, + { + version: 14, + statements: [ + `CREATE TABLE IF NOT EXISTS local_role_assignments ( + user_id TEXT PRIMARY KEY, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + assigned_at INTEGER NOT NULL, + assigned_by TEXT NOT NULL CHECK (assigned_by = 'local-cli'), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT + )`, + ], + }, + { + version: 15, + // Recovery tolerates user_version restored behind already-durable additive columns. + statements: [], + }, + { + version: 16, + // Recovery may restore current table shapes with a historical user_version. + statements: [], + }, + { + version: 17, + statements: [ + `CREATE TABLE IF NOT EXISTS hosted_authority_projections ( + deployment_id TEXT NOT NULL, + projection_kind TEXT NOT NULL, + projection_key TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + revision INTEGER NOT NULL CHECK (revision > 0), + state_json TEXT NOT NULL CHECK (json_valid(state_json)), + last_command_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (deployment_id, projection_kind, projection_key), + FOREIGN KEY (last_command_id) REFERENCES durable_application_commands(command_id) + ON DELETE RESTRICT ON UPDATE RESTRICT + )`, + ], + }, + { + version: 18, + statements: [...HOSTED_TEAM_APPROVAL_AUTHORITY_STORAGE_MIGRATION_STATEMENTS], + }, + { + version: 19, + statements: [ + `CREATE TABLE IF NOT EXISTS hosted_team_configuration_drafts (workspace_id TEXT NOT NULL, team_id TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), revision_ordinal INTEGER NOT NULL CHECK (revision_ordinal > 0), revision_token TEXT NOT NULL, metadata_json TEXT NOT NULL CHECK (json_valid(metadata_json)), members_json TEXT NOT NULL CHECK (json_valid(members_json)), created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, PRIMARY KEY (workspace_id, team_id))`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_hosted_team_configuration_team_id + ON hosted_team_configuration_drafts (team_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_hosted_team_configuration_revision_token ON hosted_team_configuration_drafts (revision_token)`, + `CREATE TABLE IF NOT EXISTS hosted_team_configuration_create_keys (workspace_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, payload_hash TEXT NOT NULL, team_id TEXT NOT NULL, initial_revision TEXT NOT NULL, created_at_ms INTEGER NOT NULL, PRIMARY KEY (workspace_id, idempotency_key), FOREIGN KEY (workspace_id, team_id) REFERENCES hosted_team_configuration_drafts(workspace_id, team_id) ON DELETE RESTRICT ON UPDATE RESTRICT)`, + ], + }, + { + version: 20, + statements: [...HOSTED_WORKSPACE_GRANT_REVISION_STORAGE_MIGRATION_STATEMENTS], + }, + ...HOSTED_TEAM_APPROVAL_IDENTITY_STORAGE_MIGRATIONS, + { + version: 25, + statements: [ + `CREATE TABLE IF NOT EXISTS external_writer_observation_checkpoints ( + deployment_id TEXT NOT NULL, + observer_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + schema_version INTEGER NOT NULL CHECK (schema_version = 2), + checkpoint_json TEXT NOT NULL CHECK (json_valid(checkpoint_json)), + PRIMARY KEY (deployment_id, observer_id) + )`, + `CREATE TABLE IF NOT EXISTS external_writer_observation_retired_team_floors ( + deployment_id TEXT NOT NULL, + observer_id TEXT NOT NULL, + team_id TEXT NOT NULL, + identity_checksum TEXT NOT NULL, + tombstoned_at TEXT NOT NULL, + writer_epoch INTEGER CHECK (writer_epoch IS NULL OR writer_epoch >= 1), + last_observation_sequence INTEGER NOT NULL CHECK (last_observation_sequence >= 0), + observation_watermark INTEGER NOT NULL CHECK ( + observation_watermark >= 0 AND observation_watermark <= last_observation_sequence + ), + PRIMARY KEY (deployment_id, observer_id, team_id), + FOREIGN KEY (team_id) REFERENCES team_identity_records(team_id) + ON DELETE RESTRICT ON UPDATE RESTRICT + )`, + `CREATE TRIGGER IF NOT EXISTS external_writer_retired_floor_no_update + BEFORE UPDATE ON external_writer_observation_retired_team_floors + BEGIN SELECT RAISE(ABORT, 'external-writer-observation-retired-floor-immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS external_writer_retired_floor_no_delete + BEFORE DELETE ON external_writer_observation_retired_team_floors + BEGIN SELECT RAISE(ABORT, 'external-writer-observation-retired-floor-immutable'); END`, + `CREATE TABLE IF NOT EXISTS external_writer_observation_handoff_eligibility ( + deployment_id TEXT NOT NULL, + observer_id TEXT NOT NULL, + expected_checkpoint_revision INTEGER NOT NULL CHECK (expected_checkpoint_revision > 0), + handoff_id TEXT NOT NULL CHECK ( + length(handoff_id) BETWEEN 1 AND 128 + AND handoff_id NOT GLOB '*[^A-Za-z0-9._:-]*' + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + checkpoint_sha256 TEXT NOT NULL CHECK ( + length(checkpoint_sha256) = 64 + AND checkpoint_sha256 NOT GLOB '*[^0-9a-f]*' + ), + captured_sequence INTEGER NOT NULL CHECK (captured_sequence >= 0), + persisted_watermark INTEGER NOT NULL CHECK (persisted_watermark >= 0), + old_catalog_token TEXT NOT NULL CHECK ( + length(old_catalog_token) = 64 + AND old_catalog_token NOT GLOB '*[^0-9a-f]*' + ), + target_catalog_token TEXT NOT NULL CHECK ( + length(target_catalog_token) = 64 + AND target_catalog_token NOT GLOB '*[^0-9a-f]*' + ), + next_registration_digest TEXT NOT NULL CHECK ( + length(next_registration_digest) = 64 + AND next_registration_digest NOT GLOB '*[^0-9a-f]*' + ), + candidate_digest TEXT NOT NULL CHECK ( + length(candidate_digest) = 64 + AND candidate_digest NOT GLOB '*[^0-9a-f]*' + ), + candidates_json TEXT NOT NULL CHECK ( + json_valid(candidates_json) + AND json_type(candidates_json) = 'array' + AND json_array_length(candidates_json) <= 1024 + AND length(CAST(candidates_json AS BLOB)) <= 67108864 + ), + retained_registrations_json TEXT NOT NULL CHECK ( + json_valid(retained_registrations_json) + AND json_type(retained_registrations_json) = 'array' + AND json_array_length(retained_registrations_json) <= 100000 + AND length(CAST(retained_registrations_json AS BLOB)) <= 67108864 + ), + removed_registrations_json TEXT NOT NULL CHECK ( + json_valid(removed_registrations_json) + AND json_type(removed_registrations_json) = 'array' + AND json_array_length(removed_registrations_json) <= 100000 + AND length(CAST(removed_registrations_json AS BLOB)) <= 67108864 + ), + created_at TEXT NOT NULL, + CHECK (captured_sequence = persisted_watermark), + PRIMARY KEY (deployment_id, observer_id), + FOREIGN KEY (deployment_id, observer_id) + REFERENCES external_writer_observation_checkpoints(deployment_id, observer_id) + ON DELETE CASCADE ON UPDATE RESTRICT + )`, + `CREATE TRIGGER IF NOT EXISTS external_writer_handoff_no_update + BEFORE UPDATE ON external_writer_observation_handoff_eligibility + BEGIN SELECT RAISE(ABORT, 'external-writer-observation-handoff-immutable'); END`, + ], + }, + EXTERNAL_WRITER_OBSERVATION_CONSUME_RECEIPT_MIGRATION, + EXTERNAL_WRITER_RECONCILIATION_MIGRATION, ]; - -export const INTERNAL_STORAGE_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; - export function readSchemaVersion(db: SqliteDatabase): number { const value = db.pragma('user_version', { simple: true }); return typeof value === 'number' ? value : 0; } - export function runInternalStorageMigrations(db: SqliteDatabase): void { + if (MIGRATIONS.at(-1)?.version !== INTERNAL_STORAGE_SCHEMA_VERSION) { + throw new Error('internal-storage-schema-contract-mismatch'); + } const current = readSchemaVersion(db); for (const migration of MIGRATIONS) { if (migration.version <= current) { continue; } const apply = db.transaction(() => { - for (const statement of migration.statements) { - db.exec(statement); + if (migration.version >= 11 && migration.version <= INTERNAL_STORAGE_SCHEMA_VERSION) { + assertNoActiveBackupFenceForMigration(db, migration.version); + } + if (migration.version === 7) ensureHistoricalV6DurabilityTables(db, v6Statements()); + if (migration.version === 8) { + ensureHistoricalV6DurabilityTables(db, v6Statements()); + ensureCommandCoordinationAttribution(db); } + if (migration.version === 15) ensureHostedAuthResetColumns(db); + if (migration.version === 16) migrateHostedWorkspaceAccess(db); + const approvalMigrationHandled = runHostedTeamApprovalMigrationRepair(db, migration.version); + if (!approvalMigrationHandled) { + for (const statement of migration.statements) { + db.exec(statement); + } + } + if (migration.version === 8) backfillCoordinationEventJournal(db); + if (migration.version === 9) backfillMemberWorkSyncTeamKeys(db); + if (migration.version === 10) verifyTeamRosterStorageMigration(db); db.pragma(`user_version = ${migration.version}`); }); apply(); } + if (current >= 9) { + db.transaction(() => ensureMemberWorkSyncTeamKeyIndexes(db))(); + } +} +function v6Statements(): readonly string[] { + const migration = MIGRATIONS.find((candidate) => candidate.version === 6); + if (!migration) throw new Error('internal-storage-v6-migration-missing'); + return migration.statements; } diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageSchema.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageSchema.ts index 8dfc187f93..49b7b0d480 100644 --- a/src/features/internal-storage/main/infrastructure/worker/internalStorageSchema.ts +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageSchema.ts @@ -1,4 +1,7 @@ +import { sql } from 'drizzle-orm'; import { + check, + foreignKey, index, integer, primaryKey, @@ -7,6 +10,28 @@ import { uniqueIndex, } from 'drizzle-orm/sqlite-core'; +export { + externalWriterObservationCheckpoints, + externalWriterObservationConsumeReceipts, + externalWriterObservationHandoffEligibility, + externalWriterObservationRetiredTeamFloors, +} from './externalWriterObservationSchema'; +export { + hostedTeamConfigurationCreateKeys, + hostedTeamConfigurationDrafts, +} from './hostedTeamConfigurationStorageOps'; +export { + legacyTeamKeyReservations, + teamAdoptionIntents, + teamIdentityRecords, + teamIdentityStorageMetadata, +} from './teamIdentityStorageSchema'; +export { + teamRosterMembers, + teamRosters, + teamRosterStorageMetadata, +} from './teamRosterStorageSchema'; + export const stallJournalEntries = sqliteTable( 'stall_journal_entries', { @@ -62,6 +87,7 @@ export const commentJournalTeams = sqliteTable('comment_journal_teams', { export const memberWorkSyncStatus = sqliteTable( 'member_work_sync_status', { + teamKey: text('team_key').notNull(), teamName: text('team_name').notNull(), memberKey: text('member_key').notNull(), memberName: text('member_name').notNull(), @@ -70,12 +96,16 @@ export const memberWorkSyncStatus = sqliteTable( providerId: text('provider_id'), statusJson: text('status_json').notNull(), }, - (table) => [primaryKey({ columns: [table.teamName, table.memberKey] })] + (table) => [ + primaryKey({ columns: [table.teamName, table.memberKey] }), + index('idx_mws_status_team_key').on(table.teamKey), + ] ); export const memberWorkSyncReportIntents = sqliteTable( 'member_work_sync_report_intents', { + teamKey: text('team_key').notNull(), teamName: text('team_name').notNull(), id: text('id').notNull(), memberKey: text('member_key').notNull(), @@ -89,6 +119,7 @@ export const memberWorkSyncReportIntents = sqliteTable( }, (table) => [ primaryKey({ columns: [table.teamName, table.id] }), + index('idx_mws_report_intents_team_key').on(table.teamKey), index('idx_mws_report_intents_pending').on(table.teamName, table.status, table.recordedAt), ] ); @@ -96,6 +127,7 @@ export const memberWorkSyncReportIntents = sqliteTable( export const memberWorkSyncOutbox = sqliteTable( 'member_work_sync_outbox', { + teamKey: text('team_key').notNull(), teamName: text('team_name').notNull(), id: text('id').notNull(), memberKey: text('member_key').notNull(), @@ -120,6 +152,7 @@ export const memberWorkSyncOutbox = sqliteTable( }, (table) => [ primaryKey({ columns: [table.teamName, table.id] }), + index('idx_mws_outbox_team_key').on(table.teamKey), index('idx_mws_outbox_due').on(table.teamName, table.status, table.nextAttemptAt), index('idx_mws_outbox_member').on(table.teamName, table.memberKey, table.status), ] @@ -128,6 +161,7 @@ export const memberWorkSyncOutbox = sqliteTable( export const memberWorkSyncMetricEvents = sqliteTable( 'member_work_sync_metric_events', { + teamKey: text('team_key').notNull(), teamName: text('team_name').notNull(), id: text('id').notNull(), memberKey: text('member_key').notNull(), @@ -138,6 +172,7 @@ export const memberWorkSyncMetricEvents = sqliteTable( }, (table) => [ primaryKey({ columns: [table.teamName, table.id] }), + index('idx_mws_metric_events_team_key').on(table.teamKey), index('idx_mws_metric_events_recent').on(table.teamName, table.recordedAt), ] ); @@ -150,7 +185,7 @@ export const applicationCommandLedger = sqliteTable( commandId: text('command_id').notNull(), idempotencyKey: text('idempotency_key').notNull(), operation: text('operation').notNull(), - payloadHash: text('payload_hash').notNull(), + payloadHash: text('payload_hash'), status: text('status').notNull(), failureKind: text('failure_kind'), retryable: integer('retryable', { mode: 'boolean' }).notNull(), @@ -174,3 +209,565 @@ export const applicationCommandLedger = sqliteTable( index('idx_app_cmd_ledger_operation').on(table.namespace, table.scopeKey, table.operation), ] ); + +export const durableApplicationCommands = sqliteTable( + 'durable_application_commands', + { + commandId: text('command_id').primaryKey(), + deploymentId: text('deployment_id').notNull(), + stableActorId: text('stable_actor_id').notNull(), + commandKind: text('command_kind').notNull(), + idempotencyKey: text('idempotency_key').notNull(), + descriptorId: text('descriptor_id').notNull(), + descriptorVersion: integer('descriptor_version').notNull(), + inputSchemaVersion: integer('input_schema_version').notNull(), + fingerprintVersion: text('fingerprint_version').notNull(), + effectPlanVersion: integer('effect_plan_version').notNull(), + fingerprintKeyVersion: text('fingerprint_key_version').notNull(), + fingerprintDigest: text('fingerprint_digest').notNull(), + attemptGeneration: integer('attempt_generation').notNull(), + attemptId: text('attempt_id').notNull(), + attemptOwnerId: text('attempt_owner_id').notNull(), + attemptLeaseToken: text('attempt_lease_token').notNull(), + attemptClaimedAt: text('attempt_claimed_at').notNull(), + attemptLeaseExpiresAt: text('attempt_lease_expires_at').notNull(), + state: text('state').notNull(), + retentionClass: text('retention_class').notNull(), + auditSessionId: text('audit_session_id'), + coordinationAttributionJson: text('coordination_attribution_json').notNull(), + outcomeJson: text('outcome_json'), + errorCode: text('error_code'), + errorJson: text('error_json'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + committedAt: text('committed_at'), + }, + (table) => [ + uniqueIndex('idx_durable_app_cmd_claim').on( + table.deploymentId, + table.stableActorId, + table.commandKind, + table.idempotencyKey + ), + index('idx_durable_app_cmd_state').on(table.deploymentId, table.state, table.updatedAt), + ] +); + +export const durableApplicationCommandEffects = sqliteTable( + 'durable_application_command_effects', + { + commandId: text('command_id') + .notNull() + .references(() => durableApplicationCommands.commandId, { onDelete: 'restrict' }), + ordinal: integer('ordinal').notNull(), + effectId: text('effect_id').notNull(), + effectVersion: integer('effect_version').notNull(), + recoveryClass: text('recovery_class').notNull(), + evidenceSchemaVersion: integer('evidence_schema_version').notNull(), + state: text('state').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.commandId, table.ordinal] }), + uniqueIndex('idx_durable_app_cmd_effect_id').on(table.commandId, table.effectId), + ] +); + +export const durableApplicationCommandEffectEvidence = sqliteTable( + 'durable_application_command_effect_evidence', + { + commandId: text('command_id').notNull(), + ordinal: integer('ordinal').notNull(), + sequence: integer('sequence').notNull(), + outcome: text('outcome').notNull(), + evidenceSchemaVersion: integer('evidence_schema_version').notNull(), + evidenceJson: text('evidence_json').notNull(), + recordedAt: text('recorded_at').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.commandId, table.ordinal, table.sequence] }), + foreignKey({ + columns: [table.commandId, table.ordinal], + foreignColumns: [ + durableApplicationCommandEffects.commandId, + durableApplicationCommandEffects.ordinal, + ], + }).onDelete('restrict'), + index('idx_durable_app_cmd_evidence_order').on(table.commandId, table.ordinal, table.sequence), + ] +); + +export const durableApplicationCommandOutbox = sqliteTable( + 'durable_application_command_outbox', + { + sequence: integer('sequence').primaryKey({ autoIncrement: true }), + eventId: text('event_id').notNull(), + commandId: text('command_id') + .notNull() + .references(() => durableApplicationCommands.commandId, { onDelete: 'restrict' }), + deploymentId: text('deployment_id').notNull(), + eventType: text('event_type').notNull(), + scopeKind: text('scope_kind').notNull(), + scopeId: text('scope_id').notNull(), + schemaVersion: integer('schema_version').notNull(), + semanticRevision: integer('semantic_revision').notNull(), + payloadJson: text('payload_json').notNull(), + createdAt: text('created_at').notNull(), + deliveryGeneration: integer('delivery_generation').notNull(), + deliveryOwnerId: text('delivery_owner_id'), + deliveryLeaseToken: text('delivery_lease_token'), + deliveryClaimedAt: text('delivery_claimed_at'), + deliveryLeaseExpiresAt: text('delivery_lease_expires_at'), + deliveryAcknowledgedAt: text('delivery_acknowledged_at'), + }, + (table) => [ + uniqueIndex('idx_durable_app_cmd_outbox_event').on(table.eventId), + uniqueIndex('idx_durable_app_cmd_outbox_command').on(table.commandId), + index('idx_durable_app_cmd_outbox_sequence').on(table.sequence), + ] +); + +export const hostedAuthorityProjections = sqliteTable( + 'hosted_authority_projections', + { + deploymentId: text('deployment_id').notNull(), + projectionKind: text('projection_kind').notNull(), + projectionKey: text('projection_key').notNull(), + generation: integer('generation').notNull(), + revision: integer('revision').notNull(), + stateJson: text('state_json').notNull(), + lastCommandId: text('last_command_id') + .notNull() + .references(() => durableApplicationCommands.commandId, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + updatedAt: text('updated_at').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.deploymentId, table.projectionKind, table.projectionKey] }), + check('ck_hosted_authority_projection_generation', sql`${table.generation} > 0`), + check('ck_hosted_authority_projection_revision', sql`${table.revision} > 0`), + check('ck_hosted_authority_projection_state_json', sql`json_valid(${table.stateJson})`), + ] +); + +export const hostedTeamApprovalRecords = sqliteTable( + 'hosted_team_approval_records', + { + workspaceId: text('workspace_id').notNull(), + teamId: text('team_id').notNull(), + authorityGeneration: text('authority_generation').notNull(), + restoreGeneration: integer('restore_generation').notNull(), + runId: text('run_id').notNull(), + requestId: text('request_id').notNull(), + approvalId: text('approval_id').notNull(), + approvalGeneration: text('approval_generation').notNull(), + category: text('category').notNull(), + summary: text('summary').notNull(), + requestedAtMs: integer('requested_at_ms').notNull(), + expiresAtMs: integer('expires_at_ms'), + previewRef: text('preview_ref'), + previewContent: text('preview_content'), + previewByteLength: integer('preview_byte_length'), + previewTruncated: integer('preview_truncated'), + previewIsBinary: integer('preview_is_binary'), + deliveryRef: text('delivery_ref').notNull(), + state: text('state').notNull(), + decision: text('decision'), + revision: integer('revision').notNull(), + observedAtMs: integer('observed_at_ms').notNull(), + resolvedAtMs: integer('resolved_at_ms'), + lastIdempotencyKey: text('last_idempotency_key'), + payloadHash: text('payload_hash'), + }, + (table) => [ + primaryKey({ + columns: [ + table.workspaceId, + table.teamId, + table.authorityGeneration, + table.restoreGeneration, + table.runId, + table.requestId, + ], + }), + uniqueIndex('idx_hosted_team_approval_identity').on( + table.teamId, + table.runId, + table.approvalId + ), + index('idx_hosted_team_approval_pending_page').on(table.teamId, table.state, table.approvalId), + index('idx_hosted_team_approval_pending_partition').on( + table.teamId, + table.runId, + table.state, + table.approvalId + ), + check('ck_hosted_team_approval_revision', sql`${table.revision} > 0`), + check( + 'ck_hosted_team_approval_chronology', + sql`${table.requestedAtMs} >= 0 AND ${table.observedAtMs} >= ${table.requestedAtMs} + AND (${table.expiresAtMs} IS NULL OR ${table.expiresAtMs} > ${table.requestedAtMs})` + ), + check( + 'ck_hosted_team_approval_state', + sql`${table.state} IN ('pending', 'superseded', 'resolved') + AND (${table.state} = 'resolved') = (${table.decision} IS NOT NULL) + AND (${table.state} = 'pending') = (${table.resolvedAtMs} IS NULL)` + ), + check( + 'ck_hosted_team_approval_preview', + sql`(${table.previewRef} IS NULL AND ${table.previewContent} IS NULL + AND ${table.previewByteLength} IS NULL AND ${table.previewTruncated} IS NULL + AND ${table.previewIsBinary} IS NULL) + OR (${table.previewRef} IS NOT NULL AND ${table.previewContent} IS NOT NULL + AND ${table.previewByteLength} IS NOT NULL AND ${table.previewTruncated} IS NOT NULL + AND ${table.previewIsBinary} IS NOT NULL)` + ), + ] +); + +export const hostedTeamApprovalIdempotency = sqliteTable( + 'hosted_team_approval_idempotency', + { + workspaceId: text('workspace_id').notNull(), + teamId: text('team_id').notNull(), + authorityGeneration: text('authority_generation').notNull(), + restoreGeneration: integer('restore_generation').notNull(), + runId: text('run_id').notNull(), + idempotencyKey: text('idempotency_key').notNull(), + requestId: text('request_id').notNull(), + approvalId: text('approval_id').notNull(), + approvalGeneration: text('approval_generation').notNull(), + decision: text('decision').notNull(), + payloadHash: text('payload_hash').notNull(), + revision: integer('revision').notNull(), + auditId: text('audit_id').notNull(), + deliveryId: text('delivery_id').notNull(), + createdAtMs: integer('created_at_ms').notNull(), + }, + (table) => [ + primaryKey({ + columns: [ + table.workspaceId, + table.teamId, + table.authorityGeneration, + table.restoreGeneration, + table.runId, + table.idempotencyKey, + ], + }), + uniqueIndex('idx_hosted_team_approval_idempotency_audit').on(table.auditId), + uniqueIndex('idx_hosted_team_approval_idempotency_delivery').on(table.deliveryId), + foreignKey({ + columns: [ + table.workspaceId, + table.teamId, + table.authorityGeneration, + table.restoreGeneration, + table.runId, + table.requestId, + ], + foreignColumns: [ + hostedTeamApprovalRecords.workspaceId, + hostedTeamApprovalRecords.teamId, + hostedTeamApprovalRecords.authorityGeneration, + hostedTeamApprovalRecords.restoreGeneration, + hostedTeamApprovalRecords.runId, + hostedTeamApprovalRecords.requestId, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check('ck_hosted_team_approval_idempotency_revision', sql`${table.revision} > 0`), + check('ck_hosted_team_approval_idempotency_time', sql`${table.createdAtMs} >= 0`), + ] +); + +export const hostedTeamApprovalAudit = sqliteTable( + 'hosted_team_approval_audit', + { + auditId: text('audit_id').primaryKey(), + workspaceId: text('workspace_id').notNull(), + teamId: text('team_id').notNull(), + authorityGeneration: text('authority_generation').notNull(), + restoreGeneration: integer('restore_generation').notNull(), + runId: text('run_id').notNull(), + requestId: text('request_id').notNull(), + approvalId: text('approval_id').notNull(), + approvalGeneration: text('approval_generation').notNull(), + decision: text('decision').notNull(), + payloadHash: text('payload_hash').notNull(), + actorId: text('actor_id').notNull(), + sessionId: text('session_id').notNull(), + occurredAtMs: integer('occurred_at_ms').notNull(), + }, + (table) => [ + index('idx_hosted_team_approval_audit_partition').on( + table.teamId, + table.runId, + table.occurredAtMs + ), + foreignKey({ + columns: [ + table.workspaceId, + table.teamId, + table.authorityGeneration, + table.restoreGeneration, + table.runId, + table.requestId, + ], + foreignColumns: [ + hostedTeamApprovalRecords.workspaceId, + hostedTeamApprovalRecords.teamId, + hostedTeamApprovalRecords.authorityGeneration, + hostedTeamApprovalRecords.restoreGeneration, + hostedTeamApprovalRecords.runId, + hostedTeamApprovalRecords.requestId, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check('ck_hosted_team_approval_audit_time', sql`${table.occurredAtMs} >= 0`), + ] +); + +export const hostedTeamApprovalDeliveryOutbox = sqliteTable( + 'hosted_team_approval_delivery_outbox', + { + deliveryId: text('delivery_id').primaryKey(), + principalId: text('principal_id').notNull(), + workspaceId: text('workspace_id').notNull(), + teamId: text('team_id').notNull(), + authorityGeneration: text('authority_generation').notNull(), + restoreGeneration: integer('restore_generation').notNull(), + runId: text('run_id').notNull(), + requestId: text('request_id').notNull(), + approvalId: text('approval_id').notNull(), + approvalGeneration: text('approval_generation').notNull(), + decision: text('decision').notNull(), + payloadHash: text('payload_hash').notNull(), + deliveryRef: text('delivery_ref').notNull(), + intentJson: text('intent_json').notNull(), + state: text('state').notNull(), + deliveryGeneration: integer('delivery_generation').notNull(), + deliveryOwnerId: text('delivery_owner_id'), + deliveryLeaseToken: text('delivery_lease_token'), + deliveryClaimedAtMs: integer('delivery_claimed_at_ms'), + deliveryLeaseExpiresAtMs: integer('delivery_lease_expires_at_ms'), + deliveredAtMs: integer('delivered_at_ms'), + createdAtMs: integer('created_at_ms').notNull(), + reconciliationRef: text('reconciliation_ref'), + operatorRequiredAtMs: integer('operator_required_at_ms'), + }, + (table) => [ + uniqueIndex('idx_hosted_team_approval_delivery_target').on( + table.workspaceId, + table.teamId, + table.authorityGeneration, + table.restoreGeneration, + table.runId, + table.requestId + ), + index('idx_hosted_team_approval_delivery_pending').on( + table.state, + table.deliveryOwnerId, + table.deliveryLeaseExpiresAtMs, + table.createdAtMs, + table.deliveryId + ), + foreignKey({ + columns: [ + table.workspaceId, + table.teamId, + table.authorityGeneration, + table.restoreGeneration, + table.runId, + table.requestId, + ], + foreignColumns: [ + hostedTeamApprovalRecords.workspaceId, + hostedTeamApprovalRecords.teamId, + hostedTeamApprovalRecords.authorityGeneration, + hostedTeamApprovalRecords.restoreGeneration, + hostedTeamApprovalRecords.runId, + hostedTeamApprovalRecords.requestId, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check('ck_hosted_team_approval_delivery_generation', sql`${table.deliveryGeneration} >= 0`), + check('ck_hosted_team_approval_delivery_created', sql`${table.createdAtMs} >= 0`), + check( + 'ck_hosted_team_approval_delivery_state', + sql`(${table.state} = 'pending' AND ${table.deliveredAtMs} IS NULL + AND ${table.reconciliationRef} IS NULL AND ${table.operatorRequiredAtMs} IS NULL) + OR (${table.state} = 'operator_required' AND ${table.deliveredAtMs} IS NULL + AND ${table.reconciliationRef} IS NOT NULL + AND ${table.operatorRequiredAtMs} IS NOT NULL) + OR (${table.state} = 'delivered' AND ${table.deliveredAtMs} IS NOT NULL + AND ((${table.reconciliationRef} IS NULL AND ${table.operatorRequiredAtMs} IS NULL) + OR (${table.reconciliationRef} IS NOT NULL + AND ${table.operatorRequiredAtMs} IS NOT NULL)))` + ), + check( + 'ck_hosted_team_approval_delivery_lease', + sql`(${table.deliveryOwnerId} IS NULL AND ${table.deliveryLeaseToken} IS NULL + AND ${table.deliveryClaimedAtMs} IS NULL AND ${table.deliveryLeaseExpiresAtMs} IS NULL) + OR (${table.deliveryOwnerId} IS NOT NULL AND ${table.deliveryLeaseToken} IS NOT NULL + AND ${table.deliveryClaimedAtMs} IS NOT NULL + AND ${table.deliveryLeaseExpiresAtMs} > ${table.deliveryClaimedAtMs})` + ), + check( + 'ck_hosted_team_approval_delivery_operator_lease', + sql`${table.state} <> 'operator_required' OR (${table.deliveryOwnerId} IS NOT NULL + AND ${table.deliveryLeaseToken} IS NOT NULL AND ${table.deliveryClaimedAtMs} IS NOT NULL + AND ${table.deliveryLeaseExpiresAtMs} IS NOT NULL)` + ), + ] +); + +export const coordinationEventJournalMetadata = sqliteTable( + 'coordination_event_journal_metadata', + { + deploymentId: text('deployment_id').primaryKey(), + eventEpoch: text('event_epoch').notNull(), + retentionFloorSequence: integer('retention_floor_sequence').notNull(), + highWatermarkSequence: integer('high_watermark_sequence').notNull(), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => [ + uniqueIndex('idx_coordination_event_metadata_identity').on( + table.deploymentId, + table.eventEpoch + ), + check( + 'ck_coordination_event_metadata_watermark', + sql`${table.retentionFloorSequence} >= 0 + AND ${table.highWatermarkSequence} >= ${table.retentionFloorSequence}` + ), + ] +); + +export const coordinationEventJournal = sqliteTable( + 'coordination_event_journal', + { + deploymentId: text('deployment_id').notNull(), + eventEpoch: text('event_epoch').notNull(), + eventSequence: integer('event_sequence').notNull(), + eventId: text('event_id').notNull(), + bodyJson: text('body_json').notNull(), + emittedAt: text('emitted_at').notNull(), + originCommandId: text('origin_command_id').references( + () => durableApplicationCommands.commandId, + { onDelete: 'restrict', onUpdate: 'restrict' } + ), + createdAt: text('created_at').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.deploymentId, table.eventEpoch, table.eventSequence] }), + uniqueIndex('idx_coordination_event_journal_event_id').on(table.eventId), + index('idx_coordination_event_journal_replay').on( + table.deploymentId, + table.eventEpoch, + table.eventSequence + ), + foreignKey({ + columns: [table.deploymentId, table.eventEpoch], + foreignColumns: [ + coordinationEventJournalMetadata.deploymentId, + coordinationEventJournalMetadata.eventEpoch, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + ] +); + +export const coordinationBackupRuns = sqliteTable( + 'coordination_backup_runs', + { + backupRunId: text('backup_run_id').primaryKey(), + deploymentId: text('deployment_id').notNull(), + state: text('state').notNull(), + revision: integer('revision').notNull(), + fenceCompletionStatus: text('fence_completion_status'), + recordJson: text('record_json').notNull(), + requestedAt: text('requested_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => [ + index('idx_coordination_backup_runs_recoverable').on( + table.state, + table.fenceCompletionStatus, + table.updatedAt + ), + ] +); + +export const coordinationBackupWriterFences = sqliteTable( + 'coordination_backup_writer_fences', + { + deploymentId: text('deployment_id').primaryKey(), + generation: integer('generation').notNull(), + admittedRunId: text('admitted_run_id') + .notNull() + .references(() => coordinationBackupRuns.backupRunId, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + leaseId: text('lease_id').notNull(), + status: text('status').notNull(), + disposition: text('disposition'), + acquiredAt: text('acquired_at').notNull(), + completedAt: text('completed_at'), + }, + (table) => [uniqueIndex('idx_coordination_backup_writer_fence_lease').on(table.leaseId)] +); + +export const durableApplicationCommandConsumerApplications = sqliteTable( + 'durable_application_command_consumer_applications', + { + consumerId: text('consumer_id').notNull(), + eventId: text('event_id') + .notNull() + .references(() => durableApplicationCommandOutbox.eventId, { onDelete: 'restrict' }), + semanticRevision: integer('semantic_revision').notNull(), + projectionKey: text('projection_key').notNull(), + stateJson: text('state_json').notNull(), + appliedAt: text('applied_at').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.consumerId, table.eventId] }), + uniqueIndex('idx_durable_app_cmd_consumer_revision').on( + table.consumerId, + table.projectionKey, + table.semanticRevision + ), + ] +); + +export const durableApplicationCommandConsumerProjections = sqliteTable( + 'durable_application_command_consumer_projections', + { + consumerId: text('consumer_id').notNull(), + projectionKey: text('projection_key').notNull(), + semanticRevision: integer('semantic_revision').notNull(), + lastEventId: text('last_event_id').notNull(), + stateJson: text('state_json').notNull(), + applicationCount: integer('application_count').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.consumerId, table.projectionKey] }), + foreignKey({ + columns: [table.consumerId, table.lastEventId], + foreignColumns: [ + durableApplicationCommandConsumerApplications.consumerId, + durableApplicationCommandConsumerApplications.eventId, + ], + }).onDelete('restrict'), + ] +); diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerEntry.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerEntry.ts index 067de9ca54..00357fb08a 100644 --- a/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerEntry.ts +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerEntry.ts @@ -43,16 +43,17 @@ function loadNativeDriver(): typeof DatabaseConstructor { const core = new InternalStorageWorkerCore({ databasePath: data.databasePath, - createDatabase: (databasePath) => { + ...(data.mode === undefined ? {} : { mode: data.mode }), + createDatabase: (databasePath, options) => { const Driver = loadNativeDriver(); - return new Driver(databasePath); + return new Driver(databasePath, options); }, }); -port.on('message', (message: InternalStorageWorkerRequest) => { +port.on('message', async (message: InternalStorageWorkerRequest) => { let response: InternalStorageWorkerResponse; try { - const result = core.handle(message.op, message.payload); + const result = await core.handleAsync(message.op, message.payload); response = { id: message.id, ok: true, result }; } catch (error) { response = { diff --git a/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerProtocol.ts b/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerProtocol.ts index 7dd2d36e9c..961ff62f3e 100644 --- a/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerProtocol.ts +++ b/src/features/internal-storage/main/infrastructure/worker/internalStorageWorkerProtocol.ts @@ -1,12 +1,312 @@ +import { parseExternalWriterObservationCheckpoint } from './externalWriterObservationStorageOps'; + +import type { + ExternalWriterCleanHandoffConsumeRequest, + ExternalWriterCleanHandoffSaveRequest, + ExternalWriterObservationCheckpointIdentity, + ExternalWriterObservationCheckpointSaveRequest, +} from '../../../contracts/externalWriterObservationStorageContracts'; +import type { ExternalWriterReconciliationCommitRequest } from '../../../contracts/externalWriterReconciliationStorageContracts'; +import type { + HostedTeamApprovalDecisionStorageRequest, + HostedTeamApprovalDeliveryAcknowledgeRequest, + HostedTeamApprovalDeliveryClaimRequest, + HostedTeamApprovalDeliveryOperatorRequiredRequest, + HostedTeamApprovalDeliveryReconciliationRequest, + HostedTeamApprovalDeliveryReconciliationSettleRequest, + HostedTeamApprovalPendingReadRequest, + HostedTeamApprovalPendingStorageRecord, + HostedTeamApprovalPreviewReadRequest, + HostedTeamApprovalTimeoutAuditRequest, +} from '../../../contracts/hostedTeamApprovalAuthorityStorageContracts'; +import type { + HostedTeamConfigurationStorageCreateRequest, + HostedTeamConfigurationStorageDeleteRequest, + HostedTeamConfigurationStorageUpdateRequest, +} from '../../../contracts/hostedTeamConfigurationStorageContracts'; import type { CommentJournalEntryRecord, StallJournalEntryRecord, } from '../../../contracts/internalStorageContracts'; +import type { TeamRosterSnapshotRecord } from '../../../contracts/teamRosterStorageContracts'; +import type { CoordinationDrainStorageEvidence } from '../../application/coordinationDurabilityStorage'; +import type { + ProcessOwnershipStorageCompareAndSwapRequest, + ProcessOwnershipStorageCompareAndSwapResult, + ProcessOwnershipStorageLoadResult, + ProcessOwnershipStorageScope, + StoredProcessOwnershipPhase, + StoredProcessOwnershipState, +} from '../../application/processOwnershipStorage'; +import type { + DurableApplicationCommandCommitRequest, + DurableApplicationCommandConsumerApplyRequest, + DurableApplicationCommandConsumerProjectionRequest, + DurableApplicationCommandPersistClaimRequest, + HostedAuthorityProjectionPersistRequest, + HostedAuthorityProjectionReadRequest, +} from '@features/application-command-ledger'; +import type { + BackupFenceCompletionDisposition, + BackupRunRecord, + BackupRunState, +} from '@features/coordination-backup/contracts'; +import type { + CoordinationEventActor, + CoordinationEventDraft, + CoordinationJsonValue, +} from '@features/coordination-events/contracts'; +import type { TeamId, WorkspaceId } from '@shared/contracts/hosted'; + +export type { + CoordinationDrainStorageEvidence, + SqliteBackupChunkStorageResult, + SqliteOnlineBackupStorageResult, + SqliteSnapshotVerificationStorageResult, + StoredCoordinationEventRow, + StoredEventJournalMetadata, +} from '../../application/coordinationDurabilityStorage'; export interface InternalStorageWorkerData { databasePath: string; + mode?: 'team-identity-read-only'; +} + +export type ApplicationCommandLedgerWorkerOp = + | 'appCommandLedger.begin' + | 'appCommandLedger.markCompleted' + | 'appCommandLedger.markFailed' + | 'appCommandLedger.getByCommandId' + | 'appCommandLedger.getByIdempotencyKey' + | 'appCommandLedger.listByScope' + | 'appCommandLedger.durable.claim' + | 'appCommandLedger.durable.getStatus' + | 'appCommandLedger.durable.getByClaim' + | 'appCommandLedger.durable.renewAttemptLease' + | 'appCommandLedger.durable.transitionCommand' + | 'appCommandLedger.durable.transitionEffect' + | 'appCommandLedger.durable.commit' + | 'appCommandLedger.durable.listOutbox' + | 'appCommandLedger.durable.claimOutbox' + | 'appCommandLedger.durable.acknowledgeOutboxDelivery' + | 'appCommandLedger.durable.applyConsumerEvent' + | 'appCommandLedger.durable.getConsumerProjection' + | 'appCommandLedger.hostedAuthorityProjection.commit' + | 'appCommandLedger.hostedAuthorityProjection.get'; + +export type ExternalWriterReconciliationWorkerOp = + | 'externalWriterReconciliation.get' + | 'externalWriterReconciliation.commit'; + +export interface ExternalWriterReconciliationWorkerPayloadByOp { + 'externalWriterReconciliation.get': { + readonly deploymentId: string; + readonly reconciliationId: string; + }; + 'externalWriterReconciliation.commit': ExternalWriterReconciliationCommitRequest; +} + +type TypedExternalWriterReconciliationWorkerRequest = { + [TOp in keyof ExternalWriterReconciliationWorkerPayloadByOp]: { + id: string; + op: TOp; + payload: ExternalWriterReconciliationWorkerPayloadByOp[TOp]; + }; +}[keyof ExternalWriterReconciliationWorkerPayloadByOp]; + +/** Payloads whose durable envelope semantics must remain typed across IPC. */ +export interface ApplicationCommandLedgerWorkerPayloadByOp { + 'appCommandLedger.durable.claim': DurableApplicationCommandPersistClaimRequest & { + /** + * Internal trusted attribution supplied by an owning command composition. + * Existing public command DTOs remain unchanged; absent values are stored + * with explicit recovery/legacy provenance and never promoted to operator. + */ + readonly coordinationAttribution?: StoredCommandCoordinationAttribution; + }; + 'appCommandLedger.durable.commit': DurableApplicationCommandCommitRequest; + 'appCommandLedger.durable.applyConsumerEvent': DurableApplicationCommandConsumerApplyRequest; + 'appCommandLedger.durable.getConsumerProjection': DurableApplicationCommandConsumerProjectionRequest; + 'appCommandLedger.hostedAuthorityProjection.commit': HostedAuthorityProjectionPersistRequest; + 'appCommandLedger.hostedAuthorityProjection.get': HostedAuthorityProjectionReadRequest; +} + +export interface StoredCommandCoordinationAttribution { + readonly actor: Exclude; + readonly runId?: string; + readonly provenance: 'trusted_context_v1' | 'legacy_recovery_v1'; +} + +type TypedApplicationCommandLedgerWorkerRequest = { + [TOp in keyof ApplicationCommandLedgerWorkerPayloadByOp]: { + id: string; + op: TOp; + payload: ApplicationCommandLedgerWorkerPayloadByOp[TOp]; + }; +}[keyof ApplicationCommandLedgerWorkerPayloadByOp]; + +interface UntypedApplicationCommandLedgerWorkerRequest { + id: string; + op: Exclude; + payload: unknown; +} + +export interface CoordinationDurabilityWorkerPayloadByOp { + 'coordinationEvents.initialize': { + readonly deploymentId: string; + readonly eventEpoch?: string; + readonly nowIso: string; + }; + 'coordinationEvents.getWatermark': { readonly deploymentId: string }; + 'coordinationEvents.read': { + readonly deploymentId: string; + readonly afterSequence: number; + readonly throughSequence: number; + readonly limit: number; + }; + 'coordinationEvents.append': { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly draft: CoordinationEventDraft; + readonly bodyJson: string; + readonly nowIso: string; + }; + 'coordinationEvents.prune': { + readonly deploymentId: string; + readonly eventEpoch: string; + readonly throughSequence: number; + readonly nowIso: string; + }; + 'coordinationBackupRuns.create': { readonly record: BackupRunRecord }; + 'coordinationBackupRuns.get': { readonly backupRunId: string }; + 'coordinationBackupRuns.listRecoverable': Record; + 'coordinationBackupRuns.compareAndSet': { + readonly backupRunId: string; + readonly expectedRevision: number; + readonly expectedState: BackupRunState; + readonly record: BackupRunRecord; + }; + 'coordinationBackupFence.acquire': { + readonly deploymentId: string; + readonly backupRunId: string; + readonly expectedGeneration: number | null; + readonly leaseId: string; + readonly acquiredAt: string; + }; + 'coordinationBackupFence.complete': { + readonly deploymentId: string; + readonly backupRunId: string; + readonly generation: number; + readonly leaseId: string; + readonly disposition: BackupFenceCompletionDisposition; + readonly completedAt: string; + }; + 'coordinationBackupFlush.drain': { + readonly deploymentId: string; + readonly backupRunId: string; + readonly fenceGeneration: number; + }; + 'coordinationBackupFlush.capture': { + readonly deploymentId: string; + readonly evidence: CoordinationDrainStorageEvidence; + }; + 'coordinationBackup.sqlite.online': { + readonly backupRunId: string; + readonly deadlineAtMs: number; + readonly busyRetryMs: number; + readonly pagesPerStep: number; + }; + 'coordinationBackup.sqlite.verify': { + readonly backupRunId: string; + }; + 'coordinationBackup.sqlite.readChunk': { + readonly backupRunId: string; + readonly offset: number; + readonly maximumBytes: number; + }; + 'coordinationBackup.sqlite.discard': { readonly backupRunId: string }; +} + +type TypedCoordinationDurabilityWorkerRequest = { + [TOp in keyof CoordinationDurabilityWorkerPayloadByOp]: { + id: string; + op: TOp; + payload: CoordinationDurabilityWorkerPayloadByOp[TOp]; + }; +}[keyof CoordinationDurabilityWorkerPayloadByOp]; + +export interface ProcessOwnershipWorkerPayloadByOp { + 'processOwnership.loadByScope': { readonly scope: ProcessOwnershipStorageScope }; + 'processOwnership.loadByProcessRef': { readonly processRef: string }; + 'processOwnership.list': Record; + 'processOwnership.compareAndSwap': { + readonly request: ProcessOwnershipStorageCompareAndSwapRequest; + readonly admission: { readonly deadlineAtMs: number }; + }; } +type TypedProcessOwnershipWorkerRequest = { + [TOp in keyof ProcessOwnershipWorkerPayloadByOp]: { + id: string; + op: TOp; + payload: ProcessOwnershipWorkerPayloadByOp[TOp]; + }; +}[keyof ProcessOwnershipWorkerPayloadByOp]; + +export interface HostedTeamApprovalAuthorityWorkerPayloadByOp { + 'hostedTeamApprovalAuthority.observe': HostedTeamApprovalPendingStorageRecord; + 'hostedTeamApprovalAuthority.readPending': HostedTeamApprovalPendingReadRequest; + 'hostedTeamApprovalAuthority.readPreview': HostedTeamApprovalPreviewReadRequest; + 'hostedTeamApprovalAuthority.decide': HostedTeamApprovalDecisionStorageRequest; + 'hostedTeamApprovalAuthority.claimDeliveries': HostedTeamApprovalDeliveryClaimRequest; + 'hostedTeamApprovalAuthority.acknowledgeDelivery': HostedTeamApprovalDeliveryAcknowledgeRequest; + 'hostedTeamApprovalAuthority.markDeliveryOperatorRequired': HostedTeamApprovalDeliveryOperatorRequiredRequest; + 'hostedTeamApprovalAuthority.readDeliveryReconciliation': HostedTeamApprovalDeliveryReconciliationRequest; + 'hostedTeamApprovalAuthority.settleDeliveryReconciliation': HostedTeamApprovalDeliveryReconciliationSettleRequest; + 'hostedTeamApprovalAuthority.auditTimeouts': HostedTeamApprovalTimeoutAuditRequest; +} + +type TypedHostedTeamApprovalAuthorityWorkerRequest = { + [TOp in keyof HostedTeamApprovalAuthorityWorkerPayloadByOp]: { + id: string; + op: TOp; + payload: HostedTeamApprovalAuthorityWorkerPayloadByOp[TOp]; + }; +}[keyof HostedTeamApprovalAuthorityWorkerPayloadByOp]; + +export interface HostedTeamConfigurationWorkerPayloadByOp { + 'hostedTeamConfiguration.create': HostedTeamConfigurationStorageCreateRequest; + 'hostedTeamConfiguration.read': { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; + }; + 'hostedTeamConfiguration.update': HostedTeamConfigurationStorageUpdateRequest; + 'hostedTeamConfiguration.delete': HostedTeamConfigurationStorageDeleteRequest; +} + +type TypedHostedTeamConfigurationWorkerRequest = { + [TOp in keyof HostedTeamConfigurationWorkerPayloadByOp]: { + id: string; + op: TOp; + payload: HostedTeamConfigurationWorkerPayloadByOp[TOp]; + }; +}[keyof HostedTeamConfigurationWorkerPayloadByOp]; + +export interface ExternalWriterObservationWorkerPayloadByOp { + 'externalWriterObservation.load': ExternalWriterObservationCheckpointIdentity; + 'externalWriterObservation.save': ExternalWriterObservationCheckpointSaveRequest; + 'externalWriterObservation.saveCleanHandoff': ExternalWriterCleanHandoffSaveRequest; + 'externalWriterObservation.consumeCleanHandoff': ExternalWriterCleanHandoffConsumeRequest; +} + +type TypedExternalWriterObservationWorkerRequest = { + [TOp in keyof ExternalWriterObservationWorkerPayloadByOp]: { + id: string; + op: TOp; + payload: ExternalWriterObservationWorkerPayloadByOp[TOp]; + }; +}[keyof ExternalWriterObservationWorkerPayloadByOp]; + export type InternalStorageWorkerRequest = | { id: string; op: 'ping'; payload: Record } | { id: string; op: 'stallJournal.load'; payload: { teamName: string } } @@ -33,10 +333,32 @@ export type InternalStorageWorkerRequest = op: 'storeImports.has'; payload: { storeId: string; teamName: string }; } + | { id: string; op: 'teamIdentity.list'; payload: Record } + | { id: string; op: 'teamIdentity.listActive'; payload: Record } + | { + id: string; + op: 'teamIdentity.captureExternalWriterInventory'; + payload: { retirementCandidates: readonly TeamId[] }; + } + | { id: string; op: 'teamIdentity.get'; payload: { teamId: TeamId } } + | { id: string; op: 'teamRoster.get'; payload: { teamId: TeamId } } + | { id: string; op: 'teamRoster.adopt'; payload: { roster: TeamRosterSnapshotRecord } } // Member-work-sync ops share one wire shape; the typed client methods and // the worker-side dispatcher (memberWorkSyncWorkerOps) own the payloads. - | { id: string; op: `appCommandLedger.${string}`; payload: unknown } + | TypedApplicationCommandLedgerWorkerRequest + | TypedCoordinationDurabilityWorkerRequest + | TypedProcessOwnershipWorkerRequest + | TypedHostedTeamApprovalAuthorityWorkerRequest + | TypedHostedTeamConfigurationWorkerRequest + | TypedExternalWriterObservationWorkerRequest + | TypedExternalWriterReconciliationWorkerRequest + | UntypedApplicationCommandLedgerWorkerRequest | { id: string; op: `mws.${string}`; payload: unknown } + | { + id: string; + op: 'hostedAuth.call'; + payload: { readonly operation: string; readonly payload: unknown }; + } | { id: string; op: 'close'; payload: Record }; export type InternalStorageWorkerOp = InternalStorageWorkerRequest['op']; @@ -85,3 +407,356 @@ export function parseJournalReplacePayload([ + 'processOwnership.loadByScope', + 'processOwnership.loadByProcessRef', + 'processOwnership.list', + 'processOwnership.compareAndSwap', +]); +const PROCESS_OWNERSHIP_PHASES = new Set([ + 'spawn_intent', + 'owned', + 'stopping', + 'drained', + 'unclassified_residual', +]); +const PROCESS_OWNERSHIP_SHA_256 = /^sha256:[a-f0-9]{64}$/; +const PROCESS_OWNERSHIP_OPAQUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const PROCESS_OWNERSHIP_REF = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,255}$/; +const PROCESS_OWNERSHIP_MAX_STATE_BYTES = 64 * 1_024; +const PROCESS_OWNERSHIP_MAX_RECORDS = 10_000; + +export function isProcessOwnershipWorkerOp( + op: InternalStorageWorkerOp +): op is keyof ProcessOwnershipWorkerPayloadByOp { + return PROCESS_OWNERSHIP_WORKER_OPS.has(op); +} + +export function parseInternalStorageWorkerResponse(value: unknown): InternalStorageWorkerResponse { + const record = exactWorkerRecord(value, 'response'); + if (typeof record.id !== 'string' || record.id.length === 0) { + throw new TypeError('internal-storage-worker-response-id-invalid'); + } + if (record.ok === true) { + exactWorkerFields(record, ['id', 'ok', 'result'], 'response'); + return { id: record.id, ok: true, result: record.result }; + } + if (record.ok === false && typeof record.error === 'string') { + exactWorkerFields(record, ['id', 'ok', 'error'], 'response'); + return { id: record.id, ok: false, error: record.error }; + } + throw new TypeError('internal-storage-worker-response-shape-invalid'); +} + +export function parseInternalStorageWorkerResponseForPending( + value: unknown, + getOp: (id: string) => InternalStorageWorkerOp | undefined +): InternalStorageWorkerResponse { + const response = parseInternalStorageWorkerResponse(value); + const op = getOp(response.id); + if (!response.ok || op === undefined) return response; + if (isProcessOwnershipWorkerOp(op)) { + return { ...response, result: parseProcessOwnershipWorkerResult(op, response.result) }; + } + if ( + op === 'externalWriterObservation.load' || + op === 'externalWriterObservation.save' || + op === 'externalWriterObservation.saveCleanHandoff' || + op === 'externalWriterObservation.consumeCleanHandoff' + ) { + if ( + response.result === null && + (op === 'externalWriterObservation.load' || + op === 'externalWriterObservation.consumeCleanHandoff') + ) { + return response; + } + const record = exactWorkerFields( + response.result, + ['revision', 'checkpoint'], + 'external-writer-observation-result' + ); + if (!Number.isSafeInteger(record.revision) || (record.revision as number) <= 0) { + throw new TypeError('external-writer-observation-result-revision-invalid'); + } + return { + ...response, + result: { + revision: record.revision, + checkpoint: parseExternalWriterObservationCheckpoint(record.checkpoint), + }, + }; + } + return response; +} + +export function parseProcessOwnershipWorkerPayload< + TOp extends keyof ProcessOwnershipWorkerPayloadByOp, +>(op: TOp, value: unknown): ProcessOwnershipWorkerPayloadByOp[TOp] { + let parsed: ProcessOwnershipWorkerPayloadByOp[keyof ProcessOwnershipWorkerPayloadByOp]; + if (op === 'processOwnership.loadByScope') { + const record = exactWorkerFields(value, ['scope'], 'ownership-load-scope'); + parsed = { scope: parseWorkerOwnershipScope(record.scope) }; + } else if (op === 'processOwnership.loadByProcessRef') { + const record = exactWorkerFields(value, ['processRef'], 'ownership-load-ref'); + parsed = { processRef: parseWorkerProcessRef(record.processRef) }; + } else if (op === 'processOwnership.list') { + exactWorkerFields(value, [], 'ownership-list'); + parsed = {}; + } else { + const record = exactWorkerFields(value, ['request', 'admission'], 'ownership-cas'); + const admission = exactWorkerFields(record.admission, ['deadlineAtMs'], 'ownership-admission'); + const deadlineAtMs = parseWorkerPositiveInteger( + admission.deadlineAtMs, + 'ownership-admission-deadline' + ); + parsed = { + request: parseWorkerCompareAndSwapRequest(record.request), + admission: { deadlineAtMs }, + }; + } + return parsed as ProcessOwnershipWorkerPayloadByOp[TOp]; +} + +interface ProcessOwnershipWorkerResultByOp { + 'processOwnership.loadByScope': ProcessOwnershipStorageLoadResult; + 'processOwnership.loadByProcessRef': ProcessOwnershipStorageLoadResult; + 'processOwnership.list': readonly StoredProcessOwnershipState[]; + 'processOwnership.compareAndSwap': ProcessOwnershipStorageCompareAndSwapResult; +} + +export function parseProcessOwnershipWorkerResult< + TOp extends keyof ProcessOwnershipWorkerResultByOp, +>(op: TOp, value: unknown): ProcessOwnershipWorkerResultByOp[TOp] { + let parsed: ProcessOwnershipWorkerResultByOp[keyof ProcessOwnershipWorkerResultByOp]; + if (op === 'processOwnership.list') { + if ( + !Array.isArray(value) || + value.length > PROCESS_OWNERSHIP_MAX_RECORDS || + Reflect.ownKeys(value).length !== value.length + 1 + ) { + throw new TypeError('internal-storage-worker-ownership-list-result-invalid'); + } + parsed = Object.freeze(value.map(parseWorkerStoredOwnershipState)); + } else { + const record = exactWorkerRecord(value, 'ownership-result'); + if (record.status === 'missing') { + exactWorkerFields(record, ['status'], 'ownership-load-result'); + parsed = { status: 'missing' }; + } else if (record.status === 'conflict') { + exactWorkerFields(record, ['status'], 'ownership-cas-result'); + parsed = { status: 'conflict' }; + } else if (record.status === 'found') { + exactWorkerFields(record, ['status', 'record'], 'ownership-load-result'); + parsed = { status: 'found', record: parseWorkerStoredOwnershipState(record.record) }; + } else if (record.status === 'applied') { + exactWorkerFields(record, ['status', 'record'], 'ownership-cas-result'); + parsed = { status: 'applied', record: parseWorkerStoredOwnershipState(record.record) }; + } else { + throw new TypeError('internal-storage-worker-ownership-result-invalid'); + } + if ( + (op === 'processOwnership.compareAndSwap') !== + (parsed.status === 'applied' || parsed.status === 'conflict') + ) { + throw new TypeError('internal-storage-worker-ownership-result-op-invalid'); + } + } + return parsed as ProcessOwnershipWorkerResultByOp[TOp]; +} + +function parseWorkerCompareAndSwapRequest( + value: unknown +): ProcessOwnershipStorageCompareAndSwapRequest { + const record = exactWorkerFields( + value, + ['scope', 'expectedRevision', 'expectedCurrent', 'next'], + 'ownership-cas-request' + ); + const scope = parseWorkerOwnershipScope(record.scope); + const expectedRevision = + record.expectedRevision === null + ? null + : parseWorkerPositiveInteger(record.expectedRevision, 'ownership-expected-revision'); + const expectedCurrent = + record.expectedCurrent === null + ? null + : parseWorkerStoredOwnershipState(record.expectedCurrent); + const next = parseWorkerStoredOwnershipState(record.next); + if ( + !workerOwnershipScopesEqual(scope, next.scope) || + (expectedRevision === null) !== (expectedCurrent === null) || + (expectedCurrent !== null && + (expectedCurrent.revision !== expectedRevision || + expectedCurrent.processRef !== next.processRef || + !workerOwnershipScopesEqual(expectedCurrent.scope, scope))) || + next.revision !== (expectedRevision ?? 0) + 1 + ) { + throw new TypeError('internal-storage-worker-ownership-cas-binding-invalid'); + } + return { scope, expectedRevision, expectedCurrent, next }; +} + +function parseWorkerStoredOwnershipState(value: unknown): StoredProcessOwnershipState { + const record = exactWorkerFields( + value, + ['scope', 'processRef', 'codecVersion', 'stateVersion', 'revision', 'phase', 'stateJson'], + 'ownership-record' + ); + if ( + record.codecVersion !== 1 || + record.stateVersion !== 1 || + !PROCESS_OWNERSHIP_PHASES.has(record.phase as StoredProcessOwnershipPhase) + ) { + throw new TypeError('internal-storage-worker-ownership-record-version-invalid'); + } + const stateJson = parseWorkerCanonicalStateJson(record.stateJson); + const envelope = JSON.parse(stateJson) as { + codecVersion: number; + state: Record; + }; + const revision = parseWorkerPositiveInteger(record.revision, 'ownership-revision'); + if ( + envelope.codecVersion !== record.codecVersion || + envelope.state.stateVersion !== record.stateVersion || + envelope.state.revision !== revision || + envelope.state.phase !== record.phase + ) { + throw new TypeError('internal-storage-worker-ownership-record-metadata-invalid'); + } + return { + scope: parseWorkerOwnershipScope(record.scope), + processRef: parseWorkerProcessRef(record.processRef), + codecVersion: 1, + stateVersion: 1, + revision, + phase: record.phase as StoredProcessOwnershipPhase, + stateJson, + }; +} + +function parseWorkerCanonicalStateJson(value: unknown): string { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > PROCESS_OWNERSHIP_MAX_STATE_BYTES + ) { + throw new TypeError('internal-storage-worker-ownership-state-json-invalid'); + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new TypeError('internal-storage-worker-ownership-state-json-invalid'); + } + const envelope = exactWorkerFields(parsed, ['codecVersion', 'state'], 'ownership-envelope'); + exactWorkerRecord(envelope.state, 'ownership-state'); + if (canonicalWorkerJson(parsed) !== value) { + throw new TypeError('internal-storage-worker-ownership-state-json-noncanonical'); + } + return value; +} + +function parseWorkerOwnershipScope(value: unknown): ProcessOwnershipStorageScope { + const record = exactWorkerFields( + value, + ['teamId', 'runId', 'planGeneration', 'planHash', 'executionUnitId'], + 'ownership-scope' + ); + if (typeof record.planHash !== 'string' || !PROCESS_OWNERSHIP_SHA_256.test(record.planHash)) { + throw new TypeError('internal-storage-worker-ownership-plan-hash-invalid'); + } + return { + teamId: parseWorkerOpaque(record.teamId, 'team-id'), + runId: parseWorkerOpaque(record.runId, 'run-id'), + planGeneration: parseWorkerPositiveInteger(record.planGeneration, 'ownership-plan-generation'), + planHash: record.planHash, + executionUnitId: parseWorkerOpaque(record.executionUnitId, 'execution-unit-id'), + }; +} + +function parseWorkerProcessRef(value: unknown): string { + if (typeof value !== 'string' || !PROCESS_OWNERSHIP_REF.test(value)) { + throw new TypeError('internal-storage-worker-ownership-process-ref-invalid'); + } + return value; +} + +function parseWorkerOpaque(value: unknown, reason: string): string { + if (typeof value !== 'string' || !PROCESS_OWNERSHIP_OPAQUE.test(value)) { + throw new TypeError(`internal-storage-worker-ownership-${reason}-invalid`); + } + return value; +} + +function parseWorkerPositiveInteger(value: unknown, reason: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new TypeError(`internal-storage-worker-${reason}-invalid`); + } + return value; +} + +function workerOwnershipScopesEqual( + left: ProcessOwnershipStorageScope, + right: ProcessOwnershipStorageScope +): boolean { + return ( + left.teamId === right.teamId && + left.runId === right.runId && + left.planGeneration === right.planGeneration && + left.planHash === right.planHash && + left.executionUnitId === right.executionUnitId + ); +} + +function exactWorkerFields( + value: unknown, + fields: readonly string[], + reason: string +): Record { + const record = exactWorkerRecord(value, reason); + const actual = Object.keys(record).sort((left, right) => left.localeCompare(right)); + const expected = [...fields].sort((left, right) => left.localeCompare(right)); + if ( + actual.length !== expected.length || + actual.some((field, index) => field !== expected[index]) + ) { + throw new TypeError(`internal-storage-worker-${reason}-fields-invalid`); + } + return record; +} + +function exactWorkerRecord(value: unknown, reason: string): Record { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Reflect.ownKeys(value).some((key) => typeof key !== 'string') + ) { + throw new TypeError(`internal-storage-worker-${reason}-invalid`); + } + const descriptors = Object.values(Object.getOwnPropertyDescriptors(value)); + if (descriptors.some((descriptor) => !descriptor.enumerable || !('value' in descriptor))) { + throw new TypeError(`internal-storage-worker-${reason}-descriptor-invalid`); + } + return value as Record; +} + +function canonicalWorkerJson(value: unknown): string { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) throw new TypeError('internal-storage-worker-number-invalid'); + return String(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalWorkerJson).join(',')}]`; + const record = exactWorkerRecord(value, 'canonical-value'); + // Code-unit ordering: validates the same persisted stateJson bytes as the + // process-ownership codec, so both must sort keys identically on any locale. + return `{${Object.keys(record) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((key) => `${JSON.stringify(key)}:${canonicalWorkerJson(record[key])}`) + .join(',')}}`; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/legacyApplicationCommandLedgerWorkerOps.ts b/src/features/internal-storage/main/infrastructure/worker/legacyApplicationCommandLedgerWorkerOps.ts new file mode 100644 index 0000000000..06a2aeefca --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/legacyApplicationCommandLedgerWorkerOps.ts @@ -0,0 +1,286 @@ +import { + ApplicationCommandBeginOutcome, + ApplicationCommandConflictReason, + ApplicationCommandFailureKind, + type ApplicationCommandLedgerCompleteRequest, + type ApplicationCommandLedgerFailRequest, + type ApplicationCommandLedgerListScopeRequest, + type ApplicationCommandLedgerReadByCommandIdRequest, + type ApplicationCommandLedgerReadByIdempotencyKeyRequest, + ApplicationCommandLedgerStatus, +} from '@features/application-command-ledger/contracts'; +import { and, asc, eq } from 'drizzle-orm'; + +import * as validation from './applicationCommandLedgerValidation'; +import { ApplicationCommandMutationFenceOps } from './applicationCommandMutationFenceOps'; +import { applicationCommandLedger } from './internalStorageSchema'; + +import type DatabaseConstructor from 'better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +type SqliteDatabase = InstanceType; + +import type { ApplicationCommandLedgerRecordRepository } from './applicationCommandLedgerRecordRepository'; +import type { + AppCommandBeginRequest, + AppCommandBeginResult, + AppCommandRecord, +} from './applicationCommandLedgerWorkerTypes'; + +export class LegacyApplicationCommandLedgerWorkerOps { + private readonly mutationFenceOps: ApplicationCommandMutationFenceOps; + + constructor( + private readonly getOrm: () => BetterSQLite3Database, + private readonly repository: ApplicationCommandLedgerRecordRepository, + getDb: () => SqliteDatabase + ) { + this.mutationFenceOps = new ApplicationCommandMutationFenceOps(getDb, (input) => + this.repository.readByCommandId(input) + ); + } + + begin(input: AppCommandBeginRequest): AppCommandBeginResult { + validation.assertValidBeginTiming(input); + this.ensureMutationFenceSchema(); + const orm = this.getOrm(); + return orm.transaction( + (): AppCommandBeginResult => { + const currentByCommand = this.repository.readByCommandId(input); + if (currentByCommand) { + return this.beginExistingCommand(currentByCommand, input, true); + } + + const currentByIdempotencyKey = this.repository.readByIdempotencyKey(input); + if (currentByIdempotencyKey) { + return this.beginExistingCommand(currentByIdempotencyKey, input, false); + } + + const fenceConflict = this.claimMutationFence(input, null); + if (fenceConflict) return fenceConflict; + const created: AppCommandRecord = { + namespace: input.namespace, + scopeKey: input.scopeKey, + commandId: input.commandId, + idempotencyKey: input.idempotencyKey, + operation: input.operation, + payloadHash: input.payloadHash, + status: ApplicationCommandLedgerStatus.Started, + failureKind: null, + retryable: false, + attemptCount: 1, + resultHash: null, + resultJson: null, + metadataJson: input.metadataJson, + startedAt: input.nowIso, + updatedAt: input.nowIso, + completedAt: null, + lastError: null, + }; + orm.insert(applicationCommandLedger).values(created).run(); + return { outcome: ApplicationCommandBeginOutcome.Started, record: created }; + }, + { behavior: 'immediate' } + ); + } + + markCompleted(input: ApplicationCommandLedgerCompleteRequest): void { + const orm = this.getOrm(); + orm.transaction(() => { + const current = this.repository.readByCommandId(input); + if (!current) { + throw new Error(`Application command ledger entry not found: ${input.commandId}`); + } + if (current.status === ApplicationCommandLedgerStatus.Completed) { + if (current.resultHash === input.resultHash && current.resultJson === input.resultJson) { + return; + } + throw new Error( + `Application command completion conflicts with stored result: ${input.commandId}` + ); + } + validation.assertAttemptMatches(current, input.attemptCount); + if (!validation.canFinalize(current.status)) { + throw new Error( + `Application command cannot be completed from status ${current.status}: ${input.commandId}` + ); + } + this.repository.replaceRow({ + ...current, + status: ApplicationCommandLedgerStatus.Completed, + failureKind: null, + retryable: false, + resultHash: input.resultHash, + resultJson: input.resultJson, + updatedAt: input.completedAtIso, + completedAt: input.completedAtIso, + lastError: null, + }); + }); + } + + markFailed(input: ApplicationCommandLedgerFailRequest): void { + const orm = this.getOrm(); + orm.transaction(() => { + const current = this.repository.readByCommandId(input); + if (!current) { + throw new Error(`Application command ledger entry not found: ${input.commandId}`); + } + validation.assertAttemptMatches(current, input.attemptCount); + const nextStatus = validation.statusForFailure(input.failureKind); + if ( + current.status === nextStatus && + current.failureKind === input.failureKind && + current.lastError === input.errorMessage + ) { + return; + } + if (!validation.canFinalize(current.status)) { + throw new Error( + `Application command cannot be failed from status ${current.status}: ${input.commandId}` + ); + } + this.repository.replaceRow({ + ...current, + status: nextStatus, + failureKind: input.failureKind, + retryable: input.failureKind === ApplicationCommandFailureKind.Retryable, + resultHash: null, + resultJson: null, + updatedAt: input.completedAtIso, + completedAt: + input.failureKind === ApplicationCommandFailureKind.UnknownAfterTimeout + ? null + : input.completedAtIso, + lastError: input.errorMessage, + }); + }); + } + + getByCommandId(input: ApplicationCommandLedgerReadByCommandIdRequest): AppCommandRecord | null { + return this.repository.readByCommandId(input); + } + + getByIdempotencyKey( + input: ApplicationCommandLedgerReadByIdempotencyKeyRequest + ): AppCommandRecord | null { + return this.repository.readByIdempotencyKey(input); + } + + listByScope(input: ApplicationCommandLedgerListScopeRequest): AppCommandRecord[] { + return this.getOrm() + .select() + .from(applicationCommandLedger) + .where( + and( + eq(applicationCommandLedger.namespace, input.namespace), + eq(applicationCommandLedger.scopeKey, input.scopeKey) + ) + ) + .orderBy(asc(applicationCommandLedger.updatedAt), asc(applicationCommandLedger.commandId)) + .all() as AppCommandRecord[]; + } + + ensureMutationFenceSchema(): void { + this.mutationFenceOps.ensureSchema(); + } + + claimMutationFence( + input: AppCommandBeginRequest, + current: AppCommandRecord | null + ): AppCommandBeginResult | null { + return this.mutationFenceOps.claim(input, current); + } + + beginExistingCommand( + current: AppCommandRecord, + input: AppCommandBeginRequest, + matchedCommandId: boolean + ): AppCommandBeginResult { + const conflict = + matchedCommandId && current.idempotencyKey !== input.idempotencyKey + ? ApplicationCommandConflictReason.CommandIdReused + : this.findSemanticConflict(current, input); + if (conflict) return validation.mutationConflict(conflict, current, input); + const fenceConflict = this.claimMutationFence(input, current); + if (fenceConflict) return fenceConflict; + return this.beginExistingMatchingCommand(current, input); + } + + beginExistingMatchingCommand( + current: AppCommandRecord, + input: AppCommandBeginRequest + ): AppCommandBeginResult { + switch (current.status) { + case ApplicationCommandLedgerStatus.Started: + if (validation.isStartedStale(current, input)) { + const next: AppCommandRecord = { + ...current, + status: ApplicationCommandLedgerStatus.UnknownAfterTimeout, + failureKind: ApplicationCommandFailureKind.UnknownAfterTimeout, + retryable: false, + updatedAt: input.nowIso, + completedAt: null, + lastError: `Started attempt ${current.attemptCount} exceeded ${input.startedStaleAfterMs}ms and requires reconciliation`, + }; + this.repository.replaceRow(next); + return { + outcome: ApplicationCommandBeginOutcome.UnknownAfterTimeout, + record: next, + }; + } + return { outcome: ApplicationCommandBeginOutcome.AlreadyStarted, record: current }; + case ApplicationCommandLedgerStatus.Completed: + return { outcome: ApplicationCommandBeginOutcome.DuplicateCompleted, record: current }; + case ApplicationCommandLedgerStatus.FailedRetryable: + return this.restartRetryable(current, input); + case ApplicationCommandLedgerStatus.FailedTerminal: + return { outcome: ApplicationCommandBeginOutcome.FailedTerminal, record: current }; + case ApplicationCommandLedgerStatus.UnknownAfterTimeout: + return { outcome: ApplicationCommandBeginOutcome.UnknownAfterTimeout, record: current }; + default: + return { + outcome: ApplicationCommandBeginOutcome.Conflict, + reason: ApplicationCommandConflictReason.OperationMismatch, + existing: current, + requested: input, + }; + } + } + + restartRetryable( + current: AppCommandRecord, + input: AppCommandBeginRequest + ): AppCommandBeginResult { + const next: AppCommandRecord = { + ...current, + operation: input.operation, + payloadHash: input.payloadHash, + status: ApplicationCommandLedgerStatus.Started, + failureKind: null, + retryable: false, + attemptCount: current.attemptCount + 1, + resultHash: null, + resultJson: null, + metadataJson: input.metadataJson, + updatedAt: input.nowIso, + completedAt: null, + lastError: null, + }; + this.repository.replaceRow(next); + return { outcome: ApplicationCommandBeginOutcome.RetryStarted, record: next }; + } + + findSemanticConflict( + current: AppCommandRecord, + input: AppCommandBeginRequest + ): ApplicationCommandConflictReason | null { + if (current.operation !== input.operation) { + return ApplicationCommandConflictReason.OperationMismatch; + } + if (current.payloadHash !== input.payloadHash) { + return ApplicationCommandConflictReason.PayloadHashMismatch; + } + return null; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerOps.ts b/src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerOps.ts index 13d39e5ca9..30b43b0ab2 100644 --- a/src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerOps.ts +++ b/src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerOps.ts @@ -1,8 +1,8 @@ import { and, asc, desc, eq, gt, gte, inArray } from 'drizzle-orm'; import { - isSameMemberWorkSyncTeam, normalizeMemberWorkSyncSnapshotTeamIdentity, + normalizeMemberWorkSyncTeamKey, } from '../../../contracts/memberWorkSyncTeamIdentity'; import { @@ -11,6 +11,18 @@ import { memberWorkSyncReportIntents, memberWorkSyncStatus, } from './internalStorageSchema'; +import { + canClaim, + canRevive, + chunked, + isOutboxTerminal, + METRIC_EVENT_RECORD_SELECTION, + METRIC_EVENTS_CAP, + OUTBOX_ITEM_RECORD_SELECTION, + REPORT_INTENT_RECORD_SELECTION, + STATUS_RECORD_SELECTION, + toPersistenceRow, +} from './memberWorkSyncWorkerState'; import type { MemberWorkSyncMetricEventRecord, @@ -23,78 +35,6 @@ import type { } from '../../../contracts/internalStorageContracts'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -// Mirrors MEMBER_WORK_SYNC_OUTBOX_CLAIM_STALE_MS in JsonMemberWorkSyncStore. -const CLAIM_STALE_MS = 5 * 60 * 1000; -// Mirrors the recentEvents cap in the JSON metrics index. -const METRIC_EVENTS_CAP = 200; -const INSERT_CHUNK_SIZE = 200; - -const OUTBOX_TERMINAL_STATUSES = ['delivered', 'superseded', 'failed_terminal']; - -function isOutboxTerminal(status: string): boolean { - return OUTBOX_TERMINAL_STATUSES.includes(status); -} - -// Mirrors canReviveOutboxItem: superseded | claimed | failed_retryable. -function canRevive(status: string): boolean { - return status === 'superseded' || (!isOutboxTerminal(status) && status !== 'pending'); -} - -function parseIsoMs(value: string | null | undefined): number | null { - if (!value) { - return null; - } - const ms = Date.parse(value); - return Number.isFinite(ms) ? ms : null; -} - -function isStaleClaim(claimedAt: string | null, updatedAt: string, nowIso: string): boolean { - const claimedAtMs = parseIsoMs(claimedAt ?? updatedAt); - const nowMs = parseIsoMs(nowIso); - return ( - claimedAtMs != null && - nowMs != null && - (claimedAtMs > nowMs || nowMs - claimedAtMs >= CLAIM_STALE_MS) - ); -} - -function isNextAttemptDue(nextAttemptAt: string | null, nowIso: string): boolean { - if (!nextAttemptAt) { - return true; - } - const nextAttemptAtMs = parseIsoMs(nextAttemptAt); - if (nextAttemptAtMs == null) { - return true; - } - const nowMs = parseIsoMs(nowIso); - return nowMs != null && nextAttemptAtMs <= nowMs; -} - -function canClaim(item: MemberWorkSyncOutboxItemRecord, nowIso: string): boolean { - if (item.status === 'claimed') { - return isStaleClaim(item.claimedAt, item.updatedAt, nowIso); - } - if (item.status !== 'pending' && item.status !== 'failed_retryable') { - return false; - } - return isNextAttemptDue(item.nextAttemptAt, nowIso); -} - -// Load-bearing guard, not just batching: drizzle's .values([]) throws, and an -// empty set is a legitimate state (fresh team import). Iterating chunks means -// zero .values() calls for empty input — do not inline bulk inserts without it. -function chunked(values: T[]): T[][] { - const chunks: T[][] = []; - for (let start = 0; start < values.length; start += INSERT_CHUNK_SIZE) { - chunks.push(values.slice(start, start + INSERT_CHUNK_SIZE)); - } - return chunks; -} - -function rowsForTeam(rows: T[], teamName: string): T[] { - return rows.filter((row) => isSameMemberWorkSyncTeam(row.teamName, teamName)); -} - /** Routes 'mws.*' worker requests to the ops instance. */ export function handleMemberWorkSyncOp( ops: MemberWorkSyncWorkerOps, @@ -193,7 +133,7 @@ export class MemberWorkSyncWorkerOps { statusRead(teamName: string, memberKey: string): MemberWorkSyncStatusRecord | null { const rows = this.getOrm() - .select() + .select(STATUS_RECORD_SELECTION) .from(memberWorkSyncStatus) .where( and( @@ -207,7 +147,7 @@ export class MemberWorkSyncWorkerOps { statusList(teamName: string): MemberWorkSyncStatusRecord[] { return this.getOrm() - .select() + .select(STATUS_RECORD_SELECTION) .from(memberWorkSyncStatus) .where(eq(memberWorkSyncStatus.teamName, teamName)) .orderBy(asc(memberWorkSyncStatus.memberKey)) @@ -224,10 +164,11 @@ export class MemberWorkSyncWorkerOps { orm.transaction(() => { orm .insert(memberWorkSyncStatus) - .values(record) + .values(toPersistenceRow(record)) .onConflictDoUpdate({ target: [memberWorkSyncStatus.teamName, memberWorkSyncStatus.memberKey], set: { + teamKey: normalizeMemberWorkSyncTeamKey(record.teamName), memberName: record.memberName, state: record.state, evaluatedAt: record.evaluatedAt, @@ -239,10 +180,11 @@ export class MemberWorkSyncWorkerOps { for (const event of events) { orm .insert(memberWorkSyncMetricEvents) - .values(event) + .values(toPersistenceRow(event)) .onConflictDoUpdate({ target: [memberWorkSyncMetricEvents.teamName, memberWorkSyncMetricEvents.id], set: { + teamKey: normalizeMemberWorkSyncTeamKey(event.teamName), memberKey: event.memberKey, memberName: event.memberName, kind: event.kind, @@ -258,7 +200,7 @@ export class MemberWorkSyncWorkerOps { metricEventsList(teamName: string): MemberWorkSyncMetricEventRecord[] { return this.getOrm() - .select() + .select(METRIC_EVENT_RECORD_SELECTION) .from(memberWorkSyncMetricEvents) .where(eq(memberWorkSyncMetricEvents.teamName, teamName)) .orderBy(asc(memberWorkSyncMetricEvents.recordedAt)) @@ -283,10 +225,11 @@ export class MemberWorkSyncWorkerOps { }; orm .insert(memberWorkSyncReportIntents) - .values(next) + .values(toPersistenceRow(next)) .onConflictDoUpdate({ target: [memberWorkSyncReportIntents.teamName, memberWorkSyncReportIntents.id], set: { + teamKey: normalizeMemberWorkSyncTeamKey(next.teamName), memberKey: next.memberKey, memberName: next.memberName, status: next.status, @@ -303,7 +246,7 @@ export class MemberWorkSyncWorkerOps { reportsListPending(teamName: string): MemberWorkSyncReportIntentRecord[] { return this.getOrm() - .select() + .select(REPORT_INTENT_RECORD_SELECTION) .from(memberWorkSyncReportIntents) .where( and( @@ -411,7 +354,7 @@ export class MemberWorkSyncWorkerOps { createdAt: nowIso, updatedAt: nowIso, }; - orm.insert(memberWorkSyncOutbox).values(created).run(); + orm.insert(memberWorkSyncOutbox).values(toPersistenceRow(created)).run(); return { ok: true, outcome: 'created', item: created }; }); } @@ -430,7 +373,7 @@ export class MemberWorkSyncWorkerOps { const orm = this.getOrm(); return orm.transaction(() => { const candidates = orm - .select() + .select(OUTBOX_ITEM_RECORD_SELECTION) .from(memberWorkSyncOutbox) .where( and( @@ -646,7 +589,7 @@ export class MemberWorkSyncWorkerOps { sinceIso: string; }): MemberWorkSyncOutboxItemRecord | null { const rows = this.getOrm() - .select() + .select(OUTBOX_ITEM_RECORD_SELECTION) .from(memberWorkSyncOutbox) .where( and( @@ -664,40 +607,74 @@ export class MemberWorkSyncWorkerOps { listTeamSnapshot(teamName: string): MemberWorkSyncTeamSnapshotRecords { const orm = this.getOrm(); + const teamKey = normalizeMemberWorkSyncTeamKey(teamName); + if (!teamKey) { + return { statuses: [], reportIntents: [], outboxItems: [], metricEvents: [] }; + } return { - statuses: rowsForTeam(orm.select().from(memberWorkSyncStatus).all(), teamName).sort( - (left, right) => left.memberKey.localeCompare(right.memberKey) - ), - reportIntents: rowsForTeam( - orm.select().from(memberWorkSyncReportIntents).all(), - teamName - ).sort((left, right) => left.id.localeCompare(right.id)), - outboxItems: rowsForTeam(orm.select().from(memberWorkSyncOutbox).all(), teamName).sort( - (left, right) => left.id.localeCompare(right.id) - ), - metricEvents: rowsForTeam(orm.select().from(memberWorkSyncMetricEvents).all(), teamName).sort( - (left, right) => { + statuses: orm + .select(STATUS_RECORD_SELECTION) + .from(memberWorkSyncStatus) + .where(eq(memberWorkSyncStatus.teamKey, teamKey)) + .all() + .sort((left, right) => left.memberKey.localeCompare(right.memberKey)), + reportIntents: orm + .select(REPORT_INTENT_RECORD_SELECTION) + .from(memberWorkSyncReportIntents) + .where(eq(memberWorkSyncReportIntents.teamKey, teamKey)) + .all() + .sort((left, right) => left.id.localeCompare(right.id)), + outboxItems: orm + .select(OUTBOX_ITEM_RECORD_SELECTION) + .from(memberWorkSyncOutbox) + .where(eq(memberWorkSyncOutbox.teamKey, teamKey)) + .all() + .sort((left, right) => left.id.localeCompare(right.id)), + metricEvents: orm + .select(METRIC_EVENT_RECORD_SELECTION) + .from(memberWorkSyncMetricEvents) + .where(eq(memberWorkSyncMetricEvents.teamKey, teamKey)) + .all() + .sort((left, right) => { const byTime = left.recordedAt.localeCompare(right.recordedAt); return byTime === 0 ? left.id.localeCompare(right.id) : byTime; - } - ), + }), }; } - /** One-transaction import: folds every case alias into the routing argument. */ + /** One-transaction import: folds every whitespace/case alias into the routing argument. */ importTeam(teamName: string, snapshot: MemberWorkSyncTeamSnapshotRecords): void { const orm = this.getOrm(); const normalizedSnapshot = normalizeMemberWorkSyncSnapshotTeamIdentity(teamName, snapshot); + const teamKey = normalizeMemberWorkSyncTeamKey(teamName); orm.transaction(() => { const aliases = new Set([teamName]); - const current = this.listTeamSnapshot(teamName); - for (const record of [ - ...current.statuses, - ...current.reportIntents, - ...current.outboxItems, - ...current.metricEvents, - ]) { - aliases.add(record.teamName); + const aliasRows = teamKey + ? [ + orm + .select({ teamName: memberWorkSyncStatus.teamName }) + .from(memberWorkSyncStatus) + .where(eq(memberWorkSyncStatus.teamKey, teamKey)) + .all(), + orm + .select({ teamName: memberWorkSyncReportIntents.teamName }) + .from(memberWorkSyncReportIntents) + .where(eq(memberWorkSyncReportIntents.teamKey, teamKey)) + .all(), + orm + .select({ teamName: memberWorkSyncOutbox.teamName }) + .from(memberWorkSyncOutbox) + .where(eq(memberWorkSyncOutbox.teamKey, teamKey)) + .all(), + orm + .select({ teamName: memberWorkSyncMetricEvents.teamName }) + .from(memberWorkSyncMetricEvents) + .where(eq(memberWorkSyncMetricEvents.teamKey, teamKey)) + .all(), + ] + : []; + for (const rows of aliasRows) { + for (const row of rows) aliases.add(row.teamName); } for (const alias of aliases) { orm.delete(memberWorkSyncStatus).where(eq(memberWorkSyncStatus.teamName, alias)).run(); @@ -712,16 +689,16 @@ export class MemberWorkSyncWorkerOps { .run(); } for (const rows of chunked(normalizedSnapshot.statuses)) { - orm.insert(memberWorkSyncStatus).values(rows).run(); + orm.insert(memberWorkSyncStatus).values(rows.map(toPersistenceRow)).run(); } for (const rows of chunked(normalizedSnapshot.reportIntents)) { - orm.insert(memberWorkSyncReportIntents).values(rows).run(); + orm.insert(memberWorkSyncReportIntents).values(rows.map(toPersistenceRow)).run(); } for (const rows of chunked(normalizedSnapshot.outboxItems)) { - orm.insert(memberWorkSyncOutbox).values(rows).run(); + orm.insert(memberWorkSyncOutbox).values(rows.map(toPersistenceRow)).run(); } for (const rows of chunked(normalizedSnapshot.metricEvents)) { - orm.insert(memberWorkSyncMetricEvents).values(rows).run(); + orm.insert(memberWorkSyncMetricEvents).values(rows.map(toPersistenceRow)).run(); } }); } @@ -757,7 +734,7 @@ export class MemberWorkSyncWorkerOps { private readReportRow(teamName: string, id: string): MemberWorkSyncReportIntentRecord | null { const rows = this.getOrm() - .select() + .select(REPORT_INTENT_RECORD_SELECTION) .from(memberWorkSyncReportIntents) .where( and( @@ -771,7 +748,7 @@ export class MemberWorkSyncWorkerOps { private readOutboxRow(teamName: string, id: string): MemberWorkSyncOutboxItemRecord | null { const rows = this.getOrm() - .select() + .select(OUTBOX_ITEM_RECORD_SELECTION) .from(memberWorkSyncOutbox) .where(and(eq(memberWorkSyncOutbox.teamName, teamName), eq(memberWorkSyncOutbox.id, id))) .all(); @@ -782,6 +759,7 @@ export class MemberWorkSyncWorkerOps { this.getOrm() .update(memberWorkSyncOutbox) .set({ + teamKey: normalizeMemberWorkSyncTeamKey(row.teamName), memberKey: row.memberKey, memberName: row.memberName, agendaFingerprint: row.agendaFingerprint, diff --git a/src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerState.ts b/src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerState.ts new file mode 100644 index 0000000000..5947b0dd52 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/memberWorkSyncWorkerState.ts @@ -0,0 +1,136 @@ +import { normalizeMemberWorkSyncTeamKey } from '../../../contracts/memberWorkSyncTeamIdentity'; + +import { + memberWorkSyncMetricEvents, + memberWorkSyncOutbox, + memberWorkSyncReportIntents, + memberWorkSyncStatus, +} from './internalStorageSchema'; + +import type { MemberWorkSyncOutboxItemRecord } from '../../../contracts/internalStorageContracts'; + +// Mirrors MEMBER_WORK_SYNC_OUTBOX_CLAIM_STALE_MS in JsonMemberWorkSyncStore. +const CLAIM_STALE_MS = 5 * 60 * 1000; +const INSERT_CHUNK_SIZE = 200; +const OUTBOX_TERMINAL_STATUSES = ['delivered', 'superseded', 'failed_terminal']; + +export const METRIC_EVENTS_CAP = 200; + +export const STATUS_RECORD_SELECTION = { + teamName: memberWorkSyncStatus.teamName, + memberKey: memberWorkSyncStatus.memberKey, + memberName: memberWorkSyncStatus.memberName, + state: memberWorkSyncStatus.state, + evaluatedAt: memberWorkSyncStatus.evaluatedAt, + providerId: memberWorkSyncStatus.providerId, + statusJson: memberWorkSyncStatus.statusJson, +}; + +export const REPORT_INTENT_RECORD_SELECTION = { + teamName: memberWorkSyncReportIntents.teamName, + id: memberWorkSyncReportIntents.id, + memberKey: memberWorkSyncReportIntents.memberKey, + memberName: memberWorkSyncReportIntents.memberName, + status: memberWorkSyncReportIntents.status, + reason: memberWorkSyncReportIntents.reason, + recordedAt: memberWorkSyncReportIntents.recordedAt, + processedAt: memberWorkSyncReportIntents.processedAt, + resultCode: memberWorkSyncReportIntents.resultCode, + requestJson: memberWorkSyncReportIntents.requestJson, +}; + +export const OUTBOX_ITEM_RECORD_SELECTION = { + teamName: memberWorkSyncOutbox.teamName, + id: memberWorkSyncOutbox.id, + memberKey: memberWorkSyncOutbox.memberKey, + memberName: memberWorkSyncOutbox.memberName, + agendaFingerprint: memberWorkSyncOutbox.agendaFingerprint, + payloadHash: memberWorkSyncOutbox.payloadHash, + status: memberWorkSyncOutbox.status, + attemptGeneration: memberWorkSyncOutbox.attemptGeneration, + claimedBy: memberWorkSyncOutbox.claimedBy, + claimedAt: memberWorkSyncOutbox.claimedAt, + deliveredMessageId: memberWorkSyncOutbox.deliveredMessageId, + deliveryState: memberWorkSyncOutbox.deliveryState, + lastError: memberWorkSyncOutbox.lastError, + nextAttemptAt: memberWorkSyncOutbox.nextAttemptAt, + createdAt: memberWorkSyncOutbox.createdAt, + updatedAt: memberWorkSyncOutbox.updatedAt, + workSyncIntent: memberWorkSyncOutbox.workSyncIntent, + workSyncIntentKey: memberWorkSyncOutbox.workSyncIntentKey, + reviewRequestEventIdsJson: memberWorkSyncOutbox.reviewRequestEventIdsJson, + deliveryDiagnosticsJson: memberWorkSyncOutbox.deliveryDiagnosticsJson, + payloadJson: memberWorkSyncOutbox.payloadJson, +}; + +export const METRIC_EVENT_RECORD_SELECTION = { + teamName: memberWorkSyncMetricEvents.teamName, + id: memberWorkSyncMetricEvents.id, + memberKey: memberWorkSyncMetricEvents.memberKey, + memberName: memberWorkSyncMetricEvents.memberName, + kind: memberWorkSyncMetricEvents.kind, + recordedAt: memberWorkSyncMetricEvents.recordedAt, + eventJson: memberWorkSyncMetricEvents.eventJson, +}; + +export function isOutboxTerminal(status: string): boolean { + return OUTBOX_TERMINAL_STATUSES.includes(status); +} + +// Mirrors canReviveOutboxItem: superseded | claimed | failed_retryable. +export function canRevive(status: string): boolean { + return status === 'superseded' || (!isOutboxTerminal(status) && status !== 'pending'); +} + +function parseIsoMs(value: string | null | undefined): number | null { + if (!value) { + return null; + } + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +function isStaleClaim(claimedAt: string | null, updatedAt: string, nowIso: string): boolean { + const claimedAtMs = parseIsoMs(claimedAt ?? updatedAt); + const nowMs = parseIsoMs(nowIso); + return ( + claimedAtMs != null && + nowMs != null && + (claimedAtMs > nowMs || nowMs - claimedAtMs >= CLAIM_STALE_MS) + ); +} + +function isNextAttemptDue(nextAttemptAt: string | null, nowIso: string): boolean { + if (!nextAttemptAt) { + return true; + } + const nextAttemptAtMs = parseIsoMs(nextAttemptAt); + if (nextAttemptAtMs == null) { + return true; + } + const nowMs = parseIsoMs(nowIso); + return nowMs != null && nextAttemptAtMs <= nowMs; +} + +export function canClaim(item: MemberWorkSyncOutboxItemRecord, nowIso: string): boolean { + if (item.status === 'claimed') { + return isStaleClaim(item.claimedAt, item.updatedAt, nowIso); + } + if (item.status !== 'pending' && item.status !== 'failed_retryable') { + return false; + } + return isNextAttemptDue(item.nextAttemptAt, nowIso); +} + +// Load-bearing guard: drizzle's .values([]) throws, while an empty import is valid. +export function chunked(values: T[]): T[][] { + const chunks: T[][] = []; + for (let start = 0; start < values.length; start += INSERT_CHUNK_SIZE) { + chunks.push(values.slice(start, start + INSERT_CHUNK_SIZE)); + } + return chunks; +} + +export function toPersistenceRow(row: T): T & { teamKey: string } { + return { ...row, teamKey: normalizeMemberWorkSyncTeamKey(row.teamName) }; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/processOwnershipStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/processOwnershipStorageOps.ts new file mode 100644 index 0000000000..a040fa5fbe --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/processOwnershipStorageOps.ts @@ -0,0 +1,556 @@ +import { + PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION, + type ProcessOwnershipStorageCompareAndSwapRequest, + type ProcessOwnershipStorageCompareAndSwapResult, + type ProcessOwnershipStorageLoadResult, + type ProcessOwnershipStorageScope, + type StoredProcessOwnershipPhase, + type StoredProcessOwnershipState, +} from '../../application/processOwnershipStorage'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +const TABLE_NAME = 'process_ownership_records'; +const SCOPE_INDEX = 'idx_process_ownership_immutable_scope'; +const PROCESS_REF_INDEX = 'idx_process_ownership_opaque_ref'; +const RESIDUAL_UPDATE_TRIGGER = 'trg_process_ownership_residual_update_immutable'; +const RESIDUAL_DELETE_TRIGGER = 'trg_process_ownership_residual_delete_immutable'; +const CORRUPTION_MARKER_TABLE = 'process_ownership_corruption_markers'; +const MAX_STATE_JSON_BYTES = 64 * 1_024; +const MAX_OWNERSHIP_RECORDS = 10_000; +const SHA_256_PATTERN = /^sha256:[a-f0-9]{64}$/; +const OPAQUE_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const PROCESS_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,255}$/; +const PHASES: readonly StoredProcessOwnershipPhase[] = [ + 'spawn_intent', + 'owned', + 'stopping', + 'drained', + 'unclassified_residual', +]; +const EXPECTED_INDEX_COLUMNS = new Map([ + [SCOPE_INDEX, ['team_id', 'run_id', 'plan_generation', 'plan_hash', 'execution_unit_id']], + [PROCESS_REF_INDEX, ['process_ref']], +]); + +export const PROCESS_OWNERSHIP_STORAGE_MIGRATION_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id TEXT NOT NULL, + run_id TEXT NOT NULL, + plan_generation INTEGER NOT NULL CHECK (plan_generation > 0), + plan_hash TEXT NOT NULL, + execution_unit_id TEXT NOT NULL, + process_ref TEXT NOT NULL, + codec_version INTEGER NOT NULL CHECK (codec_version = 1), + state_version INTEGER NOT NULL CHECK (state_version = 1), + revision INTEGER NOT NULL CHECK (revision > 0), + phase TEXT NOT NULL CHECK ( + phase IN ('spawn_intent', 'owned', 'stopping', 'drained', 'unclassified_residual') + ), + state_json TEXT NOT NULL CHECK (json_valid(state_json)) + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS ${SCOPE_INDEX} + ON ${TABLE_NAME} ( + team_id, run_id, plan_generation, plan_hash, execution_unit_id + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS ${PROCESS_REF_INDEX} + ON ${TABLE_NAME} (process_ref)`, + `CREATE TRIGGER IF NOT EXISTS ${RESIDUAL_UPDATE_TRIGGER} + BEFORE UPDATE ON ${TABLE_NAME} + WHEN OLD.phase = 'unclassified_residual' + BEGIN + SELECT RAISE(ABORT, 'process-ownership-residual-immutable'); + END`, + `CREATE TRIGGER IF NOT EXISTS ${RESIDUAL_DELETE_TRIGGER} + BEFORE DELETE ON ${TABLE_NAME} + WHEN OLD.phase = 'unclassified_residual' + BEGIN + SELECT RAISE(ABORT, 'process-ownership-residual-immutable'); + END`, + `CREATE TABLE IF NOT EXISTS ${CORRUPTION_MARKER_TABLE} ( + marker_id INTEGER PRIMARY KEY CHECK (marker_id = 1), + reason TEXT NOT NULL CHECK (reason = 'database_corruption_recovery'), + detected_at TEXT NOT NULL + )`, +] as const; + +interface StoredRow { + readonly team_id: unknown; + readonly run_id: unknown; + readonly plan_generation: unknown; + readonly plan_hash: unknown; + readonly execution_unit_id: unknown; + readonly process_ref: unknown; + readonly codec_version: unknown; + readonly state_version: unknown; + readonly revision: unknown; + readonly phase: unknown; + readonly state_json: unknown; +} + +const SELECT_COLUMNS = `team_id, run_id, plan_generation, plan_hash, execution_unit_id, + process_ref, codec_version, state_version, revision, phase, state_json`; + +export class ProcessOwnershipStorageOps { + constructor( + private readonly getDatabase: () => SqliteDatabase, + private readonly now: () => number = Date.now + ) {} + + loadByScope(scopeValue: unknown): ProcessOwnershipStorageLoadResult { + const scope = parseScope(scopeValue); + const db = this.database(); + const rows = db + .prepare( + `SELECT ${SELECT_COLUMNS} + FROM ${TABLE_NAME} + WHERE team_id = ? AND run_id = ? AND plan_generation = ? AND plan_hash = ? + AND execution_unit_id = ? + LIMIT 2` + ) + .all(...scopeParameters(scope)) as StoredRow[]; + return singleLoadResult(rows); + } + + loadByProcessRef(value: unknown): ProcessOwnershipStorageLoadResult { + const processRef = processRefValue(value); + const db = this.database(); + const rows = db + .prepare( + `SELECT ${SELECT_COLUMNS} + FROM ${TABLE_NAME} + WHERE process_ref = ? + LIMIT 2` + ) + .all(processRef) as StoredRow[]; + return singleLoadResult(rows); + } + + list(): readonly StoredProcessOwnershipState[] { + const db = this.database(); + const rows = db + .prepare( + `SELECT ${SELECT_COLUMNS} + FROM ${TABLE_NAME} + ORDER BY row_id ASC + LIMIT ?` + ) + .all(MAX_OWNERSHIP_RECORDS + 1) as StoredRow[]; + if (rows.length > MAX_OWNERSHIP_RECORDS) { + throw new Error('process-ownership-storage-record-limit-exceeded'); + } + const records = rows.map(parseStoredRow); + assertNoAmbiguousRecords(records); + return Object.freeze(records); + } + + compareAndSwap( + requestValue: unknown, + deadlineAtMsValue: unknown + ): ProcessOwnershipStorageCompareAndSwapResult { + const request = parseCompareAndSwapRequest(requestValue); + const deadlineAtMs = positiveInteger(deadlineAtMsValue, 'deadline'); + const db = this.database(); + return db.transaction(() => { + if (this.now() >= deadlineAtMs) { + throw new Error('process-ownership-storage-deadline-expired'); + } + assertProcessOwnershipStorageSchema(db); + const existing = this.readCandidates(db, request.scope, request.next.processRef); + if (request.expectedRevision === null) { + if ( + request.next.revision !== 1 || + request.next.phase !== 'spawn_intent' || + request.expectedCurrent !== null || + existing.length !== 0 + ) { + return { status: 'conflict' } as const; + } + if (this.now() >= deadlineAtMs) { + throw new Error('process-ownership-storage-deadline-expired'); + } + const result = db + .prepare( + `INSERT INTO ${TABLE_NAME} ( + team_id, run_id, plan_generation, plan_hash, execution_unit_id, process_ref, + codec_version, state_version, revision, phase, state_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING` + ) + .run(...recordParameters(request.next)); + return result.changes === 1 + ? ({ status: 'applied', record: request.next } as const) + : ({ status: 'conflict' } as const); + } + + if ( + request.next.revision !== request.expectedRevision + 1 || + existing.length !== 1 || + existing[0].revision !== request.expectedRevision || + request.expectedCurrent === null || + !storedRecordsEqual(existing[0], request.expectedCurrent) || + existing[0].processRef !== request.next.processRef || + !scopesEqual(existing[0].scope, request.scope) || + existing[0].phase === 'unclassified_residual' + ) { + return { status: 'conflict' } as const; + } + if (this.now() >= deadlineAtMs) { + throw new Error('process-ownership-storage-deadline-expired'); + } + const result = db + .prepare( + `UPDATE ${TABLE_NAME} + SET codec_version = ?, state_version = ?, revision = ?, phase = ?, state_json = ? + WHERE team_id = ? AND run_id = ? AND plan_generation = ? AND plan_hash = ? + AND execution_unit_id = ? AND process_ref = ? AND revision = ?` + ) + .run( + request.next.codecVersion, + request.next.stateVersion, + request.next.revision, + request.next.phase, + request.next.stateJson, + ...scopeParameters(request.scope), + request.next.processRef, + request.expectedRevision + ); + return result.changes === 1 + ? ({ status: 'applied', record: request.next } as const) + : ({ status: 'conflict' } as const); + })(); + } + + private database(): SqliteDatabase { + const db = this.getDatabase(); + assertProcessOwnershipStorageSchema(db); + return db; + } + + private readCandidates( + db: SqliteDatabase, + scope: ProcessOwnershipStorageScope, + processRef: string + ): StoredProcessOwnershipState[] { + const rows = db + .prepare( + `SELECT ${SELECT_COLUMNS} + FROM ${TABLE_NAME} + WHERE process_ref = ? + OR (team_id = ? AND run_id = ? AND plan_generation = ? AND plan_hash = ? + AND execution_unit_id = ?) + LIMIT 3` + ) + .all(processRef, ...scopeParameters(scope)) as StoredRow[]; + const records = rows.map(parseStoredRow); + assertNoAmbiguousRecords(records); + return records; + } +} + +function parseCompareAndSwapRequest(value: unknown): ProcessOwnershipStorageCompareAndSwapRequest { + const record = exactRecord( + value, + ['scope', 'expectedRevision', 'expectedCurrent', 'next'], + 'cas' + ); + const scope = parseScope(record.scope); + const expectedRevision = + record.expectedRevision === null ? null : positiveInteger(record.expectedRevision, 'revision'); + const expectedCurrent = + record.expectedCurrent === null ? null : parseStoredRecord(record.expectedCurrent); + const next = parseStoredRecord(record.next); + if ( + !scopesEqual(scope, next.scope) || + (expectedRevision === null) !== (expectedCurrent === null) || + (expectedCurrent !== null && + (expectedCurrent.revision !== expectedRevision || + expectedCurrent.processRef !== next.processRef || + !scopesEqual(expectedCurrent.scope, scope))) || + next.revision !== (expectedRevision ?? 0) + 1 + ) { + throw new Error('process-ownership-storage-cas-binding-invalid'); + } + return { scope, expectedRevision, expectedCurrent, next }; +} + +function parseStoredRecord(value: unknown): StoredProcessOwnershipState { + const record = exactRecord( + value, + ['scope', 'processRef', 'codecVersion', 'stateVersion', 'revision', 'phase', 'stateJson'], + 'record' + ); + const phase = record.phase; + if (!PHASES.includes(phase as StoredProcessOwnershipPhase)) { + throw new Error('process-ownership-storage-phase-invalid'); + } + if ( + record.codecVersion !== PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION || + record.stateVersion !== 1 + ) { + throw new Error('process-ownership-storage-version-invalid'); + } + const revision = positiveInteger(record.revision, 'revision'); + const encodedState = stateJson(record.stateJson); + const state = ( + JSON.parse(encodedState) as { + readonly state: Record; + } + ).state; + if (state.revision !== revision || state.phase !== phase) { + throw new Error('process-ownership-storage-state-metadata-invalid'); + } + return { + scope: parseScope(record.scope), + processRef: processRefValue(record.processRef), + codecVersion: PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION, + stateVersion: 1, + revision, + phase: phase as StoredProcessOwnershipPhase, + stateJson: encodedState, + }; +} + +function parseStoredRow(row: StoredRow): StoredProcessOwnershipState { + return parseStoredRecord({ + scope: { + teamId: row.team_id, + runId: row.run_id, + planGeneration: row.plan_generation, + planHash: row.plan_hash, + executionUnitId: row.execution_unit_id, + }, + processRef: row.process_ref, + codecVersion: row.codec_version, + stateVersion: row.state_version, + revision: row.revision, + phase: row.phase, + stateJson: row.state_json, + }); +} + +function parseScope(value: unknown): ProcessOwnershipStorageScope { + const record = exactRecord( + value, + ['teamId', 'runId', 'planGeneration', 'planHash', 'executionUnitId'], + 'scope' + ); + if (typeof record.planHash !== 'string' || !SHA_256_PATTERN.test(record.planHash)) { + throw new Error('process-ownership-storage-plan-hash-invalid'); + } + return { + teamId: opaqueValue(record.teamId, 'team-id'), + runId: opaqueValue(record.runId, 'run-id'), + planGeneration: positiveInteger(record.planGeneration, 'plan-generation'), + planHash: record.planHash, + executionUnitId: opaqueValue(record.executionUnitId, 'execution-unit-id'), + }; +} + +function singleLoadResult(rows: StoredRow[]): ProcessOwnershipStorageLoadResult { + if (rows.length === 0) return { status: 'missing' }; + if (rows.length !== 1) throw new Error('process-ownership-storage-ambiguous'); + return { status: 'found', record: parseStoredRow(rows[0]) }; +} + +function assertNoAmbiguousRecords(records: readonly StoredProcessOwnershipState[]): void { + const refs = new Set(); + const scopes = new Set(); + for (const record of records) { + const scopeKey = JSON.stringify(scopeParameters(record.scope)); + if (refs.has(record.processRef) || scopes.has(scopeKey)) { + throw new Error('process-ownership-storage-ambiguous'); + } + refs.add(record.processRef); + scopes.add(scopeKey); + } +} + +function assertProcessOwnershipStorageSchema(db: SqliteDatabase): void { + const table = db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`) + .get(TABLE_NAME) as { readonly name?: unknown } | undefined; + if (table?.name !== TABLE_NAME) throw new Error('process-ownership-storage-schema-missing'); + const indexes = db.pragma(`index_list('${TABLE_NAME}')`) as { + readonly name?: unknown; + readonly unique?: unknown; + }[]; + for (const expected of [SCOPE_INDEX, PROCESS_REF_INDEX]) { + if (!indexes.some((index) => index.name === expected && index.unique === 1)) { + throw new Error('process-ownership-storage-schema-invalid'); + } + const columns = db.pragma(`index_info('${expected}')`) as { readonly name?: unknown }[]; + if ( + columns.length !== EXPECTED_INDEX_COLUMNS.get(expected)?.length || + columns.some((column, index) => column.name !== EXPECTED_INDEX_COLUMNS.get(expected)?.[index]) + ) { + throw new Error('process-ownership-storage-schema-invalid'); + } + } + const triggers = db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?`) + .all(TABLE_NAME) as { readonly name?: unknown }[]; + for (const expected of [RESIDUAL_UPDATE_TRIGGER, RESIDUAL_DELETE_TRIGGER]) { + if (!triggers.some((trigger) => trigger.name === expected)) { + throw new Error('process-ownership-storage-schema-invalid'); + } + } + const markerTable = db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`) + .get(CORRUPTION_MARKER_TABLE) as { readonly name?: unknown } | undefined; + if (markerTable?.name !== CORRUPTION_MARKER_TABLE) { + throw new Error('process-ownership-storage-schema-invalid'); + } + const marker = db.prepare(`SELECT marker_id FROM ${CORRUPTION_MARKER_TABLE} LIMIT 1`).get() as + | { readonly marker_id?: unknown } + | undefined; + if (marker) throw new Error('process-ownership-storage-corruption-recovered'); +} + +export function recordProcessOwnershipCorruptionMarker( + db: SqliteDatabase, + detectedAt: string +): void { + db.prepare( + `INSERT INTO ${CORRUPTION_MARKER_TABLE} (marker_id, reason, detected_at) + VALUES (1, 'database_corruption_recovery', ?) + ON CONFLICT(marker_id) DO NOTHING` + ).run(detectedAt); +} + +function recordParameters(record: StoredProcessOwnershipState): readonly unknown[] { + return [ + ...scopeParameters(record.scope), + record.processRef, + record.codecVersion, + record.stateVersion, + record.revision, + record.phase, + record.stateJson, + ]; +} + +function scopeParameters(scope: ProcessOwnershipStorageScope): readonly unknown[] { + return [scope.teamId, scope.runId, scope.planGeneration, scope.planHash, scope.executionUnitId]; +} + +function scopesEqual( + left: ProcessOwnershipStorageScope, + right: ProcessOwnershipStorageScope +): boolean { + return scopeParameters(left).every((value, index) => value === scopeParameters(right)[index]); +} + +function stateJson(value: unknown): string { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_STATE_JSON_BYTES) { + throw new Error('process-ownership-storage-state-json-invalid'); + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('process-ownership-storage-state-json-invalid'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('process-ownership-storage-state-json-invalid'); + } + const envelope = exactRecord(parsed, ['codecVersion', 'state'], 'state-envelope'); + const state = exactRecord(envelope.state, Object.keys(envelope.state as object), 'state'); + if ( + envelope.codecVersion !== PROCESS_OWNERSHIP_STORAGE_CODEC_VERSION || + canonicalJson(parsed) !== value || + state.stateVersion !== 1 + ) { + throw new Error('process-ownership-storage-state-json-invalid'); + } + return value; +} + +function storedRecordsEqual( + left: StoredProcessOwnershipState, + right: StoredProcessOwnershipState +): boolean { + return ( + scopesEqual(left.scope, right.scope) && + left.processRef === right.processRef && + left.codecVersion === right.codecVersion && + left.stateVersion === right.stateVersion && + left.revision === right.revision && + left.phase === right.phase && + left.stateJson === right.stateJson + ); +} + +function opaqueValue(value: unknown, reason: string): string { + if (typeof value !== 'string' || !OPAQUE_VALUE_PATTERN.test(value)) { + throw new Error(`process-ownership-storage-${reason}-invalid`); + } + return value; +} + +function processRefValue(value: unknown): string { + if (typeof value !== 'string' || !PROCESS_REF_PATTERN.test(value)) { + throw new Error('process-ownership-storage-process-ref-invalid'); + } + return value; +} + +function positiveInteger(value: unknown, reason: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`process-ownership-storage-${reason}-invalid`); + } + return value; +} + +function exactRecord( + value: unknown, + keys: readonly string[], + reason: string +): Record { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + throw new Error(`process-ownership-storage-${reason}-invalid`); + } + const record = value as Record; + const actual = Reflect.ownKeys(record); + const sorted = [...keys].sort((left, right) => left.localeCompare(right)); + if ( + actual.some((key) => typeof key !== 'string') || + actual.length !== sorted.length || + [...(actual as string[])] + .sort((left, right) => left.localeCompare(right)) + .some((key, index) => key !== sorted[index]) + ) { + throw new Error(`process-ownership-storage-${reason}-invalid`); + } + const descriptors = Object.values(Object.getOwnPropertyDescriptors(record)); + if (descriptors.some((descriptor) => !descriptor.enumerable || !('value' in descriptor))) { + throw new Error(`process-ownership-storage-${reason}-invalid`); + } + return record; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) throw new Error('process-ownership-storage-number-invalid'); + return String(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + const record = exactRecord(value, Object.keys(value as object), 'canonical-value'); + // Code-unit ordering: state_json bytes are persisted and byte-validated on + // read, so key order must not depend on the process locale or ICU version. + return `{${Object.keys(record) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; +} diff --git a/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageErrors.ts b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageErrors.ts new file mode 100644 index 0000000000..259896bc12 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageErrors.ts @@ -0,0 +1,13 @@ +import type { TeamIdentityStorageErrorCode as TeamIdentityStorageErrorCodeValue } from '../../../contracts/teamIdentityStorageContracts'; + +export class TeamIdentityStorageInvariantError extends Error { + readonly name = 'TeamIdentityStorageInvariantError'; + + constructor(readonly code: TeamIdentityStorageErrorCodeValue) { + super(`team-identity-storage:${code}`); + } +} + +export function fail(code: TeamIdentityStorageErrorCodeValue): never { + throw new TeamIdentityStorageInvariantError(code); +} diff --git a/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts new file mode 100644 index 0000000000..91593a825c --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageOps.ts @@ -0,0 +1,617 @@ +import { parseTeamId } from '@shared/contracts/hosted/identifiers'; + +import { + MAX_TEAM_IDENTITY_READ_RECORDS, + parseLegacyTeamKey, + parseTeamAdoptionIntentId, + TeamIdentityStorageErrorCode, +} from '../../../contracts/teamIdentityStorageContracts'; + +import { fail } from './teamIdentityStorageErrors'; +import { type TeamIdentityRow, TeamIdentityStorageSupport } from './teamIdentityStorageSupport'; + +export { TeamIdentityStorageInvariantError } from './teamIdentityStorageErrors'; + +import { + TEAM_IDENTITY_STORAGE_COMPONENT, + TEAM_IDENTITY_STORAGE_COMPONENT_SCHEMA_VERSION, + TEAM_IDENTITY_STORAGE_SCHEMA_DEFINITIONS, +} from './teamIdentityStorageSchema'; + +import type { + CommitTeamAdoptionInput, + LegacyTeamKey, + LegacyTeamKeyReservation, + LegacyTeamKeyTombstoneResult, + PrepareTeamAdoptionInput, + RecordTeamIdentityFilePublishedInput, + ReserveTeamIdentityInput, + TeamAdoptionCommitResult, + TeamAdoptionIntent, + TeamAdoptionPrepareResult, + TeamIdentityFilePublishedResult, + TeamIdentityRecord, + TeamIdentityReservationResult, + TombstoneLegacyTeamKeyInput, +} from '../../../contracts/teamIdentityStorageContracts'; +import type { TeamId } from '@shared/contracts/hosted/identifiers'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; +const MAX_EXTERNAL_WRITER_RETIREMENT_CANDIDATES = 1_024; + +export class TeamIdentityStorageOps { + private readonly support = new TeamIdentityStorageSupport(); + + constructor(private readonly getDatabase: () => SqliteDatabase) {} + + getIdentity(teamId: TeamId): TeamIdentityRecord | null { + const parsedTeamId = this.support.validated(() => parseTeamId(teamId)); + const db = this.database(); + const identity = this.support.readIdentityByTeamId(db, parsedTeamId); + if (identity) this.support.assertReadableIdentityGraph(db, identity); + return identity; + } + + listIdentities(): readonly TeamIdentityRecord[] { + return this.listIdentitiesWhere(''); + } + + listActiveIdentities(): readonly TeamIdentityRecord[] { + return this.listIdentitiesWhere("WHERE state = 'active'"); + } + + captureExternalWriterInventory(retirementCandidates: readonly TeamId[]): { + readonly active: readonly TeamIdentityRecord[]; + readonly retiredCandidates: readonly { + teamId: TeamId; + identityChecksum: NonNullable; + tombstonedAt: string; + }[]; + } { + if (retirementCandidates.length > MAX_EXTERNAL_WRITER_RETIREMENT_CANDIDATES) { + throw new TypeError('external-writer-inventory-candidates-invalid'); + } + const parsed = retirementCandidates.map((teamId) => + this.support.validated(() => parseTeamId(teamId)) + ); + if (new Set(parsed).size !== parsed.length) { + throw new TypeError('external-writer-inventory-candidates-invalid'); + } + return this.database().transaction(() => { + const active = this.listActiveIdentities(); + const retiredCandidates = parsed.flatMap((teamId) => { + const identity = this.support.readIdentityByTeamId(this.database(), teamId); + if (!identity || identity.state !== 'tombstoned') return []; + this.support.assertReadableIdentityGraph(this.database(), identity); + if (identity.identityChecksum === null || identity.tombstonedAt === null) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return [ + { + teamId: identity.teamId, + identityChecksum: identity.identityChecksum, + tombstonedAt: identity.tombstonedAt, + }, + ]; + }); + return Object.freeze({ + active, + retiredCandidates: Object.freeze(retiredCandidates), + }); + })(); + } + + private listIdentitiesWhere(where: string): readonly TeamIdentityRecord[] { + const db = this.database(); + let rows: TeamIdentityRow[]; + try { + rows = db + .prepare( + `SELECT team_id, state, legacy_key, directory_fingerprint, + workspace_id, workspace_binding_generation, adoption_intent_id, + identity_checksum, created_at, activated_at, tombstoned_at + FROM team_identity_records + ${where} + ORDER BY team_id ASC + LIMIT ?` + ) + .all(MAX_TEAM_IDENTITY_READ_RECORDS + 1) as TeamIdentityRow[]; + } catch { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + if (rows.length > MAX_TEAM_IDENTITY_READ_RECORDS) { + fail(TeamIdentityStorageErrorCode.ReadLimitExceeded); + } + const identities = rows.map((row) => this.support.mapIdentity(row)); + for (const identity of identities) this.support.assertReadableIdentityGraph(db, identity); + return Object.freeze(identities); + } + + getLegacyKeyReservation(legacyKey: LegacyTeamKey): LegacyTeamKeyReservation | null { + const parsedLegacyKey = this.support.validated(() => parseLegacyTeamKey(legacyKey)); + return this.support.readReservationByKey(this.database(), parsedLegacyKey); + } + + getAdoptionIntent(intentId: TeamAdoptionIntent['intentId']): TeamAdoptionIntent | null { + const parsedIntentId = this.support.validated(() => parseTeamAdoptionIntentId(intentId)); + return this.support.readIntentById(this.database(), parsedIntentId); + } + + reserveIdentity(input: ReserveTeamIdentityInput): TeamIdentityReservationResult { + const normalized = this.support.normalizeReservationInput(input); + const db = this.database(); + + return this.support.withConstraintClassification( + db, + TeamIdentityStorageErrorCode.DuplicateIdentity, + () => + db.transaction((): TeamIdentityReservationResult => { + const existingIdentity = this.support.readIdentityByTeamId(db, normalized.teamId); + if (existingIdentity) { + if (existingIdentity.state === 'tombstoned') { + fail(TeamIdentityStorageErrorCode.LegacyKeyTombstoned); + } + if (!this.support.isSameReservedIdentity(existingIdentity, normalized)) { + fail(TeamIdentityStorageErrorCode.DuplicateIdentity); + } + const reservation = this.support.requireConsistentReservation(db, existingIdentity); + if (reservation.state === 'tombstoned') { + fail(TeamIdentityStorageErrorCode.LegacyKeyTombstoned); + } + return { outcome: 'already_reserved', identity: existingIdentity, reservation }; + } + + this.support.assertIdentitySlotsAvailable( + db, + normalized.teamId, + normalized.legacyKey, + normalized.directoryFingerprint + ); + this.support.insertIdentity(db, { + ...normalized, + state: 'reserved', + adoptionIntentId: null, + }); + this.support.insertReservation( + db, + normalized.legacyKey, + normalized.teamId, + normalized.createdAt + ); + + const identity = this.support.requireIdentity(db, normalized.teamId); + const reservation = this.support.requireConsistentReservation(db, identity); + return { outcome: 'created', identity, reservation }; + })() + ); + } + + prepareAdoption(input: PrepareTeamAdoptionInput): TeamAdoptionPrepareResult { + const normalized = this.support.normalizePrepareInput(input); + const intentChecksum = this.support.computeIntentChecksum(normalized); + const db = this.database(); + + return this.support.withConstraintClassification( + db, + TeamIdentityStorageErrorCode.DuplicateIdentity, + () => + db.transaction((): TeamAdoptionPrepareResult => { + const existingIntent = this.support.readIntentById(db, normalized.intentId); + if (existingIntent) { + if (!this.support.isSameIntentRequest(existingIntent, normalized, intentChecksum)) { + fail(TeamIdentityStorageErrorCode.AdoptionIntentMismatch); + } + const identity = this.support.requireIdentity(db, normalized.teamId); + const reservation = this.support.requireConsistentReservation(db, identity); + this.support.assertIntentGraphConsistent(existingIntent, identity, reservation); + return { + outcome: + existingIntent.state === 'committed' + ? 'already_committed' + : existingIntent.state === 'file_published' + ? 'already_file_published' + : 'already_prepared', + identity, + reservation, + intent: existingIntent, + }; + } + + const intentForTeam = this.support.readIntentByTeamId(db, normalized.teamId); + if (intentForTeam) { + fail(TeamIdentityStorageErrorCode.AdoptionIntentMismatch); + } + this.support.assertIdentitySlotsAvailable( + db, + normalized.teamId, + normalized.legacyKey, + normalized.directoryFingerprint + ); + + this.support.insertIdentity(db, { + teamId: normalized.teamId, + legacyKey: normalized.legacyKey, + directoryFingerprint: normalized.directoryFingerprint, + workspaceBinding: normalized.workspaceBinding, + createdAt: normalized.preparedAt, + state: 'adoption_prepared', + adoptionIntentId: normalized.intentId, + }); + this.support.insertReservation( + db, + normalized.legacyKey, + normalized.teamId, + normalized.preparedAt + ); + db.prepare( + `INSERT INTO team_adoption_intents ( + intent_id, team_id, state, legacy_key, directory_fingerprint, + workspace_id, workspace_binding_generation, expected_identity_checksum, + intent_checksum, prepared_at, file_published_at, published_identity_checksum, + committed_at, committed_identity_checksum + ) VALUES (?, ?, 'prepared', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL)` + ).run( + normalized.intentId, + normalized.teamId, + normalized.legacyKey, + normalized.directoryFingerprint, + normalized.workspaceBinding?.workspaceId ?? null, + normalized.workspaceBinding?.generation ?? null, + normalized.expectedIdentityChecksum, + intentChecksum, + normalized.preparedAt + ); + + const identity = this.support.requireIdentity(db, normalized.teamId); + const reservation = this.support.requireConsistentReservation(db, identity); + const intent = this.support.requireIntent(db, normalized.intentId); + this.support.assertIntentGraphConsistent(intent, identity, reservation); + return { outcome: 'prepared', identity, reservation, intent }; + })() + ); + } + + recordIdentityFilePublished( + input: RecordTeamIdentityFilePublishedInput + ): TeamIdentityFilePublishedResult { + const normalized = this.support.normalizeFilePublishedInput(input); + const db = this.database(); + + return this.support.withConstraintClassification( + db, + TeamIdentityStorageErrorCode.DuplicateIdentity, + () => + db.transaction((): TeamIdentityFilePublishedResult => { + const intent = this.support.readIntentById(db, normalized.intentId); + if (!intent) { + fail(TeamIdentityStorageErrorCode.AdoptionIntentNotFound); + } + if ( + intent.teamId !== normalized.teamId || + intent.intentChecksum !== normalized.intentChecksum + ) { + fail(TeamIdentityStorageErrorCode.AdoptionIntentMismatch); + } + if (intent.expectedIdentityChecksum !== normalized.identityChecksum) { + fail(TeamIdentityStorageErrorCode.ChecksumDisagreement); + } + if (Date.parse(normalized.filePublishedAt) < Date.parse(intent.preparedAt)) { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + + const currentIdentity = this.support.requireIdentity(db, normalized.teamId); + const currentReservation = this.support.requireConsistentReservation(db, currentIdentity); + this.support.assertIntentGraphConsistent(intent, currentIdentity, currentReservation); + + if (intent.state === 'committed') { + if ( + intent.publishedIdentityChecksum !== normalized.identityChecksum || + intent.committedIdentityChecksum !== normalized.identityChecksum || + currentIdentity.identityChecksum !== normalized.identityChecksum + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return { + outcome: 'already_committed', + identity: currentIdentity, + reservation: currentReservation, + intent, + }; + } + if (intent.state === 'file_published') { + if ( + intent.publishedIdentityChecksum !== normalized.identityChecksum || + currentIdentity.identityChecksum !== normalized.identityChecksum + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return { + outcome: 'already_file_published', + identity: currentIdentity, + reservation: currentReservation, + intent, + }; + } + if (currentIdentity.state !== 'adoption_prepared') { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + if (currentReservation.state !== 'active') { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + + const checksumOwner = this.support.readIdentityByChecksum( + db, + normalized.identityChecksum + ); + if (checksumOwner && checksumOwner.teamId !== normalized.teamId) { + fail(TeamIdentityStorageErrorCode.DuplicateIdentity); + } + + const intentUpdate = db + .prepare( + `UPDATE team_adoption_intents + SET state = 'file_published', file_published_at = ?, + published_identity_checksum = ? + WHERE intent_id = ? AND state = 'prepared'` + ) + .run(normalized.filePublishedAt, normalized.identityChecksum, normalized.intentId); + const identityUpdate = db + .prepare( + `UPDATE team_identity_records + SET state = 'file_published', identity_checksum = ? + WHERE team_id = ? AND state = 'adoption_prepared'` + ) + .run(normalized.identityChecksum, normalized.teamId); + if (intentUpdate.changes !== 1 || identityUpdate.changes !== 1) { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + + const publishedIdentity = this.support.requireIdentity(db, normalized.teamId); + const publishedReservation = this.support.requireConsistentReservation( + db, + publishedIdentity + ); + const publishedIntent = this.support.requireIntent(db, normalized.intentId); + this.support.assertIntentGraphConsistent( + publishedIntent, + publishedIdentity, + publishedReservation + ); + return { + outcome: 'file_published', + identity: publishedIdentity, + reservation: publishedReservation, + intent: publishedIntent, + }; + })() + ); + } + + commitAdoption(input: CommitTeamAdoptionInput): TeamAdoptionCommitResult { + const normalized = this.support.normalizeCommitInput(input); + const db = this.database(); + + return this.support.withConstraintClassification( + db, + TeamIdentityStorageErrorCode.DuplicateIdentity, + () => + db.transaction((): TeamAdoptionCommitResult => { + const intent = this.support.readIntentById(db, normalized.intentId); + if (!intent) { + fail(TeamIdentityStorageErrorCode.AdoptionIntentNotFound); + } + if ( + intent.teamId !== normalized.teamId || + intent.intentChecksum !== normalized.intentChecksum + ) { + fail(TeamIdentityStorageErrorCode.AdoptionIntentMismatch); + } + if (intent.expectedIdentityChecksum !== normalized.identityChecksum) { + fail(TeamIdentityStorageErrorCode.ChecksumDisagreement); + } + if ( + intent.filePublishedAt !== null && + Date.parse(normalized.committedAt) < Date.parse(intent.filePublishedAt) + ) { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + + const currentIdentity = this.support.requireIdentity(db, normalized.teamId); + const currentReservation = this.support.requireConsistentReservation(db, currentIdentity); + this.support.assertIntentGraphConsistent(intent, currentIdentity, currentReservation); + + if (intent.state === 'committed') { + if ( + intent.publishedIdentityChecksum !== normalized.identityChecksum || + intent.committedIdentityChecksum !== normalized.identityChecksum || + currentIdentity.identityChecksum !== normalized.identityChecksum + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return { + outcome: 'already_committed', + identity: currentIdentity, + reservation: currentReservation, + intent, + }; + } + if (intent.state !== 'file_published' || intent.filePublishedAt === null) { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + if (currentIdentity.state !== 'file_published') { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + if (currentReservation.state !== 'active') { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + + const checksumOwner = this.support.readIdentityByChecksum( + db, + normalized.identityChecksum + ); + if (checksumOwner && checksumOwner.teamId !== normalized.teamId) { + fail(TeamIdentityStorageErrorCode.DuplicateIdentity); + } + + const intentUpdate = db + .prepare( + `UPDATE team_adoption_intents + SET state = 'committed', committed_at = ?, committed_identity_checksum = ? + WHERE intent_id = ? AND state = 'file_published'` + ) + .run(normalized.committedAt, normalized.identityChecksum, normalized.intentId); + const identityUpdate = db + .prepare( + `UPDATE team_identity_records + SET state = 'active', activated_at = ? + WHERE team_id = ? AND state = 'file_published' AND identity_checksum = ?` + ) + .run(normalized.committedAt, normalized.teamId, normalized.identityChecksum); + if (intentUpdate.changes !== 1 || identityUpdate.changes !== 1) { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + + const committedIdentity = this.support.requireIdentity(db, normalized.teamId); + const committedReservation = this.support.requireConsistentReservation( + db, + committedIdentity + ); + const committedIntent = this.support.requireIntent(db, normalized.intentId); + this.support.assertIntentGraphConsistent( + committedIntent, + committedIdentity, + committedReservation + ); + return { + outcome: 'committed', + identity: committedIdentity, + reservation: committedReservation, + intent: committedIntent, + }; + })() + ); + } + + tombstoneLegacyKey(input: TombstoneLegacyTeamKeyInput): LegacyTeamKeyTombstoneResult { + const normalized = this.support.normalizeTombstoneInput(input); + const db = this.database(); + + return db.transaction((): LegacyTeamKeyTombstoneResult => { + const reservation = this.support.readReservationByKey(db, normalized.legacyKey); + if (reservation?.teamId !== normalized.teamId) { + fail(TeamIdentityStorageErrorCode.LegacyKeyConflict); + } + const identity = this.support.requireIdentity(db, normalized.teamId); + if (identity.legacyKey !== normalized.legacyKey) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + + if (reservation.state === 'tombstoned') { + if (reservation.tombstoneReason !== normalized.reason || identity.state !== 'tombstoned') { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return { outcome: 'already_tombstoned', identity, reservation }; + } + if (identity.state === 'tombstoned') { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + + const reservationUpdate = db + .prepare( + `UPDATE legacy_team_key_reservations + SET state = 'tombstoned', tombstoned_at = ?, tombstone_reason = ? + WHERE legacy_key = ? AND team_id = ? AND state = 'active'` + ) + .run(normalized.tombstonedAt, normalized.reason, normalized.legacyKey, normalized.teamId); + const identityUpdate = db + .prepare( + `UPDATE team_identity_records + SET state = 'tombstoned', tombstoned_at = ? + WHERE team_id = ? + AND state IN ('reserved', 'adoption_prepared', 'file_published', 'active')` + ) + .run(normalized.tombstonedAt, normalized.teamId); + if (reservationUpdate.changes !== 1 || identityUpdate.changes !== 1) { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + + return { + outcome: 'tombstoned', + identity: this.support.requireIdentity(db, normalized.teamId), + reservation: this.support.requireReservation(db, normalized.legacyKey), + }; + })(); + } + + private database(): SqliteDatabase { + const db = this.getDatabase(); + let rows: { component?: unknown; schema_version?: unknown }[]; + try { + rows = db + .prepare( + `SELECT component, schema_version + FROM team_identity_storage_metadata` + ) + .all() as { + component?: unknown; + schema_version?: unknown; + }[]; + } catch { + fail(TeamIdentityStorageErrorCode.UnknownSchema); + } + if ( + rows.length !== 1 || + rows[0]?.component !== TEAM_IDENTITY_STORAGE_COMPONENT || + rows[0]?.schema_version !== TEAM_IDENTITY_STORAGE_COMPONENT_SCHEMA_VERSION + ) { + fail(TeamIdentityStorageErrorCode.UnknownSchema); + } + const componentTableNames = [ + ...new Set(TEAM_IDENTITY_STORAGE_SCHEMA_DEFINITIONS.map(({ tableName }) => tableName)), + ]; + const placeholders = componentTableNames.map(() => '?').join(', '); + let schemaObjects: { + type?: unknown; + name?: unknown; + tbl_name?: unknown; + sql?: unknown; + }[]; + try { + schemaObjects = db + .prepare( + `SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE tbl_name IN (${placeholders})` + ) + .all(...componentTableNames) as { + type?: unknown; + name?: unknown; + tbl_name?: unknown; + sql?: unknown; + }[]; + } catch { + fail(TeamIdentityStorageErrorCode.UnknownSchema); + } + const observedObjects = new Map( + schemaObjects.map((definition) => [ + `${String(definition.type)}:${String(definition.name)}:${String(definition.tbl_name)}`, + definition, + ]) + ); + if ( + schemaObjects.length !== TEAM_IDENTITY_STORAGE_SCHEMA_DEFINITIONS.length || + !TEAM_IDENTITY_STORAGE_SCHEMA_DEFINITIONS.every((expected) => { + const observed = observedObjects.get( + `${expected.type}:${expected.name}:${expected.tableName}` + ); + return observed?.sql === expected.sql; + }) + ) { + fail(TeamIdentityStorageErrorCode.UnknownSchema); + } + db.pragma('foreign_keys = ON'); + db.pragma('recursive_triggers = ON'); + return db; + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts new file mode 100644 index 0000000000..1c38366155 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSchema.ts @@ -0,0 +1,631 @@ +import { sql } from 'drizzle-orm'; +import { check, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +export const TEAM_IDENTITY_STORAGE_COMPONENT = 'team-identity'; +export const TEAM_IDENTITY_STORAGE_COMPONENT_SCHEMA_VERSION = 1; +export const TEAM_IDENTITY_STORAGE_MIGRATION_VERSION = 5; + +const TEAM_ID_STORED_CHECK = sql`length(team_id) = 37 + AND substr(team_id, 1, 5) = 'team_' + AND substr(team_id, 6) NOT GLOB '*[^0-9a-f]*'`; +const WORKSPACE_BINDING_STORED_CHECK = sql`( + workspace_id IS NULL AND workspace_binding_generation IS NULL +) OR ( + length(workspace_id) = 42 + AND substr(workspace_id, 1, 10) = 'workspace_' + AND substr(workspace_id, 11) NOT GLOB '*[^0-9a-f]*' + AND workspace_binding_generation >= 1 +)`; +const DIRECTORY_FINGERPRINT_STORED_CHECK = sql`length(directory_fingerprint) = 64 + AND directory_fingerprint NOT GLOB '*[^0-9a-f]*'`; + +export const teamIdentityStorageMetadata = sqliteTable( + 'team_identity_storage_metadata', + { + component: text('component').primaryKey(), + schemaVersion: integer('schema_version').notNull(), + }, + (table) => [ + check( + 'ck_team_identity_metadata_component', + sql`${table.component} = ${TEAM_IDENTITY_STORAGE_COMPONENT}` + ), + check( + 'ck_team_identity_metadata_version', + sql`${table.schemaVersion} = ${TEAM_IDENTITY_STORAGE_COMPONENT_SCHEMA_VERSION}` + ), + ] +); + +export const teamIdentityRecords = sqliteTable( + 'team_identity_records', + { + teamId: text('team_id').primaryKey(), + state: text('state', { + enum: ['reserved', 'adoption_prepared', 'file_published', 'active', 'tombstoned'], + }).notNull(), + legacyKey: text('legacy_key').notNull(), + directoryFingerprint: text('directory_fingerprint').notNull(), + workspaceId: text('workspace_id'), + workspaceBindingGeneration: integer('workspace_binding_generation'), + adoptionIntentId: text('adoption_intent_id'), + identityChecksum: text('identity_checksum'), + createdAt: text('created_at').notNull(), + activatedAt: text('activated_at'), + tombstonedAt: text('tombstoned_at'), + }, + (table) => [ + uniqueIndex('idx_team_identity_legacy_key').on(table.legacyKey), + uniqueIndex('idx_team_identity_directory_fingerprint').on(table.directoryFingerprint), + uniqueIndex('idx_team_identity_checksum') + .on(table.identityChecksum) + .where(sql`${table.identityChecksum} IS NOT NULL`), + check('ck_team_identity_team_id', TEAM_ID_STORED_CHECK), + check( + 'ck_team_identity_state', + sql`${table.state} IN ('reserved', 'adoption_prepared', 'file_published', 'active', 'tombstoned')` + ), + check( + 'ck_team_identity_legacy_key', + sql`length(${table.legacyKey}) BETWEEN 1 AND 128 + AND substr(${table.legacyKey}, 1, 1) GLOB '[a-z0-9]' + AND ${table.legacyKey} NOT GLOB '*[^a-z0-9-]*' + AND ${table.legacyKey} NOT IN ( + 'aux', 'con', 'nul', 'prn', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' + )` + ), + check('ck_team_identity_directory_fingerprint', DIRECTORY_FINGERPRINT_STORED_CHECK), + check('ck_team_identity_workspace_binding', WORKSPACE_BINDING_STORED_CHECK), + check( + 'ck_team_identity_adoption_intent_id', + sql`${table.adoptionIntentId} IS NULL OR ( + length(${table.adoptionIntentId}) = 41 + AND substr(${table.adoptionIntentId}, 1, 9) = 'adoption_' + AND substr(${table.adoptionIntentId}, 10) NOT GLOB '*[^0-9a-f]*' + )` + ), + check( + 'ck_team_identity_checksum_value', + sql`${table.identityChecksum} IS NULL OR ( + length(${table.identityChecksum}) = 64 + AND ${table.identityChecksum} NOT GLOB '*[^0-9a-f]*' + )` + ), + check( + 'ck_team_identity_state_fields', + sql`( + ${table.state} = 'reserved' + AND ${table.adoptionIntentId} IS NULL + AND ${table.identityChecksum} IS NULL + AND ${table.activatedAt} IS NULL + AND ${table.tombstonedAt} IS NULL + ) OR ( + ${table.state} = 'adoption_prepared' + AND ${table.adoptionIntentId} IS NOT NULL + AND ${table.identityChecksum} IS NULL + AND ${table.activatedAt} IS NULL + AND ${table.tombstonedAt} IS NULL + ) OR ( + ${table.state} = 'file_published' + AND ${table.adoptionIntentId} IS NOT NULL + AND ${table.identityChecksum} IS NOT NULL + AND ${table.activatedAt} IS NULL + AND ${table.tombstonedAt} IS NULL + ) OR ( + ${table.state} = 'active' + AND ${table.identityChecksum} IS NOT NULL + AND ${table.activatedAt} IS NOT NULL + AND ${table.tombstonedAt} IS NULL + ) OR ( + ${table.state} = 'tombstoned' + AND ${table.tombstonedAt} IS NOT NULL + )` + ), + ] +); + +export const legacyTeamKeyReservations = sqliteTable( + 'legacy_team_key_reservations', + { + legacyKey: text('legacy_key').primaryKey(), + teamId: text('team_id') + .notNull() + .references(() => teamIdentityRecords.teamId, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + state: text('state', { enum: ['active', 'tombstoned'] }).notNull(), + reservedAt: text('reserved_at').notNull(), + tombstonedAt: text('tombstoned_at'), + tombstoneReason: text('tombstone_reason', { + enum: ['draft_deleted', 'team_deleted', 'legacy_conflict'], + }), + }, + (table) => [ + uniqueIndex('idx_legacy_team_key_active_owner') + .on(table.teamId) + .where(sql`${table.state} = 'active'`), + check( + 'ck_legacy_team_key_value', + sql`length(${table.legacyKey}) BETWEEN 1 AND 128 + AND substr(${table.legacyKey}, 1, 1) GLOB '[a-z0-9]' + AND ${table.legacyKey} NOT GLOB '*[^a-z0-9-]*' + AND ${table.legacyKey} NOT IN ( + 'aux', 'con', 'nul', 'prn', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' + )` + ), + check('ck_legacy_team_key_state', sql`${table.state} IN ('active', 'tombstoned')`), + check( + 'ck_legacy_team_key_state_fields', + sql`( + ${table.state} = 'active' + AND ${table.tombstonedAt} IS NULL + AND ${table.tombstoneReason} IS NULL + ) OR ( + ${table.state} = 'tombstoned' + AND ${table.tombstonedAt} IS NOT NULL + AND ${table.tombstoneReason} IN ('draft_deleted', 'team_deleted', 'legacy_conflict') + )` + ), + ] +); + +export const teamAdoptionIntents = sqliteTable( + 'team_adoption_intents', + { + intentId: text('intent_id').primaryKey(), + teamId: text('team_id') + .notNull() + .references(() => teamIdentityRecords.teamId, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + state: text('state', { enum: ['prepared', 'file_published', 'committed'] }).notNull(), + legacyKey: text('legacy_key').notNull(), + directoryFingerprint: text('directory_fingerprint').notNull(), + workspaceId: text('workspace_id'), + workspaceBindingGeneration: integer('workspace_binding_generation'), + expectedIdentityChecksum: text('expected_identity_checksum').notNull(), + intentChecksum: text('intent_checksum').notNull(), + preparedAt: text('prepared_at').notNull(), + filePublishedAt: text('file_published_at'), + publishedIdentityChecksum: text('published_identity_checksum'), + committedAt: text('committed_at'), + committedIdentityChecksum: text('committed_identity_checksum'), + }, + (table) => [ + uniqueIndex('idx_team_adoption_intent_team').on(table.teamId), + uniqueIndex('idx_team_adoption_intent_legacy_key').on(table.legacyKey), + uniqueIndex('idx_team_adoption_intent_directory_fingerprint').on(table.directoryFingerprint), + check( + 'ck_team_adoption_intent_id', + sql`length(${table.intentId}) = 41 + AND substr(${table.intentId}, 1, 9) = 'adoption_' + AND substr(${table.intentId}, 10) NOT GLOB '*[^0-9a-f]*'` + ), + check('ck_team_adoption_intent_team_id', TEAM_ID_STORED_CHECK), + check( + 'ck_team_adoption_intent_state', + sql`${table.state} IN ('prepared', 'file_published', 'committed')` + ), + check('ck_team_adoption_intent_directory_fingerprint', DIRECTORY_FINGERPRINT_STORED_CHECK), + check('ck_team_adoption_intent_workspace_binding', WORKSPACE_BINDING_STORED_CHECK), + check( + 'ck_team_adoption_intent_legacy_key', + sql`length(${table.legacyKey}) BETWEEN 1 AND 128 + AND substr(${table.legacyKey}, 1, 1) GLOB '[a-z0-9]' + AND ${table.legacyKey} NOT GLOB '*[^a-z0-9-]*' + AND ${table.legacyKey} NOT IN ( + 'aux', 'con', 'nul', 'prn', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' + )` + ), + check( + 'ck_team_adoption_intent_checksums', + sql`length(${table.expectedIdentityChecksum}) = 64 + AND ${table.expectedIdentityChecksum} NOT GLOB '*[^0-9a-f]*' + AND length(${table.intentChecksum}) = 64 + AND ${table.intentChecksum} NOT GLOB '*[^0-9a-f]*'` + ), + check( + 'ck_team_adoption_intent_state_fields', + sql`( + ${table.state} = 'prepared' + AND ${table.filePublishedAt} IS NULL + AND ${table.publishedIdentityChecksum} IS NULL + AND ${table.committedAt} IS NULL + AND ${table.committedIdentityChecksum} IS NULL + ) OR ( + ${table.state} = 'file_published' + AND ${table.filePublishedAt} IS NOT NULL + AND ${table.publishedIdentityChecksum} = ${table.expectedIdentityChecksum} + AND ${table.committedAt} IS NULL + AND ${table.committedIdentityChecksum} IS NULL + ) OR ( + ${table.state} = 'committed' + AND ${table.filePublishedAt} IS NOT NULL + AND ${table.publishedIdentityChecksum} = ${table.expectedIdentityChecksum} + AND ${table.committedAt} IS NOT NULL + AND ${table.committedIdentityChecksum} = ${table.expectedIdentityChecksum} + )` + ), + ] +); + +/** + * Integration appends this fragment as internal-storage migration v5. The + * component metadata is separate from PRAGMA user_version so a later shared + * migration cannot silently widen this component's understood schema. + */ +export const TEAM_IDENTITY_STORAGE_MIGRATION_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS team_identity_storage_metadata ( + component TEXT PRIMARY KEY CHECK (component = 'team-identity'), + schema_version INTEGER NOT NULL CHECK (schema_version = 1) + )`, + `INSERT OR IGNORE INTO team_identity_storage_metadata (component, schema_version) + VALUES ('team-identity', 1)`, + `CREATE TABLE IF NOT EXISTS team_identity_records ( + team_id TEXT PRIMARY KEY + CHECK (length(team_id) = 37 AND substr(team_id, 1, 5) = 'team_' + AND substr(team_id, 6) NOT GLOB '*[^0-9a-f]*'), + state TEXT NOT NULL + CHECK (state IN ('reserved', 'adoption_prepared', 'file_published', 'active', 'tombstoned')), + legacy_key TEXT NOT NULL UNIQUE + CHECK (length(legacy_key) BETWEEN 1 AND 128 + AND substr(legacy_key, 1, 1) GLOB '[a-z0-9]' + AND legacy_key NOT GLOB '*[^a-z0-9-]*' + AND legacy_key NOT IN ( + 'aux', 'con', 'nul', 'prn', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' + )), + directory_fingerprint TEXT NOT NULL UNIQUE + CHECK (length(directory_fingerprint) = 64 + AND directory_fingerprint NOT GLOB '*[^0-9a-f]*'), + workspace_id TEXT, + workspace_binding_generation INTEGER, + adoption_intent_id TEXT, + identity_checksum TEXT, + created_at TEXT NOT NULL, + activated_at TEXT, + tombstoned_at TEXT, + CHECK ((workspace_id IS NULL AND workspace_binding_generation IS NULL) OR + (length(workspace_id) = 42 AND substr(workspace_id, 1, 10) = 'workspace_' + AND substr(workspace_id, 11) NOT GLOB '*[^0-9a-f]*' + AND workspace_binding_generation >= 1)), + CHECK (adoption_intent_id IS NULL OR + (length(adoption_intent_id) = 41 AND substr(adoption_intent_id, 1, 9) = 'adoption_' + AND substr(adoption_intent_id, 10) NOT GLOB '*[^0-9a-f]*')), + CHECK (identity_checksum IS NULL OR + (length(identity_checksum) = 64 AND identity_checksum NOT GLOB '*[^0-9a-f]*')), + CHECK ( + (state = 'reserved' AND adoption_intent_id IS NULL AND identity_checksum IS NULL + AND activated_at IS NULL AND tombstoned_at IS NULL) OR + (state = 'adoption_prepared' AND adoption_intent_id IS NOT NULL + AND identity_checksum IS NULL AND activated_at IS NULL AND tombstoned_at IS NULL) OR + (state = 'file_published' AND adoption_intent_id IS NOT NULL + AND identity_checksum IS NOT NULL AND activated_at IS NULL AND tombstoned_at IS NULL) OR + (state = 'active' AND identity_checksum IS NOT NULL + AND activated_at IS NOT NULL AND tombstoned_at IS NULL) OR + (state = 'tombstoned' AND tombstoned_at IS NOT NULL) + ) + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_team_identity_checksum + ON team_identity_records (identity_checksum) WHERE identity_checksum IS NOT NULL`, + `CREATE TABLE IF NOT EXISTS legacy_team_key_reservations ( + legacy_key TEXT PRIMARY KEY + CHECK (length(legacy_key) BETWEEN 1 AND 128 + AND substr(legacy_key, 1, 1) GLOB '[a-z0-9]' + AND legacy_key NOT GLOB '*[^a-z0-9-]*' + AND legacy_key NOT IN ( + 'aux', 'con', 'nul', 'prn', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' + )), + team_id TEXT NOT NULL REFERENCES team_identity_records(team_id) + ON DELETE RESTRICT ON UPDATE RESTRICT, + state TEXT NOT NULL CHECK (state IN ('active', 'tombstoned')), + reserved_at TEXT NOT NULL, + tombstoned_at TEXT, + tombstone_reason TEXT, + CHECK ( + (state = 'active' AND tombstoned_at IS NULL AND tombstone_reason IS NULL) OR + (state = 'tombstoned' AND tombstoned_at IS NOT NULL + AND tombstone_reason IN ('draft_deleted', 'team_deleted', 'legacy_conflict')) + ) + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_legacy_team_key_active_owner + ON legacy_team_key_reservations (team_id) WHERE state = 'active'`, + `CREATE TABLE IF NOT EXISTS team_adoption_intents ( + intent_id TEXT PRIMARY KEY + CHECK (length(intent_id) = 41 AND substr(intent_id, 1, 9) = 'adoption_' + AND substr(intent_id, 10) NOT GLOB '*[^0-9a-f]*'), + team_id TEXT NOT NULL UNIQUE REFERENCES team_identity_records(team_id) + ON DELETE RESTRICT ON UPDATE RESTRICT, + state TEXT NOT NULL CHECK (state IN ('prepared', 'file_published', 'committed')), + legacy_key TEXT NOT NULL UNIQUE + CHECK (length(legacy_key) BETWEEN 1 AND 128 + AND substr(legacy_key, 1, 1) GLOB '[a-z0-9]' + AND legacy_key NOT GLOB '*[^a-z0-9-]*' + AND legacy_key NOT IN ( + 'aux', 'con', 'nul', 'prn', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' + )), + directory_fingerprint TEXT NOT NULL UNIQUE + CHECK (length(directory_fingerprint) = 64 + AND directory_fingerprint NOT GLOB '*[^0-9a-f]*'), + workspace_id TEXT, + workspace_binding_generation INTEGER, + expected_identity_checksum TEXT NOT NULL + CHECK (length(expected_identity_checksum) = 64 + AND expected_identity_checksum NOT GLOB '*[^0-9a-f]*'), + intent_checksum TEXT NOT NULL + CHECK (length(intent_checksum) = 64 AND intent_checksum NOT GLOB '*[^0-9a-f]*'), + prepared_at TEXT NOT NULL, + file_published_at TEXT, + published_identity_checksum TEXT, + committed_at TEXT, + committed_identity_checksum TEXT, + CHECK ((workspace_id IS NULL AND workspace_binding_generation IS NULL) OR + (length(workspace_id) = 42 AND substr(workspace_id, 1, 10) = 'workspace_' + AND substr(workspace_id, 11) NOT GLOB '*[^0-9a-f]*' + AND workspace_binding_generation >= 1)), + CHECK ( + (state = 'prepared' AND file_published_at IS NULL + AND published_identity_checksum IS NULL + AND committed_at IS NULL AND committed_identity_checksum IS NULL) OR + (state = 'file_published' AND file_published_at IS NOT NULL + AND published_identity_checksum = expected_identity_checksum + AND committed_at IS NULL AND committed_identity_checksum IS NULL) OR + (state = 'committed' AND file_published_at IS NOT NULL + AND published_identity_checksum = expected_identity_checksum AND committed_at IS NOT NULL + AND committed_identity_checksum = expected_identity_checksum) + ) + )`, + `CREATE TRIGGER IF NOT EXISTS trg_team_identity_metadata_no_update + BEFORE UPDATE ON team_identity_storage_metadata + BEGIN SELECT RAISE(ABORT, 'team identity schema metadata is immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_team_identity_metadata_no_delete + BEFORE DELETE ON team_identity_storage_metadata + BEGIN SELECT RAISE(ABORT, 'team identity schema metadata is immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_team_identity_no_delete + BEFORE DELETE ON team_identity_records + BEGIN SELECT RAISE(ABORT, 'team identity records are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_team_identity_transition + BEFORE UPDATE ON team_identity_records + WHEN NOT ( + OLD.state = 'adoption_prepared' AND NEW.state = 'file_published' + AND NEW.team_id = OLD.team_id AND NEW.legacy_key = OLD.legacy_key + AND NEW.directory_fingerprint = OLD.directory_fingerprint + AND NEW.workspace_id IS OLD.workspace_id + AND NEW.workspace_binding_generation IS OLD.workspace_binding_generation + AND NEW.adoption_intent_id IS OLD.adoption_intent_id + AND OLD.identity_checksum IS NULL AND NEW.identity_checksum IS NOT NULL + AND OLD.activated_at IS NULL AND NEW.activated_at IS NULL + AND NEW.created_at = OLD.created_at AND NEW.tombstoned_at IS NULL + ) AND NOT ( + OLD.state = 'file_published' AND NEW.state = 'active' + AND NEW.team_id = OLD.team_id AND NEW.legacy_key = OLD.legacy_key + AND NEW.directory_fingerprint = OLD.directory_fingerprint + AND NEW.workspace_id IS OLD.workspace_id + AND NEW.workspace_binding_generation IS OLD.workspace_binding_generation + AND NEW.adoption_intent_id IS OLD.adoption_intent_id + AND NEW.identity_checksum = OLD.identity_checksum + AND OLD.activated_at IS NULL AND NEW.activated_at IS NOT NULL + AND NEW.created_at = OLD.created_at AND NEW.tombstoned_at IS NULL + ) AND NOT ( + OLD.state IN ('reserved', 'adoption_prepared', 'file_published', 'active') + AND NEW.state = 'tombstoned' + AND NEW.team_id = OLD.team_id AND NEW.legacy_key = OLD.legacy_key + AND NEW.directory_fingerprint = OLD.directory_fingerprint + AND NEW.workspace_id IS OLD.workspace_id + AND NEW.workspace_binding_generation IS OLD.workspace_binding_generation + AND NEW.adoption_intent_id IS OLD.adoption_intent_id + AND NEW.identity_checksum IS OLD.identity_checksum + AND NEW.created_at = OLD.created_at AND NEW.activated_at IS OLD.activated_at + AND OLD.tombstoned_at IS NULL AND NEW.tombstoned_at IS NOT NULL + ) + BEGIN SELECT RAISE(ABORT, 'illegal team identity transition'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_legacy_team_key_no_delete + BEFORE DELETE ON legacy_team_key_reservations + BEGIN SELECT RAISE(ABORT, 'legacy team key reservations are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_legacy_team_key_transition + BEFORE UPDATE ON legacy_team_key_reservations + WHEN NOT ( + OLD.state = 'active' AND NEW.state = 'tombstoned' + AND NEW.legacy_key = OLD.legacy_key AND NEW.team_id = OLD.team_id + AND NEW.reserved_at = OLD.reserved_at + AND OLD.tombstoned_at IS NULL AND NEW.tombstoned_at IS NOT NULL + AND OLD.tombstone_reason IS NULL + AND NEW.tombstone_reason IN ('draft_deleted', 'team_deleted', 'legacy_conflict') + ) + BEGIN SELECT RAISE(ABORT, 'illegal legacy team key transition'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_team_adoption_intent_no_delete + BEFORE DELETE ON team_adoption_intents + BEGIN SELECT RAISE(ABORT, 'team adoption intents are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_team_adoption_intent_transition + BEFORE UPDATE ON team_adoption_intents + WHEN NOT ( + OLD.state = 'prepared' AND NEW.state = 'file_published' + AND NEW.intent_id = OLD.intent_id AND NEW.team_id = OLD.team_id + AND NEW.legacy_key = OLD.legacy_key + AND NEW.directory_fingerprint = OLD.directory_fingerprint + AND NEW.workspace_id IS OLD.workspace_id + AND NEW.workspace_binding_generation IS OLD.workspace_binding_generation + AND NEW.expected_identity_checksum = OLD.expected_identity_checksum + AND NEW.intent_checksum = OLD.intent_checksum AND NEW.prepared_at = OLD.prepared_at + AND OLD.file_published_at IS NULL AND NEW.file_published_at IS NOT NULL + AND OLD.published_identity_checksum IS NULL + AND NEW.published_identity_checksum = OLD.expected_identity_checksum + AND OLD.committed_at IS NULL AND NEW.committed_at IS NULL + AND OLD.committed_identity_checksum IS NULL AND NEW.committed_identity_checksum IS NULL + ) AND NOT ( + OLD.state = 'file_published' AND NEW.state = 'committed' + AND NEW.intent_id = OLD.intent_id AND NEW.team_id = OLD.team_id + AND NEW.legacy_key = OLD.legacy_key + AND NEW.directory_fingerprint = OLD.directory_fingerprint + AND NEW.workspace_id IS OLD.workspace_id + AND NEW.workspace_binding_generation IS OLD.workspace_binding_generation + AND NEW.expected_identity_checksum = OLD.expected_identity_checksum + AND NEW.intent_checksum = OLD.intent_checksum AND NEW.prepared_at = OLD.prepared_at + AND NEW.file_published_at = OLD.file_published_at + AND NEW.published_identity_checksum = OLD.published_identity_checksum + AND OLD.committed_at IS NULL AND NEW.committed_at IS NOT NULL + AND OLD.committed_identity_checksum IS NULL + AND NEW.committed_identity_checksum = OLD.expected_identity_checksum + ) + BEGIN SELECT RAISE(ABORT, 'illegal team adoption intent transition'); END`, +] as const; + +export interface TeamIdentityStorageSchemaDefinition { + type: 'table' | 'index' | 'trigger'; + name: string; + tableName: string; + sql: string | null; +} + +const TEAM_IDENTITY_STORAGE_SCHEMA_STATEMENT_SPECS = [ + { + statementIndex: 0, + type: 'table', + name: 'team_identity_storage_metadata', + tableName: 'team_identity_storage_metadata', + }, + { + statementIndex: 2, + type: 'table', + name: 'team_identity_records', + tableName: 'team_identity_records', + }, + { + statementIndex: 3, + type: 'index', + name: 'idx_team_identity_checksum', + tableName: 'team_identity_records', + }, + { + statementIndex: 4, + type: 'table', + name: 'legacy_team_key_reservations', + tableName: 'legacy_team_key_reservations', + }, + { + statementIndex: 5, + type: 'index', + name: 'idx_legacy_team_key_active_owner', + tableName: 'legacy_team_key_reservations', + }, + { + statementIndex: 6, + type: 'table', + name: 'team_adoption_intents', + tableName: 'team_adoption_intents', + }, + { + statementIndex: 7, + type: 'trigger', + name: 'trg_team_identity_metadata_no_update', + tableName: 'team_identity_storage_metadata', + }, + { + statementIndex: 8, + type: 'trigger', + name: 'trg_team_identity_metadata_no_delete', + tableName: 'team_identity_storage_metadata', + }, + { + statementIndex: 9, + type: 'trigger', + name: 'trg_team_identity_no_delete', + tableName: 'team_identity_records', + }, + { + statementIndex: 10, + type: 'trigger', + name: 'trg_team_identity_transition', + tableName: 'team_identity_records', + }, + { + statementIndex: 11, + type: 'trigger', + name: 'trg_legacy_team_key_no_delete', + tableName: 'legacy_team_key_reservations', + }, + { + statementIndex: 12, + type: 'trigger', + name: 'trg_legacy_team_key_transition', + tableName: 'legacy_team_key_reservations', + }, + { + statementIndex: 13, + type: 'trigger', + name: 'trg_team_adoption_intent_no_delete', + tableName: 'team_adoption_intents', + }, + { + statementIndex: 14, + type: 'trigger', + name: 'trg_team_adoption_intent_transition', + tableName: 'team_adoption_intents', + }, +] as const; + +/** + * sqlite_schema removes IF NOT EXISTS from stored CREATE statements. Keeping + * the expected SQL derived from the migration fragment makes provenance + * verification byte-exact after that documented SQLite normalization. + */ +const TEAM_IDENTITY_STORAGE_CREATED_SCHEMA_DEFINITIONS: readonly TeamIdentityStorageSchemaDefinition[] = + TEAM_IDENTITY_STORAGE_SCHEMA_STATEMENT_SPECS.map((spec) => ({ + type: spec.type, + name: spec.name, + tableName: spec.tableName, + sql: TEAM_IDENTITY_STORAGE_MIGRATION_STATEMENTS[spec.statementIndex].replace( + /^CREATE (TABLE|UNIQUE INDEX|TRIGGER) IF NOT EXISTS /, + 'CREATE $1 ' + ), + })); + +const TEAM_IDENTITY_STORAGE_AUTO_INDEX_DEFINITIONS: readonly TeamIdentityStorageSchemaDefinition[] = + [ + { + type: 'index', + name: 'sqlite_autoindex_team_identity_storage_metadata_1', + tableName: 'team_identity_storage_metadata', + sql: null, + }, + ...Array.from({ length: 3 }, (_, index) => ({ + type: 'index' as const, + name: `sqlite_autoindex_team_identity_records_${index + 1}`, + tableName: 'team_identity_records', + sql: null, + })), + { + type: 'index', + name: 'sqlite_autoindex_legacy_team_key_reservations_1', + tableName: 'legacy_team_key_reservations', + sql: null, + }, + ...Array.from({ length: 4 }, (_, index) => ({ + type: 'index' as const, + name: `sqlite_autoindex_team_adoption_intents_${index + 1}`, + tableName: 'team_adoption_intents', + sql: null, + })), + ]; + +export const TEAM_IDENTITY_STORAGE_SCHEMA_DEFINITIONS: readonly TeamIdentityStorageSchemaDefinition[] = + [ + ...TEAM_IDENTITY_STORAGE_CREATED_SCHEMA_DEFINITIONS, + ...TEAM_IDENTITY_STORAGE_AUTO_INDEX_DEFINITIONS, + ]; + +export const TEAM_IDENTITY_STORAGE_REQUIRED_SCHEMA_OBJECTS = + TEAM_IDENTITY_STORAGE_SCHEMA_DEFINITIONS.map(({ type, name }) => [type, name] as const); diff --git a/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSupport.ts b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSupport.ts new file mode 100644 index 0000000000..6b2dd5b641 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/teamIdentityStorageSupport.ts @@ -0,0 +1,706 @@ +import { createHash } from 'node:crypto'; + +import { parseTeamId, parseWorkspaceId } from '@shared/contracts/hosted/identifiers'; + +import { + parseDirectoryFingerprint, + parseLegacyTeamKey, + parseTeamAdoptionIntentChecksum, + parseTeamAdoptionIntentId, + parseTeamIdentityChecksum, + TeamIdentityStorageErrorCode, +} from '../../../contracts/teamIdentityStorageContracts'; + +import { fail, TeamIdentityStorageInvariantError } from './teamIdentityStorageErrors'; + +import type { + CommitTeamAdoptionInput, + LegacyTeamKey, + LegacyTeamKeyReservation, + LegacyTeamKeyTombstoneReason, + PrepareTeamAdoptionInput, + RecordTeamIdentityFilePublishedInput, + ReserveTeamIdentityInput, + TeamAdoptionIntent, + TeamAdoptionIntentChecksum, + TeamIdentityRecord, + TeamIdentityStorageErrorCode as TeamIdentityStorageErrorCodeValue, + TeamWorkspaceBindingEvidence, + TombstoneLegacyTeamKeyInput, +} from '../../../contracts/teamIdentityStorageContracts'; +import type { TeamId } from '@shared/contracts/hosted/identifiers'; +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export interface TeamIdentityRow { + team_id: unknown; + state: unknown; + legacy_key: unknown; + directory_fingerprint: unknown; + workspace_id: unknown; + workspace_binding_generation: unknown; + adoption_intent_id: unknown; + identity_checksum: unknown; + created_at: unknown; + activated_at: unknown; + tombstoned_at: unknown; +} + +export interface LegacyTeamKeyReservationRow { + legacy_key: unknown; + team_id: unknown; + state: unknown; + reserved_at: unknown; + tombstoned_at: unknown; + tombstone_reason: unknown; +} + +export interface TeamAdoptionIntentRow { + intent_id: unknown; + team_id: unknown; + state: unknown; + legacy_key: unknown; + directory_fingerprint: unknown; + workspace_id: unknown; + workspace_binding_generation: unknown; + expected_identity_checksum: unknown; + intent_checksum: unknown; + prepared_at: unknown; + file_published_at: unknown; + published_identity_checksum: unknown; + committed_at: unknown; + committed_identity_checksum: unknown; +} + +export class TeamIdentityStorageSupport { + normalizeReservationInput(input: ReserveTeamIdentityInput): ReserveTeamIdentityInput { + return this.validated(() => ({ + teamId: parseTeamId(input.teamId), + legacyKey: parseLegacyTeamKey(input.legacyKey), + directoryFingerprint: parseDirectoryFingerprint(input.directoryFingerprint), + workspaceBinding: this.parseWorkspaceBinding(input.workspaceBinding), + createdAt: this.parseTimestamp(input.createdAt), + })); + } + + normalizePrepareInput(input: PrepareTeamAdoptionInput): PrepareTeamAdoptionInput { + return this.validated(() => ({ + intentId: parseTeamAdoptionIntentId(input.intentId), + teamId: parseTeamId(input.teamId), + legacyKey: parseLegacyTeamKey(input.legacyKey), + directoryFingerprint: parseDirectoryFingerprint(input.directoryFingerprint), + workspaceBinding: this.parseWorkspaceBinding(input.workspaceBinding), + expectedIdentityChecksum: parseTeamIdentityChecksum(input.expectedIdentityChecksum), + preparedAt: this.parseTimestamp(input.preparedAt), + })); + } + + normalizeCommitInput(input: CommitTeamAdoptionInput): CommitTeamAdoptionInput { + return this.validated(() => ({ + intentId: parseTeamAdoptionIntentId(input.intentId), + teamId: parseTeamId(input.teamId), + intentChecksum: parseTeamAdoptionIntentChecksum(input.intentChecksum), + identityChecksum: parseTeamIdentityChecksum(input.identityChecksum), + committedAt: this.parseTimestamp(input.committedAt), + })); + } + + normalizeFilePublishedInput( + input: RecordTeamIdentityFilePublishedInput + ): RecordTeamIdentityFilePublishedInput { + return this.validated(() => ({ + intentId: parseTeamAdoptionIntentId(input.intentId), + teamId: parseTeamId(input.teamId), + intentChecksum: parseTeamAdoptionIntentChecksum(input.intentChecksum), + identityChecksum: parseTeamIdentityChecksum(input.identityChecksum), + filePublishedAt: this.parseTimestamp(input.filePublishedAt), + })); + } + + normalizeTombstoneInput(input: TombstoneLegacyTeamKeyInput): TombstoneLegacyTeamKeyInput { + return this.validated(() => ({ + teamId: parseTeamId(input.teamId), + legacyKey: parseLegacyTeamKey(input.legacyKey), + reason: this.parseTombstoneReason(input.reason), + tombstonedAt: this.parseTimestamp(input.tombstonedAt), + })); + } + + parseWorkspaceBinding( + value: TeamWorkspaceBindingEvidence | null + ): TeamWorkspaceBindingEvidence | null { + if (value === null) { + return null; + } + if ( + typeof value !== 'object' || + !Number.isSafeInteger(value.generation) || + value.generation < 1 + ) { + throw new TypeError('team-identity-workspace-binding-invalid'); + } + return { workspaceId: parseWorkspaceId(value.workspaceId), generation: value.generation }; + } + + parseTimestamp(value: unknown): string { + if ( + typeof value !== 'string' || + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString() !== value + ) { + throw new TypeError('team-identity-timestamp-invalid'); + } + return value; + } + + parseTombstoneReason(value: unknown): LegacyTeamKeyTombstoneReason { + if (value !== 'draft_deleted' && value !== 'team_deleted' && value !== 'legacy_conflict') { + throw new TypeError('team-identity-tombstone-reason-invalid'); + } + return value; + } + + validated(callback: () => T): T { + try { + return callback(); + } catch (error) { + if (error instanceof TeamIdentityStorageInvariantError) { + throw error; + } + fail(TeamIdentityStorageErrorCode.InvalidInput); + } + } + + withConstraintClassification( + _db: SqliteDatabase, + code: TeamIdentityStorageErrorCodeValue, + callback: () => T + ): T { + try { + return callback(); + } catch (error) { + if (error instanceof TeamIdentityStorageInvariantError) { + throw error; + } + const sqliteCode = (error as { code?: unknown }).code; + if (typeof sqliteCode === 'string' && sqliteCode.startsWith('SQLITE_CONSTRAINT')) { + fail(code); + } + throw error; + } + } + + assertIdentitySlotsAvailable( + db: SqliteDatabase, + teamId: TeamId, + legacyKey: LegacyTeamKey, + directoryFingerprint: ReserveTeamIdentityInput['directoryFingerprint'] + ): void { + if (this.readIdentityByTeamId(db, teamId)) { + fail(TeamIdentityStorageErrorCode.DuplicateIdentity); + } + const identityByKey = this.readIdentityByLegacyKey(db, legacyKey); + if (identityByKey) { + fail( + identityByKey.state === 'tombstoned' + ? TeamIdentityStorageErrorCode.LegacyKeyTombstoned + : TeamIdentityStorageErrorCode.LegacyKeyConflict + ); + } + const reservation = this.readReservationByKey(db, legacyKey); + if (reservation) { + fail( + reservation.state === 'tombstoned' + ? TeamIdentityStorageErrorCode.LegacyKeyTombstoned + : TeamIdentityStorageErrorCode.LegacyKeyConflict + ); + } + if (this.readIdentityByDirectoryFingerprint(db, directoryFingerprint)) { + fail(TeamIdentityStorageErrorCode.DuplicateIdentity); + } + } + + insertIdentity( + db: SqliteDatabase, + input: ReserveTeamIdentityInput & { + state: 'reserved' | 'adoption_prepared'; + adoptionIntentId: TeamAdoptionIntent['intentId'] | null; + } + ): void { + db.prepare( + `INSERT INTO team_identity_records ( + team_id, state, legacy_key, directory_fingerprint, workspace_id, + workspace_binding_generation, adoption_intent_id, identity_checksum, + created_at, activated_at, tombstoned_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, NULL, NULL)` + ).run( + input.teamId, + input.state, + input.legacyKey, + input.directoryFingerprint, + input.workspaceBinding?.workspaceId ?? null, + input.workspaceBinding?.generation ?? null, + input.adoptionIntentId, + input.createdAt + ); + } + + insertReservation( + db: SqliteDatabase, + legacyKey: LegacyTeamKey, + teamId: TeamId, + reservedAt: string + ): void { + db.prepare( + `INSERT INTO legacy_team_key_reservations ( + legacy_key, team_id, state, reserved_at, tombstoned_at, tombstone_reason + ) VALUES (?, ?, 'active', ?, NULL, NULL)` + ).run(legacyKey, teamId, reservedAt); + } + + readIdentityByTeamId(db: SqliteDatabase, teamId: TeamId): TeamIdentityRecord | null { + const row = db.prepare('SELECT * FROM team_identity_records WHERE team_id = ?').get(teamId) as + | TeamIdentityRow + | undefined; + return row ? this.mapIdentity(row) : null; + } + + readIdentityByLegacyKey(db: SqliteDatabase, legacyKey: LegacyTeamKey): TeamIdentityRecord | null { + const row = db + .prepare('SELECT * FROM team_identity_records WHERE legacy_key = ?') + .get(legacyKey) as TeamIdentityRow | undefined; + return row ? this.mapIdentity(row) : null; + } + + readIdentityByDirectoryFingerprint( + db: SqliteDatabase, + directoryFingerprint: ReserveTeamIdentityInput['directoryFingerprint'] + ): TeamIdentityRecord | null { + const row = db + .prepare('SELECT * FROM team_identity_records WHERE directory_fingerprint = ?') + .get(directoryFingerprint) as TeamIdentityRow | undefined; + return row ? this.mapIdentity(row) : null; + } + + readIdentityByChecksum( + db: SqliteDatabase, + checksum: CommitTeamAdoptionInput['identityChecksum'] + ): TeamIdentityRecord | null { + const row = db + .prepare('SELECT * FROM team_identity_records WHERE identity_checksum = ?') + .get(checksum) as TeamIdentityRow | undefined; + return row ? this.mapIdentity(row) : null; + } + + readReservationByKey( + db: SqliteDatabase, + legacyKey: LegacyTeamKey + ): LegacyTeamKeyReservation | null { + const row = db + .prepare('SELECT * FROM legacy_team_key_reservations WHERE legacy_key = ?') + .get(legacyKey) as LegacyTeamKeyReservationRow | undefined; + return row ? this.mapReservation(row) : null; + } + + readIntentById( + db: SqliteDatabase, + intentId: TeamAdoptionIntent['intentId'] + ): TeamAdoptionIntent | null { + const row = db + .prepare('SELECT * FROM team_adoption_intents WHERE intent_id = ?') + .get(intentId) as TeamAdoptionIntentRow | undefined; + return row ? this.mapIntent(row) : null; + } + + readIntentByTeamId(db: SqliteDatabase, teamId: TeamId): TeamAdoptionIntent | null { + const row = db.prepare('SELECT * FROM team_adoption_intents WHERE team_id = ?').get(teamId) as + | TeamAdoptionIntentRow + | undefined; + return row ? this.mapIntent(row) : null; + } + + requireIdentity(db: SqliteDatabase, teamId: TeamId): TeamIdentityRecord { + const identity = this.readIdentityByTeamId(db, teamId); + if (!identity) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return identity; + } + + requireReservation(db: SqliteDatabase, legacyKey: LegacyTeamKey): LegacyTeamKeyReservation { + const reservation = this.readReservationByKey(db, legacyKey); + if (!reservation) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return reservation; + } + + requireIntent(db: SqliteDatabase, intentId: TeamAdoptionIntent['intentId']): TeamAdoptionIntent { + const intent = this.readIntentById(db, intentId); + if (!intent) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return intent; + } + + requireConsistentReservation( + db: SqliteDatabase, + identity: TeamIdentityRecord + ): LegacyTeamKeyReservation { + const reservation = this.requireReservation(db, identity.legacyKey); + if ( + reservation.teamId !== identity.teamId || + reservation.reservedAt !== identity.createdAt || + reservation.tombstonedAt !== identity.tombstonedAt || + (identity.state === 'tombstoned') !== (reservation.state === 'tombstoned') + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return reservation; + } + + assertReadableIdentityGraph(db: SqliteDatabase, identity: TeamIdentityRecord): void { + const reservation = this.requireConsistentReservation(db, identity); + if (identity.adoptionIntentId === null) { + if ( + (identity.state !== 'reserved' && identity.state !== 'tombstoned') || + identity.identityChecksum !== null || + identity.activatedAt !== null + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return; + } + + const intent = this.requireIntent(db, identity.adoptionIntentId); + if (identity.state !== 'tombstoned') { + this.assertIntentGraphConsistent(intent, identity, reservation); + return; + } + if ( + intent.teamId !== identity.teamId || + intent.legacyKey !== identity.legacyKey || + intent.directoryFingerprint !== identity.directoryFingerprint || + !this.sameWorkspaceBinding(intent.workspaceBinding, identity.workspaceBinding) || + intent.preparedAt !== identity.createdAt || + reservation.state !== 'tombstoned' || + (intent.state === 'prepared' && + (identity.identityChecksum !== null || identity.activatedAt !== null)) || + (intent.state === 'file_published' && + (identity.identityChecksum !== intent.expectedIdentityChecksum || + identity.activatedAt !== null)) || + (intent.state === 'committed' && + (identity.identityChecksum !== intent.expectedIdentityChecksum || + identity.activatedAt !== intent.committedAt)) + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + } + + assertIntentGraphConsistent( + intent: TeamAdoptionIntent, + identity: TeamIdentityRecord, + reservation: LegacyTeamKeyReservation + ): void { + if ( + intent.teamId !== identity.teamId || + intent.legacyKey !== identity.legacyKey || + intent.directoryFingerprint !== identity.directoryFingerprint || + !this.sameWorkspaceBinding(intent.workspaceBinding, identity.workspaceBinding) || + intent.preparedAt !== identity.createdAt || + identity.adoptionIntentId !== intent.intentId || + reservation.teamId !== identity.teamId || + reservation.legacyKey !== identity.legacyKey + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + if (intent.state === 'prepared') { + if (identity.state !== 'adoption_prepared' || reservation.state !== 'active') { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + return; + } + if (intent.state === 'file_published') { + if (identity.state === 'tombstoned' || reservation.state === 'tombstoned') { + fail(TeamIdentityStorageErrorCode.IllegalTransition); + } + if ( + identity.state !== 'file_published' || + identity.identityChecksum !== intent.publishedIdentityChecksum || + intent.publishedIdentityChecksum !== intent.expectedIdentityChecksum + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return; + } + if (identity.state !== 'active' && identity.state !== 'tombstoned') { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + if ( + identity.identityChecksum !== intent.publishedIdentityChecksum || + identity.identityChecksum !== intent.committedIdentityChecksum || + intent.publishedIdentityChecksum !== intent.expectedIdentityChecksum || + identity.activatedAt !== intent.committedAt || + (identity.state === 'active' && reservation.state !== 'active') + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + } + + mapIdentity(row: TeamIdentityRow): TeamIdentityRecord { + if ( + row.state !== 'reserved' && + row.state !== 'adoption_prepared' && + row.state !== 'file_published' && + row.state !== 'active' && + row.state !== 'tombstoned' + ) { + fail(TeamIdentityStorageErrorCode.UnknownState); + } + try { + const identity: TeamIdentityRecord = { + teamId: parseTeamId(row.team_id), + state: row.state, + legacyKey: this.parseStoredLegacyKey(row.legacy_key), + directoryFingerprint: parseDirectoryFingerprint(row.directory_fingerprint), + workspaceBinding: this.parseStoredWorkspaceBinding( + row.workspace_id, + row.workspace_binding_generation + ), + adoptionIntentId: + row.adoption_intent_id === null + ? null + : parseTeamAdoptionIntentId(row.adoption_intent_id), + identityChecksum: + row.identity_checksum === null ? null : parseTeamIdentityChecksum(row.identity_checksum), + createdAt: this.parseTimestamp(row.created_at), + activatedAt: row.activated_at === null ? null : this.parseTimestamp(row.activated_at), + tombstonedAt: row.tombstoned_at === null ? null : this.parseTimestamp(row.tombstoned_at), + }; + const validStateFields = + (identity.state === 'reserved' && + identity.adoptionIntentId === null && + identity.identityChecksum === null && + identity.activatedAt === null && + identity.tombstonedAt === null) || + (identity.state === 'adoption_prepared' && + identity.adoptionIntentId !== null && + identity.identityChecksum === null && + identity.activatedAt === null && + identity.tombstonedAt === null) || + (identity.state === 'file_published' && + identity.adoptionIntentId !== null && + identity.identityChecksum !== null && + identity.activatedAt === null && + identity.tombstonedAt === null) || + (identity.state === 'active' && + identity.identityChecksum !== null && + identity.activatedAt !== null && + identity.tombstonedAt === null) || + (identity.state === 'tombstoned' && identity.tombstonedAt !== null); + if (!validStateFields) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return identity; + } catch (error) { + if (error instanceof TeamIdentityStorageInvariantError) { + throw error; + } + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + } + + mapReservation(row: LegacyTeamKeyReservationRow): LegacyTeamKeyReservation { + if (row.state !== 'active' && row.state !== 'tombstoned') { + fail(TeamIdentityStorageErrorCode.UnknownState); + } + try { + const reason = + row.tombstone_reason === null ? null : this.parseTombstoneReason(row.tombstone_reason); + const reservation: LegacyTeamKeyReservation = { + legacyKey: this.parseStoredLegacyKey(row.legacy_key), + teamId: parseTeamId(row.team_id), + state: row.state, + reservedAt: this.parseTimestamp(row.reserved_at), + tombstonedAt: row.tombstoned_at === null ? null : this.parseTimestamp(row.tombstoned_at), + tombstoneReason: reason, + }; + if ( + (reservation.state === 'active' && + (reservation.tombstonedAt !== null || reservation.tombstoneReason !== null)) || + (reservation.state === 'tombstoned' && + (reservation.tombstonedAt === null || reservation.tombstoneReason === null)) + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return reservation; + } catch (error) { + if (error instanceof TeamIdentityStorageInvariantError) { + throw error; + } + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + } + + mapIntent(row: TeamAdoptionIntentRow): TeamAdoptionIntent { + if (row.state !== 'prepared' && row.state !== 'file_published' && row.state !== 'committed') { + fail(TeamIdentityStorageErrorCode.UnknownState); + } + try { + const intent: TeamAdoptionIntent = { + intentId: parseTeamAdoptionIntentId(row.intent_id), + teamId: parseTeamId(row.team_id), + state: row.state, + legacyKey: this.parseStoredLegacyKey(row.legacy_key), + directoryFingerprint: parseDirectoryFingerprint(row.directory_fingerprint), + workspaceBinding: this.parseStoredWorkspaceBinding( + row.workspace_id, + row.workspace_binding_generation + ), + expectedIdentityChecksum: parseTeamIdentityChecksum(row.expected_identity_checksum), + intentChecksum: parseTeamAdoptionIntentChecksum(row.intent_checksum), + preparedAt: this.parseTimestamp(row.prepared_at), + filePublishedAt: + row.file_published_at === null ? null : this.parseTimestamp(row.file_published_at), + publishedIdentityChecksum: + row.published_identity_checksum === null + ? null + : parseTeamIdentityChecksum(row.published_identity_checksum), + committedAt: row.committed_at === null ? null : this.parseTimestamp(row.committed_at), + committedIdentityChecksum: + row.committed_identity_checksum === null + ? null + : parseTeamIdentityChecksum(row.committed_identity_checksum), + }; + const expectedIntentChecksum = this.computeIntentChecksum({ + intentId: intent.intentId, + teamId: intent.teamId, + legacyKey: intent.legacyKey, + directoryFingerprint: intent.directoryFingerprint, + workspaceBinding: intent.workspaceBinding, + expectedIdentityChecksum: intent.expectedIdentityChecksum, + preparedAt: intent.preparedAt, + }); + if (intent.intentChecksum !== expectedIntentChecksum) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + if ( + (intent.state === 'prepared' && + (intent.filePublishedAt !== null || + intent.publishedIdentityChecksum !== null || + intent.committedAt !== null || + intent.committedIdentityChecksum !== null)) || + (intent.state === 'file_published' && + (intent.filePublishedAt === null || + intent.publishedIdentityChecksum !== intent.expectedIdentityChecksum || + intent.committedAt !== null || + intent.committedIdentityChecksum !== null)) || + (intent.state === 'committed' && + (intent.filePublishedAt === null || + intent.publishedIdentityChecksum !== intent.expectedIdentityChecksum || + intent.committedAt === null || + intent.committedIdentityChecksum !== intent.expectedIdentityChecksum)) + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + if ( + intent.filePublishedAt !== null && + Date.parse(intent.filePublishedAt) < Date.parse(intent.preparedAt) + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + if ( + intent.committedAt !== null && + intent.filePublishedAt !== null && + Date.parse(intent.committedAt) < Date.parse(intent.filePublishedAt) + ) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return intent; + } catch (error) { + if (error instanceof TeamIdentityStorageInvariantError) { + throw error; + } + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + } + + parseStoredLegacyKey(value: unknown): LegacyTeamKey { + const parsed = parseLegacyTeamKey(value); + if (parsed !== value) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return parsed; + } + + parseStoredWorkspaceBinding( + workspaceId: unknown, + generation: unknown + ): TeamWorkspaceBindingEvidence | null { + if (workspaceId === null && generation === null) { + return null; + } + if (!Number.isSafeInteger(generation) || (generation as number) < 1) { + fail(TeamIdentityStorageErrorCode.TamperingDetected); + } + return { + workspaceId: parseWorkspaceId(workspaceId), + generation: generation as number, + }; + } + + computeIntentChecksum(input: PrepareTeamAdoptionInput): TeamAdoptionIntentChecksum { + const canonical = JSON.stringify({ + schemaVersion: 1, + intentId: input.intentId, + teamId: input.teamId, + legacyKey: input.legacyKey, + directoryFingerprint: input.directoryFingerprint, + workspaceId: input.workspaceBinding?.workspaceId ?? null, + workspaceBindingGeneration: input.workspaceBinding?.generation ?? null, + expectedIdentityChecksum: input.expectedIdentityChecksum, + preparedAt: input.preparedAt, + }); + return parseTeamAdoptionIntentChecksum(createHash('sha256').update(canonical).digest('hex')); + } + + isSameReservedIdentity(identity: TeamIdentityRecord, input: ReserveTeamIdentityInput): boolean { + return ( + identity.state === 'reserved' && + identity.legacyKey === input.legacyKey && + identity.directoryFingerprint === input.directoryFingerprint && + this.sameWorkspaceBinding(identity.workspaceBinding, input.workspaceBinding) && + identity.createdAt === input.createdAt + ); + } + + isSameIntentRequest( + intent: TeamAdoptionIntent, + input: PrepareTeamAdoptionInput, + checksum: TeamAdoptionIntentChecksum + ): boolean { + return ( + intent.teamId === input.teamId && + intent.legacyKey === input.legacyKey && + intent.directoryFingerprint === input.directoryFingerprint && + this.sameWorkspaceBinding(intent.workspaceBinding, input.workspaceBinding) && + intent.expectedIdentityChecksum === input.expectedIdentityChecksum && + intent.preparedAt === input.preparedAt && + intent.intentChecksum === checksum + ); + } + + sameWorkspaceBinding( + left: TeamWorkspaceBindingEvidence | null, + right: TeamWorkspaceBindingEvidence | null + ): boolean { + return ( + (left === null && right === null) || + (left !== null && + right !== null && + left.workspaceId === right.workspaceId && + left.generation === right.generation) + ); + } +} diff --git a/src/features/internal-storage/main/infrastructure/worker/teamRosterStorageOps.ts b/src/features/internal-storage/main/infrastructure/worker/teamRosterStorageOps.ts new file mode 100644 index 0000000000..294c865978 --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/teamRosterStorageOps.ts @@ -0,0 +1,169 @@ +import { parseTeamId, type TeamId } from '@shared/contracts/hosted'; + +import { + parseTeamRosterSnapshotRecord, + TEAM_ROSTER_STORAGE_SCHEMA_VERSION, + type TeamRosterAdoptRecordResult, + type TeamRosterMemberRecord, + type TeamRosterSnapshotRecord, +} from '../../../contracts/teamRosterStorageContracts'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +interface StoredRosterRow { + readonly schema_version: number; + readonly team_id: string; + readonly roster_generation: number; + readonly adoption_fingerprint: string; + readonly adopted_at: string; +} + +interface StoredRosterMemberRow { + readonly ordinal: number; + readonly member_id: string; + readonly legacy_member_key: string; + readonly member_revision: number; + readonly state: string; + readonly provider_id: string; + readonly model: string | null; + readonly role: string | null; + readonly workflow: string | null; + readonly isolation: string | null; +} + +export class TeamRosterStorageOps { + constructor(private readonly getDatabase: () => SqliteDatabase) {} + + getRoster(teamIdValue: TeamId): TeamRosterSnapshotRecord | null { + const teamId = parseTeamId(teamIdValue); + const database = this.getDatabase(); + return database.transaction(() => { + assertTeamRosterComponentSchema(database); + return readRoster(database, teamId); + })(); + } + + adoptRoster(value: TeamRosterSnapshotRecord): TeamRosterAdoptRecordResult { + const record = parseTeamRosterSnapshotRecord(value); + if ( + record.rosterGeneration !== 1 || + record.members.some((member) => member.memberRevision !== 1) + ) { + throw new Error('team-roster-adoption-initial-generation-required'); + } + const database = this.getDatabase(); + return database.transaction(() => { + assertTeamRosterComponentSchema(database); + const identity = database + .prepare(`SELECT state FROM team_identity_records WHERE team_id = ?`) + .get(record.teamId) as { readonly state: string } | undefined; + if (identity?.state !== 'active') { + throw new Error('team-roster-team-identity-not-active'); + } + const existing = readRoster(database, parseTeamId(record.teamId)); + if (existing) { + if (existing.adoptionFingerprint !== record.adoptionFingerprint) { + throw new Error('team-roster-adoption-conflict'); + } + return { outcome: 'existing' as const, roster: existing }; + } + + database + .prepare( + `INSERT INTO team_rosters ( + team_id, schema_version, roster_generation, adoption_fingerprint, adopted_at + ) VALUES (?, ?, ?, ?, ?)` + ) + .run( + record.teamId, + record.schemaVersion, + record.rosterGeneration, + record.adoptionFingerprint, + record.adoptedAt + ); + const insertMember = database.prepare( + `INSERT INTO team_roster_members ( + member_id, team_id, ordinal, legacy_member_key, legacy_member_key_folded, + member_revision, state, provider_id, model, role, workflow, isolation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + for (const member of record.members) { + insertMember.run( + member.memberId, + record.teamId, + member.ordinal, + member.legacyMemberKey, + member.legacyMemberKey.toLowerCase(), + member.memberRevision, + member.state, + member.providerId, + member.model, + member.role, + member.workflow, + member.isolation + ); + } + const persisted = readRoster(database, parseTeamId(record.teamId)); + if (!persisted) throw new Error('team-roster-adoption-readback-missing'); + return { outcome: 'created' as const, roster: persisted }; + })(); + } +} + +function assertTeamRosterComponentSchema(database: SqliteDatabase): void { + const row = database + .prepare( + `SELECT schema_version + FROM team_roster_storage_metadata + WHERE component = 'team-roster'` + ) + .get() as { readonly schema_version: number } | undefined; + if (row?.schema_version !== TEAM_ROSTER_STORAGE_SCHEMA_VERSION) { + throw new Error('team-roster-storage-schema-unsupported'); + } +} + +function readRoster(database: SqliteDatabase, teamId: TeamId): TeamRosterSnapshotRecord | null { + const row = database + .prepare( + `SELECT + schema_version, team_id, roster_generation, adoption_fingerprint, adopted_at + FROM team_rosters + WHERE team_id = ?` + ) + .get(teamId) as StoredRosterRow | undefined; + if (!row) return null; + const memberRows = database + .prepare( + `SELECT + ordinal, member_id, legacy_member_key, member_revision, state, provider_id, + model, role, workflow, isolation + FROM team_roster_members + WHERE team_id = ? + ORDER BY ordinal ASC` + ) + .all(teamId) as StoredRosterMemberRow[]; + return parseTeamRosterSnapshotRecord({ + schemaVersion: row.schema_version, + teamId: row.team_id, + rosterGeneration: row.roster_generation, + adoptionFingerprint: row.adoption_fingerprint, + adoptedAt: row.adopted_at, + members: memberRows.map( + (member): TeamRosterMemberRecord => ({ + ordinal: member.ordinal, + memberId: member.member_id, + legacyMemberKey: member.legacy_member_key, + memberRevision: member.member_revision, + state: member.state as TeamRosterMemberRecord['state'], + providerId: member.provider_id as TeamRosterMemberRecord['providerId'], + model: member.model, + role: member.role, + workflow: member.workflow, + isolation: member.isolation as TeamRosterMemberRecord['isolation'], + }) + ), + }); +} diff --git a/src/features/internal-storage/main/infrastructure/worker/teamRosterStorageSchema.ts b/src/features/internal-storage/main/infrastructure/worker/teamRosterStorageSchema.ts new file mode 100644 index 0000000000..3f778e97ef --- /dev/null +++ b/src/features/internal-storage/main/infrastructure/worker/teamRosterStorageSchema.ts @@ -0,0 +1,225 @@ +import { sql } from 'drizzle-orm'; +import { check, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +import { teamIdentityRecords } from './teamIdentityStorageSchema'; + +import type DatabaseConstructor from 'better-sqlite3'; + +type SqliteDatabase = InstanceType; + +export const teamRosterStorageMetadata = sqliteTable( + 'team_roster_storage_metadata', + { + component: text('component').primaryKey(), + schemaVersion: integer('schema_version').notNull(), + }, + (table) => [ + check('ck_team_roster_storage_component', sql`${table.component} = 'team-roster'`), + check('ck_team_roster_storage_schema', sql`${table.schemaVersion} = 1`), + ] +); + +export const teamRosters = sqliteTable( + 'team_rosters', + { + teamId: text('team_id') + .primaryKey() + .references(() => teamIdentityRecords.teamId, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + schemaVersion: integer('schema_version').notNull(), + rosterGeneration: integer('roster_generation').notNull(), + adoptionFingerprint: text('adoption_fingerprint').notNull(), + adoptedAt: text('adopted_at').notNull(), + }, + (table) => [ + check('ck_team_roster_schema', sql`${table.schemaVersion} = 1`), + check('ck_team_roster_generation', sql`${table.rosterGeneration} > 0`), + check( + 'ck_team_roster_fingerprint', + sql`length(${table.adoptionFingerprint}) = 71 + AND substr(${table.adoptionFingerprint}, 1, 7) = 'sha256:' + AND substr(${table.adoptionFingerprint}, 8) NOT GLOB '*[^0-9a-f]*'` + ), + ] +); + +export const teamRosterMembers = sqliteTable( + 'team_roster_members', + { + memberId: text('member_id').primaryKey(), + teamId: text('team_id') + .notNull() + .references(() => teamRosters.teamId, { onDelete: 'restrict', onUpdate: 'restrict' }), + ordinal: integer('ordinal').notNull(), + legacyMemberKey: text('legacy_member_key').notNull(), + legacyMemberKeyFolded: text('legacy_member_key_folded').notNull(), + memberRevision: integer('member_revision').notNull(), + state: text('state').notNull(), + providerId: text('provider_id').notNull(), + model: text('model'), + role: text('role'), + workflow: text('workflow'), + isolation: text('isolation'), + }, + (table) => [ + uniqueIndex('idx_team_roster_member_ordinal').on(table.teamId, table.ordinal), + uniqueIndex('idx_team_roster_member_exact_key').on(table.teamId, table.legacyMemberKey), + uniqueIndex('idx_team_roster_member_folded_key').on(table.teamId, table.legacyMemberKeyFolded), + check('ck_team_roster_member_ordinal', sql`${table.ordinal} >= 0`), + check( + 'ck_team_roster_member_id', + sql`length(${table.memberId}) = 39 + AND substr(${table.memberId}, 1, 7) = 'member_' + AND substr(${table.memberId}, 8) NOT GLOB '*[^0-9a-f]*'` + ), + check( + 'ck_team_roster_member_key', + sql`length(${table.legacyMemberKey}) BETWEEN 1 AND 128 + AND substr(${table.legacyMemberKey}, 1, 1) GLOB '[A-Za-z0-9]' + AND ${table.legacyMemberKey} NOT GLOB '*[^A-Za-z0-9._-]*' + AND substr(${table.legacyMemberKey}, -1, 1) <> '.' + AND ${table.legacyMemberKeyFolded} = lower(${table.legacyMemberKey})` + ), + check('ck_team_roster_member_revision', sql`${table.memberRevision} > 0`), + check('ck_team_roster_member_state', sql`${table.state} IN ('active', 'removed')`), + check( + 'ck_team_roster_member_provider', + sql`${table.providerId} IN ('anthropic', 'codex', 'gemini', 'opencode')` + ), + check( + 'ck_team_roster_member_fields', + sql`(${table.model} IS NULL OR length(${table.model}) BETWEEN 1 AND 512) + AND (${table.role} IS NULL OR length(${table.role}) BETWEEN 1 AND 4096) + AND (${table.workflow} IS NULL OR length(${table.workflow}) BETWEEN 1 AND 131072) + AND (${table.isolation} IS NULL OR ${table.isolation} = 'worktree')` + ), + ] +); + +export const TEAM_ROSTER_STORAGE_MIGRATION_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS team_roster_storage_metadata ( + component TEXT PRIMARY KEY CHECK (component = 'team-roster'), + schema_version INTEGER NOT NULL CHECK (schema_version = 1) + )`, + `INSERT OR IGNORE INTO team_roster_storage_metadata (component, schema_version) + VALUES ('team-roster', 1)`, + `CREATE TABLE IF NOT EXISTS team_rosters ( + team_id TEXT PRIMARY KEY REFERENCES team_identity_records(team_id) + ON DELETE RESTRICT ON UPDATE RESTRICT, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + roster_generation INTEGER NOT NULL CHECK (roster_generation > 0), + adoption_fingerprint TEXT NOT NULL + CHECK (length(adoption_fingerprint) = 71 + AND substr(adoption_fingerprint, 1, 7) = 'sha256:' + AND substr(adoption_fingerprint, 8) NOT GLOB '*[^0-9a-f]*'), + adopted_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS team_roster_members ( + member_id TEXT PRIMARY KEY + CHECK (length(member_id) = 39 AND substr(member_id, 1, 7) = 'member_' + AND substr(member_id, 8) NOT GLOB '*[^0-9a-f]*'), + team_id TEXT NOT NULL REFERENCES team_rosters(team_id) + ON DELETE RESTRICT ON UPDATE RESTRICT, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + legacy_member_key TEXT NOT NULL + CHECK (length(legacy_member_key) BETWEEN 1 AND 128 + AND substr(legacy_member_key, 1, 1) GLOB '[A-Za-z0-9]' + AND legacy_member_key NOT GLOB '*[^A-Za-z0-9._-]*' + AND substr(legacy_member_key, -1, 1) <> '.'), + legacy_member_key_folded TEXT NOT NULL + CHECK (legacy_member_key_folded = lower(legacy_member_key)), + member_revision INTEGER NOT NULL CHECK (member_revision > 0), + state TEXT NOT NULL CHECK (state IN ('active', 'removed')), + provider_id TEXT NOT NULL + CHECK (provider_id IN ('anthropic', 'codex', 'gemini', 'opencode')), + model TEXT CHECK (model IS NULL OR length(model) BETWEEN 1 AND 512), + role TEXT CHECK (role IS NULL OR length(role) BETWEEN 1 AND 4096), + workflow TEXT CHECK (workflow IS NULL OR length(workflow) BETWEEN 1 AND 131072), + isolation TEXT CHECK (isolation IS NULL OR isolation = 'worktree') + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_team_roster_member_ordinal + ON team_roster_members (team_id, ordinal)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_team_roster_member_exact_key + ON team_roster_members (team_id, legacy_member_key)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_team_roster_member_folded_key + ON team_roster_members (team_id, legacy_member_key_folded)`, + `CREATE TRIGGER IF NOT EXISTS trg_team_roster_metadata_no_update + BEFORE UPDATE ON team_roster_storage_metadata + BEGIN SELECT RAISE(ABORT, 'team roster schema metadata is immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS trg_team_roster_metadata_no_delete + BEFORE DELETE ON team_roster_storage_metadata + BEGIN SELECT RAISE(ABORT, 'team roster schema metadata is immutable'); END`, +] as const; + +const EXPECTED_TEAM_ROSTER_COLUMNS = Object.freeze({ + team_roster_storage_metadata: ['component', 'schema_version'], + team_rosters: [ + 'team_id', + 'schema_version', + 'roster_generation', + 'adoption_fingerprint', + 'adopted_at', + ], + team_roster_members: [ + 'member_id', + 'team_id', + 'ordinal', + 'legacy_member_key', + 'legacy_member_key_folded', + 'member_revision', + 'state', + 'provider_id', + 'model', + 'role', + 'workflow', + 'isolation', + ], +} as const); + +/** Runs inside the shared migration transaction before user_version advances. */ +export function verifyTeamRosterStorageMigration(database: SqliteDatabase): void { + const metadata = database + .prepare( + `SELECT schema_version + FROM team_roster_storage_metadata + WHERE component = 'team-roster'` + ) + .get() as { readonly schema_version: number } | undefined; + if (metadata?.schema_version !== 1) { + throw new Error('team-roster-storage-migration-metadata-invalid'); + } + for (const [tableName, expectedColumns] of Object.entries(EXPECTED_TEAM_ROSTER_COLUMNS)) { + const actualColumns = ( + database.pragma(`table_info(${tableName})`) as { readonly name: string }[] + ).map(({ name }) => name); + if ( + actualColumns.length !== expectedColumns.length || + actualColumns.some((column, index) => column !== expectedColumns[index]) + ) { + throw new Error(`team-roster-storage-migration-table-invalid:${tableName}`); + } + } + const rosterForeignKeys = database.pragma('foreign_key_list(team_rosters)') as { + readonly from: string; + readonly table: string; + readonly to: string; + }[]; + const memberForeignKeys = database.pragma('foreign_key_list(team_roster_members)') as { + readonly from: string; + readonly table: string; + readonly to: string; + }[]; + if ( + !rosterForeignKeys.some( + (key) => + key.from === 'team_id' && key.table === 'team_identity_records' && key.to === 'team_id' + ) || + !memberForeignKeys.some( + (key) => key.from === 'team_id' && key.table === 'team_rosters' && key.to === 'team_id' + ) + ) { + throw new Error('team-roster-storage-migration-foreign-key-invalid'); + } +} diff --git a/src/features/localization/renderer/locales/ar/team.json b/src/features/localization/renderer/locales/ar/team.json index 0c01be1bba..bb0c63a078 100644 --- a/src/features/localization/renderer/locales/ar/team.json +++ b/src/features/localization/renderer/locales/ar/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "قد تحتوي علامة التبويب هذه على مخرجات طرفية أو عمليات قيد التشغيل. سيؤدي إغلاقها إلى إزالة علامة التبويب ومخرجاتها المرئية من مساحة العمل هذه.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/bn/team.json b/src/features/localization/renderer/locales/bn/team.json index 674fe96cfe..d386b2fd2f 100644 --- a/src/features/localization/renderer/locales/bn/team.json +++ b/src/features/localization/renderer/locales/bn/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "এই ট্যাবে টার্মিনাল আউটপুট বা চলমান প্রসেস থাকতে পারে। এটি বন্ধ করলে এই ওয়ার্কস্পেস থেকে ট্যাবটি ও এর দৃশ্যমান আউটপুট সরিয়ে দেওয়া হবে।", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/de/team.json b/src/features/localization/renderer/locales/de/team.json index a7f43fd31b..8f140cc77d 100644 --- a/src/features/localization/renderer/locales/de/team.json +++ b/src/features/localization/renderer/locales/de/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Dieser Tab kann Terminalausgaben oder laufende Prozesse enthalten. Beim Schließen werden der Tab und seine sichtbaren Ausgaben aus diesem Arbeitsbereich entfernt.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json index 8c510d9ecb..2fd65ae845 100644 --- a/src/features/localization/renderer/locales/en/team.json +++ b/src/features/localization/renderer/locales/en/team.json @@ -2649,7 +2649,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "This tab may contain terminal output or running processes. Closing it will remove the tab and its visible output from this workspace.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/es/team.json b/src/features/localization/renderer/locales/es/team.json index af34b77ae8..4cfd5d0051 100644 --- a/src/features/localization/renderer/locales/es/team.json +++ b/src/features/localization/renderer/locales/es/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Esta pestaña puede contener salida del terminal o procesos en ejecución. Al cerrarla, se eliminarán la pestaña y su salida visible de este espacio de trabajo.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/fa/team.json b/src/features/localization/renderer/locales/fa/team.json index 3fcd99591d..59e162ac86 100644 --- a/src/features/localization/renderer/locales/fa/team.json +++ b/src/features/localization/renderer/locales/fa/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "این زبانه ممکن است شامل خروجی ترمینال یا فرایندهای در حال اجرا باشد. با بستن آن، زبانه و خروجی قابل مشاهده آن از این فضای کاری حذف می‌شود.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/fil/team.json b/src/features/localization/renderer/locales/fil/team.json index b774fe8f77..67d0fc3078 100644 --- a/src/features/localization/renderer/locales/fil/team.json +++ b/src/features/localization/renderer/locales/fil/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Maaaring may terminal output o mga tumatakbong proseso ang tab na ito. Kapag isinara ito, aalisin ang tab at ang nakikitang output nito sa workspace na ito.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/fr/team.json b/src/features/localization/renderer/locales/fr/team.json index 68b89f4436..88883a107b 100644 --- a/src/features/localization/renderer/locales/fr/team.json +++ b/src/features/localization/renderer/locales/fr/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Cet onglet peut contenir une sortie de terminal ou des processus en cours d'exécution. Le fermer supprimera l'onglet et sa sortie visible de cet espace de travail.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/hi/team.json b/src/features/localization/renderer/locales/hi/team.json index 0934ce1868..b3507577a8 100644 --- a/src/features/localization/renderer/locales/hi/team.json +++ b/src/features/localization/renderer/locales/hi/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "इस टैब में टर्मिनल आउटपुट या चल रही प्रक्रियाएं हो सकती हैं। इसे बंद करने पर यह टैब और इसका दिखाई देने वाला आउटपुट इस वर्कस्पेस से हट जाएगा।", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/id/team.json b/src/features/localization/renderer/locales/id/team.json index e9a5b55883..096a34ad6f 100644 --- a/src/features/localization/renderer/locales/id/team.json +++ b/src/features/localization/renderer/locales/id/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Tab ini mungkin berisi output terminal atau proses yang sedang berjalan. Menutupnya akan menghapus tab dan output yang terlihat dari ruang kerja ini.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/it/team.json b/src/features/localization/renderer/locales/it/team.json index a3a2849311..d403a6c0bf 100644 --- a/src/features/localization/renderer/locales/it/team.json +++ b/src/features/localization/renderer/locales/it/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Questa scheda potrebbe contenere output del terminale o processi in esecuzione. Chiudendola, la scheda e il relativo output visibile verranno rimossi da questo spazio di lavoro.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/ja/team.json b/src/features/localization/renderer/locales/ja/team.json index c792efa3c1..0619bb8b3a 100644 --- a/src/features/localization/renderer/locales/ja/team.json +++ b/src/features/localization/renderer/locales/ja/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "このタブには、ターミナル出力や実行中のプロセスが含まれている可能性があります。閉じると、このワークスペースからタブと表示中の出力が削除されます。", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/ko/team.json b/src/features/localization/renderer/locales/ko/team.json index 88a4b24c20..3a012ab475 100644 --- a/src/features/localization/renderer/locales/ko/team.json +++ b/src/features/localization/renderer/locales/ko/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "이 탭에는 터미널 출력이나 실행 중인 프로세스가 있을 수 있습니다. 탭을 닫으면 이 작업 공간에서 탭과 표시된 출력이 제거됩니다.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/mr/team.json b/src/features/localization/renderer/locales/mr/team.json index de7164a37f..026d93c913 100644 --- a/src/features/localization/renderer/locales/mr/team.json +++ b/src/features/localization/renderer/locales/mr/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "या टॅबमध्ये टर्मिनल आउटपुट किंवा सुरू असलेल्या प्रक्रिया असू शकतात. हा टॅब बंद केल्यास तो आणि त्याचे दृश्यमान आउटपुट या वर्कस्पेसमधून काढले जाईल.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/ms/team.json b/src/features/localization/renderer/locales/ms/team.json index d58f398316..a39a405e0e 100644 --- a/src/features/localization/renderer/locales/ms/team.json +++ b/src/features/localization/renderer/locales/ms/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Tab ini mungkin mengandungi output terminal atau proses yang sedang berjalan. Menutupnya akan mengalih keluar tab dan output yang kelihatan daripada ruang kerja ini.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/nl/team.json b/src/features/localization/renderer/locales/nl/team.json index cd5036f6e9..9fd55ac4fd 100644 --- a/src/features/localization/renderer/locales/nl/team.json +++ b/src/features/localization/renderer/locales/nl/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Dit tabblad kan terminaluitvoer of actieve processen bevatten. Als u het sluit, worden het tabblad en de zichtbare uitvoer uit deze werkruimte verwijderd.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/pl/team.json b/src/features/localization/renderer/locales/pl/team.json index be9513f8e9..092a79582a 100644 --- a/src/features/localization/renderer/locales/pl/team.json +++ b/src/features/localization/renderer/locales/pl/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Ta karta może zawierać dane wyjściowe terminala lub uruchomione procesy. Jej zamknięcie usunie kartę i widoczne dane wyjściowe z tego obszaru roboczego.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/pt/team.json b/src/features/localization/renderer/locales/pt/team.json index c55b48a33f..e5d39a0c31 100644 --- a/src/features/localization/renderer/locales/pt/team.json +++ b/src/features/localization/renderer/locales/pt/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Esta aba pode conter saída do terminal ou processos em execução. Fechá-la removerá a aba e a saída visível deste espaço de trabalho.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/ro/team.json b/src/features/localization/renderer/locales/ro/team.json index a42c4bbf41..37375a0094 100644 --- a/src/features/localization/renderer/locales/ro/team.json +++ b/src/features/localization/renderer/locales/ro/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Această filă poate conține ieșire de terminal sau procese în execuție. Închiderea ei va elimina fila și ieșirea vizibilă din acest spațiu de lucru.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/ru/team.json b/src/features/localization/renderer/locales/ru/team.json index 9d9b62f882..8092f63c29 100644 --- a/src/features/localization/renderer/locales/ru/team.json +++ b/src/features/localization/renderer/locales/ru/team.json @@ -2640,7 +2640,7 @@ "createTerminalTab": "Создать вкладку терминала", "terminalTabsUnavailable": "Вкладки терминала недоступны", "closeTerminalTabDialogTitle": "Закрыть вкладку терминала?", - "closeTerminalTabDialogDescription": "В этой вкладке есть история вывода терминала. Закрытие удалит вкладку и ее видимый вывод из workspace.", + "closeTerminalTabDialogDescription": "Эта вкладка может содержать вывод терминала или запущенные процессы. При закрытии вкладка и ее видимый вывод будут удалены из этого рабочего пространства.", "cancel": "Отмена", "closeTab": "Закрыть вкладку", "commandPlaceholder": "Введите команду...", diff --git a/src/features/localization/renderer/locales/sw/team.json b/src/features/localization/renderer/locales/sw/team.json index 9c16bb9f99..b59f2c93b9 100644 --- a/src/features/localization/renderer/locales/sw/team.json +++ b/src/features/localization/renderer/locales/sw/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Kichupo hiki kinaweza kuwa na matokeo ya terminali au michakato inayoendelea. Kukifunga kutaondoa kichupo na matokeo yake yanayoonekana kwenye nafasi hii ya kazi.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/ta/team.json b/src/features/localization/renderer/locales/ta/team.json index aeffac5c3b..d83fa2ec8c 100644 --- a/src/features/localization/renderer/locales/ta/team.json +++ b/src/features/localization/renderer/locales/ta/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "இந்தத் தாவலில் முனைய வெளியீடு அல்லது இயங்கிக்கொண்டிருக்கும் செயல்முறைகள் இருக்கலாம். இதை மூடினால், இந்தப் பணியிடத்திலிருந்து தாவலும் அதன் காணக்கூடிய வெளியீடும் அகற்றப்படும்.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/te/team.json b/src/features/localization/renderer/locales/te/team.json index 0417f97041..93ee722bac 100644 --- a/src/features/localization/renderer/locales/te/team.json +++ b/src/features/localization/renderer/locales/te/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "ఈ ట్యాబ్‌లో టెర్మినల్ అవుట్‌పుట్ లేదా నడుస్తున్న ప్రాసెస్‌లు ఉండవచ్చు. దీన్ని మూసివేస్తే, ఈ వర్క్‌స్పేస్ నుండి ట్యాబ్ మరియు దాని కనిపించే అవుట్‌పుట్ తొలగించబడతాయి.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/th/team.json b/src/features/localization/renderer/locales/th/team.json index b0a7f78091..86632e4081 100644 --- a/src/features/localization/renderer/locales/th/team.json +++ b/src/features/localization/renderer/locales/th/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "แท็บนี้อาจมีเอาต์พุตของเทอร์มินัลหรือกระบวนการที่กำลังทำงานอยู่ การปิดแท็บจะนำแท็บและเอาต์พุตที่มองเห็นออกจากพื้นที่ทำงานนี้", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/tr/team.json b/src/features/localization/renderer/locales/tr/team.json index 9b52194f97..28d0b987fb 100644 --- a/src/features/localization/renderer/locales/tr/team.json +++ b/src/features/localization/renderer/locales/tr/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Bu sekmede terminal çıktısı veya çalışan işlemler bulunabilir. Sekmeyi kapatmak, sekmeyi ve görünür çıktısını bu çalışma alanından kaldırır.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/uk/team.json b/src/features/localization/renderer/locales/uk/team.json index 33b5ca00f9..dc31c54214 100644 --- a/src/features/localization/renderer/locales/uk/team.json +++ b/src/features/localization/renderer/locales/uk/team.json @@ -2604,7 +2604,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Ця вкладка може містити вивід термінала або запущені процеси. Після закриття вкладку та її видимий вивід буде видалено з цього робочого простору.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/ur/team.json b/src/features/localization/renderer/locales/ur/team.json index dea42e8a9e..7459204447 100644 --- a/src/features/localization/renderer/locales/ur/team.json +++ b/src/features/localization/renderer/locales/ur/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "اس ٹیب میں ٹرمینل آؤٹ پٹ یا چلنے والے پراسیس موجود ہو سکتے ہیں۔ اسے بند کرنے سے یہ ٹیب اور اس کا نظر آنے والا آؤٹ پٹ اس ورک اسپیس سے ہٹا دیا جائے گا۔", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/vi/team.json b/src/features/localization/renderer/locales/vi/team.json index 2c69e17ec5..b5e188a46e 100644 --- a/src/features/localization/renderer/locales/vi/team.json +++ b/src/features/localization/renderer/locales/vi/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "Tab này có thể chứa đầu ra của terminal hoặc các tiến trình đang chạy. Việc đóng tab sẽ xóa tab và phần đầu ra đang hiển thị khỏi không gian làm việc này.", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/locales/zh/team.json b/src/features/localization/renderer/locales/zh/team.json index 1e57eb712d..f9a84ecef9 100644 --- a/src/features/localization/renderer/locales/zh/team.json +++ b/src/features/localization/renderer/locales/zh/team.json @@ -2602,7 +2602,7 @@ "createTerminalTab": "Create terminal tab", "terminalTabsUnavailable": "Terminal tabs are unavailable", "closeTerminalTabDialogTitle": "Close terminal tab?", - "closeTerminalTabDialogDescription": "This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.", + "closeTerminalTabDialogDescription": "此标签页可能包含终端输出或正在运行的进程。关闭后,该标签页及其可见输出将从此工作区中移除。", "cancel": "Cancel", "closeTab": "Close tab", "commandPlaceholder": "Type a command...", diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts index 7cbf1e4612..d54f7e8698 100644 --- a/src/features/localization/renderer/resources.d.ts +++ b/src/features/localization/renderer/resources.d.ts @@ -6208,7 +6208,7 @@ export default interface Resources { closeTerminalSettingsTab: 'Close terminal settings tab'; closeTerminalSheet: 'Close terminal sheet'; closeTerminalTab: 'Close terminal tab {{tab}}'; - closeTerminalTabDialogDescription: 'This tab has terminal output history. Closing it will remove the tab and its visible output from this workspace.'; + closeTerminalTabDialogDescription: 'This tab may contain terminal output or running processes. Closing it will remove the tab and its visible output from this workspace.'; closeTerminalTabDialogTitle: 'Close terminal tab?'; commandInterrupt: 'Ctrl+C'; commandInterruptTitle: 'Send Ctrl+C to the focused pane'; diff --git a/src/features/member-log-stream/contracts/hosted.ts b/src/features/member-log-stream/contracts/hosted.ts new file mode 100644 index 0000000000..1bfa6b255c --- /dev/null +++ b/src/features/member-log-stream/contracts/hosted.ts @@ -0,0 +1,716 @@ +import { + createSafeAppError, + type Cursor, + HOSTED_SCHEMA_VERSION, + type MemberId, + parseCursor, + parseMemberId, + parseRevision, + parseTeamId, + type Revision, + type SafeAppError, + type TeamId, +} from '@shared/contracts/hosted'; + +declare const hostedMemberLogBrand: unique symbol; + +type HostedMemberLogOpaqueValue = string & { + readonly [hostedMemberLogBrand]: Name; +}; + +/** An authority-issued opaque identity for one browser-safe member-log entry. */ +export type HostedMemberLogEntryId = HostedMemberLogOpaqueValue<'HostedMemberLogEntryId'>; +/** An authenticated, authority-issued locator for one selected member log. */ +export type HostedMemberLogSelectionId = HostedMemberLogOpaqueValue<'HostedMemberLogSelectionId'>; +/** Binds a continuation to one immutable member-log source snapshot. */ +export type HostedMemberLogSourceGeneration = + HostedMemberLogOpaqueValue<'HostedMemberLogSourceGeneration'>; + +export const HOSTED_MEMBER_LOG_SCHEMA_VERSION = HOSTED_SCHEMA_VERSION; +export const HOSTED_MEMBER_LOG_PAGE_HTTP_PATH = '/api/hosted/v1/member-log/page' as const; + +export const HOSTED_MEMBER_LOG_MAX_PAGE_ITEMS = 50; +export const HOSTED_MEMBER_LOG_MAX_SOURCE_ITEMS = HOSTED_MEMBER_LOG_MAX_PAGE_ITEMS + 1; +export const HOSTED_MEMBER_LOG_MAX_PAGE_BYTES = 64 * 1024; +export const HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS = 250; +export const HOSTED_MEMBER_LOG_MAX_TEXT_LENGTH = 4_000; +/** Prevents one visible member-log view from retaining an unbounded continuation history. */ +export const HOSTED_MEMBER_LOG_MAX_RENDERED_ENTRIES = 200; +/** Caps browser cursor tracking; reaching it stops pagination rather than forgetting loop evidence. */ +export const HOSTED_MEMBER_LOG_MAX_CURSOR_HISTORY = 200; + +const ENTRY_ID = /^member_log_[0-9a-f]{32}$/; +const SELECTION_ID = /^member_log_selection_[0-9a-f]{32}$/; +const SOURCE_GENERATION = /^generation_[A-Za-z0-9][A-Za-z0-9._-]{0,245}$/; + +const PAGE_REQUEST_KEYS = Object.freeze([ + 'schemaVersion', + 'selectionId', + 'cursor', + 'expectedSourceGeneration', + 'limit', +] as const); +const ENTRY_KEYS = Object.freeze([ + 'teamId', + 'memberId', + 'entryId', + 'level', + 'occurredAtMs', + 'text', +]); +const PAGE_KEYS = Object.freeze([ + 'schemaVersion', + 'kind', + 'selectionId', + 'teamId', + 'memberId', + 'sourceGeneration', + 'revision', + 'entries', + 'nextCursor', + 'truncated', + 'truncationReasons', + 'budget', +] as const); +const BUDGET_KEYS = Object.freeze([ + 'itemLimit', + 'byteLimit', + 'timeLimitMs', + 'usedItems', + 'usedBytes', + 'elapsedMs', +] as const); +const ERROR_REQUIRED_KEYS = Object.freeze(['schemaVersion', 'kind', 'error', 'retryable'] as const); +const ERROR_ALLOWED_KEYS = Object.freeze([ + ...ERROR_REQUIRED_KEYS, + 'currentSourceGeneration', +] as const); + +const REDACTED = '[REDACTED]'; +const PRIVATE_KEY = + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g; +/** Cookie attributes can themselves carry credentials, so their values are all-or-nothing. */ +const COOKIE_HEADER_VALUE = /(\b(?:set-)?cookie\b\s*[:=]\s*)[^\r\n]*/gi; +const AUTHORIZATION_VALUE = /(\b(?:proxy-)?authorization)(\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+/gi; +const NAMED_SECRET_LABELS = Object.freeze([ + /^(?:[a-z0-9]+[-_])?api[-_]?key$/i, + /^(?:[a-z0-9]+[-_])?access[-_]?token$/i, + /^(?:[a-z0-9]+[-_])?auth[-_]?token$/i, + /^(?:[a-z0-9]+[-_])?client[-_]?secret$/i, + /^(?:[a-z0-9]+[-_])?private[-_]?key$/i, + /^(?:[a-z0-9]+[-_])?key$/i, + /^(?:[a-z0-9]+[-_])?token$/i, + /^(?:[a-z0-9]+[-_])?password$/i, + /^(?:[a-z0-9]+[-_])?(?:aws[-_]?)?secret(?:[-_]?(?:access[-_]?)?key)?$/i, +] as const); +/** Connection strings are sensitive even when their exact URL format is not recognized. */ +const CONNECTION_LABELS = Object.freeze([ + /^(?:[a-z0-9]+[-_])?database[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?db[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?postgres(?:ql)?[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?mysql[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?mariadb[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?mongo(?:db)?[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?redis[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?amqp[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?kafka[-_]?(?:url|uri|dsn)["']?$/i, + /^(?:[a-z0-9]+[-_])?connection(?:[-_]?string)?["']?$/i, + /^(?:[a-z0-9]+[-_])?dsn["']?$/i, +] as const); +/** Covers provider-specific labels before a new credential naming scheme can be allowlisted. */ +const UNKNOWN_CREDENTIAL_LABELS = Object.freeze([ + /^[a-z][\w-]*auth(?:entication|orization)?[\w-]*["']?$/i, + /^[a-z][\w-]*credential[\w-]*["']?$/i, + /^[a-z][\w-]*secret[\w-]*["']?$/i, + /^[a-z][\w-]*token[\w-]*["']?$/i, + /^[a-z][\w-]*password[\w-]*["']?$/i, + /^[a-z][\w-]*passwd[\w-]*["']?$/i, + /^[a-z][\w-]*passphrase[\w-]*["']?$/i, + /^[a-z][\w-]*cookie[\w-]*["']?$/i, + /^[a-z][\w-]*session[\w-]*["']?$/i, + /^[a-z][\w-]*key[\w-]*["']?$/i, +] as const); +const LABELED_VALUE_PREFIX = /\b([a-z0-9][\w-]*["']?)(\s*[:=]\s*)/gi; +const LABELED_VALUE_DELIMITER = /[\s,;]/; +const URL_QUERY_PARAMETER_PREFIX = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>|]*[?&]$/i; +const BEARER_VALUE = /\bbearer\s+[\w.~+/=-]{16,}/gi; +const TOKEN_VALUES = Object.freeze([ + /\bsk-[\w-]{16,}\b/g, + /\bgh[pousr]_\w{20,}\b/g, + /\bgithub_pat_\w{20,}\b/g, + /\bxox[baprs]-[\w-]{16,}\b/g, + /\bAKIA[0-9A-Z]{16}\b/g, +] as const); +/** A URI with user info or sensitive query params is never browser-safe. */ +const CREDENTIAL_BEARING_URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#@"`<>|]*@[^\s"'`<>|]*/gi; +const URL_VALUE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>|]*/gi; +const CREDENTIAL_QUERY_PARAMETER_NAMES = Object.freeze([ + 'api-key', + 'access-token', + 'auth-token', + 'client-secret', + 'private-key', + 'credential', + 'secret', + 'secret-key', + 'secret-access-key', + 'aws-secret', + 'aws-secret-key', + 'aws-secret-access-key', + 'token', + 'password', + 'passwd', + 'passphrase', + 'key', +] as const); +/** Long, high-entropy opaque values are treated as credentials unless the contract explicitly models them. */ +const OPAQUE_CREDENTIAL_VALUE = /\b[A-Za-z0-9._~+/-]{24,}\b/g; +const USER_PATH = /(?:\/Users\/|\/home\/)[A-Za-z0-9._-]+(?:\/[^\s"'`<>|]*)*/g; +const WINDOWS_USER_PATH = /[A-Za-z]:\\Users\\[^\s"'`<>|]*/g; +const EMAIL_ADDRESS = + /\b[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+\b/g; + +const textEncoder = new TextEncoder(); + +export const HOSTED_MEMBER_LOG_LEVELS = Object.freeze(['info', 'warning', 'error'] as const); +export type HostedMemberLogLevel = (typeof HOSTED_MEMBER_LOG_LEVELS)[number]; + +export const HOSTED_MEMBER_LOG_TRUNCATION_REASONS = Object.freeze([ + 'item_budget', + 'byte_budget', + 'time_budget', + 'source_budget', +] as const); +export type HostedMemberLogTruncationReason = (typeof HOSTED_MEMBER_LOG_TRUNCATION_REASONS)[number]; + +export interface HostedMemberLogEntry { + readonly teamId: TeamId; + readonly memberId: MemberId; + readonly entryId: HostedMemberLogEntryId; + readonly level: HostedMemberLogLevel; + readonly occurredAtMs: number; + /** Redacted plain text only; source metadata and raw sensitive text are never browser data. */ + readonly text: string; +} + +export interface HostedMemberLogPageRequest { + readonly schemaVersion: typeof HOSTED_MEMBER_LOG_SCHEMA_VERSION; + /** Opaque locator only. The server resolves workspace and member authority from its live grant. */ + readonly selectionId: HostedMemberLogSelectionId; + readonly cursor: Cursor | null; + readonly expectedSourceGeneration: HostedMemberLogSourceGeneration | null; + readonly limit: number; +} + +export interface HostedMemberLogPageBudget { + readonly itemLimit: number; + readonly byteLimit: number; + readonly timeLimitMs: number; + readonly usedItems: number; + /** Exact UTF-8 byte count of the complete JSON page envelope, entries and metadata included. */ + readonly usedBytes: number; + readonly elapsedMs: number; +} + +export interface HostedMemberLogPage { + readonly schemaVersion: typeof HOSTED_MEMBER_LOG_SCHEMA_VERSION; + readonly kind: 'member_log_page'; + readonly selectionId: HostedMemberLogSelectionId; + /** Opaque authority projection; never selected from caller-provided workspace/member input. */ + readonly teamId: TeamId; + readonly memberId: MemberId; + readonly sourceGeneration: HostedMemberLogSourceGeneration; + readonly revision: Revision; + /** Authority continuation order is preserved exactly. */ + readonly entries: readonly HostedMemberLogEntry[]; + readonly nextCursor: Cursor | null; + readonly truncated: boolean; + readonly truncationReasons: readonly HostedMemberLogTruncationReason[]; + readonly budget: HostedMemberLogPageBudget; +} + +export type GetHostedMemberLogPageResult = + | { readonly kind: 'success'; readonly page: HostedMemberLogPage } + | { readonly kind: 'invalid_request' } + | { + readonly kind: 'stale_generation'; + readonly currentSourceGeneration: HostedMemberLogSourceGeneration; + } + | { readonly kind: 'not_found' } + | { readonly kind: 'cancelled' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +export interface HostedMemberLogErrorEnvelope { + readonly schemaVersion: typeof HOSTED_MEMBER_LOG_SCHEMA_VERSION; + readonly kind: 'error'; + readonly error: SafeAppError; + readonly retryable: boolean; + readonly currentSourceGeneration?: HostedMemberLogSourceGeneration; +} + +export type HostedMemberLogParseResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false }; + +function success(value: T): HostedMemberLogParseResult { + return Object.freeze({ ok: true, value }); +} + +function failure(): HostedMemberLogParseResult { + return Object.freeze({ ok: false }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function hasDisallowedControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + code === 127 || + (code < 32 && code !== 9 && code !== 10) || + (code >= 0x80 && code <= 0x9f) || + code === 0x061c || + (code >= 0x200e && code <= 0x200f) || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ) { + return true; + } + } + return false; +} + +function safeIntegerInRange(value: unknown, minimum: number, maximum: number): value is number { + return ( + Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum + ); +} + +function parsePlainHostedMemberLogText(value: unknown): string { + if ( + typeof value !== 'string' || + value.length < 1 || + value.length > HOSTED_MEMBER_LOG_MAX_TEXT_LENGTH || + value.trim().length === 0 || + value.includes('\r') || + hasDisallowedControl(value) + ) { + throw new TypeError('hosted-member-log-text-invalid'); + } + return value; +} + +function redactOpaqueCredentialValue(value: string): string { + const distinctCharacters = new Set(value).size; + const characterClasses = [ + /[a-z]/.test(value), + /[A-Z]/.test(value), + /\d/.test(value), + /[._~+/-]/.test(value), + ].filter(Boolean).length; + return characterClasses >= 2 || distinctCharacters >= 12 ? REDACTED : value; +} + +function isEscapedQuote(value: string, index: number): boolean { + let consecutiveBackslashes = 0; + for (let cursor = index - 1; cursor >= 0 && value.charAt(cursor) === '\\'; cursor -= 1) { + consecutiveBackslashes += 1; + } + return consecutiveBackslashes % 2 === 1; +} + +function labeledValueEnd(value: string, start: number): number { + const quote = value.charAt(start); + if (quote === '"' || quote === "'") { + for (let cursor = start + 1; cursor < value.length; cursor += 1) { + const character = value.charAt(cursor); + if (character === '\n') return cursor; + if (character === quote && !isEscapedQuote(value, cursor)) return cursor + 1; + } + return value.length; + } + let end = start; + while (end < value.length && !LABELED_VALUE_DELIMITER.test(value.charAt(end))) { + end += 1; + } + return end; +} + +function isUrlQueryParameter(value: string, index: number): boolean { + return URL_QUERY_PARAMETER_PREFIX.test(value.slice(0, index)); +} + +function redactLabeledValues(value: string, labels: readonly RegExp[]): string { + let cursor = 0; + let redacted = ''; + for (const match of value.matchAll(LABELED_VALUE_PREFIX)) { + const index = match.index; + const label = match[1]; + const separator = match[2]; + if ( + index < cursor || + isUrlQueryParameter(value, index) || + !labels.some((pattern) => pattern.test(label)) + ) { + continue; + } + const valueStart = index + match[0].length; + const valueEnd = labeledValueEnd(value, valueStart); + if (valueEnd === valueStart) continue; + redacted += `${value.slice(cursor, index)}${label}${separator}${REDACTED}`; + cursor = valueEnd; + } + return cursor === 0 ? value : `${redacted}${value.slice(cursor)}`; +} + +function redactValues(value: string, patterns: readonly RegExp[]): string { + let redacted = value; + for (const pattern of patterns) { + redacted = redacted.replace(pattern, REDACTED); + } + return redacted; +} + +function normalizeQueryParameterName(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function isCredentialQueryParameterName(value: string): boolean { + const normalizedName = normalizeQueryParameterName(value); + const compactName = normalizedName.replace(/-/g, ''); + return CREDENTIAL_QUERY_PARAMETER_NAMES.some((credentialName) => { + const compactCredentialName = credentialName.replace(/-/g, ''); + return ( + normalizedName === credentialName || + compactName === compactCredentialName || + normalizedName.endsWith(`-${credentialName}`) || + normalizedName.endsWith(`-${compactCredentialName}`) + ); + }); +} + +function redactCredentialQueryUrls(value: string): string { + return value.replace(URL_VALUE, (url) => { + try { + for (const parameterName of new URL(url).searchParams.keys()) { + if (isCredentialQueryParameterName(parameterName)) return REDACTED; + } + return url; + } catch { + return url; + } + }); +} + +export function parseHostedMemberLogEntryId(value: unknown): HostedMemberLogEntryId { + if (typeof value !== 'string' || !ENTRY_ID.test(value)) { + throw new TypeError('hosted-member-log-entry-id-invalid'); + } + return value as HostedMemberLogEntryId; +} + +export function parseHostedMemberLogSelectionId(value: unknown): HostedMemberLogSelectionId { + if (typeof value !== 'string' || !SELECTION_ID.test(value)) { + throw new TypeError('hosted-member-log-selection-id-invalid'); + } + return value as HostedMemberLogSelectionId; +} + +export function parseHostedMemberLogSourceGeneration( + value: unknown +): HostedMemberLogSourceGeneration { + if (typeof value !== 'string' || !SOURCE_GENERATION.test(value)) { + throw new TypeError('hosted-member-log-source-generation-invalid'); + } + return value as HostedMemberLogSourceGeneration; +} + +/** Removes credential, private-key, personal-path, and email material before any browser boundary. */ +export function redactHostedMemberLogText(value: unknown): string { + const text = parsePlainHostedMemberLogText(value); + let redacted = text + .replace(PRIVATE_KEY, REDACTED) + .replace(COOKIE_HEADER_VALUE, (_match, prefix: string) => `${prefix}${REDACTED}`) + .replace( + AUTHORIZATION_VALUE, + (_match, label: string, separator: string) => `${label}${separator}${REDACTED}` + ) + .replace(BEARER_VALUE, `Bearer ${REDACTED}`); + redacted = redactLabeledValues(redacted, NAMED_SECRET_LABELS); + redacted = redactLabeledValues(redacted, CONNECTION_LABELS); + redacted = redactLabeledValues(redacted, UNKNOWN_CREDENTIAL_LABELS); + redacted = redactValues(redacted, TOKEN_VALUES); + redacted = redacted.replace(CREDENTIAL_BEARING_URL, REDACTED); + redacted = redactCredentialQueryUrls(redacted); + return redacted + .replace(USER_PATH, '[REDACTED_PATH]') + .replace(WINDOWS_USER_PATH, '[REDACTED_PATH]') + .replace(EMAIL_ADDRESS, '[REDACTED_EMAIL]') + .replace(OPAQUE_CREDENTIAL_VALUE, redactOpaqueCredentialValue); +} + +/** Accepts only redacted, bounded, renderer-safe text. Rendering must still use text nodes. */ +export function parseHostedMemberLogText(value: unknown): string { + const text = parsePlainHostedMemberLogText(value); + if (redactHostedMemberLogText(text) !== text) { + throw new TypeError('hosted-member-log-text-unredacted'); + } + return text; +} + +export function parseHostedMemberLogPageRequest( + value: unknown +): HostedMemberLogParseResult { + try { + if (!isRecord(value) || !hasExactKeys(value, PAGE_REQUEST_KEYS)) return failure(); + const schemaVersion = value.schemaVersion; + const cursorValue = value.cursor; + const generationValue = value.expectedSourceGeneration; + const limit = value.limit; + if ( + schemaVersion !== HOSTED_MEMBER_LOG_SCHEMA_VERSION || + !safeIntegerInRange(limit, 1, HOSTED_MEMBER_LOG_MAX_PAGE_ITEMS) + ) { + return failure(); + } + const cursor = cursorValue === null ? null : parseCursor(cursorValue); + const expectedSourceGeneration = + generationValue === null ? null : parseHostedMemberLogSourceGeneration(generationValue); + if ((cursor === null) !== (expectedSourceGeneration === null)) return failure(); + return success( + Object.freeze({ + schemaVersion, + selectionId: parseHostedMemberLogSelectionId(value.selectionId), + cursor, + expectedSourceGeneration, + limit, + }) + ); + } catch { + return failure(); + } +} + +export function parseHostedMemberLogEntry( + value: unknown, + expectedTeamId: TeamId, + expectedMemberId: MemberId +): HostedMemberLogEntry { + if (!isRecord(value) || !hasExactKeys(value, ENTRY_KEYS)) { + throw new TypeError('hosted-member-log-entry-invalid'); + } + const teamId = parseTeamId(value.teamId); + const memberId = parseMemberId(value.memberId); + const level = value.level; + const occurredAtMs = value.occurredAtMs; + if ( + teamId !== expectedTeamId || + memberId !== expectedMemberId || + !HOSTED_MEMBER_LOG_LEVELS.includes(level as HostedMemberLogLevel) || + !safeIntegerInRange(occurredAtMs, 0, Number.MAX_SAFE_INTEGER) + ) { + throw new TypeError('hosted-member-log-entry-invalid'); + } + return Object.freeze({ + teamId, + memberId, + entryId: parseHostedMemberLogEntryId(value.entryId), + level: level as HostedMemberLogLevel, + occurredAtMs, + text: parseHostedMemberLogText(value.text), + }); +} + +/** Normalizes untrusted source text before the result can cross into the HTTP adapter. */ +export function redactHostedMemberLogEntry( + value: unknown, + expectedTeamId: TeamId, + expectedMemberId: MemberId +): HostedMemberLogEntry { + if (!isRecord(value) || !hasExactKeys(value, ENTRY_KEYS)) { + throw new TypeError('hosted-member-log-entry-invalid'); + } + return parseHostedMemberLogEntry( + Object.freeze({ ...value, text: redactHostedMemberLogText(value.text) }), + expectedTeamId, + expectedMemberId + ); +} + +export function hostedMemberLogJsonByteLength(value: unknown): number { + const serialized = JSON.stringify(value); + if (typeof serialized !== 'string') { + throw new TypeError('hosted-member-log-json-value-invalid'); + } + return textEncoder.encode(serialized).byteLength; +} + +/** Measures an entry only; page admission uses `hostedMemberLogPageByteLength` instead. */ +export function hostedMemberLogEntryByteLength(entry: HostedMemberLogEntry): number { + return hostedMemberLogJsonByteLength(entry); +} + +/** Measures the exact JSON envelope admitted by the hosted response byte budget. */ +export function hostedMemberLogPageByteLength(page: HostedMemberLogPage): number { + return hostedMemberLogJsonByteLength(page); +} + +function parseBudget( + value: unknown, + itemCount: number, + requestLimit: number +): HostedMemberLogPageBudget { + if (!isRecord(value)) throw new TypeError('hosted-member-log-page-budget-invalid'); + const itemLimit = value.itemLimit; + const byteLimit = value.byteLimit; + const timeLimitMs = value.timeLimitMs; + const usedItems = value.usedItems; + const usedBytes = value.usedBytes; + const elapsedMs = value.elapsedMs; + if ( + !hasExactKeys(value, BUDGET_KEYS) || + itemLimit !== requestLimit || + byteLimit !== HOSTED_MEMBER_LOG_MAX_PAGE_BYTES || + timeLimitMs !== HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS || + usedItems !== itemCount || + !safeIntegerInRange(usedItems, 0, requestLimit) || + !safeIntegerInRange(usedBytes, 0, HOSTED_MEMBER_LOG_MAX_PAGE_BYTES) || + !safeIntegerInRange(elapsedMs, 0, HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS) + ) { + throw new TypeError('hosted-member-log-page-budget-invalid'); + } + return Object.freeze({ + itemLimit: requestLimit, + byteLimit: HOSTED_MEMBER_LOG_MAX_PAGE_BYTES, + timeLimitMs: HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS, + usedItems: itemCount, + usedBytes, + elapsedMs, + }); +} + +export function parseHostedMemberLogPage( + value: unknown, + request: HostedMemberLogPageRequest +): HostedMemberLogParseResult { + try { + if ( + !isRecord(value) || + !hasExactKeys(value, PAGE_KEYS) || + value.schemaVersion !== HOSTED_MEMBER_LOG_SCHEMA_VERSION || + value.kind !== 'member_log_page' || + parseHostedMemberLogSelectionId(value.selectionId) !== request.selectionId || + typeof value.truncated !== 'boolean' || + !Array.isArray(value.entries) || + value.entries.length > request.limit || + !Array.isArray(value.truncationReasons) || + value.truncationReasons.length > HOSTED_MEMBER_LOG_TRUNCATION_REASONS.length || + new Set(value.truncationReasons).size !== value.truncationReasons.length || + !value.truncationReasons.every((reason) => + HOSTED_MEMBER_LOG_TRUNCATION_REASONS.includes(reason as HostedMemberLogTruncationReason) + ) + ) { + return failure(); + } + for (let index = 0; index < value.entries.length; index += 1) { + if (!Object.hasOwn(value.entries, index)) return failure(); + } + const teamId = parseTeamId(value.teamId); + const memberId = parseMemberId(value.memberId); + const sourceGeneration = parseHostedMemberLogSourceGeneration(value.sourceGeneration); + if ( + request.expectedSourceGeneration !== null && + sourceGeneration !== request.expectedSourceGeneration + ) { + return failure(); + } + const entries = value.entries.map((entry) => + parseHostedMemberLogEntry(entry, teamId, memberId) + ); + if (new Set(entries.map((entry) => entry.entryId)).size !== entries.length) return failure(); + const nextCursor = value.nextCursor === null ? null : parseCursor(value.nextCursor); + const truncationReasons = Object.freeze([ + ...(value.truncationReasons as HostedMemberLogTruncationReason[]), + ]); + if ( + value.truncated !== (nextCursor !== null) || + value.truncated !== truncationReasons.length > 0 || + (nextCursor !== null && entries.length === 0) || + (nextCursor !== null && nextCursor === request.cursor) + ) { + return failure(); + } + const budget = parseBudget(value.budget, entries.length, request.limit); + const page = Object.freeze({ + schemaVersion: HOSTED_MEMBER_LOG_SCHEMA_VERSION, + kind: 'member_log_page' as const, + selectionId: request.selectionId, + teamId, + memberId, + sourceGeneration, + revision: parseRevision(value.revision), + entries: Object.freeze(entries), + nextCursor, + truncated: value.truncated, + truncationReasons, + budget, + }); + const measuredBytes = hostedMemberLogPageByteLength(page); + if (measuredBytes > HOSTED_MEMBER_LOG_MAX_PAGE_BYTES || budget.usedBytes !== measuredBytes) { + return failure(); + } + return success(page); + } catch { + return failure(); + } +} + +export function parseHostedMemberLogErrorEnvelope( + value: unknown +): HostedMemberLogParseResult { + try { + if (!isRecord(value)) return failure(); + if ( + value.schemaVersion !== HOSTED_MEMBER_LOG_SCHEMA_VERSION || + value.kind !== 'error' || + typeof value.retryable !== 'boolean' || + !ERROR_REQUIRED_KEYS.every((key) => Object.hasOwn(value, key)) || + Reflect.ownKeys(value).some( + (key) => + typeof key !== 'string' || + !ERROR_ALLOWED_KEYS.includes(key as (typeof ERROR_ALLOWED_KEYS)[number]) + ) + ) { + return failure(); + } + const currentSourceGeneration = Object.hasOwn(value, 'currentSourceGeneration') + ? parseHostedMemberLogSourceGeneration(value.currentSourceGeneration) + : undefined; + return success( + Object.freeze({ + schemaVersion: HOSTED_MEMBER_LOG_SCHEMA_VERSION, + kind: 'error', + error: createSafeAppError(value.error), + retryable: value.retryable, + ...(currentSourceGeneration === undefined ? {} : { currentSourceGeneration }), + }) + ); + } catch { + return failure(); + } +} diff --git a/src/features/member-log-stream/contracts/index.ts b/src/features/member-log-stream/contracts/index.ts index 41e0bc74fc..e3f060467f 100644 --- a/src/features/member-log-stream/contracts/index.ts +++ b/src/features/member-log-stream/contracts/index.ts @@ -1,4 +1,5 @@ export type * from './api'; export * from './channels'; export type * from './dto'; +export type { HostedMemberLogSelectionId } from './hosted'; export * from './normalize'; diff --git a/src/features/member-log-stream/core/application/ports/HostedMemberLogPorts.ts b/src/features/member-log-stream/core/application/ports/HostedMemberLogPorts.ts new file mode 100644 index 0000000000..20cd28b76a --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/HostedMemberLogPorts.ts @@ -0,0 +1,61 @@ +import type { + HostedMemberLogEntry, + HostedMemberLogSelectionId, + HostedMemberLogSourceGeneration, +} from '../../../contracts/hosted'; +import type { Cursor, MemberId, QueryContext, Revision, TeamId } from '@shared/contracts/hosted'; + +/** + * The browser's selection id is an opaque locator, not authority. The implementation must resolve + * it from a production-owned grant bound to `context` before choosing any team, member, or source. + */ +export interface HostedMemberLogAuthorityReadRequest { + readonly selectionId: HostedMemberLogSelectionId; + readonly cursor: Cursor | null; + readonly expectedSourceGeneration: HostedMemberLogSourceGeneration | null; + /** The authority may return at most this many candidates in its continuation order. */ + readonly itemLimit: number; + /** Applies to the final browser-safe projection, including its envelope. */ + readonly byteLimit: number; + readonly deadlineAtMs: number; +} + +export interface HostedMemberLogAuthorityCandidate { + /** Raw authority text is normalized and redacted by the use case before it can reach HTTP. */ + readonly entry: HostedMemberLogEntry; + readonly cursorAfter: Cursor; +} + +export type HostedMemberLogAuthorityReadResult = + | { + readonly kind: 'found'; + readonly selectionId: HostedMemberLogSelectionId; + readonly teamId: TeamId; + readonly memberId: MemberId; + readonly sourceGeneration: HostedMemberLogSourceGeneration; + readonly revision: Revision; + readonly candidates: readonly HostedMemberLogAuthorityCandidate[]; + readonly hasMore: boolean; + } + | { + readonly kind: 'stale_generation'; + readonly currentSourceGeneration: HostedMemberLogSourceGeneration; + } + | { readonly kind: 'not_found' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +/** + * Production-trusted hosted member-log authority. It revalidates the live grant in `context`, + * resolves `selectionId` server-side, and scopes continuation state to that resolved selection. + * Caller supplied workspace paths, team ids, member names, and member ids are deliberately absent. + */ +export interface HostedMemberLogAuthorityPort { + readPage( + request: HostedMemberLogAuthorityReadRequest, + context: QueryContext + ): Promise; +} + +export interface HostedMemberLogClockPort { + now(): number; +} diff --git a/src/features/member-log-stream/core/application/use-cases/GetHostedMemberLogPageUseCase.ts b/src/features/member-log-stream/core/application/use-cases/GetHostedMemberLogPageUseCase.ts new file mode 100644 index 0000000000..6a39469de0 --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/GetHostedMemberLogPageUseCase.ts @@ -0,0 +1,306 @@ +import { + parseCursor, + parseMemberId, + parseRevision, + parseTeamId, + type QueryContext, +} from '@shared/contracts/hosted'; + +import { + type GetHostedMemberLogPageResult, + HOSTED_MEMBER_LOG_MAX_PAGE_BYTES, + HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS, + HOSTED_MEMBER_LOG_MAX_SOURCE_ITEMS, + HOSTED_MEMBER_LOG_SCHEMA_VERSION, + HOSTED_MEMBER_LOG_TRUNCATION_REASONS, + type HostedMemberLogEntry, + type HostedMemberLogPage, + hostedMemberLogPageByteLength, + type HostedMemberLogTruncationReason, + parseHostedMemberLogPageRequest, + parseHostedMemberLogSelectionId, + parseHostedMemberLogSourceGeneration, + redactHostedMemberLogEntry, +} from '../../../contracts/hosted'; + +import type { + HostedMemberLogAuthorityCandidate, + HostedMemberLogAuthorityPort, + HostedMemberLogClockPort, +} from '../ports/HostedMemberLogPorts'; + +interface OrderedCandidate { + readonly entry: HostedMemberLogEntry; + readonly cursorAfter: ReturnType; +} + +interface PageInput { + readonly selectionId: HostedMemberLogPage['selectionId']; + readonly teamId: HostedMemberLogPage['teamId']; + readonly memberId: HostedMemberLogPage['memberId']; + readonly sourceGeneration: HostedMemberLogPage['sourceGeneration']; + readonly revision: HostedMemberLogPage['revision']; + readonly entries: readonly HostedMemberLogEntry[]; + readonly nextCursor: HostedMemberLogPage['nextCursor']; + readonly truncated: boolean; + readonly truncationReasons: readonly HostedMemberLogTruncationReason[]; + readonly itemLimit: number; + readonly elapsedMs: number; +} + +function unavailable(retryAfterMs?: number): GetHostedMemberLogPageResult { + return retryAfterMs === undefined + ? Object.freeze({ kind: 'unavailable' }) + : Object.freeze({ kind: 'unavailable', retryAfterMs }); +} + +function validRetryAfterMs(value: unknown): number | undefined { + return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= 60_000 + ? (value as number) + : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function normalizeCandidates( + value: readonly HostedMemberLogAuthorityCandidate[], + expectedTeamId: ReturnType, + expectedMemberId: ReturnType, + requestCursor: ReturnType | null, + itemLimit: number +): readonly OrderedCandidate[] | null { + if (!Array.isArray(value) || value.length > itemLimit) return null; + try { + const seenEntryIds = new Set(); + const seenCursors = new Set(); + const candidates: OrderedCandidate[] = []; + for (const candidate of value) { + if (!isRecord(candidate) || !hasExactKeys(candidate, ['entry', 'cursorAfter'])) return null; + const entry = redactHostedMemberLogEntry(candidate.entry, expectedTeamId, expectedMemberId); + const cursorAfter = parseCursor(candidate.cursorAfter); + if ( + seenEntryIds.has(entry.entryId) || + seenCursors.has(cursorAfter) || + (requestCursor !== null && cursorAfter === requestCursor) + ) { + return null; + } + seenEntryIds.add(entry.entryId); + seenCursors.add(cursorAfter); + candidates.push(Object.freeze({ entry, cursorAfter })); + } + return Object.freeze(candidates); + } catch { + return null; + } +} + +function orderedReasons(values: Iterable) { + const rank = new Map(HOSTED_MEMBER_LOG_TRUNCATION_REASONS.map((value, index) => [value, index])); + return Object.freeze( + [...new Set(values)].sort((left, right) => (rank.get(left) ?? 999) - (rank.get(right) ?? 999)) + ); +} + +/** Builds a page whose reported usage is a fixed point of its complete serialized JSON envelope. */ +function pageWithMeasuredBudget(input: PageInput): HostedMemberLogPage | null { + let usedBytes = 0; + for (let attempt = 0; attempt < 16; attempt += 1) { + const page = Object.freeze({ + schemaVersion: HOSTED_MEMBER_LOG_SCHEMA_VERSION, + kind: 'member_log_page' as const, + selectionId: input.selectionId, + teamId: input.teamId, + memberId: input.memberId, + sourceGeneration: input.sourceGeneration, + revision: input.revision, + entries: Object.freeze([...input.entries]), + nextCursor: input.nextCursor, + truncated: input.truncated, + truncationReasons: Object.freeze([...input.truncationReasons]), + budget: Object.freeze({ + itemLimit: input.itemLimit, + byteLimit: HOSTED_MEMBER_LOG_MAX_PAGE_BYTES, + timeLimitMs: HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS, + usedItems: input.entries.length, + usedBytes, + elapsedMs: input.elapsedMs, + }), + }); + const measuredBytes = hostedMemberLogPageByteLength(page); + if ( + !Number.isSafeInteger(measuredBytes) || + measuredBytes < 0 || + measuredBytes > HOSTED_MEMBER_LOG_MAX_PAGE_BYTES + ) { + return null; + } + if (measuredBytes === usedBytes) return page; + usedBytes = measuredBytes; + } + return null; +} + +/** + * Reads one opaque, bounded hosted member-log page through a server-trusted authority. The request + * carries only an opaque selection id; the authority, never the browser, binds it to team/member. + */ +export class GetHostedMemberLogPageUseCase { + constructor( + private readonly authority: HostedMemberLogAuthorityPort, + private readonly clock: HostedMemberLogClockPort + ) {} + + async execute( + requestValue: unknown, + context: QueryContext + ): Promise { + const request = parseHostedMemberLogPageRequest(requestValue); + if (!request.ok) return Object.freeze({ kind: 'invalid_request' }); + if (context.signal.aborted) return Object.freeze({ kind: 'cancelled' }); + + try { + const startedAtMs = this.clock.now(); + if (!Number.isSafeInteger(startedAtMs) || startedAtMs < 0) return unavailable(); + const deadlineAtMs = Math.min( + context.deadlineAtMs, + startedAtMs + HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS + ); + if (!Number.isSafeInteger(deadlineAtMs) || deadlineAtMs <= startedAtMs) return unavailable(); + + const itemLimit = Math.min(request.value.limit + 1, HOSTED_MEMBER_LOG_MAX_SOURCE_ITEMS); + const authorityResult = await this.authority.readPage( + Object.freeze({ + selectionId: request.value.selectionId, + cursor: request.value.cursor, + expectedSourceGeneration: request.value.expectedSourceGeneration, + itemLimit, + byteLimit: HOSTED_MEMBER_LOG_MAX_PAGE_BYTES, + deadlineAtMs, + }), + context + ); + if (context.signal.aborted) return Object.freeze({ kind: 'cancelled' }); + if (this.clock.now() >= deadlineAtMs) return unavailable(); + + if (authorityResult.kind === 'not_found') return Object.freeze({ kind: 'not_found' }); + if (authorityResult.kind === 'unavailable') { + return unavailable(validRetryAfterMs(authorityResult.retryAfterMs)); + } + if (authorityResult.kind === 'stale_generation') { + const currentSourceGeneration = parseHostedMemberLogSourceGeneration( + authorityResult.currentSourceGeneration + ); + if ( + request.value.expectedSourceGeneration === null || + currentSourceGeneration === request.value.expectedSourceGeneration + ) { + return unavailable(); + } + return Object.freeze({ kind: 'stale_generation', currentSourceGeneration }); + } + if (authorityResult.kind !== 'found') return unavailable(); + + if ( + parseHostedMemberLogSelectionId(authorityResult.selectionId) !== request.value.selectionId + ) { + return unavailable(); + } + const teamId = parseTeamId(authorityResult.teamId); + const memberId = parseMemberId(authorityResult.memberId); + const sourceGeneration = parseHostedMemberLogSourceGeneration( + authorityResult.sourceGeneration + ); + if ( + request.value.expectedSourceGeneration !== null && + sourceGeneration !== request.value.expectedSourceGeneration + ) { + return Object.freeze({ + kind: 'stale_generation', + currentSourceGeneration: sourceGeneration, + }); + } + const revision = parseRevision(authorityResult.revision); + if ( + !Array.isArray(authorityResult.candidates) || + typeof authorityResult.hasMore !== 'boolean' + ) { + return unavailable(); + } + const candidates = normalizeCandidates( + authorityResult.candidates, + teamId, + memberId, + request.value.cursor, + itemLimit + ); + if (candidates === null) return unavailable(); + + const selected: OrderedCandidate[] = []; + let skippedBy: HostedMemberLogTruncationReason | null = null; + for (const candidate of candidates) { + if (this.clock.now() >= deadlineAtMs) { + skippedBy = 'time_budget'; + break; + } + if (selected.length >= request.value.limit) { + skippedBy = 'item_budget'; + break; + } + selected.push(candidate); + } + + let byteBudgetExceeded = false; + while (true) { + const hasMore = authorityResult.hasMore || selected.length < candidates.length; + if (hasMore && selected.length === 0) return unavailable(); + + const reasons = new Set(); + if (authorityResult.hasMore) reasons.add('source_budget'); + if (selected.length < candidates.length) { + reasons.add(skippedBy ?? (byteBudgetExceeded ? 'byte_budget' : 'source_budget')); + } + if (byteBudgetExceeded) reasons.add('byte_budget'); + + const endedAtMs = this.clock.now(); + if ( + !Number.isSafeInteger(endedAtMs) || + endedAtMs < startedAtMs || + endedAtMs >= deadlineAtMs + ) { + return unavailable(); + } + const page = pageWithMeasuredBudget({ + selectionId: request.value.selectionId, + teamId, + memberId, + sourceGeneration, + revision, + entries: selected.map(({ entry }) => entry), + nextCursor: hasMore ? (selected.at(-1)?.cursorAfter ?? null) : null, + truncated: hasMore, + truncationReasons: orderedReasons(reasons), + itemLimit: request.value.limit, + elapsedMs: endedAtMs - startedAtMs, + }); + if (page !== null) return Object.freeze({ kind: 'success', page }); + if (selected.length === 0) return unavailable(); + selected.pop(); + byteBudgetExceeded = true; + } + } catch { + return unavailable(); + } + } +} diff --git a/src/features/member-log-stream/main/adapters/input/http/registerHostedMemberLogHttp.ts b/src/features/member-log-stream/main/adapters/input/http/registerHostedMemberLogHttp.ts new file mode 100644 index 0000000000..b4a4d2d762 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/input/http/registerHostedMemberLogHttp.ts @@ -0,0 +1,160 @@ +import { createSafeAppError, type QueryContext } from '@shared/contracts/hosted'; + +import { + type GetHostedMemberLogPageResult, + HOSTED_MEMBER_LOG_PAGE_HTTP_PATH, + HOSTED_MEMBER_LOG_SCHEMA_VERSION, + type HostedMemberLogErrorEnvelope, + type HostedMemberLogSourceGeneration, + parseHostedMemberLogPage, + parseHostedMemberLogPageRequest, + parseHostedMemberLogSourceGeneration, +} from '../../../../contracts/hosted'; +import { HOSTED_MEMBER_LOG_ROUTE_DESCRIPTORS } from '../../../composition/createHostedMemberLogFeature'; + +import type { + HostedRouteAdmission, + HostedRouteContribution, +} from '@main/composition/hosted/application'; +import type { RouteDescriptor } from '@main/composition/hosted/routing'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export const HOSTED_MEMBER_LOG_PAGE_ROUTE = HOSTED_MEMBER_LOG_PAGE_HTTP_PATH; + +export interface HostedMemberLogHttpFacade { + getPage(request: unknown, context: QueryContext): Promise; +} + +export type HostedMemberLogContextFactory = ( + descriptor: RouteDescriptor, + request: FastifyRequest, + signal: AbortSignal +) => QueryContext | Promise; + +function errorEnvelope( + code: 'conflict' | 'invalid_request' | 'not_found' | 'unavailable', + reason: string, + retryable: boolean, + metadata: { + readonly currentSourceGeneration?: HostedMemberLogSourceGeneration; + readonly retryAfterMs?: number; + } = {} +): HostedMemberLogErrorEnvelope { + return Object.freeze({ + schemaVersion: HOSTED_MEMBER_LOG_SCHEMA_VERSION, + kind: 'error', + error: createSafeAppError({ + code, + reason, + ...(metadata.retryAfterMs === undefined ? {} : { retryAfterMs: metadata.retryAfterMs }), + }), + retryable, + ...(metadata.currentSourceGeneration === undefined + ? {} + : { currentSourceGeneration: metadata.currentSourceGeneration }), + }); +} + +function sendUnavailable(reply: FastifyReply, retryAfterMs?: number): FastifyReply { + if (retryAfterMs !== undefined) { + void reply.header('Retry-After', String(Math.max(1, Math.ceil(retryAfterMs / 1_000)))); + } + return reply + .status(503) + .send(errorEnvelope('unavailable', 'member_log_unavailable', true, { retryAfterMs })); +} + +function sendPageResult( + reply: FastifyReply, + result: GetHostedMemberLogPageResult, + requestValue: unknown +): FastifyReply { + switch (result.kind) { + case 'success': { + const request = parseHostedMemberLogPageRequest(requestValue); + const page = request.ok ? parseHostedMemberLogPage(result.page, request.value) : null; + if (page !== null && page.ok) return reply.status(200).send(page.value); + return sendUnavailable(reply); + } + case 'invalid_request': + return reply + .status(400) + .send(errorEnvelope('invalid_request', 'member_log_page_request_invalid', false)); + case 'stale_generation': { + try { + return reply.status(409).send( + errorEnvelope('conflict', 'stale_generation', false, { + currentSourceGeneration: parseHostedMemberLogSourceGeneration( + result.currentSourceGeneration + ), + }) + ); + } catch { + return sendUnavailable(reply); + } + } + case 'not_found': + return reply.status(404).send(errorEnvelope('not_found', 'member_log_not_found', false)); + case 'cancelled': + return sendUnavailable(reply); + case 'unavailable': + return sendUnavailable(reply, result.retryAfterMs); + default: + return sendUnavailable(reply); + } +} + +async function withRequestSignal( + request: FastifyRequest, + reply: FastifyReply, + operation: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + request.raw.once('aborted', abort); + request.raw.socket.once('close', abort); + reply.raw.once('close', abort); + if (request.raw.aborted || request.raw.socket.destroyed || reply.raw.destroyed) abort(); + try { + return await operation(controller.signal); + } finally { + request.raw.removeListener('aborted', abort); + request.raw.socket.removeListener('close', abort); + reply.raw.removeListener('close', abort); + } +} + +/** Registers one authenticated browser read route; composition chooses whether to mount it. */ +export function registerHostedMemberLogHttp( + app: FastifyInstance, + contribution: HostedRouteContribution, + routeAdmission: HostedRouteAdmission, + createContext: HostedMemberLogContextFactory +): void { + const descriptor = contribution.routes[0]; + if ( + contribution.id !== 'member-log-stream.hosted.v1' || + contribution.routes.length !== 1 || + descriptor !== HOSTED_MEMBER_LOG_ROUTE_DESCRIPTORS[0] + ) { + throw new TypeError('hosted-member-log-route-contribution-invalid'); + } + const facade = contribution.facade; + app.post<{ Body: unknown }>(descriptor.path, async (request, reply) => { + void reply.header('Cache-Control', 'no-store'); + try { + return await withRequestSignal(request, reply, async (signal) => { + const invocation = await routeAdmission.invoke(descriptor.id, async () => { + const context = await createContext(descriptor, request, signal); + if (signal.aborted || context.signal !== signal) return null; + return facade.getPage(request.body, context); + }); + return invocation.admitted && invocation.value !== null + ? sendPageResult(reply, invocation.value, request.body) + : sendUnavailable(reply); + }); + } catch { + return sendUnavailable(reply); + } + }); +} diff --git a/src/features/member-log-stream/main/composition/createHostedMemberLogFeature.ts b/src/features/member-log-stream/main/composition/createHostedMemberLogFeature.ts new file mode 100644 index 0000000000..3a9f13f1d0 --- /dev/null +++ b/src/features/member-log-stream/main/composition/createHostedMemberLogFeature.ts @@ -0,0 +1,63 @@ +import { HOSTED_MEMBER_LOG_PAGE_HTTP_PATH } from '../../contracts/hosted'; +import { GetHostedMemberLogPageUseCase } from '../../core/application/use-cases/GetHostedMemberLogPageUseCase'; +import { + HOSTED_MEMBER_LOG_PAGE_ROUTE, + type HostedMemberLogHttpFacade, +} from '../adapters/input/http/registerHostedMemberLogHttp'; + +import type { + HostedMemberLogAuthorityPort, + HostedMemberLogClockPort, +} from '../../core/application/ports/HostedMemberLogPorts'; +import type { HostedRouteContribution } from '@main/composition/hosted/application'; +import type { RouteDescriptor } from '@main/composition/hosted/routing'; + +const READINESS = Object.freeze(['serve', 'auth', 'read'] as const); + +export const HOSTED_MEMBER_LOG_ROUTE_DESCRIPTORS = Object.freeze([ + Object.freeze({ + id: 'member-log.page.v1', + method: 'POST', + path: HOSTED_MEMBER_LOG_PAGE_HTTP_PATH, + owner: 'member-log-stream', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session', + readiness: READINESS, + requestSchemaId: 'member-log.page.request.v1', + responseSchemaId: 'member-log.page.response.v1', + handlerId: 'member-log.page.handler.v1', + clientId: 'member-log.page.client.v1', + semanticTestId: 'member-log.page.semantic.v1', + testOnly: false, + } satisfies RouteDescriptor), +] satisfies readonly RouteDescriptor[]); + +export interface HostedMemberLogFeature extends HostedMemberLogHttpFacade { + readonly routes: typeof HOSTED_MEMBER_LOG_ROUTE_DESCRIPTORS; +} + +export function createHostedMemberLogFeature(dependencies: { + /** The host must supply the production-trusted authority, never a raw member-log reader. */ + readonly authority: HostedMemberLogAuthorityPort; + readonly clock?: HostedMemberLogClockPort; +}): HostedMemberLogFeature { + const clock = dependencies.clock ?? Object.freeze({ now: Date.now }); + const getPage = new GetHostedMemberLogPageUseCase(dependencies.authority, clock); + return Object.freeze({ + routes: HOSTED_MEMBER_LOG_ROUTE_DESCRIPTORS, + getPage: getPage.execute.bind(getPage), + }); +} + +/** Deliberately returns an unmounted contribution; central hosted composition owns admission. */ +export function createHostedMemberLogRouteContribution( + feature: HostedMemberLogFeature +): HostedRouteContribution { + return Object.freeze({ + id: 'member-log-stream.hosted.v1', + facade: feature, + routes: feature.routes, + }); +} + +export { HOSTED_MEMBER_LOG_PAGE_ROUTE }; diff --git a/src/features/member-log-stream/main/hosted.ts b/src/features/member-log-stream/main/hosted.ts new file mode 100644 index 0000000000..5d33a96731 --- /dev/null +++ b/src/features/member-log-stream/main/hosted.ts @@ -0,0 +1,54 @@ +export { + type GetHostedMemberLogPageResult, + HOSTED_MEMBER_LOG_LEVELS, + HOSTED_MEMBER_LOG_MAX_CURSOR_HISTORY, + HOSTED_MEMBER_LOG_MAX_PAGE_BYTES, + HOSTED_MEMBER_LOG_MAX_PAGE_ITEMS, + HOSTED_MEMBER_LOG_MAX_PAGE_TIME_MS, + HOSTED_MEMBER_LOG_MAX_RENDERED_ENTRIES, + HOSTED_MEMBER_LOG_MAX_SOURCE_ITEMS, + HOSTED_MEMBER_LOG_MAX_TEXT_LENGTH, + HOSTED_MEMBER_LOG_PAGE_HTTP_PATH, + HOSTED_MEMBER_LOG_SCHEMA_VERSION, + HOSTED_MEMBER_LOG_TRUNCATION_REASONS, + type HostedMemberLogEntry, + hostedMemberLogEntryByteLength, + type HostedMemberLogEntryId, + type HostedMemberLogErrorEnvelope, + hostedMemberLogJsonByteLength, + type HostedMemberLogLevel, + type HostedMemberLogPage, + type HostedMemberLogPageBudget, + hostedMemberLogPageByteLength, + type HostedMemberLogPageRequest, + type HostedMemberLogSelectionId, + type HostedMemberLogSourceGeneration, + type HostedMemberLogTruncationReason, + parseHostedMemberLogEntryId, + parseHostedMemberLogPage, + parseHostedMemberLogPageRequest, + parseHostedMemberLogSelectionId, + parseHostedMemberLogSourceGeneration, + parseHostedMemberLogText, + redactHostedMemberLogEntry, + redactHostedMemberLogText, +} from '../contracts/hosted'; +export type { + HostedMemberLogAuthorityCandidate, + HostedMemberLogAuthorityPort, + HostedMemberLogAuthorityReadRequest, + HostedMemberLogAuthorityReadResult, + HostedMemberLogClockPort, +} from '../core/application/ports/HostedMemberLogPorts'; +export { + HOSTED_MEMBER_LOG_PAGE_ROUTE, + type HostedMemberLogContextFactory, + type HostedMemberLogHttpFacade, + registerHostedMemberLogHttp, +} from './adapters/input/http/registerHostedMemberLogHttp'; +export { + createHostedMemberLogFeature, + createHostedMemberLogRouteContribution, + HOSTED_MEMBER_LOG_ROUTE_DESCRIPTORS, + type HostedMemberLogFeature, +} from './composition/createHostedMemberLogFeature'; diff --git a/src/features/member-log-stream/renderer/adapters/__tests__/createMemberLogObservationRendererPorts.test.ts b/src/features/member-log-stream/renderer/adapters/__tests__/createMemberLogObservationRendererPorts.test.ts new file mode 100644 index 0000000000..dc3195a4ae --- /dev/null +++ b/src/features/member-log-stream/renderer/adapters/__tests__/createMemberLogObservationRendererPorts.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMemberLogObservationRendererPorts } from '../createMemberLogObservationRendererPorts'; + +import type { MemberLogPreviewResponse } from '../../../contracts'; + +const apiMock = vi.hoisted(() => ({ + memberLogStream: { + getMemberLogPreviews: vi.fn(), + getMemberLogStream: vi.fn(), + setMemberLogStreamTracking: vi.fn(), + }, + teams: { + getLogsForTask: vi.fn(), + getMemberLogs: vi.fn(), + onTeamChange: vi.fn(), + }, +})); + +vi.mock('@renderer/api', () => ({ + api: apiMock, +})); + +describe('createMemberLogObservationRendererPorts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards provider-neutral member preview reads to the renderer API adapter', async () => { + const previewResponse: MemberLogPreviewResponse = { + members: [], + generatedAt: '2026-07-31T00:00:00.000Z', + }; + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(previewResponse); + const ports = createMemberLogObservationRendererPorts(); + const options = { + maxItemsPerMember: 3, + textLimit: 200, + laneIdsByMember: { alice: 'secondary:runtime:alice' }, + forceRefresh: true, + }; + + await expect( + ports.readMemberLogPreviews('alpha-team', ['alice', 'bob'], options) + ).resolves.toBe(previewResponse); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledWith( + 'alpha-team', + ['alice', 'bob'], + options + ); + }); + + it('forwards provider-neutral changes and returns the transport unsubscribe', () => { + let transportListener: + | ((event: unknown, change: { teamName: string; type: string }) => void) + | undefined; + const transportUnsubscribe = vi.fn(); + apiMock.teams.onTeamChange.mockImplementation((listener) => { + transportListener = listener; + return transportUnsubscribe; + }); + const ports = createMemberLogObservationRendererPorts(); + const listener = vi.fn(); + + const unsubscribe = ports.subscribeToChanges(listener); + transportListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + + expect(listener).toHaveBeenCalledWith({ + teamName: 'alpha-team', + type: 'tool-activity', + }); + unsubscribe(); + expect(transportUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('returns a no-op unsubscribe when the transport omits one', () => { + apiMock.teams.onTeamChange.mockReturnValue(undefined); + const ports = createMemberLogObservationRendererPorts(); + + const unsubscribe = ports.subscribeToChanges(vi.fn()); + + expect(unsubscribe).toEqual(expect.any(Function)); + expect(() => unsubscribe()).not.toThrow(); + }); +}); diff --git a/src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts b/src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts new file mode 100644 index 0000000000..dd4cb34614 --- /dev/null +++ b/src/features/member-log-stream/renderer/adapters/createMemberLogObservationRendererPorts.ts @@ -0,0 +1,25 @@ +import { api } from '@renderer/api'; + +import type { MemberLogObservationRendererPorts } from '../ports/MemberLogObservationRendererPorts'; + +export function createMemberLogObservationRendererPorts(): MemberLogObservationRendererPorts { + return { + readTaskLogs: (teamName, taskId, query) => api.teams.getLogsForTask(teamName, taskId, query), + readMemberLogs: (teamName, memberName) => api.teams.getMemberLogs(teamName, memberName), + readMemberLogPreviews: (teamName, memberNames, options) => + api.memberLogStream.getMemberLogPreviews(teamName, memberNames, options), + readMemberLogStream: ({ teamName, memberName, options }) => + api.memberLogStream.getMemberLogStream(teamName, memberName, options), + setStreamTracking: (teamName, enabled) => + api.memberLogStream.setMemberLogStreamTracking(teamName, enabled), + subscribeToChanges: (listener) => { + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + listener({ teamName: event.teamName, type: event.type }); + }); + return typeof unsubscribe === 'function' ? unsubscribe : () => undefined; + }, + }; +} + +export const productionMemberLogObservationRendererPorts = + createMemberLogObservationRendererPorts(); diff --git a/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx b/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx index 3d9fb6a13b..6afc608e7f 100644 --- a/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx +++ b/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { memberLogObservationPorts } from '../../index'; import { useMemberLogStream } from '../useMemberLogStream'; import type { MemberLogStreamResponse } from '../../../contracts'; @@ -14,6 +15,8 @@ const apiMock = vi.hoisted(() => ({ setMemberLogStreamTracking: vi.fn(), }, teams: { + getLogsForTask: vi.fn(), + getMemberLogs: vi.fn(), onTeamChange: vi.fn(), }, })); @@ -88,6 +91,8 @@ describe('useMemberLogStream', () => { apiMock.memberLogStream.getMemberLogStream.mockReset(); apiMock.memberLogStream.setMemberLogStreamTracking.mockReset(); apiMock.memberLogStream.setMemberLogStreamTracking.mockResolvedValue(undefined); + apiMock.teams.getLogsForTask.mockReset(); + apiMock.teams.getMemberLogs.mockReset(); apiMock.teams.onTeamChange.mockReset(); apiMock.teams.onTeamChange.mockReturnValue(() => undefined); }); @@ -221,6 +226,74 @@ describe('useMemberLogStream', () => { }); }); + it('tracks the mounted team and releases both tracking and the change subscription', async () => { + const unsubscribe = vi.fn(); + apiMock.teams.onTeamChange.mockReturnValue(unsubscribe); + apiMock.memberLogStream.getMemberLogStream.mockResolvedValue( + response('2026-04-03T00:00:00.000Z') + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.setMemberLogStreamTracking).toHaveBeenCalledWith( + 'alpha-team', + true + ); + expect(apiMock.teams.onTeamChange).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(apiMock.memberLogStream.setMemberLogStreamTracking).toHaveBeenLastCalledWith( + 'alpha-team', + false + ); + }); + + it('preserves the initial load error for the renderer', async () => { + apiMock.memberLogStream.getMemberLogStream.mockRejectedValue( + new Error('member stream unavailable') + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onState = vi.fn((_: ReturnType) => undefined); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + expect(onState.mock.calls.at(-1)?.[0]).toMatchObject({ + error: 'member stream unavailable', + loading: false, + stream: null, + }); + + act(() => { + root.unmount(); + }); + }); + it('releases stale in-flight state when the section is disabled before a request finishes', async () => { const firstLoad = createDeferred(); apiMock.memberLogStream.getMemberLogStream @@ -323,4 +396,25 @@ describe('useMemberLogStream', () => { root.unmount(); }); }); + + it('maps member and task summary queries through the feature adapter', async () => { + apiMock.teams.getMemberLogs.mockResolvedValue([]); + apiMock.teams.getLogsForTask.mockResolvedValue([]); + + await memberLogObservationPorts.readMemberLogs('alpha-team', 'alice'); + await memberLogObservationPorts.readTaskLogs('alpha-team', 'task-7', { + owner: 'alice', + status: 'in_progress', + intervals: [{ startedAt: '2026-04-03T00:00:00.000Z' }], + since: '2026-04-02T23:00:00.000Z', + }); + + expect(apiMock.teams.getMemberLogs).toHaveBeenCalledWith('alpha-team', 'alice'); + expect(apiMock.teams.getLogsForTask).toHaveBeenCalledWith('alpha-team', 'task-7', { + owner: 'alice', + status: 'in_progress', + intervals: [{ startedAt: '2026-04-03T00:00:00.000Z' }], + since: '2026-04-02T23:00:00.000Z', + }); + }); }); diff --git a/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts b/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts index 52440d6da2..76a0219b73 100644 --- a/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts +++ b/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts @@ -1,14 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { api } from '@renderer/api'; - import { type MemberLogStreamRequestOptions, type MemberLogStreamResponse, normalizeMemberLogStreamResponse, } from '../../contracts'; +import { productionMemberLogObservationRendererPorts } from '../adapters/createMemberLogObservationRendererPorts'; import { normalizeExecutionLogStream } from '../ui/executionLogStreamUtils'; +import type { MemberLogObservationRendererPorts } from '../ports/MemberLogObservationRendererPorts'; import type { ResolvedTeamMember } from '@shared/types'; const LIVE_RELOAD_DEBOUNCE_MS = 650; @@ -20,17 +20,23 @@ function getSafeOpenCodeLaneId(member: ResolvedTeamMember): string | undefined { return laneId ? laneId : undefined; } -export function useMemberLogStream(input: { +export interface UseMemberLogStreamInput { teamName: string; member: ResolvedTeamMember; enabled?: boolean; -}): { + ports?: MemberLogObservationRendererPorts; +} + +export interface UseMemberLogStreamResult { stream: MemberLogStreamResponse | null; loading: boolean; error: string | null; reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise; -} { +} + +export function useMemberLogStream(input: UseMemberLogStreamInput): UseMemberLogStreamResult { const enabled = input.enabled ?? true; + const ports = input.ports ?? productionMemberLogObservationRendererPorts; const [stream, setStream] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -82,7 +88,11 @@ export function useMemberLogStream(input: { const response = normalizeExecutionLogStream( normalizeMemberLogStreamResponse( - await api.memberLogStream.getMemberLogStream(input.teamName, memberName, requestOptions) + await ports.readMemberLogStream({ + teamName: input.teamName, + memberName, + options: requestOptions, + }) ) ); if (requestSeqRef.current !== requestSeq) return; @@ -115,7 +125,7 @@ export function useMemberLogStream(input: { } } }, - [enabled, input.teamName, memberName, openCodeLaneId, streamKey] + [enabled, input.teamName, memberName, openCodeLaneId, ports, streamKey] ); useEffect(() => { @@ -138,17 +148,13 @@ export function useMemberLogStream(input: { useEffect(() => { if (!enabled) return; let cancelled = false; - void api.memberLogStream - .setMemberLogStreamTracking(input.teamName, true) - .catch(() => undefined); + void ports.setStreamTracking(input.teamName, true).catch(() => undefined); return () => { if (cancelled) return; cancelled = true; - void api.memberLogStream - .setMemberLogStreamTracking(input.teamName, false) - .catch(() => undefined); + void ports.setStreamTracking(input.teamName, false).catch(() => undefined); }; - }, [enabled, input.teamName]); + }, [enabled, input.teamName, ports]); useEffect(() => { if (!enabled) return; @@ -162,7 +168,7 @@ export function useMemberLogStream(input: { }, LIVE_RELOAD_DEBOUNCE_MS); }; - const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + const unsubscribe = ports.subscribeToChanges((event) => { if (event.teamName !== input.teamName) return; if (event.type === 'log-source-change') { scheduleReload(true); @@ -189,9 +195,9 @@ export function useMemberLogStream(input: { if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', handleVisibilityChange); } - if (typeof unsubscribe === 'function') unsubscribe(); + unsubscribe(); }; - }, [enabled, input.teamName, loadStream]); + }, [enabled, input.teamName, loadStream, ports]); return { stream, loading, error, reload: loadStream }; } diff --git a/src/features/member-log-stream/renderer/hosted.ts b/src/features/member-log-stream/renderer/hosted.ts new file mode 100644 index 0000000000..e04c3e95f1 --- /dev/null +++ b/src/features/member-log-stream/renderer/hosted.ts @@ -0,0 +1,18 @@ +export { + useHostedMemberLog, + type UseHostedMemberLogInput, + type UseHostedMemberLogResult, +} from './hosted/hooks/useHostedMemberLog'; +export type { + HostedMemberLogFetchPort, + HostedMemberLogHttpRequestInit, + HostedMemberLogHttpResponse, + HostedMemberLogTransport, + HostedMemberLogTransportDependencies, + HostedMemberLogTransportOptions, +} from './hosted/ports/HostedMemberLogRendererPorts'; +export { createHostedMemberLogTransport } from './hosted/transport/createHostedMemberLogTransport'; +export { + HostedMemberLogPanel, + type HostedMemberLogPanelProps, +} from './hosted/ui/HostedMemberLogPanel'; diff --git a/src/features/member-log-stream/renderer/hosted/hooks/useHostedMemberLog.ts b/src/features/member-log-stream/renderer/hosted/hooks/useHostedMemberLog.ts new file mode 100644 index 0000000000..9962b8b110 --- /dev/null +++ b/src/features/member-log-stream/renderer/hosted/hooks/useHostedMemberLog.ts @@ -0,0 +1,420 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import { + HOSTED_MEMBER_LOG_MAX_CURSOR_HISTORY, + HOSTED_MEMBER_LOG_MAX_PAGE_ITEMS, + HOSTED_MEMBER_LOG_MAX_RENDERED_ENTRIES, + HOSTED_MEMBER_LOG_SCHEMA_VERSION, + type HostedMemberLogEntry, + type HostedMemberLogSelectionId, + type HostedMemberLogSourceGeneration, +} from '../../../contracts/hosted'; + +import type { HostedMemberLogTransport } from '../ports/HostedMemberLogRendererPorts'; +import type { Cursor, Revision } from '@shared/contracts/hosted'; + +const DEFAULT_PAGE_LIMIT = 25; +const SAFE_LOAD_ERROR = 'Member log entries are temporarily unavailable. Refresh to try again.'; + +export interface UseHostedMemberLogInput { + /** Authority-issued selection id; team and member authority never come from the renderer request. */ + readonly selectionId: HostedMemberLogSelectionId; + readonly transport: HostedMemberLogTransport; + readonly enabled?: boolean; + readonly pageLimit?: number; +} + +export interface UseHostedMemberLogResult { + readonly entries: readonly HostedMemberLogEntry[]; + readonly sourceGeneration: HostedMemberLogSourceGeneration | null; + readonly revision: Revision | null; + readonly nextCursor: Cursor | null; + readonly loading: boolean; + readonly loadingMore: boolean; + readonly error: string | null; + reload(): Promise; + loadMore(): Promise; +} + +interface HostedMemberLogViewState { + /** The selection/transport incarnation that owns every retained value in this state. */ + readonly selectionEpoch: number; + readonly entries: readonly HostedMemberLogEntry[]; + readonly sourceGeneration: HostedMemberLogSourceGeneration | null; + readonly revision: Revision | null; + readonly nextCursor: Cursor | null; + readonly loading: boolean; + readonly loadingMore: boolean; + readonly error: string | null; +} + +interface CurrentSelection { + readonly selectionId: HostedMemberLogSelectionId; + readonly transport: HostedMemberLogTransport; + readonly epoch: number; +} + +interface MergedEntries { + readonly entries: readonly HostedMemberLogEntry[]; + readonly atCapacity: boolean; +} + +function initialState(selectionEpoch: number, loading: boolean): HostedMemberLogViewState { + return Object.freeze({ + selectionEpoch, + entries: Object.freeze([]), + sourceGeneration: null, + revision: null, + nextCursor: null, + loading, + loadingMore: false, + error: null, + }); +} + +function mergeEntries( + existing: readonly HostedMemberLogEntry[], + incoming: readonly HostedMemberLogEntry[] +): MergedEntries { + const seen = new Set(); + const entries: HostedMemberLogEntry[] = []; + for (const entry of [...existing, ...incoming]) { + if (seen.has(entry.entryId)) continue; + seen.add(entry.entryId); + if (entries.length >= HOSTED_MEMBER_LOG_MAX_RENDERED_ENTRIES) break; + entries.push(entry); + } + return Object.freeze({ + entries: Object.freeze(entries), + atCapacity: entries.length >= HOSTED_MEMBER_LOG_MAX_RENDERED_ENTRIES, + }); +} + +function validPageLimit(value: number): boolean { + return Number.isSafeInteger(value) && value >= 1 && value <= HOSTED_MEMBER_LOG_MAX_PAGE_ITEMS; +} + +/** + * Loads authority-selected member-log pages with generation, selection, and bounded cursor fences. + * A completion only commits when the same selection incarnation that started it is still current. + */ +export function useHostedMemberLog(input: UseHostedMemberLogInput): UseHostedMemberLogResult { + const enabled = input.enabled ?? true; + const pageLimit = input.pageLimit ?? DEFAULT_PAGE_LIMIT; + if (!validPageLimit(pageLimit)) { + throw new TypeError('hosted-member-log-renderer-page-limit-invalid'); + } + + const [state, setState] = useState(() => initialState(0, enabled)); + const stateRef = useRef(state); + const requestEpochRef = useRef(0); + const requestControllerRef = useRef(null); + const inFlightRef = useRef(false); + const seenCursorsRef = useRef(new Set()); + const currentSelectionRef = useRef({ + selectionId: input.selectionId, + transport: input.transport, + epoch: 0, + }); + const renderedSelectionIsCurrent = + currentSelectionRef.current.selectionId === input.selectionId && + currentSelectionRef.current.transport === input.transport; + const selectionEpoch = currentSelectionRef.current.epoch + (renderedSelectionIsCurrent ? 0 : 1); + /** + * Mask prior data during render, before any effect can reset state, so member A can never render + * for member B after a selection or transport change. + */ + const visibleState = + renderedSelectionIsCurrent && state.selectionEpoch === selectionEpoch + ? state + : initialState(selectionEpoch, enabled); + + const publish = useCallback((next: HostedMemberLogViewState): void => { + stateRef.current = next; + setState(next); + }, []); + + const isCurrent = useCallback( + ( + requestEpoch: number, + selectionEpoch: number, + controller: AbortController, + selectionId: HostedMemberLogSelectionId, + transport: HostedMemberLogTransport + ): boolean => { + const currentSelection = currentSelectionRef.current; + return ( + requestEpochRef.current === requestEpoch && + currentSelection.epoch === selectionEpoch && + currentSelection.selectionId === selectionId && + currentSelection.transport === transport && + !controller.signal.aborted + ); + }, + [] + ); + + const retainNextCursor = useCallback((cursor: Cursor | null): Cursor | null => { + if (cursor === null) return null; + const seen = seenCursorsRef.current; + if (seen.has(cursor) || seen.size >= HOSTED_MEMBER_LOG_MAX_CURSOR_HISTORY) return null; + seen.add(cursor); + return cursor; + }, []); + + const loadFirstPage = useCallback(async (): Promise => { + if (!enabled) return; + const selection = currentSelectionRef.current; + const requestEpoch = requestEpochRef.current + 1; + requestEpochRef.current = requestEpoch; + requestControllerRef.current?.abort(); + const controller = new AbortController(); + requestControllerRef.current = controller; + inFlightRef.current = true; + seenCursorsRef.current = new Set(); + const before = stateRef.current; + publish( + Object.freeze({ + ...before, + selectionEpoch: selection.epoch, + entries: Object.freeze([]), + sourceGeneration: null, + revision: null, + nextCursor: null, + loading: true, + loadingMore: false, + error: null, + }) + ); + + try { + const result = await selection.transport.getPage( + Object.freeze({ + schemaVersion: HOSTED_MEMBER_LOG_SCHEMA_VERSION, + selectionId: selection.selectionId, + cursor: null, + expectedSourceGeneration: null, + limit: pageLimit, + }), + Object.freeze({ signal: controller.signal }) + ); + if ( + !isCurrent( + requestEpoch, + selection.epoch, + controller, + selection.selectionId, + selection.transport + ) + ) { + return; + } + inFlightRef.current = false; + requestControllerRef.current = null; + if (result.kind !== 'success' || result.page.selectionId !== selection.selectionId) { + publish( + Object.freeze({ + ...stateRef.current, + loading: false, + loadingMore: false, + error: SAFE_LOAD_ERROR, + }) + ); + return; + } + const merged = mergeEntries([], result.page.entries); + const nextCursor = merged.atCapacity ? null : retainNextCursor(result.page.nextCursor); + publish( + Object.freeze({ + selectionEpoch: selection.epoch, + entries: merged.entries, + sourceGeneration: result.page.sourceGeneration, + revision: result.page.revision, + nextCursor, + loading: false, + loadingMore: false, + error: null, + }) + ); + } catch { + if ( + !isCurrent( + requestEpoch, + selection.epoch, + controller, + selection.selectionId, + selection.transport + ) + ) { + return; + } + inFlightRef.current = false; + requestControllerRef.current = null; + publish( + Object.freeze({ + ...stateRef.current, + loading: false, + loadingMore: false, + error: SAFE_LOAD_ERROR, + }) + ); + } + }, [enabled, isCurrent, pageLimit, publish, retainNextCursor]); + + const loadMore = useCallback(async (): Promise => { + if (!enabled || inFlightRef.current) return; + const snapshot = stateRef.current; + const currentSelection = currentSelectionRef.current; + if (snapshot.selectionEpoch !== currentSelection.epoch) return; + if ( + snapshot.nextCursor === null || + snapshot.sourceGeneration === null || + snapshot.revision === null + ) { + return; + } + if ( + snapshot.entries.length >= HOSTED_MEMBER_LOG_MAX_RENDERED_ENTRIES || + seenCursorsRef.current.size >= HOSTED_MEMBER_LOG_MAX_CURSOR_HISTORY + ) { + publish(Object.freeze({ ...snapshot, nextCursor: null, loadingMore: false })); + return; + } + + const selection = currentSelection; + const requestCursor = snapshot.nextCursor; + const expectedSourceGeneration = snapshot.sourceGeneration; + const expectedRevision = snapshot.revision; + const requestEpoch = requestEpochRef.current + 1; + requestEpochRef.current = requestEpoch; + requestControllerRef.current?.abort(); + const controller = new AbortController(); + requestControllerRef.current = controller; + inFlightRef.current = true; + publish(Object.freeze({ ...snapshot, loadingMore: true, error: null })); + + try { + const result = await selection.transport.getPage( + Object.freeze({ + schemaVersion: HOSTED_MEMBER_LOG_SCHEMA_VERSION, + selectionId: selection.selectionId, + cursor: requestCursor, + expectedSourceGeneration, + limit: pageLimit, + }), + Object.freeze({ signal: controller.signal }) + ); + if ( + !isCurrent( + requestEpoch, + selection.epoch, + controller, + selection.selectionId, + selection.transport + ) + ) { + return; + } + inFlightRef.current = false; + requestControllerRef.current = null; + if (result.kind === 'stale_generation') { + void loadFirstPage(); + return; + } + if ( + result.kind !== 'success' || + result.page.selectionId !== selection.selectionId || + result.page.sourceGeneration !== expectedSourceGeneration || + result.page.revision !== expectedRevision || + (result.page.nextCursor !== null && seenCursorsRef.current.has(result.page.nextCursor)) + ) { + if (result.kind === 'success') { + void loadFirstPage(); + return; + } + publish( + Object.freeze({ + ...stateRef.current, + loading: false, + loadingMore: false, + error: SAFE_LOAD_ERROR, + }) + ); + return; + } + const merged = mergeEntries(stateRef.current.entries, result.page.entries); + const nextCursor = merged.atCapacity ? null : retainNextCursor(result.page.nextCursor); + publish( + Object.freeze({ + selectionEpoch: selection.epoch, + entries: merged.entries, + sourceGeneration: result.page.sourceGeneration, + revision: result.page.revision, + nextCursor, + loading: false, + loadingMore: false, + error: null, + }) + ); + } catch { + if ( + !isCurrent( + requestEpoch, + selection.epoch, + controller, + selection.selectionId, + selection.transport + ) + ) { + return; + } + inFlightRef.current = false; + requestControllerRef.current = null; + publish( + Object.freeze({ + ...stateRef.current, + loadingMore: false, + error: SAFE_LOAD_ERROR, + }) + ); + } + }, [enabled, isCurrent, loadFirstPage, pageLimit, publish, retainNextCursor]); + + useLayoutEffect(() => { + if ( + currentSelectionRef.current.selectionId !== input.selectionId || + currentSelectionRef.current.transport !== input.transport + ) { + currentSelectionRef.current = { + selectionId: input.selectionId, + transport: input.transport, + epoch: selectionEpoch, + }; + } + requestEpochRef.current += 1; + requestControllerRef.current?.abort(); + requestControllerRef.current = null; + inFlightRef.current = false; + seenCursorsRef.current = new Set(); + publish(initialState(selectionEpoch, enabled)); + if (enabled) void loadFirstPage(); + return () => { + requestEpochRef.current += 1; + requestControllerRef.current?.abort(); + requestControllerRef.current = null; + inFlightRef.current = false; + }; + }, [enabled, input.selectionId, input.transport, loadFirstPage, publish, selectionEpoch]); + + return Object.freeze({ + entries: visibleState.entries, + sourceGeneration: visibleState.sourceGeneration, + revision: visibleState.revision, + nextCursor: visibleState.nextCursor, + loading: visibleState.loading, + loadingMore: visibleState.loadingMore, + error: visibleState.error, + reload: loadFirstPage, + loadMore, + }); +} diff --git a/src/features/member-log-stream/renderer/hosted/ports/HostedMemberLogRendererPorts.ts b/src/features/member-log-stream/renderer/hosted/ports/HostedMemberLogRendererPorts.ts new file mode 100644 index 0000000000..41677e8126 --- /dev/null +++ b/src/features/member-log-stream/renderer/hosted/ports/HostedMemberLogRendererPorts.ts @@ -0,0 +1,41 @@ +import type { + GetHostedMemberLogPageResult, + HostedMemberLogPageRequest, +} from '../../../contracts/hosted'; + +export interface HostedMemberLogHttpRequestInit { + readonly method: 'POST'; + readonly credentials: 'include'; + readonly cache: 'no-store'; + readonly headers: Readonly>; + readonly body: string; + readonly signal?: AbortSignal; +} + +export interface HostedMemberLogHttpResponse { + readonly status: number; + json(): Promise; +} + +/** The URL is always feature-owned and relative, so this can only use the current origin. */ +export type HostedMemberLogFetchPort = ( + input: string, + init: HostedMemberLogHttpRequestInit +) => Promise; + +export interface HostedMemberLogTransportDependencies { + readonly fetch: HostedMemberLogFetchPort; + /** Reads only an in-memory CSRF token; it is never persisted or returned by this transport. */ + readonly getCsrfToken: () => string | null; +} + +export interface HostedMemberLogTransportOptions { + readonly signal?: AbortSignal; +} + +export interface HostedMemberLogTransport { + getPage( + request: HostedMemberLogPageRequest, + options?: HostedMemberLogTransportOptions + ): Promise; +} diff --git a/src/features/member-log-stream/renderer/hosted/transport/createHostedMemberLogTransport.ts b/src/features/member-log-stream/renderer/hosted/transport/createHostedMemberLogTransport.ts new file mode 100644 index 0000000000..5b23498fb3 --- /dev/null +++ b/src/features/member-log-stream/renderer/hosted/transport/createHostedMemberLogTransport.ts @@ -0,0 +1,204 @@ +import { + type GetHostedMemberLogPageResult, + HOSTED_MEMBER_LOG_PAGE_HTTP_PATH, + type HostedMemberLogErrorEnvelope, + type HostedMemberLogPageRequest, + parseHostedMemberLogErrorEnvelope, + parseHostedMemberLogPage, + parseHostedMemberLogPageRequest, +} from '../../../contracts/hosted'; + +import type { + HostedMemberLogHttpRequestInit, + HostedMemberLogHttpResponse, + HostedMemberLogTransport, + HostedMemberLogTransportDependencies, + HostedMemberLogTransportOptions, +} from '../ports/HostedMemberLogRendererPorts'; + +const JSON_HEADERS = Object.freeze({ + Accept: 'application/json', + 'Content-Type': 'application/json', +}); +const CSRF_HEADER = 'x-agent-teams-csrf'; +const CSRF_TOKEN = /^[A-Za-z0-9_-]{32,512}$/; + +interface UnavailableResult { + readonly kind: 'unavailable'; + readonly retryAfterMs?: number; +} + +interface ExpectedErrorTuple { + readonly status: number; + readonly code: HostedMemberLogErrorEnvelope['error']['code']; + readonly reason: string; + readonly retryable: boolean; + readonly currentSourceGeneration: 'required' | 'forbidden'; + readonly retryAfterMs: 'optional' | 'forbidden'; +} + +const INVALID_REQUEST_ERROR = Object.freeze({ + status: 400, + code: 'invalid_request', + reason: 'member_log_page_request_invalid', + retryable: false, + currentSourceGeneration: 'forbidden', + retryAfterMs: 'forbidden', +} satisfies ExpectedErrorTuple); + +const NOT_FOUND_ERROR = Object.freeze({ + status: 404, + code: 'not_found', + reason: 'member_log_not_found', + retryable: false, + currentSourceGeneration: 'forbidden', + retryAfterMs: 'forbidden', +} satisfies ExpectedErrorTuple); + +const STALE_GENERATION_ERROR = Object.freeze({ + status: 409, + code: 'conflict', + reason: 'stale_generation', + retryable: false, + currentSourceGeneration: 'required', + retryAfterMs: 'forbidden', +} satisfies ExpectedErrorTuple); + +const UNAVAILABLE_ERROR = Object.freeze({ + status: 503, + code: 'unavailable', + reason: 'member_log_unavailable', + retryable: true, + currentSourceGeneration: 'forbidden', + retryAfterMs: 'optional', +} satisfies ExpectedErrorTuple); + +function unavailable(retryAfterMs?: number): UnavailableResult { + return retryAfterMs === undefined + ? Object.freeze({ kind: 'unavailable' }) + : Object.freeze({ kind: 'unavailable', retryAfterMs }); +} + +function readCsrfToken(dependencies: HostedMemberLogTransportDependencies): string | null { + try { + const value: unknown = dependencies.getCsrfToken(); + return typeof value === 'string' && CSRF_TOKEN.test(value) ? value : null; + } catch { + return null; + } +} + +async function readJson(response: HostedMemberLogHttpResponse): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function readStatus(response: HostedMemberLogHttpResponse): number | null { + try { + const status = response.status; + return Number.isSafeInteger(status) && status >= 100 && status <= 599 ? status : null; + } catch { + return null; + } +} + +function requestInit( + body: string, + options?: HostedMemberLogTransportOptions +): HostedMemberLogHttpRequestInit { + return Object.freeze({ + method: 'POST' as const, + credentials: 'include' as const, + cache: 'no-store' as const, + headers: Object.freeze({ ...JSON_HEADERS }), + body, + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); +} + +/** Maps an error only when the response exactly matches this route's public error contract. */ +function matchesErrorTuple( + status: number, + envelope: HostedMemberLogErrorEnvelope, + expected: ExpectedErrorTuple +): boolean { + const hasCurrentSourceGeneration = envelope.currentSourceGeneration !== undefined; + const hasRetryAfterMs = envelope.error.retryAfterMs !== undefined; + return ( + status === expected.status && + envelope.error.code === expected.code && + envelope.error.reason === expected.reason && + envelope.retryable === expected.retryable && + envelope.error.diagnosticId === undefined && + hasCurrentSourceGeneration === (expected.currentSourceGeneration === 'required') && + (expected.retryAfterMs === 'optional' || !hasRetryAfterMs) + ); +} + +function mapPageError(status: number, value: unknown): GetHostedMemberLogPageResult { + const envelope = parseHostedMemberLogErrorEnvelope(value); + if (!envelope.ok) return unavailable(); + if (matchesErrorTuple(status, envelope.value, INVALID_REQUEST_ERROR)) { + return Object.freeze({ kind: 'invalid_request' }); + } + if (matchesErrorTuple(status, envelope.value, NOT_FOUND_ERROR)) { + return Object.freeze({ kind: 'not_found' }); + } + if (matchesErrorTuple(status, envelope.value, STALE_GENERATION_ERROR)) { + return Object.freeze({ + kind: 'stale_generation', + currentSourceGeneration: envelope.value.currentSourceGeneration as NonNullable< + HostedMemberLogErrorEnvelope['currentSourceGeneration'] + >, + }); + } + return matchesErrorTuple(status, envelope.value, UNAVAILABLE_ERROR) + ? unavailable(envelope.value.error.retryAfterMs) + : unavailable(); +} + +/** Creates an injected, browser-only same-origin transport with no durable client state. */ +export function createHostedMemberLogTransport( + dependencies: HostedMemberLogTransportDependencies +): HostedMemberLogTransport { + const send = async ( + body: string, + options: HostedMemberLogTransportOptions | undefined, + csrfToken: string + ): Promise => + dependencies.fetch(HOSTED_MEMBER_LOG_PAGE_HTTP_PATH, { + ...requestInit(body, options), + headers: Object.freeze({ ...JSON_HEADERS, [CSRF_HEADER]: csrfToken }), + }); + + return Object.freeze({ + async getPage( + requestValue: HostedMemberLogPageRequest, + options?: HostedMemberLogTransportOptions + ): Promise { + const request = parseHostedMemberLogPageRequest(requestValue); + if (!request.ok) return Object.freeze({ kind: 'invalid_request' }); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + const csrfToken = readCsrfToken(dependencies); + if (csrfToken === null) return unavailable(); + + let response: HostedMemberLogHttpResponse; + try { + response = await send(JSON.stringify(request.value), options, csrfToken); + } catch { + return options?.signal?.aborted ? Object.freeze({ kind: 'cancelled' }) : unavailable(); + } + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + const value = await readJson(response); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + const status = readStatus(response); + if (status === null) return unavailable(); + if (status !== 200) return mapPageError(status, value); + const page = parseHostedMemberLogPage(value, request.value); + return page.ok ? Object.freeze({ kind: 'success', page: page.value }) : unavailable(); + }, + }); +} diff --git a/src/features/member-log-stream/renderer/hosted/ui/HostedMemberLogPanel.tsx b/src/features/member-log-stream/renderer/hosted/ui/HostedMemberLogPanel.tsx new file mode 100644 index 0000000000..c9de46eb33 --- /dev/null +++ b/src/features/member-log-stream/renderer/hosted/ui/HostedMemberLogPanel.tsx @@ -0,0 +1,80 @@ +import { Button } from '@renderer/components/ui/button'; +import { Loader2, RefreshCw } from 'lucide-react'; + +import { useHostedMemberLog } from '../hooks/useHostedMemberLog'; + +import type { HostedMemberLogSelectionId } from '../../../contracts/hosted'; +import type { HostedMemberLogTransport } from '../ports/HostedMemberLogRendererPorts'; + +export interface HostedMemberLogPanelProps { + readonly selectionId: HostedMemberLogSelectionId; + readonly transport: HostedMemberLogTransport; + readonly enabled?: boolean; + readonly heading?: string; +} + +export const HostedMemberLogPanel = ({ + selectionId, + transport, + enabled = true, + heading = 'Member log', +}: HostedMemberLogPanelProps): React.JSX.Element => { + const log = useHostedMemberLog({ selectionId, transport, enabled }); + return ( +
+
+

{heading}

+ +
+ {log.loading && log.entries.length === 0 ? ( +

+ Loading member log… +

+ ) : null} + {log.error !== null ? ( +

+ {log.error} +

+ ) : null} + {!log.loading && log.error === null && log.entries.length === 0 ? ( +

No member log entries.

+ ) : null} + {log.entries.length > 0 ? ( +
    + {log.entries.map((entry) => ( +
  1. +

    {entry.level}

    +

    {entry.text}

    +
  2. + ))} +
+ ) : null} + {log.nextCursor !== null ? ( + + ) : null} +
+ ); +}; diff --git a/src/features/member-log-stream/renderer/index.ts b/src/features/member-log-stream/renderer/index.ts index 6fab34617a..7f462c4e46 100644 --- a/src/features/member-log-stream/renderer/index.ts +++ b/src/features/member-log-stream/renderer/index.ts @@ -1,4 +1,18 @@ +import { productionMemberLogObservationRendererPorts } from './adapters/createMemberLogObservationRendererPorts'; +import { configureMemberLogObservationRendererPorts } from './ports/MemberLogObservationRendererPorts'; + +configureMemberLogObservationRendererPorts(productionMemberLogObservationRendererPorts); + export { MemberLogStreamSection } from './adapters/MemberLogStreamSection'; +export type { + MemberLogObservationChange, + MemberLogObservationListener, + MemberLogObservationRendererPorts, + MemberLogStreamQuery, + MemberLogTaskQuery, + MemberLogWorkInterval, +} from './ports/MemberLogObservationRendererPorts'; +export { memberLogObservationPorts } from './ports/MemberLogObservationRendererPorts'; export { buildDefaultExecutionSegmentRenderKey, normalizeExecutionLogStream, diff --git a/src/features/member-log-stream/renderer/ports/MemberLogObservationRendererPorts.ts b/src/features/member-log-stream/renderer/ports/MemberLogObservationRendererPorts.ts new file mode 100644 index 0000000000..93c4341bd0 --- /dev/null +++ b/src/features/member-log-stream/renderer/ports/MemberLogObservationRendererPorts.ts @@ -0,0 +1,77 @@ +import type { + MemberLogPreviewRequestOptions, + MemberLogPreviewResponse, + MemberLogStreamRequestOptions, + MemberLogStreamResponse, +} from '../../contracts'; +import type { MemberLogSummary } from '@shared/types'; + +export interface MemberLogWorkInterval { + startedAt: string; + completedAt?: string; +} + +export interface MemberLogTaskQuery { + owner?: string; + status?: string; + intervals?: MemberLogWorkInterval[]; + since?: string; +} + +export interface MemberLogStreamQuery { + teamName: string; + memberName: string; + options?: MemberLogStreamRequestOptions; +} + +export interface MemberLogObservationChange { + teamName: string; + type: string; +} + +export type MemberLogObservationListener = (change: MemberLogObservationChange) => void; + +export interface MemberLogObservationRendererPorts { + readTaskLogs( + teamName: string, + taskId: string, + query?: MemberLogTaskQuery + ): Promise; + readMemberLogs(teamName: string, memberName: string): Promise; + readMemberLogPreviews( + teamName: string, + memberNames: string[], + options?: MemberLogPreviewRequestOptions + ): Promise; + readMemberLogStream(query: MemberLogStreamQuery): Promise; + setStreamTracking(teamName: string, enabled: boolean): Promise; + subscribeToChanges(listener: MemberLogObservationListener): () => void; +} + +let configuredPorts: MemberLogObservationRendererPorts | null = null; + +export function configureMemberLogObservationRendererPorts( + ports: MemberLogObservationRendererPorts +): void { + configuredPorts = ports; +} + +function getConfiguredPorts(): MemberLogObservationRendererPorts { + if (!configuredPorts) { + throw new Error('Member log observation renderer ports are not configured'); + } + return configuredPorts; +} + +export const memberLogObservationPorts: MemberLogObservationRendererPorts = { + readTaskLogs: (teamName, taskId, query) => + getConfiguredPorts().readTaskLogs(teamName, taskId, query), + readMemberLogs: (teamName, memberName) => + getConfiguredPorts().readMemberLogs(teamName, memberName), + readMemberLogPreviews: (teamName, memberNames, options) => + getConfiguredPorts().readMemberLogPreviews(teamName, memberNames, options), + readMemberLogStream: (query) => getConfiguredPorts().readMemberLogStream(query), + setStreamTracking: (teamName, enabled) => + getConfiguredPorts().setStreamTracking(teamName, enabled), + subscribeToChanges: (listener) => getConfiguredPorts().subscribeToChanges(listener), +}; diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 577fc4b9fe..d26dd0cfa7 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -51,7 +51,7 @@ export interface MemberWorkSyncActionableWorkItem { reviewStartedAt?: string; reviewStartedBy?: string; reviewObligation?: MemberWorkSyncReviewObligation; - canBypassPhase2?: boolean; + canBypassDeliveryReadiness?: boolean; reviewDiagnostics?: string[]; needsClarification?: 'lead' | 'user'; blockerTaskIds?: string[]; @@ -168,15 +168,15 @@ export interface MemberWorkSyncTeamMetrics { reportAcceptedCount: number; reportRejectedCount: number; recentEvents: MemberWorkSyncMetricEvent[]; - phase2Readiness: MemberWorkSyncPhase2ReadinessAssessment; + deliveryReadiness: MemberWorkSyncDeliveryReadinessAssessment; } -export type MemberWorkSyncPhase2ReadinessState = +export type MemberWorkSyncDeliveryReadinessState = | 'collecting_shadow_data' | 'shadow_ready' | 'blocked'; -export type MemberWorkSyncPhase2ReadinessReason = +export type MemberWorkSyncDeliveryReadinessReason = | 'insufficient_members' | 'insufficient_status_events' | 'insufficient_observation_window' @@ -184,7 +184,7 @@ export type MemberWorkSyncPhase2ReadinessReason = | 'fingerprint_churn_high' | 'report_rejection_rate_high'; -export interface MemberWorkSyncPhase2ReadinessThresholds { +export interface MemberWorkSyncDeliveryReadinessThresholds { minObservedMembers: number; minStatusEvents: number; minObservationHours: number; @@ -193,7 +193,7 @@ export interface MemberWorkSyncPhase2ReadinessThresholds { maxReportRejectionRate: number; } -export interface MemberWorkSyncPhase2ReadinessRates { +export interface MemberWorkSyncDeliveryReadinessRates { observationHours: number; statusEventCount: number; wouldNudgesPerMemberHour: number; @@ -201,11 +201,11 @@ export interface MemberWorkSyncPhase2ReadinessRates { reportRejectionRate: number; } -export interface MemberWorkSyncPhase2ReadinessAssessment { - state: MemberWorkSyncPhase2ReadinessState; - reasons: MemberWorkSyncPhase2ReadinessReason[]; - thresholds: MemberWorkSyncPhase2ReadinessThresholds; - rates: MemberWorkSyncPhase2ReadinessRates; +export interface MemberWorkSyncDeliveryReadinessAssessment { + state: MemberWorkSyncDeliveryReadinessState; + reasons: MemberWorkSyncDeliveryReadinessReason[]; + thresholds: MemberWorkSyncDeliveryReadinessThresholds; + rates: MemberWorkSyncDeliveryReadinessRates; diagnostics: string[]; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts b/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts index 534c729bf2..d879463d97 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts @@ -1,4 +1,4 @@ -import type { MemberWorkSyncPhase2ReadinessAssessment } from '../../contracts'; +import type { MemberWorkSyncDeliveryReadinessAssessment } from '../../contracts'; import type { MemberWorkSyncAuditEvent, MemberWorkSyncAuditEventName, @@ -31,26 +31,26 @@ export async function appendMemberWorkSyncAudit( } } -export function buildMemberWorkSyncPhase2ReadinessAuditFields( - phase2Readiness?: MemberWorkSyncPhase2ReadinessAssessment +export function buildMemberWorkSyncDeliveryReadinessAuditFields( + deliveryReadiness?: MemberWorkSyncDeliveryReadinessAssessment ): Pick { - if (!phase2Readiness) { + if (!deliveryReadiness) { return {}; } return { - diagnostics: phase2Readiness.diagnostics, + diagnostics: deliveryReadiness.diagnostics, metadata: { - phase2ReadinessState: phase2Readiness.state, - phase2ReadinessReasons: phase2Readiness.reasons.join(','), - observationHours: phase2Readiness.rates.observationHours, - statusEventCount: phase2Readiness.rates.statusEventCount, - wouldNudgesPerMemberHour: phase2Readiness.rates.wouldNudgesPerMemberHour, - maxWouldNudgesPerMemberHour: phase2Readiness.thresholds.maxWouldNudgesPerMemberHour, - fingerprintChangesPerMemberHour: phase2Readiness.rates.fingerprintChangesPerMemberHour, + deliveryReadinessState: deliveryReadiness.state, + deliveryReadinessReasons: deliveryReadiness.reasons.join(','), + observationHours: deliveryReadiness.rates.observationHours, + statusEventCount: deliveryReadiness.rates.statusEventCount, + wouldNudgesPerMemberHour: deliveryReadiness.rates.wouldNudgesPerMemberHour, + maxWouldNudgesPerMemberHour: deliveryReadiness.thresholds.maxWouldNudgesPerMemberHour, + fingerprintChangesPerMemberHour: deliveryReadiness.rates.fingerprintChangesPerMemberHour, maxFingerprintChangesPerMemberHour: - phase2Readiness.thresholds.maxFingerprintChangesPerMemberHour, - reportRejectionRate: phase2Readiness.rates.reportRejectionRate, - maxReportRejectionRate: phase2Readiness.thresholds.maxReportRejectionRate, + deliveryReadiness.thresholds.maxFingerprintChangesPerMemberHour, + reportRejectionRate: deliveryReadiness.rates.reportRejectionRate, + maxReportRejectionRate: deliveryReadiness.thresholds.maxReportRejectionRate, }, }; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts index 513a17b034..27a97386b8 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts @@ -1,4 +1,4 @@ -import { assessMemberWorkSyncPhase2Readiness } from '../domain'; +import { assessMemberWorkSyncDeliveryReadiness } from '../domain'; import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; @@ -22,7 +22,7 @@ function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeam reportAcceptedCount: 0, reportRejectedCount: 0, recentEvents: [], - phase2Readiness: assessMemberWorkSyncPhase2Readiness({ + deliveryReadiness: assessMemberWorkSyncDeliveryReadiness({ memberCount: 0, recentEvents: [], }), @@ -39,9 +39,9 @@ export class MemberWorkSyncMetricsReader { const metrics = await this.deps.statusStore.readTeamMetrics(request.teamName); return { ...metrics, - phase2Readiness: - metrics.phase2Readiness ?? - assessMemberWorkSyncPhase2Readiness({ + deliveryReadiness: + metrics.deliveryReadiness ?? + assessMemberWorkSyncDeliveryReadiness({ memberCount: metrics.memberCount, recentEvents: metrics.recentEvents, }), diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index 9007e7bad1..7b9c292337 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -23,7 +23,7 @@ export type MemberWorkSyncNudgeActivationReason = | 'native_stale_assigned_work' | 'status_not_nudgeable' | 'blocking_metrics' - | 'phase2_not_ready'; + | 'delivery_not_ready'; const NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS = 6 * 60_000; const NATIVE_STALE_IN_PROGRESS_PROVIDERS = new Set(['anthropic', 'codex', 'gemini']); @@ -41,11 +41,11 @@ export interface MemberWorkSyncNudgeActivationDecision { // rate to suppress recovery locks out the members that currently need syncing. // Actual deliveries remain bounded by per-fingerprint outbox idempotency, // dispatcher rate limits, busy checks, and cooldowns. -const DELIVERY_BLOCKING_PHASE2_REASONS = new Set(['report_rejection_rate_high']); +const DELIVERY_READINESS_BLOCKING_REASONS = new Set(['report_rejection_rate_high']); function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean { - return metrics.phase2Readiness.reasons.some((reason) => - DELIVERY_BLOCKING_PHASE2_REASONS.has(reason) + return metrics.deliveryReadiness.reasons.some((reason) => + DELIVERY_READINESS_BLOCKING_REASONS.has(reason) ); } @@ -279,7 +279,7 @@ export function decideMemberWorkSyncNudgeActivation(input: { } if ( - input.metrics.phase2Readiness.state === 'collecting_shadow_data' && + input.metrics.deliveryReadiness.state === 'collecting_shadow_data' && isReviewPickupRequiredCandidate(input.status) ) { return { active: true, reason: 'review_pickup_required' }; @@ -302,7 +302,7 @@ export function decideMemberWorkSyncNudgeActivation(input: { if (hasBlockingMetrics(input.metrics)) { return { active: false, reason: 'blocking_metrics' }; } - if (input.metrics.phase2Readiness.state !== 'shadow_ready') { + if (input.metrics.deliveryReadiness.state !== 'shadow_ready') { return { active: true, reason: targetedRecovery.reason }; } } @@ -315,9 +315,9 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: true, reason: 'review_pickup_required' }; } - if (input.metrics.phase2Readiness.state === 'shadow_ready') { + if (input.metrics.deliveryReadiness.state === 'shadow_ready') { return { active: true, reason: 'shadow_ready' }; } - return { active: false, reason: 'phase2_not_ready' }; + return { active: false, reason: 'delivery_not_ready' }; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts index c8abacf4d9..927d017fba 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts @@ -6,7 +6,7 @@ export function isStrictReviewPickupItem( return ( item.kind === 'review' && item.evidence.reviewObligation === 'review_pickup_required' && - item.evidence.canBypassPhase2 === true && + item.evidence.canBypassDeliveryReadiness === true && typeof item.evidence.reviewRequestEventId === 'string' && item.evidence.reviewRequestEventId.length > 0 && (item.evidence.reviewDiagnostics?.length ?? 0) === 0 diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatchPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatchPolicy.ts index 30a21a4056..66086e6db3 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatchPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatchPolicy.ts @@ -120,7 +120,7 @@ function getAgendaReviewPickupRequestEventIds(agenda: MemberWorkSyncAgenda): str (item) => item.kind === 'review' && item.evidence.reviewObligation === 'review_pickup_required' && - item.evidence.canBypassPhase2 === true && + item.evidence.canBypassDeliveryReadiness === true && (item.evidence.reviewDiagnostics?.length ?? 0) === 0 ) .map((item) => item.evidence.reviewRequestEventId) diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index c9457e56f1..e6a6185342 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -2,7 +2,7 @@ import { decideMemberWorkSyncStatus } from '../domain'; import { appendMemberWorkSyncAudit, - buildMemberWorkSyncPhase2ReadinessAuditFields, + buildMemberWorkSyncDeliveryReadinessAuditFields, reasonToAuditEvent, } from './MemberWorkSyncAudit'; import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy'; @@ -30,8 +30,8 @@ import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler'; import { resolveMemberWorkSyncRuntimeActivity } from './MemberWorkSyncRuntimeActivity'; import type { + MemberWorkSyncDeliveryReadinessAssessment, MemberWorkSyncOutboxItem, - MemberWorkSyncPhase2ReadinessAssessment, MemberWorkSyncStatus, } from '../../contracts'; import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; @@ -427,7 +427,7 @@ export class MemberWorkSyncNudgeDispatcher { item, reasonToAuditEvent(revalidation.reason), revalidation.reason, - revalidation.phase2Readiness + revalidation.deliveryReadiness ); return 'retryable'; } @@ -689,7 +689,7 @@ export class MemberWorkSyncNudgeDispatcher { item: MemberWorkSyncOutboxItem, event: MemberWorkSyncAuditEventName, reason: string, - phase2Readiness?: MemberWorkSyncPhase2ReadinessAssessment + deliveryReadiness?: MemberWorkSyncDeliveryReadinessAssessment ): Promise { await appendMemberWorkSyncAudit(this.deps, { teamName: item.teamName, @@ -698,7 +698,7 @@ export class MemberWorkSyncNudgeDispatcher { source: 'nudge_dispatcher', agendaFingerprint: item.agendaFingerprint, reason, - ...buildMemberWorkSyncPhase2ReadinessAuditFields(phase2Readiness), + ...buildMemberWorkSyncDeliveryReadinessAuditFields(deliveryReadiness), taskRefs: item.payload.taskRefs, messagePreview: item.payload.text, }); @@ -714,7 +714,7 @@ export class MemberWorkSyncNudgeDispatcher { reason: string; retryable: boolean; nextAttemptAt?: string; - phase2Readiness?: MemberWorkSyncPhase2ReadinessAssessment; + deliveryReadiness?: MemberWorkSyncDeliveryReadinessAssessment; } > { const runtimeActivity = await resolveMemberWorkSyncRuntimeActivity(this.deps, { @@ -813,12 +813,12 @@ export class MemberWorkSyncNudgeDispatcher { ? 'blocking_metrics' : activation.reason === 'status_not_nudgeable' ? 'status_not_nudgeable' - : 'phase2_not_ready'; + : 'delivery_not_ready'; return { ok: false, reason, retryable: true, - phase2Readiness: metrics.phase2Readiness, + deliveryReadiness: metrics.deliveryReadiness, }; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts index 0bec871d89..1a403d27a3 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -6,7 +6,7 @@ import { import { appendMemberWorkSyncAudit, - buildMemberWorkSyncPhase2ReadinessAuditFields, + buildMemberWorkSyncDeliveryReadinessAuditFields, } from './MemberWorkSyncAudit'; import { decideMemberWorkSyncNudgeActivation, @@ -19,9 +19,9 @@ import { } from './MemberWorkSyncNudgeRecoveryPolicy'; import type { + MemberWorkSyncDeliveryReadinessAssessment, MemberWorkSyncOutboxEnsureInput, MemberWorkSyncOutboxItem, - MemberWorkSyncPhase2ReadinessAssessment, MemberWorkSyncStatus, } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; @@ -152,7 +152,7 @@ export interface MemberWorkSyncNudgeOutboxPlanResult { | 'metrics_unavailable' | 'status_not_nudgeable' | 'blocking_metrics' - | 'phase2_not_ready' + | 'delivery_not_ready' | 'review_pickup_delivery_unavailable' | 'review_pickup_already_delivered_still_stuck' | 'review_pickup_delivery_failed_still_stuck' @@ -478,8 +478,8 @@ export class MemberWorkSyncNudgeOutboxPlanner { ? 'blocking_metrics' : activation.reason === 'status_not_nudgeable' ? 'status_not_nudgeable' - : 'phase2_not_ready'; - await this.appendPlanAudit(status, { planned: false, code }, metrics.phase2Readiness); + : 'delivery_not_ready'; + await this.appendPlanAudit(status, { planned: false, code }, metrics.deliveryReadiness); return { planned: false, code }; } @@ -777,7 +777,7 @@ export class MemberWorkSyncNudgeOutboxPlanner { private async appendPlanAudit( status: MemberWorkSyncStatus, result: MemberWorkSyncNudgeOutboxPlanResult, - phase2Readiness?: MemberWorkSyncPhase2ReadinessAssessment + deliveryReadiness?: MemberWorkSyncDeliveryReadinessAssessment ): Promise { await appendMemberWorkSyncAudit(this.deps, { teamName: status.teamName, @@ -789,7 +789,7 @@ export class MemberWorkSyncNudgeOutboxPlanner { actionableCount: status.agenda.items.length, reason: result.code, ...(status.providerId ? { providerId: status.providerId } : {}), - ...buildMemberWorkSyncPhase2ReadinessAuditFields(phase2Readiness), + ...buildMemberWorkSyncDeliveryReadinessAuditFields(deliveryReadiness), taskRefs: status.agenda.items.map((item) => ({ taskId: item.taskId, displayId: item.displayId, diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncQueueDiagnostics.ts b/src/features/member-work-sync/core/application/MemberWorkSyncQueueDiagnostics.ts new file mode 100644 index 0000000000..d014b7ebc2 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncQueueDiagnostics.ts @@ -0,0 +1,47 @@ +export type MemberWorkSyncTriggerReason = + | 'startup_scan' + | 'config_changed' + | 'task_changed' + | 'inbox_changed' + | 'member_spawned' + | 'tool_finished' + | 'runtime_activity' + | 'turn_settled' + | 'proof_missing_recovery' + | 'manual_refresh'; + +export interface MemberWorkSyncQueueDiagnostics { + queued: number; + running: number; + enqueued: number; + coalesced: number; + reconciled: number; + dropped: number; + failed: number; + nextRunAt?: string; + oldestQueuedAgeMs?: number; + oldestRunningAgeMs?: number; + queuedItems: MemberWorkSyncQueuedItemDiagnostics[]; + runningItems: MemberWorkSyncRunningItemDiagnostics[]; +} + +export interface MemberWorkSyncQueuedItemDiagnostics { + teamName: string; + memberName: string; + firstQueuedAt: string; + lastQueuedAt: string; + runAt: string; + maxRunAt: string; + triggerReasons: MemberWorkSyncTriggerReason[]; + triggerReasonCounts: Partial>; +} + +export interface MemberWorkSyncRunningItemDiagnostics { + teamName: string; + memberName: string; + startedAt: string; + ageMs: number; + rerunRequested: boolean; + settlingAfterTimeout?: boolean; + triggerReasons: MemberWorkSyncTriggerReason[]; +} diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 26183a66ad..758b4433b2 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -7,6 +7,7 @@ export * from './MemberWorkSyncNudgeDispatcher'; export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncNudgeSuppressionPolicy'; export * from './MemberWorkSyncPendingReportIntentReplayer'; +export type * from './MemberWorkSyncQueueDiagnostics'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; export * from './MemberWorkSyncRuntimeActivity'; diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 96367a54aa..edf81e38cf 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -268,7 +268,7 @@ export function buildActionableWorkAgenda( reviewState: workflowColumn, reviewCycleId: reviewCycle.reviewCycleId, reviewObligation: reviewCycle.obligation, - canBypassPhase2: reviewCycle.canBypassPhase2, + canBypassDeliveryReadiness: reviewCycle.canBypassDeliveryReadiness, ...(reviewCycle.reviewRequestEventId ? { reviewRequestEventId: reviewCycle.reviewRequestEventId } : {}), diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncDeliveryReadiness.ts similarity index 80% rename from src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts rename to src/features/member-work-sync/core/domain/MemberWorkSyncDeliveryReadiness.ts index b989f160c2..69d8edc9a5 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncDeliveryReadiness.ts @@ -1,11 +1,11 @@ import type { + MemberWorkSyncDeliveryReadinessAssessment, + MemberWorkSyncDeliveryReadinessReason, + MemberWorkSyncDeliveryReadinessThresholds, MemberWorkSyncMetricEvent, - MemberWorkSyncPhase2ReadinessAssessment, - MemberWorkSyncPhase2ReadinessReason, - MemberWorkSyncPhase2ReadinessThresholds, } from '../../contracts'; -export const DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS: MemberWorkSyncPhase2ReadinessThresholds = +export const DEFAULT_MEMBER_WORK_SYNC_DELIVERY_READINESS_THRESHOLDS: MemberWorkSyncDeliveryReadinessThresholds = { minObservedMembers: 1, minStatusEvents: 20, @@ -15,10 +15,10 @@ export const DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS: MemberWorkSyn maxReportRejectionRate: 0.2, }; -interface AssessMemberWorkSyncPhase2ReadinessInput { +interface AssessMemberWorkSyncDeliveryReadinessInput { memberCount: number; recentEvents: MemberWorkSyncMetricEvent[]; - thresholds?: Partial; + thresholds?: Partial; } function parseTime(value: string): number | null { @@ -44,22 +44,22 @@ function roundRate(value: number): number { } function pushIf( - reasons: MemberWorkSyncPhase2ReadinessReason[], + reasons: MemberWorkSyncDeliveryReadinessReason[], condition: boolean, - reason: MemberWorkSyncPhase2ReadinessReason + reason: MemberWorkSyncDeliveryReadinessReason ): void { if (condition) { reasons.push(reason); } } -export function assessMemberWorkSyncPhase2Readiness({ +export function assessMemberWorkSyncDeliveryReadiness({ memberCount, recentEvents, thresholds: thresholdOverrides, -}: AssessMemberWorkSyncPhase2ReadinessInput): MemberWorkSyncPhase2ReadinessAssessment { +}: AssessMemberWorkSyncDeliveryReadinessInput): MemberWorkSyncDeliveryReadinessAssessment { const thresholds = { - ...DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS, + ...DEFAULT_MEMBER_WORK_SYNC_DELIVERY_READINESS_THRESHOLDS, ...thresholdOverrides, }; const statusEvents = recentEvents.filter((event) => event.kind === 'status_evaluated'); @@ -77,7 +77,7 @@ export function assessMemberWorkSyncPhase2Readiness({ const reportRejectionRate = reportEventCount > 0 ? reportRejectedEvents.length / reportEventCount : 0; - const collectingReasons: MemberWorkSyncPhase2ReadinessReason[] = []; + const collectingReasons: MemberWorkSyncDeliveryReadinessReason[] = []; pushIf(collectingReasons, memberCount < thresholds.minObservedMembers, 'insufficient_members'); pushIf( collectingReasons, @@ -90,7 +90,7 @@ export function assessMemberWorkSyncPhase2Readiness({ 'insufficient_observation_window' ); - const blockingReasons: MemberWorkSyncPhase2ReadinessReason[] = []; + const blockingReasons: MemberWorkSyncDeliveryReadinessReason[] = []; pushIf( blockingReasons, wouldNudgesPerMemberHour > thresholds.maxWouldNudgesPerMemberHour, @@ -126,6 +126,6 @@ export function assessMemberWorkSyncPhase2Readiness({ fingerprintChangesPerMemberHour: roundRate(fingerprintChangesPerMemberHour), reportRejectionRate: roundRate(reportRejectionRate), }, - diagnostics: reasons.map((reason) => `phase2_readiness:${reason}`), + diagnostics: reasons.map((reason) => `delivery_readiness:${reason}`), }; } diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts index ba595bf9f0..6643b0b20e 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts @@ -56,7 +56,7 @@ function isReviewPickupNudgeStatus(status: MemberWorkSyncStatus): boolean { (item) => item.kind === 'review' && item.evidence.reviewObligation === 'review_pickup_required' && - item.evidence.canBypassPhase2 === true && + item.evidence.canBypassDeliveryReadiness === true && typeof item.evidence.reviewRequestEventId === 'string' && item.evidence.reviewRequestEventId.length > 0 && (item.evidence.reviewDiagnostics?.length ?? 0) === 0 diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts index 81c457fd0d..e42014c154 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -31,7 +31,7 @@ function agendaIsReviewPickupRequired(agenda: MemberWorkSyncAgenda): boolean { (item) => item.kind === 'review' && item.evidence.reviewObligation === 'review_pickup_required' && - item.evidence.canBypassPhase2 === true + item.evidence.canBypassDeliveryReadiness === true ) ); } diff --git a/src/features/member-work-sync/core/domain/currentReviewCycle.ts b/src/features/member-work-sync/core/domain/currentReviewCycle.ts index 951ff96109..525dc637d3 100644 --- a/src/features/member-work-sync/core/domain/currentReviewCycle.ts +++ b/src/features/member-work-sync/core/domain/currentReviewCycle.ts @@ -22,7 +22,7 @@ export interface CurrentReviewCycle { reviewStartedEventId?: string; reviewStartedAt?: string; reviewStartedBy?: string; - canBypassPhase2: boolean; + canBypassDeliveryReadiness: boolean; diagnostics: string[]; } @@ -145,7 +145,7 @@ export function resolveCurrentReviewCycle(input: { obligation: 'review_in_progress', reviewCycleId: `kanban:${kanbanReviewer}`, historyEventIds: [], - canBypassPhase2: false, + canBypassDeliveryReadiness: false, diagnostics, }; } @@ -198,7 +198,7 @@ export function resolveCurrentReviewCycle(input: { } const reviewCycleId = requestEventId ?? startedEventId ?? `kanban:${reviewer}`; - const canBypassPhase2 = + const canBypassDeliveryReadiness = obligation === 'review_pickup_required' && Boolean(requestEventId) && diagnostics.length === 0; return { @@ -215,7 +215,7 @@ export function resolveCurrentReviewCycle(input: { ? { reviewStartedAt: historyEventTimestamp(openCycle.started) } : {}), ...(startedBy ? { reviewStartedBy: startedBy } : {}), - canBypassPhase2, + canBypassDeliveryReadiness, diagnostics, }; } diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts index 4ac73ffae3..4220f6de6f 100644 --- a/src/features/member-work-sync/core/domain/index.ts +++ b/src/features/member-work-sync/core/domain/index.ts @@ -2,8 +2,8 @@ export * from './ActionableWorkAgenda'; export * from './AgendaFingerprint'; export * from './currentReviewCycle'; export * from './memberName'; +export * from './MemberWorkSyncDeliveryReadiness'; export * from './MemberWorkSyncNudge'; -export * from './MemberWorkSyncPhase2Readiness'; export * from './MemberWorkSyncReportValidator'; export * from './RuntimeTurnSettledEvent'; export * from './RuntimeTurnSettledProvider'; diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts index f4dd682caa..1681efe5b0 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts @@ -1,9 +1,7 @@ import { extractMemberWorkSyncTaskId } from './MemberWorkSyncTaskImpactResolver'; -import type { - MemberWorkSyncEventQueue, - MemberWorkSyncTriggerReason, -} from '../../infrastructure/MemberWorkSyncEventQueue'; +import type { MemberWorkSyncTriggerReason } from '../../../core/application'; +import type { MemberWorkSyncEventQueue } from '../../infrastructure/MemberWorkSyncEventQueue'; import type { MemberWorkSyncTaskImpactResolver } from './MemberWorkSyncTaskImpactResolver'; import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; diff --git a/src/features/member-work-sync/main/composition/memberWorkSyncFeatureContracts.ts b/src/features/member-work-sync/main/composition/memberWorkSyncFeatureContracts.ts index ab9abf4224..0747d34e2f 100644 --- a/src/features/member-work-sync/main/composition/memberWorkSyncFeatureContracts.ts +++ b/src/features/member-work-sync/main/composition/memberWorkSyncFeatureContracts.ts @@ -11,10 +11,10 @@ import type { import type { MemberWorkSyncNudgeDispatchSummary, MemberWorkSyncPendingReportReplaySummary, + MemberWorkSyncQueueDiagnostics, RuntimeTurnSettledDrainSummary, } from '../../core/application'; import type { RuntimeTurnSettledProvider } from '../../core/domain'; -import type { MemberWorkSyncQueueDiagnostics } from '../infrastructure/MemberWorkSyncEventQueue'; import type { TeamChangeEvent } from '@shared/types'; export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: { diff --git a/src/features/member-work-sync/main/composition/memberWorkSyncHttpPorts.ts b/src/features/member-work-sync/main/composition/memberWorkSyncHttpPorts.ts new file mode 100644 index 0000000000..acebd0d863 --- /dev/null +++ b/src/features/member-work-sync/main/composition/memberWorkSyncHttpPorts.ts @@ -0,0 +1,36 @@ +export interface MemberWorkSyncHttpIdentifierValidationResult { + valid: boolean; + value?: string; + error?: string; +} + +export interface MemberWorkSyncHttpIdentifierValidationPort { + validateTeamName(value: unknown): MemberWorkSyncHttpIdentifierValidationResult; + validateMemberName(value: unknown): MemberWorkSyncHttpIdentifierValidationResult; +} + +export interface MemberWorkSyncHttpClockPort { + now(): Date; +} + +export interface MemberWorkSyncHttpLoggerPort { + error(message: string, detail: string): void; +} + +export interface MemberWorkSyncHttpUnexpectedErrorMapping { + statusCode: number; + responseMessage: string; + shouldLog: boolean; + logMessage: string; +} + +export interface MemberWorkSyncHttpUnexpectedErrorPort { + map(error: unknown): MemberWorkSyncHttpUnexpectedErrorMapping; +} + +export interface MemberWorkSyncHttpHostPorts { + identifiers: MemberWorkSyncHttpIdentifierValidationPort; + clock: MemberWorkSyncHttpClockPort; + logger: MemberWorkSyncHttpLoggerPort; + unexpectedErrors: MemberWorkSyncHttpUnexpectedErrorPort; +} diff --git a/src/features/member-work-sync/main/index.ts b/src/features/member-work-sync/main/index.ts index 4c7ae0a97a..76886fbba2 100644 --- a/src/features/member-work-sync/main/index.ts +++ b/src/features/member-work-sync/main/index.ts @@ -9,6 +9,15 @@ export { buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, } from './composition/createMemberWorkSyncFeature'; +export type { + MemberWorkSyncHttpClockPort, + MemberWorkSyncHttpHostPorts, + MemberWorkSyncHttpIdentifierValidationPort, + MemberWorkSyncHttpIdentifierValidationResult, + MemberWorkSyncHttpLoggerPort, + MemberWorkSyncHttpUnexpectedErrorMapping, + MemberWorkSyncHttpUnexpectedErrorPort, +} from './composition/memberWorkSyncHttpPorts'; export { hasUncertainWorkSyncRuntimeActivity, hasWorkSyncActiveRuntime, diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 769c9346d4..ad7c1a8e99 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -5,7 +5,7 @@ import { createHash } from 'crypto'; import { access, mkdir, readdir, readFile } from 'fs/promises'; import { dirname, join } from 'path'; -import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain'; +import { assessMemberWorkSyncDeliveryReadiness } from '../../core/domain'; import { purgeJsonMemberWorkSyncActiveState } from './JsonMemberWorkSyncActiveStatePurger'; import { @@ -640,7 +640,7 @@ export function toMetrics(teamName: string, file: MetricsIndexFile): MemberWorkS }; return { ...metrics, - phase2Readiness: assessMemberWorkSyncPhase2Readiness({ + deliveryReadiness: assessMemberWorkSyncDeliveryReadiness({ memberCount: metrics.memberCount, recentEvents: metrics.recentEvents, }), diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts index 7fb35a5ce4..0d2ea1ec8d 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -2,57 +2,11 @@ import type { MemberWorkSyncAuditEvent, MemberWorkSyncAuditJournalPort, MemberWorkSyncLoggerPort, + MemberWorkSyncQueueDiagnostics, + MemberWorkSyncTriggerReason, } from '../../core/application'; import type { MemberWorkSyncReconcileContext } from '../../core/application/MemberWorkSyncReconciler'; -export type MemberWorkSyncTriggerReason = - | 'startup_scan' - | 'config_changed' - | 'task_changed' - | 'inbox_changed' - | 'member_spawned' - | 'tool_finished' - | 'runtime_activity' - | 'turn_settled' - | 'proof_missing_recovery' - | 'manual_refresh'; - -export interface MemberWorkSyncQueueDiagnostics { - queued: number; - running: number; - enqueued: number; - coalesced: number; - reconciled: number; - dropped: number; - failed: number; - nextRunAt?: string; - oldestQueuedAgeMs?: number; - oldestRunningAgeMs?: number; - queuedItems: MemberWorkSyncQueuedItemDiagnostics[]; - runningItems: MemberWorkSyncRunningItemDiagnostics[]; -} - -export interface MemberWorkSyncQueuedItemDiagnostics { - teamName: string; - memberName: string; - firstQueuedAt: string; - lastQueuedAt: string; - runAt: string; - maxRunAt: string; - triggerReasons: MemberWorkSyncTriggerReason[]; - triggerReasonCounts: Partial>; -} - -export interface MemberWorkSyncRunningItemDiagnostics { - teamName: string; - memberName: string; - startedAt: string; - ageMs: number; - rerunRequested: boolean; - settlingAfterTimeout?: boolean; - triggerReasons: MemberWorkSyncTriggerReason[]; -} - interface QueueItem { teamName: string; memberName: string; @@ -409,6 +363,18 @@ export class MemberWorkSyncEventQueue { .sort((left, right) => left[1].runAt - right[1].runAt); for (const [key, item] of due) { + if (this.quiescedTeams.has(item.teamName)) { + this.items.delete(key); + this.counters.dropped += 1; + this.appendAudit({ + teamName: item.teamName, + memberName: item.memberName, + event: 'queue_dropped', + source: 'event_queue', + reason: 'team_quiesced', + }); + continue; + } if (this.activeKeys.size >= this.concurrency) { break; } @@ -436,6 +402,23 @@ export class MemberWorkSyncEventQueue { let failed = false; let failure: unknown = null; + let resolveTeamCompletion!: () => void; + const teamCompletion = new Promise((resolve) => { + resolveTeamCompletion = resolve; + }); + this.addTrackedPromise(this.inFlightByTeam, item.teamName, teamCompletion); + void teamCompletion.finally(() => { + this.removeTrackedPromise(this.inFlightByTeam, item.teamName, teamCompletion); + }); + + const finishTrackedItem = (): void => { + try { + this.finishItem(key, item, running, failed); + } finally { + resolveTeamCompletion(); + } + }; + const promise = this.executeItem(key, item, running) .catch((error: unknown) => { failed = true; @@ -449,21 +432,19 @@ export class MemberWorkSyncEventQueue { }) .finally(() => { this.inFlight.delete(promise); - this.removeTrackedPromise(this.inFlightByTeam, item.teamName, promise); const settlePromise = getReconcileTimeoutSettlePromise(failure); if (settlePromise) { this.activeKeys.delete(key); this.pump(); void settlePromise.finally(() => { - this.finishItem(key, item, running, failed); + finishTrackedItem(); }); return; } - this.finishItem(key, item, running, failed); + finishTrackedItem(); }); this.inFlight.add(promise); - this.addTrackedPromise(this.inFlightByTeam, item.teamName, promise); } private finishItem(key: string, item: QueueItem, running: RunningItem, failed: boolean): void { @@ -472,9 +453,10 @@ export class MemberWorkSyncEventQueue { } this.activeKeys.delete(key); this.running.delete(key); - if (running.rerunRequested && !this.stopped && !this.quiescedTeams.has(item.teamName)) { + const canAdmitFollowUp = !this.stopped && !this.quiescedTeams.has(item.teamName); + if (running.rerunRequested && canAdmitFollowUp) { this.enqueueFollowUp(item, running); - } else if (failed && !this.stopped) { + } else if (failed && canAdmitFollowUp) { this.enqueueRetryAfterFailure(key, item, running); } this.pump(); diff --git a/src/features/member-work-sync/main/infrastructure/memberWorkSyncSnapshotMerge.ts b/src/features/member-work-sync/main/infrastructure/memberWorkSyncSnapshotMerge.ts index 05074a762d..fe105492aa 100644 --- a/src/features/member-work-sync/main/infrastructure/memberWorkSyncSnapshotMerge.ts +++ b/src/features/member-work-sync/main/infrastructure/memberWorkSyncSnapshotMerge.ts @@ -1,4 +1,4 @@ -import { normalizeMemberWorkSyncSnapshotTeamIdentity } from '@features/internal-storage/contracts/memberWorkSyncTeamIdentity'; +import { normalizeMemberWorkSyncSnapshotTeamIdentity } from '@features/internal-storage/contracts'; import type { MemberWorkSyncMetricEventRecord, diff --git a/src/features/member-work-sync/main/infrastructure/memberWorkSyncSqliteMappers.ts b/src/features/member-work-sync/main/infrastructure/memberWorkSyncSqliteMappers.ts index 36cbe2086a..95fb01d7ac 100644 --- a/src/features/member-work-sync/main/infrastructure/memberWorkSyncSqliteMappers.ts +++ b/src/features/member-work-sync/main/infrastructure/memberWorkSyncSqliteMappers.ts @@ -1,4 +1,4 @@ -import { normalizeMemberWorkSyncSnapshotTeamIdentity } from '@features/internal-storage/contracts/memberWorkSyncTeamIdentity'; +import { normalizeMemberWorkSyncSnapshotTeamIdentity } from '@features/internal-storage/contracts'; import { buildMetricEvents, normalizeMemberKey } from './JsonMemberWorkSyncStore'; @@ -239,7 +239,7 @@ function stableStringify(value: unknown): string { } const record = value as Record; return `{${Object.keys(record) - .sort((a, b) => a.localeCompare(b)) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) .join(',')}}`; } diff --git a/src/features/mutation-readiness/contracts/index.ts b/src/features/mutation-readiness/contracts/index.ts new file mode 100644 index 0000000000..97ab1799da --- /dev/null +++ b/src/features/mutation-readiness/contracts/index.ts @@ -0,0 +1 @@ +export * from './mutationReadinessContracts'; diff --git a/src/features/mutation-readiness/contracts/mutationReadinessContracts.ts b/src/features/mutation-readiness/contracts/mutationReadinessContracts.ts new file mode 100644 index 0000000000..6fab3685f3 --- /dev/null +++ b/src/features/mutation-readiness/contracts/mutationReadinessContracts.ts @@ -0,0 +1,294 @@ +import type { InstanceLeaseAnchorEvidence } from '@features/instance-lease/contracts'; +import type { + RuntimeInstanceContext, + RuntimeRootReference, +} from '@features/runtime-instance-context'; +import type { WorkspaceMountBindingRef } from '@features/workspace-registry/contracts'; + +export const MUTATION_READINESS_DIMENSIONS = Object.freeze([ + 'instanceLease', + 'runtimeBinding', + 'workspaceBinding', + 'storage', + 'filesystem', + 'externalWriter', + 'recoveryOutbox', +] as const); + +export const MAX_MUTATION_READINESS_ASSESSMENT_TIMEOUT_MS = 30_000; + +export type MutationReadinessDimension = (typeof MUTATION_READINESS_DIMENSIONS)[number]; +export type MutationReadinessDecisionStatus = 'verified' | 'denied'; +export type MutationReadinessEvidenceAvailability = 'unavailable' | 'unknown'; + +export type InstanceLeaseReadinessDiagnosticCode = + | 'instance_lease_held' + | 'instance_lease_unavailable' + | 'instance_lease_evidence_timeout' + | 'instance_lease_invalid' + | 'instance_lease_released' + | 'instance_lease_changed'; + +export type RuntimeBindingReadinessDiagnosticCode = + | 'runtime_binding_verified' + | 'runtime_context_unavailable' + | 'runtime_binding_evidence_unavailable' + | 'runtime_binding_evidence_timeout' + | 'runtime_binding_evidence_unknown' + | 'runtime_binding_evidence_stale' + | 'runtime_binding_evidence_invalid' + | 'runtime_binding_deployment_mismatch' + | 'runtime_binding_boot_mismatch' + | 'runtime_binding_roots_mismatch' + | 'runtime_binding_lease_anchor_unverified' + | 'runtime_binding_lease_anchor_mismatch'; + +export type WorkspaceBindingReadinessDiagnosticCode = + | 'workspace_binding_verified' + | 'workspace_binding_context_unavailable' + | 'workspace_binding_evidence_unavailable' + | 'workspace_binding_evidence_timeout' + | 'workspace_binding_evidence_unknown' + | 'workspace_binding_evidence_stale' + | 'workspace_binding_evidence_invalid' + | 'workspace_binding_workspace_mismatch' + | 'workspace_binding_boot_mismatch' + | 'workspace_binding_mount_generation_mismatch' + | 'workspace_binding_root_mismatch' + | 'workspace_binding_registration_mismatch' + | 'workspace_binding_not_writable'; + +export type StorageReadinessDiagnosticCode = + | 'storage_ready' + | 'storage_evidence_unavailable' + | 'storage_evidence_timeout' + | 'storage_evidence_unknown' + | 'storage_evidence_stale' + | 'storage_evidence_invalid' + | 'storage_binding_mismatch' + | 'storage_backend_unavailable' + | 'storage_compatibility_unverified' + | 'storage_schema_mismatch' + | 'storage_migration_incomplete' + | 'storage_integrity_unverified' + | 'storage_critical_fallback_enabled'; + +export type FilesystemReadinessDiagnosticCode = + | 'filesystem_ready' + | 'filesystem_evidence_unavailable' + | 'filesystem_evidence_timeout' + | 'filesystem_evidence_unknown' + | 'filesystem_evidence_stale' + | 'filesystem_evidence_invalid' + | 'filesystem_binding_mismatch' + | 'filesystem_unsupported' + | 'filesystem_permission_unverified' + | 'filesystem_free_space_insufficient' + | 'filesystem_atomic_replace_unverified' + | 'filesystem_directory_durability_unverified'; + +export type ExternalWriterReadinessDiagnosticCode = + | 'external_writer_coordinated' + | 'external_writer_evidence_unavailable' + | 'external_writer_evidence_timeout' + | 'external_writer_evidence_unknown' + | 'external_writer_evidence_stale' + | 'external_writer_evidence_invalid' + | 'external_writer_binding_mismatch' + | 'external_writer_class_unknown' + | 'external_writer_class_unavailable' + | 'external_writer_coordination_unverified' + | 'external_writer_observation_dirty'; + +export type RecoveryOutboxReadinessDiagnosticCode = + | 'recovery_outbox_ready' + | 'recovery_outbox_evidence_unavailable' + | 'recovery_outbox_evidence_timeout' + | 'recovery_outbox_evidence_unknown' + | 'recovery_outbox_evidence_stale' + | 'recovery_outbox_evidence_invalid' + | 'recovery_outbox_binding_mismatch' + | 'recovery_scan_incomplete' + | 'recovery_pending' + | 'recovery_operator_required' + | 'recovery_unknown_records' + | 'outbox_unavailable'; + +export type MutationReadinessDiagnosticCode = + | InstanceLeaseReadinessDiagnosticCode + | RuntimeBindingReadinessDiagnosticCode + | WorkspaceBindingReadinessDiagnosticCode + | StorageReadinessDiagnosticCode + | FilesystemReadinessDiagnosticCode + | ExternalWriterReadinessDiagnosticCode + | RecoveryOutboxReadinessDiagnosticCode; + +export interface MutationReadinessDimensionDecision< + TDimension extends MutationReadinessDimension, + TCode extends MutationReadinessDiagnosticCode, +> { + readonly dimension: TDimension; + readonly status: MutationReadinessDecisionStatus; + readonly code: TCode; +} + +export type InstanceLeaseReadinessDecision = MutationReadinessDimensionDecision< + 'instanceLease', + InstanceLeaseReadinessDiagnosticCode +>; +export type RuntimeBindingReadinessDecision = MutationReadinessDimensionDecision< + 'runtimeBinding', + RuntimeBindingReadinessDiagnosticCode +>; +export type WorkspaceBindingReadinessDecision = MutationReadinessDimensionDecision< + 'workspaceBinding', + WorkspaceBindingReadinessDiagnosticCode +>; +export type StorageReadinessDecision = MutationReadinessDimensionDecision< + 'storage', + StorageReadinessDiagnosticCode +>; +export type FilesystemReadinessDecision = MutationReadinessDimensionDecision< + 'filesystem', + FilesystemReadinessDiagnosticCode +>; +export type ExternalWriterReadinessDecision = MutationReadinessDimensionDecision< + 'externalWriter', + ExternalWriterReadinessDiagnosticCode +>; +export type RecoveryOutboxReadinessDecision = MutationReadinessDimensionDecision< + 'recoveryOutbox', + RecoveryOutboxReadinessDiagnosticCode +>; + +export interface MutationReadinessDecisions { + readonly instanceLease: InstanceLeaseReadinessDecision; + readonly runtimeBinding: RuntimeBindingReadinessDecision; + readonly workspaceBinding: WorkspaceBindingReadinessDecision; + readonly storage: StorageReadinessDecision; + readonly filesystem: FilesystemReadinessDecision; + readonly externalWriter: ExternalWriterReadinessDecision; + readonly recoveryOutbox: RecoveryOutboxReadinessDecision; +} + +/** + * Diagnostic evidence assessment only. It is deliberately not mutation + * authority and cannot be converted into a reusable capability. A future + * one-shot mutation capability must acquire and hold both the writer fence and + * mount fence, then revalidate every dimension under one evidence generation + * while those fences remain held. + */ +export interface MutationReadinessAssessment { + readonly kind: 'mutation_readiness_diagnostic'; + readonly assessment: 'all_evidence_verified' | 'denied'; + readonly authoritativeForMutation: false; + readonly decisions: MutationReadinessDecisions; + readonly diagnosticCodes: readonly MutationReadinessDiagnosticCode[]; +} + +export interface MutationReadinessWorkspaceTarget { + readonly binding: WorkspaceMountBindingRef; + readonly rootReference: RuntimeRootReference<'workspace'>; + readonly declaredRootHash: string; + readonly registrationRevision: number; +} + +export interface MutationReadinessRequirements { + readonly storageSchemaVersion: number; + readonly minimumFreeBytes: number; + readonly evidenceMaxAgeMs: number; + /** One end-to-end deadline shared by all initial and final inspections. */ + readonly evaluationTimeoutMs: number; +} + +export interface MutationReadinessScope { + readonly runtimeInstance: RuntimeInstanceContext; + readonly workspace: MutationReadinessWorkspaceTarget; + readonly requirements: MutationReadinessRequirements; +} + +export interface VerifiedRuntimeBindingReadinessEvidence { + readonly runtimeInstance: RuntimeInstanceContext; + readonly leaseAnchor: InstanceLeaseAnchorEvidence; +} + +export interface VerifiedWorkspaceBindingReadinessEvidence { + readonly binding: WorkspaceMountBindingRef; + readonly rootReference: RuntimeRootReference<'workspace'>; + readonly declaredRootHash: string; + readonly registrationRevision: number; + readonly health: 'healthy' | 'read-only' | 'unavailable'; +} + +export interface VerifiedStorageReadinessEvidence { + readonly deploymentId: RuntimeInstanceContext['deploymentId']; + readonly appDataRootReference: RuntimeInstanceContext['appDataRoot']['reference']; + readonly backend: 'sqlite' | 'unavailable' | 'unknown'; + readonly compatibility: 'verified' | 'unverified' | 'unknown'; + readonly schemaVersion: number; + readonly migrationState: 'complete' | 'pending' | 'failed' | 'unknown'; + readonly integrity: 'ok' | 'failed' | 'unknown'; + readonly criticalFallback: 'disabled' | 'enabled' | 'unknown'; +} + +export interface VerifiedFilesystemReadinessEvidence { + readonly deploymentId: RuntimeInstanceContext['deploymentId']; + readonly bootId: RuntimeInstanceContext['bootId']; + readonly workspaceBinding: WorkspaceMountBindingRef; + readonly rootReference: RuntimeRootReference<'workspace'>; + readonly filesystem: 'supported' | 'unsupported' | 'unknown'; + readonly permission: 'read_write' | 'read_only' | 'denied' | 'unknown'; + readonly freeBytes: number; + readonly atomicReplace: 'verified' | 'unverified' | 'unknown'; + readonly directoryDurability: 'verified' | 'unverified' | 'unknown'; +} + +export type ExternalWriterReadinessClassification = + | 'app_exclusive' + | 'cooperative_external' + | 'provider_mediated' + | 'quiescent_only' + | 'unavailable' + | 'unknown'; + +export type ExternalWriterReadinessCoordination = + | 'lease_fenced' + | 'protocol_verified' + | 'provider_protocol_verified' + | 'quiesced' + | 'busy' + | 'dirty' + | 'unknown'; + +export interface VerifiedExternalWriterReadinessEvidence { + readonly deploymentId: RuntimeInstanceContext['deploymentId']; + readonly bootId: RuntimeInstanceContext['bootId']; + readonly workspaceBinding: WorkspaceMountBindingRef; + readonly classification: ExternalWriterReadinessClassification; + readonly coordination: ExternalWriterReadinessCoordination; + readonly observation: 'clean' | 'dirty' | 'unknown'; + readonly fileWriterEpoch: number; + readonly observationWatermark: number; +} + +export interface VerifiedRecoveryOutboxReadinessEvidence { + readonly deploymentId: RuntimeInstanceContext['deploymentId']; + readonly storageSchemaVersion: number; + readonly scanState: 'complete' | 'incomplete' | 'unknown'; + readonly recoveryState: 'complete' | 'pending' | 'unknown'; + readonly outboxState: 'ready' | 'unavailable' | 'unknown'; + readonly pendingCommandCount: number; + readonly recoveringCommandCount: number; + readonly operatorRequiredCount: number; + readonly unknownRecordCount: number; +} + +export type ReadinessEvidenceInspection = + | { + readonly status: 'verified'; + readonly checkedAtMs: number; + readonly evidence: TEvidence; + } + | { + readonly status: MutationReadinessEvidenceAvailability; + }; diff --git a/src/features/mutation-readiness/core/application/MutationReadinessAssessor.ts b/src/features/mutation-readiness/core/application/MutationReadinessAssessor.ts new file mode 100644 index 0000000000..e402ba5a92 --- /dev/null +++ b/src/features/mutation-readiness/core/application/MutationReadinessAssessor.ts @@ -0,0 +1,372 @@ +import { + decideMutationReadiness, + mutationReadinessDiagnosticCodes, + type MutationReadinessInspectionOutcomes, + type ReadinessEvidenceInspectionOutcome, + snapshotMutationReadinessRequirements, + snapshotMutationReadinessScope, +} from '../domain'; + +import type { + MutationReadinessAssessment, + MutationReadinessRequirements, + MutationReadinessScope, + MutationReadinessWorkspaceTarget, + ReadinessEvidenceInspection, + VerifiedExternalWriterReadinessEvidence, + VerifiedFilesystemReadinessEvidence, + VerifiedRecoveryOutboxReadinessEvidence, + VerifiedRuntimeBindingReadinessEvidence, + VerifiedStorageReadinessEvidence, + VerifiedWorkspaceBindingReadinessEvidence, +} from '../../contracts'; +import type { InstanceLeaseAdmissionInspection } from '@features/instance-lease/contracts'; + +type MaybePromise = T | Promise; +type InspectionInvocation = (() => unknown | Promise) | null; + +export interface InstanceLeaseReadinessInspectionContext { + /** Cooperative cancellation; the assessor also enforces its own deadline. */ + readonly signal: AbortSignal; +} + +/** Narrow, non-ambient lease evidence port used by readiness assessment. */ +export interface InstanceLeaseReadinessEvidencePort { + inspectForAdmission( + context: InstanceLeaseReadinessInspectionContext + ): MaybePromise; +} + +export interface MutationReadinessInspectionContext { + /** + * Cooperative cancellation only. Core enforces the deadline independently, + * aborts this signal once, and discards any later result. + */ + readonly signal: AbortSignal; +} + +export interface RuntimeBindingReadinessEvidencePort { + inspectRuntimeBinding( + scope: MutationReadinessScope, + context: MutationReadinessInspectionContext + ): MaybePromise>; +} + +export interface WorkspaceBindingReadinessEvidencePort { + inspectWorkspaceBinding( + scope: MutationReadinessScope, + context: MutationReadinessInspectionContext + ): MaybePromise>; +} + +export interface StorageReadinessEvidencePort { + inspectStorageReadiness( + scope: MutationReadinessScope, + context: MutationReadinessInspectionContext + ): MaybePromise>; +} + +export interface FilesystemReadinessEvidencePort { + inspectFilesystemReadiness( + scope: MutationReadinessScope, + context: MutationReadinessInspectionContext + ): MaybePromise>; +} + +export interface ExternalWriterReadinessEvidencePort { + inspectExternalWriterReadiness( + scope: MutationReadinessScope, + context: MutationReadinessInspectionContext + ): MaybePromise>; +} + +export interface RecoveryOutboxReadinessEvidencePort { + inspectRecoveryOutboxReadiness( + scope: MutationReadinessScope, + context: MutationReadinessInspectionContext + ): MaybePromise>; +} + +export interface MutationReadinessEvidencePorts { + readonly runtimeBinding?: RuntimeBindingReadinessEvidencePort | null; + readonly workspaceBinding?: WorkspaceBindingReadinessEvidencePort | null; + readonly storage?: StorageReadinessEvidencePort | null; + readonly filesystem?: FilesystemReadinessEvidencePort | null; + readonly externalWriter?: ExternalWriterReadinessEvidencePort | null; + readonly recoveryOutbox?: RecoveryOutboxReadinessEvidencePort | null; +} + +export interface MutationReadinessClock { + nowMs(): number; + /** Schedules the shared assessment deadline and returns an idempotent cancellation callback. */ + scheduleDeadline(delayMs: number, onDeadline: () => void): () => void; +} + +export interface MutationReadinessAssessmentInput { + readonly instanceLease?: InstanceLeaseReadinessEvidencePort | null; + readonly runtimeInstance: MutationReadinessScope['runtimeInstance'] | null; + readonly workspace: MutationReadinessWorkspaceTarget | null; + readonly requirements: MutationReadinessRequirements; + readonly clock: MutationReadinessClock; + readonly evidence?: MutationReadinessEvidencePorts; +} + +export interface MutationReadinessAssessor { + readonly authoritativeForMutation: false; + assess(): Promise; +} + +const UNAVAILABLE_OUTCOME: ReadinessEvidenceInspectionOutcome = Object.freeze({ + status: 'unavailable', +}); +const TIMEOUT_OUTCOME: ReadinessEvidenceInspectionOutcome = Object.freeze({ status: 'timeout' }); + +function invokeSafely(invoke: InspectionInvocation): Promise { + if (!invoke) return Promise.resolve(UNAVAILABLE_OUTCOME); + return Promise.resolve() + .then(invoke) + .then( + (value): ReadinessEvidenceInspectionOutcome => Object.freeze({ status: 'settled', value }), + (): ReadinessEvidenceInspectionOutcome => UNAVAILABLE_OUTCOME + ); +} + +function invokeEvidencePort( + evidence: MutationReadinessEvidencePorts, + dimension: Exclude, + scope: MutationReadinessScope, + context: MutationReadinessInspectionContext +): unknown | Promise { + switch (dimension) { + case 'runtimeBinding': { + const port = evidence.runtimeBinding; + if (!port || typeof port.inspectRuntimeBinding !== 'function') throw new Error('unavailable'); + return port.inspectRuntimeBinding(scope, context); + } + case 'workspaceBinding': { + const port = evidence.workspaceBinding; + if (!port || typeof port.inspectWorkspaceBinding !== 'function') + throw new Error('unavailable'); + return port.inspectWorkspaceBinding(scope, context); + } + case 'storage': { + const port = evidence.storage; + if (!port || typeof port.inspectStorageReadiness !== 'function') + throw new Error('unavailable'); + return port.inspectStorageReadiness(scope, context); + } + case 'filesystem': { + const port = evidence.filesystem; + if (!port || typeof port.inspectFilesystemReadiness !== 'function') { + throw new Error('unavailable'); + } + return port.inspectFilesystemReadiness(scope, context); + } + case 'externalWriter': { + const port = evidence.externalWriter; + if (!port || typeof port.inspectExternalWriterReadiness !== 'function') { + throw new Error('unavailable'); + } + return port.inspectExternalWriterReadiness(scope, context); + } + case 'recoveryOutbox': { + const port = evidence.recoveryOutbox; + if (!port || typeof port.inspectRecoveryOutboxReadiness !== 'function') { + throw new Error('unavailable'); + } + return port.inspectRecoveryOutboxReadiness(scope, context); + } + } +} + +function buildInvocations(input: { + readonly instanceLease: MutationReadinessAssessmentInput['instanceLease']; + readonly evidence: MutationReadinessEvidencePorts; + readonly scope: MutationReadinessScope | null; + readonly context: MutationReadinessInspectionContext; +}): Readonly> { + const evidenceInvocation = ( + dimension: Exclude + ): InspectionInvocation => + input.scope + ? () => invokeEvidencePort(input.evidence, dimension, input.scope!, input.context) + : null; + + return Object.freeze({ + instanceLease: input.instanceLease + ? () => input.instanceLease!.inspectForAdmission(input.context) + : null, + runtimeBinding: evidenceInvocation('runtimeBinding'), + workspaceBinding: evidenceInvocation('workspaceBinding'), + storage: evidenceInvocation('storage'), + filesystem: evidenceInvocation('filesystem'), + externalWriter: evidenceInvocation('externalWriter'), + recoveryOutbox: evidenceInvocation('recoveryOutbox'), + }); +} + +async function collectInspectionsBeforeDeadline(input: { + readonly invocations: Readonly< + Record + >; + readonly deadline: Promise; + readonly deadlineAtMs: number | null; + readonly clock: MutationReadinessClock; + readonly abortController: AbortController; +}): Promise { + const settle = async ( + invoke: InspectionInvocation + ): Promise => { + const outcome = await Promise.race([invokeSafely(invoke), input.deadline]); + const settledAtMs = readMutationReadinessClock(input.clock); + if ( + input.deadlineAtMs !== null && + (settledAtMs === null || settledAtMs >= input.deadlineAtMs) + ) { + input.abortController.abort(); + return TIMEOUT_OUTCOME; + } + return outcome; + }; + const [ + instanceLease, + runtimeBinding, + workspaceBinding, + storage, + filesystem, + externalWriter, + recoveryOutbox, + ] = await Promise.all([ + settle(input.invocations.instanceLease), + settle(input.invocations.runtimeBinding), + settle(input.invocations.workspaceBinding), + settle(input.invocations.storage), + settle(input.invocations.filesystem), + settle(input.invocations.externalWriter), + settle(input.invocations.recoveryOutbox), + ]); + return Object.freeze({ + instanceLease, + runtimeBinding, + workspaceBinding, + storage, + filesystem, + externalWriter, + recoveryOutbox, + }); +} + +function containsTimeout(outcomes: MutationReadinessInspectionOutcomes): boolean { + return Object.values(outcomes).some((outcome) => outcome.status === 'timeout'); +} + +function readMutationReadinessClock(clock: MutationReadinessClock): number | null { + try { + const nowMs = clock.nowMs(); + return Number.isSafeInteger(nowMs) && nowMs >= 0 ? nowMs : null; + } catch { + return null; + } +} + +/** + * Builds a read-only, diagnostic assessor. It performs an initial inspection + * and one final reinspection behind a shared barrier, all under one bounded + * deadline. The final pass catches evidence invalidated while another initial + * inspection was pending. Late adapter results are ignored. + */ +export function createMutationReadinessAssessor( + input: MutationReadinessAssessmentInput +): MutationReadinessAssessor { + const requirements = snapshotMutationReadinessRequirements(input.requirements); + const scope = snapshotMutationReadinessScope({ + runtimeInstance: input.runtimeInstance, + workspace: input.workspace, + requirements, + }); + const instanceLease = input.instanceLease ?? null; + const evidence = input.evidence ?? {}; + const clock = input.clock; + + return Object.freeze({ + authoritativeForMutation: false as const, + async assess(): Promise { + const abortController = new AbortController(); + const context: MutationReadinessInspectionContext = Object.freeze({ + signal: abortController.signal, + }); + let deadlineReached = false; + let cancelDeadline = (): void => undefined; + const startedAtMs = readMutationReadinessClock(clock); + const deadlineAtMs = + startedAtMs === null ? null : startedAtMs + requirements.evaluationTimeoutMs; + const deadline = new Promise((resolve) => { + try { + const cancel = clock.scheduleDeadline(requirements.evaluationTimeoutMs, () => { + deadlineReached = true; + abortController.abort(); + resolve(TIMEOUT_OUTCOME); + }); + if (typeof cancel !== 'function') { + deadlineReached = true; + abortController.abort(); + resolve(TIMEOUT_OUTCOME); + return; + } + cancelDeadline = cancel; + } catch { + deadlineReached = true; + abortController.abort(); + resolve(TIMEOUT_OUTCOME); + } + }); + + const invocations = buildInvocations({ instanceLease, evidence, scope, context }); + let initial: MutationReadinessInspectionOutcomes; + let final: MutationReadinessInspectionOutcomes; + try { + initial = await collectInspectionsBeforeDeadline({ + invocations, + deadline, + deadlineAtMs, + clock, + abortController, + }); + final = + deadlineReached || containsTimeout(initial) + ? initial + : await collectInspectionsBeforeDeadline({ + invocations, + deadline, + deadlineAtMs, + clock, + abortController, + }); + } finally { + try { + cancelDeadline(); + } catch { + // Deadline cancellation is best effort; assessment authority remains denied/read-only. + } + abortController.abort(); + } + + const decisions = decideMutationReadiness({ + initial, + final, + scope, + nowMs: readMutationReadinessClock(clock), + }); + const diagnosticCodes = mutationReadinessDiagnosticCodes(decisions); + return Object.freeze({ + kind: 'mutation_readiness_diagnostic' as const, + assessment: Object.values(decisions).every((value) => value.status === 'verified') + ? ('all_evidence_verified' as const) + : ('denied' as const), + authoritativeForMutation: false as const, + decisions, + diagnosticCodes, + }); + }, + }); +} diff --git a/src/features/mutation-readiness/core/application/index.ts b/src/features/mutation-readiness/core/application/index.ts new file mode 100644 index 0000000000..6140fc9a95 --- /dev/null +++ b/src/features/mutation-readiness/core/application/index.ts @@ -0,0 +1,16 @@ +export { + createMutationReadinessAssessor, + type ExternalWriterReadinessEvidencePort, + type FilesystemReadinessEvidencePort, + type InstanceLeaseReadinessEvidencePort, + type InstanceLeaseReadinessInspectionContext, + type MutationReadinessAssessmentInput, + type MutationReadinessAssessor, + type MutationReadinessClock, + type MutationReadinessEvidencePorts, + type MutationReadinessInspectionContext, + type RecoveryOutboxReadinessEvidencePort, + type RuntimeBindingReadinessEvidencePort, + type StorageReadinessEvidencePort, + type WorkspaceBindingReadinessEvidencePort, +} from './MutationReadinessAssessor'; diff --git a/src/features/mutation-readiness/core/domain/MutationReadinessDecisions.ts b/src/features/mutation-readiness/core/domain/MutationReadinessDecisions.ts new file mode 100644 index 0000000000..593bc80dbd --- /dev/null +++ b/src/features/mutation-readiness/core/domain/MutationReadinessDecisions.ts @@ -0,0 +1,798 @@ +import { + INSTANCE_LEASE_PROTOCOL_VERSION, + type InstanceLeaseAdmissionInspection, + type InstanceLeaseAnchorEvidence, + type InstanceLeaseLauncherEvidence, +} from '@features/instance-lease/contracts'; + +import { + type ExternalWriterReadinessClassification, + type ExternalWriterReadinessCoordination, + type ExternalWriterReadinessDecision, + type FilesystemReadinessDecision, + type InstanceLeaseReadinessDecision, + MUTATION_READINESS_DIMENSIONS, + type MutationReadinessDecisions, + type MutationReadinessDecisionStatus, + type MutationReadinessDiagnosticCode, + type MutationReadinessDimension, + type MutationReadinessDimensionDecision, + type MutationReadinessScope, + type RecoveryOutboxReadinessDecision, + type RuntimeBindingReadinessDecision, + type StorageReadinessDecision, + type WorkspaceBindingReadinessDecision, +} from '../../contracts'; + +import { + parseWorkspaceBinding, + parseWorkspaceRootReference, + positiveInteger, + readExactRecord, + snapshotMutationReadinessRequirements, + snapshotWorkspaceTarget, +} from './MutationReadinessScope'; + +import type { WorkspaceMountBindingRef } from '@features/workspace-registry/contracts'; + +const DECIMAL_KERNEL_ID = /^(?:0|[1-9][0-9]*)$/; +type MutationReadinessRuntimeInstance = MutationReadinessScope['runtimeInstance']; +type RuntimeRootKind = 'claude' | 'app-data' | 'workspace' | 'temp' | 'logs'; +function snapshotRuntimeRoot( + value: unknown, + expectedKind: Kind +): Readonly<{ kind: Kind; reference: string }> | null { + const record = readExactRecord(value, ['kind', 'reference']); + const reference = record?.reference; + return !( + record?.kind !== expectedKind || + typeof reference !== 'string' || + reference.length === 0 || + reference.length > 4_096 || + reference.trim() !== reference || + // eslint-disable-next-line no-control-regex -- Runtime roots reject ASCII controls. + /[\x00-\x1f\x7f]/.test(reference) + ) + ? Object.freeze({ kind: expectedKind, reference }) + : null; +} +export type ReadinessEvidenceInspectionOutcome = + | { readonly status: 'settled'; readonly value: unknown } + | { readonly status: 'unavailable' | 'timeout' }; + +export interface MutationReadinessInspectionOutcomes { + readonly instanceLease: ReadinessEvidenceInspectionOutcome; + readonly runtimeBinding: ReadinessEvidenceInspectionOutcome; + readonly workspaceBinding: ReadinessEvidenceInspectionOutcome; + readonly storage: ReadinessEvidenceInspectionOutcome; + readonly filesystem: ReadinessEvidenceInspectionOutcome; + readonly externalWriter: ReadinessEvidenceInspectionOutcome; + readonly recoveryOutbox: ReadinessEvidenceInspectionOutcome; +} +interface VerifiedInspection { + readonly status: 'verified'; + readonly checkedAtMs: number; + readonly evidence: unknown; +} +type ParsedInspection = + | VerifiedInspection + | { readonly status: 'unavailable' | 'timeout' | 'unknown' | 'invalid' }; +type ParsedLeaseInspection = + | InstanceLeaseAdmissionInspection + | { readonly status: 'unavailable' | 'timeout' }; +function parseAnchorEvidence(value: unknown): InstanceLeaseAnchorEvidence | null { + const record = readExactRecord(value, ['device', 'inode', 'mode', 'uid', 'linkCount']); + if ( + !record || + typeof record.device !== 'string' || + !DECIMAL_KERNEL_ID.test(record.device) || + typeof record.inode !== 'string' || + !DECIMAL_KERNEL_ID.test(record.inode) || + record.inode === '0' || + !Number.isSafeInteger(record.mode) || + (record.mode as number) < 0 || + ((record.mode as number) & 0o170000) !== 0o100000 || + ((record.mode as number) & 0o22) !== 0 || + record.uid !== 0 || + record.linkCount !== 1 + ) { + return null; + } + return Object.freeze({ + device: record.device, + inode: record.inode, + mode: record.mode as number, + uid: 0, + linkCount: 1, + }); +} +function parseLeaseEvidence(value: unknown): InstanceLeaseLauncherEvidence | null { + const record = readExactRecord(value, [ + 'protocolVersion', + 'launcherPid', + 'controllerPid', + 'anchor', + ]); + const anchor = parseAnchorEvidence(record?.anchor); + if ( + record?.protocolVersion !== INSTANCE_LEASE_PROTOCOL_VERSION || + !positiveInteger(record.launcherPid) || + !positiveInteger(record.controllerPid) || + !anchor + ) { + return null; + } + return Object.freeze({ + protocolVersion: INSTANCE_LEASE_PROTOCOL_VERSION, + launcherPid: record.launcherPid, + controllerPid: record.controllerPid, + anchor, + }); +} +function parseLeaseInspection(outcome: ReadinessEvidenceInspectionOutcome): ParsedLeaseInspection { + if (outcome.status !== 'settled') return Object.freeze({ status: outcome.status }); + const terminal = readExactRecord(outcome.value, ['status']); + if (terminal?.status === 'invalid' || terminal?.status === 'released') { + return Object.freeze({ status: terminal.status }); + } + const held = readExactRecord(outcome.value, ['status', 'evidence']); + const evidence = parseLeaseEvidence(held?.evidence); + if (held?.status !== 'held' || !evidence) return Object.freeze({ status: 'invalid' }); + return Object.freeze({ status: 'held', evidence }); +} +function parseInspection(outcome: ReadinessEvidenceInspectionOutcome): ParsedInspection { + if (outcome.status !== 'settled') return Object.freeze({ status: outcome.status }); + const statusRecord = readExactRecord(outcome.value, ['status']); + if (statusRecord?.status === 'unavailable' || statusRecord?.status === 'unknown') { + return Object.freeze({ status: statusRecord.status }); + } + const verified = readExactRecord(outcome.value, ['status', 'checkedAtMs', 'evidence']); + if ( + verified?.status !== 'verified' || + !Number.isSafeInteger(verified.checkedAtMs) || + (verified.checkedAtMs as number) < 0 + ) { + return Object.freeze({ status: 'invalid' }); + } + return Object.freeze({ + status: 'verified', + checkedAtMs: verified.checkedAtMs as number, + evidence: verified.evidence, + }); +} +function snapshotRuntimeInstance(value: unknown): MutationReadinessRuntimeInstance | null { + try { + const record = readExactRecord(value, [ + 'deploymentId', + 'bootId', + 'claudeRoot', + 'appDataRoot', + 'workspaceRoots', + 'tempRoot', + 'logsRoot', + ]); + const rawRoots = record?.workspaceRoots; + if ( + typeof record?.deploymentId !== 'string' || + record.deploymentId.length > 128 || + !/^deployment_[A-Za-z0-9][A-Za-z0-9._-]*$/.test(record.deploymentId) || + typeof record.bootId !== 'string' || + record.bootId.length > 128 || + !/^boot_[A-Za-z0-9][A-Za-z0-9._-]*$/.test(record.bootId) || + !Array.isArray(rawRoots) || + Object.getPrototypeOf(rawRoots) !== Array.prototype || + rawRoots.length > 1_000 || + Reflect.ownKeys(rawRoots).length !== rawRoots.length + 1 + ) { + return null; + } + const workspaceRoots: Array> = []; + for (let index = 0; index < rawRoots.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(rawRoots, String(index)); + const root = + descriptor?.enumerable && 'value' in descriptor + ? snapshotRuntimeRoot(descriptor.value, 'workspace') + : null; + if (!root) return null; + workspaceRoots.push(root); + } + const roots = { + claudeRoot: snapshotRuntimeRoot(record.claudeRoot, 'claude'), + appDataRoot: snapshotRuntimeRoot(record.appDataRoot, 'app-data'), + tempRoot: snapshotRuntimeRoot(record.tempRoot, 'temp'), + logsRoot: snapshotRuntimeRoot(record.logsRoot, 'logs'), + }; + if (Object.values(roots).some((root) => !root)) { + return null; + } + return Object.freeze({ + deploymentId: record.deploymentId, + bootId: record.bootId, + ...roots, + workspaceRoots: Object.freeze(workspaceRoots), + }) as MutationReadinessRuntimeInstance; + } catch { + return null; + } +} +function snapshotMutationReadinessScope(input: { + readonly runtimeInstance: unknown; + readonly workspace: unknown; + readonly requirements: MutationReadinessScope['requirements']; +}): MutationReadinessScope | null { + const runtimeInstance = snapshotRuntimeInstance(input.runtimeInstance); + const workspace = snapshotWorkspaceTarget(input.workspace); + return runtimeInstance && workspace + ? Object.freeze({ runtimeInstance, workspace, requirements: input.requirements }) + : null; +} +function isFresh( + inspection: VerifiedInspection, + nowMs: number | null, + evidenceMaxAgeMs: number +): boolean { + return ( + nowMs !== null && + inspection.checkedAtMs <= nowMs && + nowMs - inspection.checkedAtMs <= evidenceMaxAgeMs + ); +} +function sameAnchor( + left: InstanceLeaseAnchorEvidence, + right: InstanceLeaseAnchorEvidence +): boolean { + return ( + left.device === right.device && + left.inode === right.inode && + left.mode === right.mode && + left.uid === right.uid && + left.linkCount === right.linkCount + ); +} +function sameLeaseEvidence( + left: InstanceLeaseLauncherEvidence, + right: InstanceLeaseLauncherEvidence +): boolean { + return ( + left.protocolVersion === right.protocolVersion && + left.launcherPid === right.launcherPid && + left.controllerPid === right.controllerPid && + sameAnchor(left.anchor, right.anchor) + ); +} +function sameRuntimeInstance( + left: MutationReadinessRuntimeInstance, + right: MutationReadinessRuntimeInstance +): boolean { + return ( + left.deploymentId === right.deploymentId && + left.bootId === right.bootId && + left.claudeRoot.reference === right.claudeRoot.reference && + left.appDataRoot.reference === right.appDataRoot.reference && + left.tempRoot.reference === right.tempRoot.reference && + left.logsRoot.reference === right.logsRoot.reference && + left.workspaceRoots.length === right.workspaceRoots.length && + left.workspaceRoots.every( + (root, index) => root.reference === right.workspaceRoots[index]?.reference + ) + ); +} +function sameWorkspaceBinding( + left: WorkspaceMountBindingRef, + right: WorkspaceMountBindingRef +): boolean { + return ( + left.workspaceId === right.workspaceId && + left.bootId === right.bootId && + left.mountGeneration === right.mountGeneration + ); +} +function nonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} +function decision< + TDimension extends MutationReadinessDimension, + TCode extends MutationReadinessDiagnosticCode, +>( + dimension: TDimension, + status: MutationReadinessDecisionStatus, + code: TCode +): MutationReadinessDimensionDecision { + return Object.freeze({ dimension, status, code }); +} +function unavailableUnknownOrTimeoutCode( + inspection: ParsedInspection, + codes: { + readonly unavailable: TCode; + readonly timeout: TCode; + readonly unknown: TCode; + readonly invalid: TCode; + } +): TCode { + if (inspection.status === 'unavailable') return codes.unavailable; + if (inspection.status === 'timeout') return codes.timeout; + if (inspection.status === 'unknown') return codes.unknown; + return codes.invalid; +} +function decideLease( + initial: ParsedLeaseInspection, + final: ParsedLeaseInspection +): InstanceLeaseReadinessDecision { + if (initial.status === 'timeout' || final.status === 'timeout') { + return decision('instanceLease', 'denied', 'instance_lease_evidence_timeout'); + } + if (initial.status === 'unavailable' || final.status === 'unavailable') { + return decision('instanceLease', 'denied', 'instance_lease_unavailable'); + } + if (initial.status === 'released' || final.status === 'released') { + return decision('instanceLease', 'denied', 'instance_lease_released'); + } + if (initial.status !== 'held' || final.status !== 'held') { + return decision('instanceLease', 'denied', 'instance_lease_invalid'); + } + if (!sameLeaseEvidence(initial.evidence, final.evidence)) { + return decision('instanceLease', 'denied', 'instance_lease_changed'); + } + return decision('instanceLease', 'verified', 'instance_lease_held'); +} +function decideRuntimeBinding( + inspection: ParsedInspection, + scope: MutationReadinessScope | null, + lease: ParsedLeaseInspection, + nowMs: number | null +): RuntimeBindingReadinessDecision { + if (!scope) { + return decision('runtimeBinding', 'denied', 'runtime_context_unavailable'); + } + if (inspection.status !== 'verified') { + return decision( + 'runtimeBinding', + 'denied', + unavailableUnknownOrTimeoutCode(inspection, { + unavailable: 'runtime_binding_evidence_unavailable', + timeout: 'runtime_binding_evidence_timeout', + unknown: 'runtime_binding_evidence_unknown', + invalid: 'runtime_binding_evidence_invalid', + }) + ); + } + if (!isFresh(inspection, nowMs, scope.requirements.evidenceMaxAgeMs)) { + return decision('runtimeBinding', 'denied', 'runtime_binding_evidence_stale'); + } + const evidenceRecord = readExactRecord(inspection.evidence, ['runtimeInstance', 'leaseAnchor']); + const observedRuntime = snapshotRuntimeInstance(evidenceRecord?.runtimeInstance); + const observedAnchor = parseAnchorEvidence(evidenceRecord?.leaseAnchor); + if (!evidenceRecord || !observedRuntime || !observedAnchor) { + return decision('runtimeBinding', 'denied', 'runtime_binding_evidence_invalid'); + } + if (observedRuntime.deploymentId !== scope.runtimeInstance.deploymentId) { + return decision('runtimeBinding', 'denied', 'runtime_binding_deployment_mismatch'); + } + if (observedRuntime.bootId !== scope.runtimeInstance.bootId) { + return decision('runtimeBinding', 'denied', 'runtime_binding_boot_mismatch'); + } + if (!sameRuntimeInstance(observedRuntime, scope.runtimeInstance)) { + return decision('runtimeBinding', 'denied', 'runtime_binding_roots_mismatch'); + } + if (lease.status !== 'held') { + return decision('runtimeBinding', 'denied', 'runtime_binding_lease_anchor_unverified'); + } + if (!sameAnchor(observedAnchor, lease.evidence.anchor)) { + return decision('runtimeBinding', 'denied', 'runtime_binding_lease_anchor_mismatch'); + } + return decision('runtimeBinding', 'verified', 'runtime_binding_verified'); +} +function decideWorkspaceBinding( + inspection: ParsedInspection, + scope: MutationReadinessScope | null, + nowMs: number | null +): WorkspaceBindingReadinessDecision { + if (!scope) { + return decision('workspaceBinding', 'denied', 'workspace_binding_context_unavailable'); + } + if (inspection.status !== 'verified') { + return decision( + 'workspaceBinding', + 'denied', + unavailableUnknownOrTimeoutCode(inspection, { + unavailable: 'workspace_binding_evidence_unavailable', + timeout: 'workspace_binding_evidence_timeout', + unknown: 'workspace_binding_evidence_unknown', + invalid: 'workspace_binding_evidence_invalid', + }) + ); + } + if (!isFresh(inspection, nowMs, scope.requirements.evidenceMaxAgeMs)) { + return decision('workspaceBinding', 'denied', 'workspace_binding_evidence_stale'); + } + const record = readExactRecord(inspection.evidence, [ + 'binding', + 'rootReference', + 'declaredRootHash', + 'registrationRevision', + 'health', + ]); + const binding = parseWorkspaceBinding(record?.binding); + const rootReference = parseWorkspaceRootReference(record?.rootReference); + if ( + !record || + !binding || + !rootReference || + !['healthy', 'read-only', 'unavailable'].includes(record.health as string) + ) { + return decision('workspaceBinding', 'denied', 'workspace_binding_evidence_invalid'); + } + if (binding.workspaceId !== scope.workspace.binding.workspaceId) { + return decision('workspaceBinding', 'denied', 'workspace_binding_workspace_mismatch'); + } + if ( + binding.bootId !== scope.runtimeInstance.bootId || + binding.bootId !== scope.workspace.binding.bootId + ) { + return decision('workspaceBinding', 'denied', 'workspace_binding_boot_mismatch'); + } + if (binding.mountGeneration !== scope.workspace.binding.mountGeneration) { + return decision('workspaceBinding', 'denied', 'workspace_binding_mount_generation_mismatch'); + } + const matchingRuntimeRoots = scope.runtimeInstance.workspaceRoots.filter( + (candidate) => candidate.reference === rootReference.reference + ); + if ( + rootReference.reference !== scope.workspace.rootReference.reference || + matchingRuntimeRoots.length !== 1 + ) { + return decision('workspaceBinding', 'denied', 'workspace_binding_root_mismatch'); + } + if ( + record.declaredRootHash !== scope.workspace.declaredRootHash || + record.registrationRevision !== scope.workspace.registrationRevision + ) { + return decision('workspaceBinding', 'denied', 'workspace_binding_registration_mismatch'); + } + if (record.health !== 'healthy') { + return decision('workspaceBinding', 'denied', 'workspace_binding_not_writable'); + } + return decision('workspaceBinding', 'verified', 'workspace_binding_verified'); +} +function decideStorage( + inspection: ParsedInspection, + scope: MutationReadinessScope | null, + nowMs: number | null +): StorageReadinessDecision { + if (inspection.status !== 'verified') { + return decision( + 'storage', + 'denied', + unavailableUnknownOrTimeoutCode(inspection, { + unavailable: 'storage_evidence_unavailable', + timeout: 'storage_evidence_timeout', + unknown: 'storage_evidence_unknown', + invalid: 'storage_evidence_invalid', + }) + ); + } + if (!scope) return decision('storage', 'denied', 'storage_evidence_invalid'); + if (!isFresh(inspection, nowMs, scope.requirements.evidenceMaxAgeMs)) { + return decision('storage', 'denied', 'storage_evidence_stale'); + } + const record = readExactRecord(inspection.evidence, [ + 'deploymentId', + 'appDataRootReference', + 'backend', + 'compatibility', + 'schemaVersion', + 'migrationState', + 'integrity', + 'criticalFallback', + ]); + if (!record || !positiveInteger(record.schemaVersion)) { + return decision('storage', 'denied', 'storage_evidence_invalid'); + } + if ( + record.deploymentId !== scope.runtimeInstance.deploymentId || + record.appDataRootReference !== scope.runtimeInstance.appDataRoot.reference + ) { + return decision('storage', 'denied', 'storage_binding_mismatch'); + } + if (record.backend !== 'sqlite') { + return decision('storage', 'denied', 'storage_backend_unavailable'); + } + if (record.compatibility !== 'verified') { + return decision('storage', 'denied', 'storage_compatibility_unverified'); + } + if (record.schemaVersion !== scope.requirements.storageSchemaVersion) { + return decision('storage', 'denied', 'storage_schema_mismatch'); + } + if (record.migrationState !== 'complete') { + return decision('storage', 'denied', 'storage_migration_incomplete'); + } + if (record.integrity !== 'ok') { + return decision('storage', 'denied', 'storage_integrity_unverified'); + } + if (record.criticalFallback !== 'disabled') { + return decision('storage', 'denied', 'storage_critical_fallback_enabled'); + } + return decision('storage', 'verified', 'storage_ready'); +} +function decideFilesystem( + inspection: ParsedInspection, + scope: MutationReadinessScope | null, + nowMs: number | null +): FilesystemReadinessDecision { + if (inspection.status !== 'verified') { + return decision( + 'filesystem', + 'denied', + unavailableUnknownOrTimeoutCode(inspection, { + unavailable: 'filesystem_evidence_unavailable', + timeout: 'filesystem_evidence_timeout', + unknown: 'filesystem_evidence_unknown', + invalid: 'filesystem_evidence_invalid', + }) + ); + } + if (!scope) return decision('filesystem', 'denied', 'filesystem_evidence_invalid'); + if (!isFresh(inspection, nowMs, scope.requirements.evidenceMaxAgeMs)) { + return decision('filesystem', 'denied', 'filesystem_evidence_stale'); + } + const record = readExactRecord(inspection.evidence, [ + 'deploymentId', + 'bootId', + 'workspaceBinding', + 'rootReference', + 'filesystem', + 'permission', + 'freeBytes', + 'atomicReplace', + 'directoryDurability', + ]); + const binding = parseWorkspaceBinding(record?.workspaceBinding); + const rootReference = parseWorkspaceRootReference(record?.rootReference); + if (!record || !binding || !rootReference || !nonNegativeInteger(record.freeBytes)) { + return decision('filesystem', 'denied', 'filesystem_evidence_invalid'); + } + if ( + record.deploymentId !== scope.runtimeInstance.deploymentId || + record.bootId !== scope.runtimeInstance.bootId || + !sameWorkspaceBinding(binding, scope.workspace.binding) || + rootReference.reference !== scope.workspace.rootReference.reference + ) { + return decision('filesystem', 'denied', 'filesystem_binding_mismatch'); + } + if (record.filesystem !== 'supported') { + return decision('filesystem', 'denied', 'filesystem_unsupported'); + } + if (record.permission !== 'read_write') { + return decision('filesystem', 'denied', 'filesystem_permission_unverified'); + } + if (record.freeBytes < scope.requirements.minimumFreeBytes) { + return decision('filesystem', 'denied', 'filesystem_free_space_insufficient'); + } + if (record.atomicReplace !== 'verified') { + return decision('filesystem', 'denied', 'filesystem_atomic_replace_unverified'); + } + if (record.directoryDurability !== 'verified') { + return decision('filesystem', 'denied', 'filesystem_directory_durability_unverified'); + } + return decision('filesystem', 'verified', 'filesystem_ready'); +} +function expectedExternalCoordination( + classification: ExternalWriterReadinessClassification +): ExternalWriterReadinessCoordination | null { + switch (classification) { + case 'app_exclusive': + return 'lease_fenced'; + case 'cooperative_external': + return 'protocol_verified'; + case 'provider_mediated': + return 'provider_protocol_verified'; + case 'quiescent_only': + return 'quiesced'; + case 'unknown': + case 'unavailable': + return null; + } +} +function decideExternalWriter( + inspection: ParsedInspection, + scope: MutationReadinessScope | null, + nowMs: number | null +): ExternalWriterReadinessDecision { + if (inspection.status !== 'verified') { + return decision( + 'externalWriter', + 'denied', + unavailableUnknownOrTimeoutCode(inspection, { + unavailable: 'external_writer_evidence_unavailable', + timeout: 'external_writer_evidence_timeout', + unknown: 'external_writer_evidence_unknown', + invalid: 'external_writer_evidence_invalid', + }) + ); + } + if (!scope) { + return decision('externalWriter', 'denied', 'external_writer_evidence_invalid'); + } + if (!isFresh(inspection, nowMs, scope.requirements.evidenceMaxAgeMs)) { + return decision('externalWriter', 'denied', 'external_writer_evidence_stale'); + } + const record = readExactRecord(inspection.evidence, [ + 'deploymentId', + 'bootId', + 'workspaceBinding', + 'classification', + 'coordination', + 'observation', + 'fileWriterEpoch', + 'observationWatermark', + ]); + const binding = parseWorkspaceBinding(record?.workspaceBinding); + if ( + !record || + !binding || + !positiveInteger(record.fileWriterEpoch) || + !nonNegativeInteger(record.observationWatermark) + ) { + return decision('externalWriter', 'denied', 'external_writer_evidence_invalid'); + } + if ( + record.deploymentId !== scope.runtimeInstance.deploymentId || + record.bootId !== scope.runtimeInstance.bootId || + !sameWorkspaceBinding(binding, scope.workspace.binding) + ) { + return decision('externalWriter', 'denied', 'external_writer_binding_mismatch'); + } + if (record.classification === 'unknown') { + return decision('externalWriter', 'denied', 'external_writer_class_unknown'); + } + if (record.classification === 'unavailable') { + return decision('externalWriter', 'denied', 'external_writer_class_unavailable'); + } + const expected = expectedExternalCoordination( + record.classification as ExternalWriterReadinessClassification + ); + if (!expected || record.coordination !== expected) { + return decision('externalWriter', 'denied', 'external_writer_coordination_unverified'); + } + if (record.observation !== 'clean') { + return decision('externalWriter', 'denied', 'external_writer_observation_dirty'); + } + return decision('externalWriter', 'verified', 'external_writer_coordinated'); +} + +function decideRecoveryOutbox( + inspection: ParsedInspection, + scope: MutationReadinessScope | null, + nowMs: number | null +): RecoveryOutboxReadinessDecision { + if (inspection.status !== 'verified') { + return decision( + 'recoveryOutbox', + 'denied', + unavailableUnknownOrTimeoutCode(inspection, { + unavailable: 'recovery_outbox_evidence_unavailable', + timeout: 'recovery_outbox_evidence_timeout', + unknown: 'recovery_outbox_evidence_unknown', + invalid: 'recovery_outbox_evidence_invalid', + }) + ); + } + if (!scope) { + return decision('recoveryOutbox', 'denied', 'recovery_outbox_evidence_invalid'); + } + if (!isFresh(inspection, nowMs, scope.requirements.evidenceMaxAgeMs)) { + return decision('recoveryOutbox', 'denied', 'recovery_outbox_evidence_stale'); + } + const record = readExactRecord(inspection.evidence, [ + 'deploymentId', + 'storageSchemaVersion', + 'scanState', + 'recoveryState', + 'outboxState', + 'pendingCommandCount', + 'recoveringCommandCount', + 'operatorRequiredCount', + 'unknownRecordCount', + ]); + if ( + !record || + !positiveInteger(record.storageSchemaVersion) || + !nonNegativeInteger(record.pendingCommandCount) || + !nonNegativeInteger(record.recoveringCommandCount) || + !nonNegativeInteger(record.operatorRequiredCount) || + !nonNegativeInteger(record.unknownRecordCount) + ) { + return decision('recoveryOutbox', 'denied', 'recovery_outbox_evidence_invalid'); + } + if ( + record.deploymentId !== scope.runtimeInstance.deploymentId || + record.storageSchemaVersion !== scope.requirements.storageSchemaVersion + ) { + return decision('recoveryOutbox', 'denied', 'recovery_outbox_binding_mismatch'); + } + if (record.scanState !== 'complete') { + return decision('recoveryOutbox', 'denied', 'recovery_scan_incomplete'); + } + if ( + record.recoveryState !== 'complete' || + record.pendingCommandCount !== 0 || + record.recoveringCommandCount !== 0 + ) { + return decision('recoveryOutbox', 'denied', 'recovery_pending'); + } + if (record.operatorRequiredCount !== 0) { + return decision('recoveryOutbox', 'denied', 'recovery_operator_required'); + } + if (record.unknownRecordCount !== 0) { + return decision('recoveryOutbox', 'denied', 'recovery_unknown_records'); + } + if (record.outboxState !== 'ready') { + return decision('recoveryOutbox', 'denied', 'outbox_unavailable'); + } + return decision('recoveryOutbox', 'verified', 'recovery_outbox_ready'); +} + +export function decideMutationReadiness(input: { + readonly initial: MutationReadinessInspectionOutcomes; + readonly final: MutationReadinessInspectionOutcomes; + readonly scope: MutationReadinessScope | null; + readonly nowMs: number | null; +}): MutationReadinessDecisions { + const initialLease = parseLeaseInspection(input.initial.instanceLease); + const finalLease = parseLeaseInspection(input.final.instanceLease); + const initial = input.initial; + const final = input.final; + const stableDecision = ( + initialDecision: TDecision, + finalDecision: TDecision + ): TDecision => { + if (finalDecision.status === 'denied') return finalDecision; + if (initialDecision.status === 'denied') return initialDecision; + return finalDecision; + }; + return Object.freeze({ + instanceLease: decideLease(initialLease, finalLease), + runtimeBinding: stableDecision( + decideRuntimeBinding( + parseInspection(initial.runtimeBinding), + input.scope, + initialLease, + input.nowMs + ), + decideRuntimeBinding( + parseInspection(final.runtimeBinding), + input.scope, + finalLease, + input.nowMs + ) + ), + workspaceBinding: stableDecision( + decideWorkspaceBinding(parseInspection(initial.workspaceBinding), input.scope, input.nowMs), + decideWorkspaceBinding(parseInspection(final.workspaceBinding), input.scope, input.nowMs) + ), + storage: stableDecision( + decideStorage(parseInspection(initial.storage), input.scope, input.nowMs), + decideStorage(parseInspection(final.storage), input.scope, input.nowMs) + ), + filesystem: stableDecision( + decideFilesystem(parseInspection(initial.filesystem), input.scope, input.nowMs), + decideFilesystem(parseInspection(final.filesystem), input.scope, input.nowMs) + ), + externalWriter: stableDecision( + decideExternalWriter(parseInspection(initial.externalWriter), input.scope, input.nowMs), + decideExternalWriter(parseInspection(final.externalWriter), input.scope, input.nowMs) + ), + recoveryOutbox: stableDecision( + decideRecoveryOutbox(parseInspection(initial.recoveryOutbox), input.scope, input.nowMs), + decideRecoveryOutbox(parseInspection(final.recoveryOutbox), input.scope, input.nowMs) + ), + }); +} + +export function mutationReadinessDiagnosticCodes( + decisions: MutationReadinessDecisions +): readonly MutationReadinessDiagnosticCode[] { + return Object.freeze(MUTATION_READINESS_DIMENSIONS.map((dimension) => decisions[dimension].code)); +} + +export { snapshotMutationReadinessRequirements, snapshotMutationReadinessScope }; diff --git a/src/features/mutation-readiness/core/domain/MutationReadinessScope.ts b/src/features/mutation-readiness/core/domain/MutationReadinessScope.ts new file mode 100644 index 0000000000..e793cd7038 --- /dev/null +++ b/src/features/mutation-readiness/core/domain/MutationReadinessScope.ts @@ -0,0 +1,122 @@ +import { + parseDeclaredRootHash, + parseRegistrationRevision, + parseWorkspaceMountBindingRef, + type WorkspaceMountBindingRef, +} from '@features/workspace-registry/contracts'; + +import { + MAX_MUTATION_READINESS_ASSESSMENT_TIMEOUT_MS, + type MutationReadinessRequirements, + type MutationReadinessWorkspaceTarget, +} from '../../contracts'; + +import type { RuntimeRootReference } from '@features/runtime-instance-context/contracts'; + +export function readExactRecord( + value: unknown, + expectedKeys: readonly string[] +): Readonly> | null { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return null; + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length || + keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + return null; + } + const record: Record = Object.create(null) as Record; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return null; + record[key] = descriptor.value; + } + return record; + } catch { + return null; + } +} + +export function parseWorkspaceRootReference( + value: unknown +): RuntimeRootReference<'workspace'> | null { + const record = readExactRecord(value, ['kind', 'reference']); + if ( + record?.kind !== 'workspace' || + typeof record.reference !== 'string' || + record.reference.length === 0 || + record.reference.trim() !== record.reference + ) { + return null; + } + return Object.freeze({ + kind: 'workspace', + reference: record.reference as RuntimeRootReference<'workspace'>['reference'], + }); +} + +export function parseWorkspaceBinding(value: unknown): WorkspaceMountBindingRef | null { + if (!readExactRecord(value, ['workspaceId', 'bootId', 'mountGeneration'])) return null; + try { + return parseWorkspaceMountBindingRef(value); + } catch { + return null; + } +} + +export function snapshotWorkspaceTarget(value: unknown): MutationReadinessWorkspaceTarget | null { + const record = readExactRecord(value, [ + 'binding', + 'rootReference', + 'declaredRootHash', + 'registrationRevision', + ]); + if (!record) return null; + const binding = parseWorkspaceBinding(record.binding); + const rootReference = parseWorkspaceRootReference(record.rootReference); + if (!binding || !rootReference) return null; + try { + return Object.freeze({ + binding, + rootReference, + declaredRootHash: parseDeclaredRootHash(record.declaredRootHash), + registrationRevision: parseRegistrationRevision(record.registrationRevision), + }); + } catch { + return null; + } +} + +export function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +export function snapshotMutationReadinessRequirements( + value: unknown +): MutationReadinessRequirements { + const record = readExactRecord(value, [ + 'storageSchemaVersion', + 'minimumFreeBytes', + 'evidenceMaxAgeMs', + 'evaluationTimeoutMs', + ]); + if ( + !record || + !positiveInteger(record.storageSchemaVersion) || + !positiveInteger(record.minimumFreeBytes) || + !positiveInteger(record.evidenceMaxAgeMs) || + !positiveInteger(record.evaluationTimeoutMs) || + record.evaluationTimeoutMs > MAX_MUTATION_READINESS_ASSESSMENT_TIMEOUT_MS + ) { + throw new TypeError('mutation-readiness-requirements-invalid'); + } + return Object.freeze({ + storageSchemaVersion: record.storageSchemaVersion, + minimumFreeBytes: record.minimumFreeBytes, + evidenceMaxAgeMs: record.evidenceMaxAgeMs, + evaluationTimeoutMs: record.evaluationTimeoutMs, + }); +} diff --git a/src/features/mutation-readiness/core/domain/index.ts b/src/features/mutation-readiness/core/domain/index.ts new file mode 100644 index 0000000000..e244a4103a --- /dev/null +++ b/src/features/mutation-readiness/core/domain/index.ts @@ -0,0 +1,8 @@ +export { + decideMutationReadiness, + mutationReadinessDiagnosticCodes, + type MutationReadinessInspectionOutcomes, + type ReadinessEvidenceInspectionOutcome, + snapshotMutationReadinessRequirements, + snapshotMutationReadinessScope, +} from './MutationReadinessDecisions'; diff --git a/src/features/mutation-readiness/index.ts b/src/features/mutation-readiness/index.ts new file mode 100644 index 0000000000..3a11bf931d --- /dev/null +++ b/src/features/mutation-readiness/index.ts @@ -0,0 +1,18 @@ +export * from './contracts'; +export { + createMutationReadinessAssessor, + type ExternalWriterReadinessEvidencePort, + type FilesystemReadinessEvidencePort, + type InstanceLeaseReadinessEvidencePort, + type InstanceLeaseReadinessInspectionContext, + type MutationReadinessAssessmentInput, + type MutationReadinessAssessor, + type MutationReadinessClock, + type MutationReadinessEvidencePorts, + type MutationReadinessInspectionContext, + type RecoveryOutboxReadinessEvidencePort, + type RuntimeBindingReadinessEvidencePort, + type StorageReadinessEvidencePort, + type WorkspaceBindingReadinessEvidencePort, +} from './core/application'; +export { decideMutationReadiness } from './core/domain'; diff --git a/src/features/organizations/main/adapters/output/TeamDirectoryOrganizationAdapter.ts b/src/features/organizations/main/adapters/output/TeamDirectoryOrganizationAdapter.ts index ee2ffe34b2..73c5217aef 100644 --- a/src/features/organizations/main/adapters/output/TeamDirectoryOrganizationAdapter.ts +++ b/src/features/organizations/main/adapters/output/TeamDirectoryOrganizationAdapter.ts @@ -1,6 +1,6 @@ import type { OrganizationsTeamDirectoryPort } from '../../../core/application'; import type { OrgTaskCandidate, OrgTeamCandidate } from '../../../core/domain'; -import type { TeamDataService } from '@main/services/team/TeamDataService'; +import type { OrganizationsTeamDataPort } from '../../application/OrganizationsTeamDataPort'; import type { GlobalTask, TeamSummary, TeamSummaryMember } from '@shared/types'; function toTaskCandidate(task: GlobalTask): OrgTaskCandidate { @@ -33,13 +33,13 @@ function ensureLeadMember(team: TeamSummary, members: TeamSummaryMember[]): Team } export class TeamDirectoryOrganizationAdapter implements OrganizationsTeamDirectoryPort { - constructor(private readonly teamDataService: TeamDataService) {} + constructor(private readonly teamData: OrganizationsTeamDataPort) {} async listTeams(input: { includeDeletedTeams: boolean }): Promise { const [teams, globalTasks, aliveTeams] = await Promise.all([ - this.teamDataService.listTeams(), - this.teamDataService.getAllTasks(), - this.teamDataService.listAliveProcessTeams(), + this.teamData.listTeams(), + this.teamData.getAllTasks(), + this.teamData.listAliveProcessTeams(), ]); const aliveSet = new Set(aliveTeams); const tasksByTeam = new Map(); diff --git a/src/features/organizations/main/application/OrganizationsTeamDataPort.ts b/src/features/organizations/main/application/OrganizationsTeamDataPort.ts new file mode 100644 index 0000000000..001e287dab --- /dev/null +++ b/src/features/organizations/main/application/OrganizationsTeamDataPort.ts @@ -0,0 +1,8 @@ +import type { GlobalTask, TeamSummary } from '@shared/types'; + +/** Team data capabilities needed to project organization directory entries. */ +export interface OrganizationsTeamDataPort { + listTeams(): Promise; + getAllTasks(): Promise; + listAliveProcessTeams(): Promise; +} diff --git a/src/features/organizations/main/composition.ts b/src/features/organizations/main/composition.ts new file mode 100644 index 0000000000..05e49fdcb0 --- /dev/null +++ b/src/features/organizations/main/composition.ts @@ -0,0 +1 @@ +export { createOrganizationsFeature } from './composition/createOrganizationsFeature'; diff --git a/src/features/organizations/main/composition/createOrganizationsFeature.ts b/src/features/organizations/main/composition/createOrganizationsFeature.ts index badb1d2cdf..b0a6f5d4e7 100644 --- a/src/features/organizations/main/composition/createOrganizationsFeature.ts +++ b/src/features/organizations/main/composition/createOrganizationsFeature.ts @@ -21,8 +21,8 @@ import type { UpsertOrganizationRelationRequest, UpsertOrganizationUnitRequest, } from '../../contracts'; +import type { OrganizationsTeamDataPort } from '../application/OrganizationsTeamDataPort'; import type { CrossTeamService } from '@main/services/team/CrossTeamService'; -import type { TeamDataService } from '@main/services/team/TeamDataService'; export interface OrganizationsFeatureFacade { getOrganizationMap(request: NormalizedOrganizationMapRequest): Promise; @@ -33,9 +33,7 @@ export interface OrganizationsFeatureFacade { upsertOrganizationUnit( request: UpsertOrganizationUnitRequest ): Promise; - moveOrganizationUnit( - request: MoveOrganizationUnitRequest - ): Promise; + moveOrganizationUnit(request: MoveOrganizationUnitRequest): Promise; removeOrganizationUnit( request: RemoveOrganizationUnitRequest ): Promise; @@ -52,13 +50,13 @@ export interface OrganizationsFeatureFacade { } export function createOrganizationsFeature(deps: { - teamDataService: TeamDataService; + teamData: OrganizationsTeamDataPort; crossTeamService: CrossTeamService; logger: OrganizationsLoggerPort; clock?: OrganizationsClockPort; }): OrganizationsFeatureFacade { const structureRepository = new JsonOrganizationStructureRepository(deps.logger); - const teamDirectory = new TeamDirectoryOrganizationAdapter(deps.teamDataService); + const teamDirectory = new TeamDirectoryOrganizationAdapter(deps.teamData); const clock = deps.clock ?? { now: () => Date.now() }; const mapUseCase = new GetOrganizationMapUseCase({ structure: structureRepository, diff --git a/src/features/organizations/main/index.ts b/src/features/organizations/main/index.ts index b0bbbe9778..ca41672028 100644 --- a/src/features/organizations/main/index.ts +++ b/src/features/organizations/main/index.ts @@ -3,5 +3,5 @@ export { registerOrganizationsIpc, removeOrganizationsIpc, } from './adapters/input/ipc/registerOrganizationsIpc'; +export type { OrganizationsTeamDataPort } from './application/OrganizationsTeamDataPort'; export type { OrganizationsFeatureFacade } from './composition/createOrganizationsFeature'; -export { createOrganizationsFeature } from './composition/createOrganizationsFeature'; diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index f1429e4e0a..ce15307e52 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -15,6 +15,30 @@ import type { const DEFAULT_CACHE_TTL_MS = 10_000; const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000; +interface RecentProjectsDeadline { + schedule(delayMs: number, onDeadline: () => void): () => void; +} + +const PORTABLE_DEADLINE: RecentProjectsDeadline = { + schedule(delayMs, onDeadline) { + const signal = AbortSignal.timeout(delayMs); + let active = true; + const handleDeadline = (): void => { + if (!active) return; + active = false; + onDeadline(); + }; + signal.addEventListener('abort', handleDeadline, { once: true }); + if (signal.aborted) { + handleDeadline(); + } + return () => { + active = false; + signal.removeEventListener('abort', handleDeadline); + }; + }, +}; + interface SourceLoadResult { candidates: RecentProjectCandidate[]; degraded: boolean; @@ -39,6 +63,8 @@ export interface ListDashboardRecentProjectsDeps { cache: RecentProjectsCachePort; output: ListDashboardRecentProjectsOutputPort; clock: ClockPort; + /** Injectable for deterministic tests and runtimes with a specialized scheduler. */ + deadline?: RecentProjectsDeadline; logger: LoggerPort; cacheTtlMs?: number; degradedCacheTtlMs?: number; @@ -47,11 +73,13 @@ export interface ListDashboardRecentProjectsDeps { export class ListDashboardRecentProjectsUseCase { readonly #cacheTtlMs: number; readonly #degradedCacheTtlMs: number; + readonly #deadline: RecentProjectsDeadline; readonly #inFlightByCacheKey = new Map>(); constructor(private readonly deps: ListDashboardRecentProjectsDeps) { this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; this.#degradedCacheTtlMs = deps.degradedCacheTtlMs ?? DEFAULT_DEGRADED_CACHE_TTL_MS; + this.#deadline = deps.deadline ?? PORTABLE_DEADLINE; } async execute(cacheKey: string): Promise { @@ -122,8 +150,22 @@ export class ListDashboardRecentProjectsUseCase { return this.#loadSourceWithoutTimeout(source, sourceId, sourceIndex); } - let timer: ReturnType | null = null; + let cancelDeadline = (): void => undefined; try { + const deadline = new Promise<{ kind: 'timeout' }>((resolve) => { + try { + const cancel = this.#deadline.schedule(source.timeoutMs!, () => + resolve({ kind: 'timeout' }) + ); + if (typeof cancel !== 'function') { + resolve({ kind: 'timeout' }); + return; + } + cancelDeadline = cancel; + } catch { + resolve({ kind: 'timeout' }); + } + }); const result = await Promise.race([ source .list() @@ -141,9 +183,7 @@ export class ListDashboardRecentProjectsUseCase { error, }) as const ), - new Promise<{ kind: 'timeout' }>((resolve) => { - timer = setTimeout(() => resolve({ kind: 'timeout' }), source.timeoutMs); - }), + deadline, ]); if (result.kind === 'success') { @@ -166,8 +206,10 @@ export class ListDashboardRecentProjectsUseCase { }); return { candidates: [], degraded: true }; } finally { - if (timer) { - clearTimeout(timer); + try { + cancelDeadline(); + } catch { + // The load result is already settled; cancellation is best effort. } } } diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts index ec27002ac0..b8593f54f2 100644 --- a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -17,28 +17,58 @@ const logger = createLogger('Feature:RecentProjects:HTTP'); export function registerRecentProjectsHttp( app: FastifyInstance, - feature: RecentProjectsFeatureFacade + feature: RecentProjectsFeatureFacade, + projectWorkspaceId?: (request: unknown, runtimeWorkspaceId: string) => Promise ): void { - app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { - const startedAt = Date.now(); - try { - const payload = normalizeDashboardRecentProjectsPayload( - await feature.listDashboardRecentProjects() - ) ?? { - projects: [], - degraded: true, - }; - logger.info('dashboard recent-projects HTTP loaded', { - count: payload.projects.length, - degraded: payload.degraded, - durationMs: Date.now() - startedAt, - estimatedPayloadBytes: estimateDashboardRecentProjectsPayloadBytes(payload), - ...getRecentProjectsMemoryDiagnostics(), - }); - return payload; - } catch (error) { - logger.error('Failed to load dashboard recent projects via HTTP', error); - return { projects: [], degraded: true }; + app.get( + DASHBOARD_RECENT_PROJECTS_ROUTE, + async (request): Promise => { + const startedAt = Date.now(); + try { + const payload = normalizeDashboardRecentProjectsPayload( + await feature.listDashboardRecentProjects() + ) ?? { + projects: [], + degraded: true, + }; + logger.info('dashboard recent-projects HTTP loaded', { + count: payload.projects.length, + degraded: payload.degraded, + durationMs: Date.now() - startedAt, + estimatedPayloadBytes: estimateDashboardRecentProjectsPayloadBytes(payload), + ...getRecentProjectsMemoryDiagnostics(), + }); + if (!projectWorkspaceId) return payload; + const decisions = await Promise.all( + payload.projects.map(async (project) => ({ + project, + publicWorkspaceId: await projectWorkspaceId(request, project.id), + })) + ); + return { + ...payload, + projects: decisions.flatMap(({ project, publicWorkspaceId }) => + publicWorkspaceId === null + ? [] + : [ + { + ...project, + id: publicWorkspaceId, + primaryPath: publicWorkspaceId, + associatedPaths: [], + openTarget: { + type: 'existing-worktree' as const, + repositoryId: publicWorkspaceId, + worktreeId: publicWorkspaceId, + }, + }, + ] + ), + }; + } catch (error) { + logger.error('Failed to load dashboard recent projects via HTTP', error); + return { projects: [], degraded: true }; + } } - }); + ); } diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 8d02be472a..c805b28e45 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type DashboardRecentProject } from '@features/recent-projects/contracts'; import { api, isElectronMode } from '@renderer/api'; +import { createTeamAliveListReadPort } from '@renderer/composition/team/createTeamAliveListReadPort'; import { useStore } from '@renderer/store'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { @@ -31,6 +32,7 @@ const LOAD_MORE_STEP = 8; const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000; const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000; const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3; +const teamAliveListReadPort = createTeamAliveListReadPort(); function matchesSearch(project: DashboardRecentProject, query: string): boolean { if (!query) { @@ -237,8 +239,8 @@ export function useRecentProjectsSection( const requestContextId = activeContextId; const requestContextEpoch = captureContextScopedRequestEpoch(); - void api.teams - .aliveList() + void teamAliveListReadPort + .listAliveTeams() .then((teamNames) => { if ( !cancelled && diff --git a/src/features/review-mutations/contracts/index.ts b/src/features/review-mutations/contracts/index.ts index fcb073fefc..242b5035fc 100644 --- a/src/features/review-mutations/contracts/index.ts +++ b/src/features/review-mutations/contracts/index.ts @@ -1 +1,2 @@ +export * from './ipcChannels'; export * from './types'; diff --git a/src/features/review-mutations/contracts/ipcChannels.ts b/src/features/review-mutations/contracts/ipcChannels.ts new file mode 100644 index 0000000000..a97d0ef966 --- /dev/null +++ b/src/features/review-mutations/contracts/ipcChannels.ts @@ -0,0 +1,9 @@ +export const REVIEW_EXECUTE_MUTATION = 'review:executeMutation'; +export const REVIEW_RETRY_MUTATION_RECOVERY = 'review:retryMutationRecovery'; +export const REVIEW_RESTORE_HISTORY = 'review:restoreHistory'; + +export const REVIEW_MUTATION_RECOVERY_IPC_CHANNELS = [ + REVIEW_EXECUTE_MUTATION, + REVIEW_RETRY_MUTATION_RECOVERY, + REVIEW_RESTORE_HISTORY, +] as const; diff --git a/src/features/review-mutations/core/application/ReviewMutationApplyResultError.ts b/src/features/review-mutations/core/application/ReviewMutationApplyResultError.ts new file mode 100644 index 0000000000..7ef3e06427 --- /dev/null +++ b/src/features/review-mutations/core/application/ReviewMutationApplyResultError.ts @@ -0,0 +1,21 @@ +import type { ReviewMutationUndoAction } from './ReviewMutationJournalTypes'; + +export interface ReviewMutationApplyResult { + applied: number; + skipped: number; + conflicts: number; + errors: { + filePath: string; + error: string; + code?: 'conflict' | 'unavailable' | 'manual-review-required' | 'io-error'; + }[]; + decisionRevision?: number; + committedReviewAction?: ReviewMutationUndoAction; + diskPostimages?: { filePath: string; content: string | null }[]; +} + +export class ReviewMutationApplyResultError extends Error { + constructor(readonly result: ReviewMutationApplyResult) { + super(result.errors[0]?.error ?? 'Review mutation could not be applied safely'); + } +} diff --git a/src/features/review-mutations/core/application/ReviewMutationJournalTypes.ts b/src/features/review-mutations/core/application/ReviewMutationJournalTypes.ts new file mode 100644 index 0000000000..caf3680a92 --- /dev/null +++ b/src/features/review-mutations/core/application/ReviewMutationJournalTypes.ts @@ -0,0 +1,249 @@ +import type { ReviewMutationKind, ReviewMutationPhase } from '../../contracts'; +import type { ReviewMutationJournalPathTransition } from '../domain/reviewDecisionBatch'; + +export type { ReviewMutationJournalPathTransition } from '../domain/reviewDecisionBatch'; + +export type ReviewMutationDecision = 'accepted' | 'rejected' | 'pending'; + +export interface ReviewMutationFileDecision { + filePath: string; + reviewKey?: string; + fileDecision: ReviewMutationDecision; + hunkDecisions: Record; + contentSnapshotToken?: string; + hunkContextHashes?: Record; +} + +export interface ReviewMutationPersistenceScope { + scopeKey: string; + scopeToken: string; +} + +export interface ReviewMutationReviewScope { + teamName: string; + taskId?: string; + memberName?: string; +} + +export interface ReviewMutationDecisionSnapshot { + hunkDecisions: Record; + fileDecisions: Record; +} + +export type ReviewMutationActionDescriptor = + | { + intent: 'accept-hunk' | 'reject-hunk'; + filePath: string; + hunkIndex: number; + } + | { + intent: 'accept-file' | 'reject-file' | 'restore-file' | 'restore-rename'; + filePath: string; + } + | { intent: 'accept-all' | 'reject-all'; fileCount: number }; + +interface ReviewMutationUndoActionBase { + id: string; + createdAt: string; + descriptor?: ReviewMutationActionDescriptor; +} + +export type ReviewMutationUndoAction = + | (ReviewMutationUndoActionBase & { + kind: 'bulk'; + decisionSnapshot: ReviewMutationDecisionSnapshot; + diskSnapshots: { + filePath: string; + beforeContent: string; + afterContent: string | null; + }[]; + }) + | (ReviewMutationUndoActionBase & { + kind: 'disk'; + action: { + snapshot: { + filePath: string; + beforeContent: string; + afterContent: string | null; + }; + originalIndex?: number; + decisionSnapshot?: ReviewMutationDecisionSnapshot; + }; + }) + | (ReviewMutationUndoActionBase & { + kind: 'hunk'; + action: { filePath: string; originalIndex: number }; + }); + +export interface ReviewMutationRedoAction { + action: ReviewMutationUndoAction; + decisionSnapshot: ReviewMutationDecisionSnapshot; + hunkContextHashesByFile?: Record>; +} + +export interface ReviewMutationPersistedStateSnapshot extends ReviewMutationDecisionSnapshot { + hunkContextHashesByFile?: Record>; + reviewActionHistory: ReviewMutationUndoAction[]; + reviewRedoHistory: ReviewMutationRedoAction[]; +} + +export interface ReviewMutationFileSnippet { + toolUseId: string; + filePath: string; + toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit' | 'Bash' | 'PowerShell' | 'PostToolUse'; + type: + | 'edit' + | 'write-new' + | 'write-update' + | 'multi-edit' + | 'notebook-edit' + | 'shell-snapshot' + | 'hook-snapshot'; + oldString: string; + newString: string; + replaceAll: boolean; + timestamp: string; + isError: boolean; + contextHash?: string; + ledger?: { + eventId: string; + source: 'ledger-exact' | 'ledger-snapshot'; + confidence: 'exact' | 'high' | 'medium' | 'low' | 'ambiguous'; + originalFullContent: string | null; + modifiedFullContent: string | null; + beforeHash: string | null; + afterHash: string | null; + operation?: 'create' | 'modify' | 'delete'; + beforeState?: { + exists?: boolean; + sha256?: string; + sizeBytes?: number; + contentKind?: 'text' | 'binary' | 'unknown'; + blobRef?: string; + unavailableCode?: 'binary' | 'too-large' | 'read-error' | 'not-captured' | 'blob-missing'; + unavailableReason?: string; + }; + afterState?: { + exists?: boolean; + sha256?: string; + sizeBytes?: number; + contentKind?: 'text' | 'binary' | 'unknown'; + blobRef?: string; + unavailableCode?: 'binary' | 'too-large' | 'read-error' | 'not-captured' | 'blob-missing'; + unavailableReason?: string; + }; + relation?: { + kind: 'rename' | 'copy'; + oldPath: string; + newPath: string; + }; + executionSeq?: number; + linesAdded?: number; + linesRemoved?: number; + textAvailability?: 'patch-text' | 'full-text' | 'unavailable'; + worktreePath?: string; + worktreeBranch?: string; + baseWorkspaceRoot?: string; + dirtyLeaderWarning?: string; + }; +} + +export interface ReviewMutationFileContent { + filePath: string; + relativePath: string; + snippets: ReviewMutationFileSnippet[]; + linesAdded: number; + linesRemoved: number; + isNewFile: boolean; + changeKey?: string; + diffStatKnown?: boolean; + reviewSnapshotToken?: string; + originalFullContent: string | null; + modifiedFullContent: string | null; + contentSource: + | 'ledger-exact' + | 'ledger-snapshot' + | 'file-history' + | 'snippet-reconstruction' + | 'disk-current' + | 'git-fallback' + | 'unavailable'; +} + +export interface ReviewMutationRenameRecoveryExpectation { + eventId: string; + beforeHash: string | null; + afterHash: string | null; + relation: { + kind: 'rename' | 'copy'; + oldPath: string; + newPath: string; + }; +} + +export type ReviewMutationDirectDiskStep = + | { + id: string; + type: 'write'; + filePath: string; + expectedContent: string | null; + content: string; + } + | { + id: string; + type: 'delete'; + filePath: string; + expectedContent: string; + } + | { + id: string; + type: 'restore-rejected-rename' | 'reapply-rejected-rename'; + filePath: string; + expectation: ReviewMutationRenameRecoveryExpectation; + }; + +export interface ReviewMutationJournalRecord { + version: 2; + id: string; + phase: ReviewMutationPhase; + kind: ReviewMutationKind; + teamName: string; + persistenceScope: ReviewMutationPersistenceScope; + reviewScope: ReviewMutationReviewScope; + decisions: (ReviewMutationFileDecision & { reviewKey: string })[]; + fileContents: ReviewMutationFileContent[]; + decisionStatuses?: ('pending' | 'applied')[]; + decisionPostimages?: (ReviewMutationJournalPathPostimage[] | null)[]; + decisionTransitions?: (ReviewMutationJournalPathTransition[] | null)[]; + diskSteps?: ReviewMutationJournalDiskStep[]; + persistedState?: ReviewMutationPersistedStateSnapshot; + expectedDecisionRevision?: number; + createdAt: string; + updatedAt: string; + blocked?: boolean; + failure?: string; +} + +export interface PrepareReviewMutationInput { + teamName: string; + persistenceScope: ReviewMutationPersistenceScope; + reviewScope: ReviewMutationReviewScope; + kind: ReviewMutationKind; + decisions: (ReviewMutationFileDecision & { reviewKey: string })[]; + fileContents: ReviewMutationFileContent[]; + diskSteps?: ReviewMutationJournalDiskStep[]; + persistedState?: ReviewMutationPersistedStateSnapshot; + expectedDecisionRevision?: number; +} + +export type ReviewMutationJournalDiskStep = ReviewMutationDirectDiskStep & { + status: 'pending' | 'applied'; + /** Main-resolved immutable rename evidence needed after the renderer is gone. */ + authoritativeContent?: ReviewMutationFileContent; +}; + +export interface ReviewMutationJournalPathPostimage { + filePath: string; + /** Null means the path must be absent. Existing text is stored by digest only. */ + sha256: string | null; +} diff --git a/src/features/review-mutations/core/domain/durableReviewValue.ts b/src/features/review-mutations/core/domain/durableReviewValue.ts new file mode 100644 index 0000000000..87343eba68 --- /dev/null +++ b/src/features/review-mutations/core/domain/durableReviewValue.ts @@ -0,0 +1,55 @@ +function normalizeDurableReviewValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => normalizeDurableReviewValue(entry)); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .map(([key, entry]) => [key, normalizeDurableReviewValue(entry)]) + ); +} + +function areNormalizedValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index++) { + const leftHasEntry = Object.prototype.hasOwnProperty.call(left, index); + const rightHasEntry = Object.prototype.hasOwnProperty.call(right, index); + if ( + leftHasEntry !== rightHasEntry || + (leftHasEntry && !areNormalizedValuesEqual(left[index], right[index])) + ) { + return false; + } + } + return true; + } + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') { + return false; + } + const leftEntries = Object.entries(left); + const rightRecord = right as Record; + if (leftEntries.length !== Object.keys(rightRecord).length) return false; + return leftEntries.every( + ([key, value]) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + areNormalizedValuesEqual(value, rightRecord[key]) + ); +} + +/** + * Compares JSON-durable review values while treating omitted and undefined + * object properties identically. Array order remains significant. + */ +export function isDurableReviewEqual(left: unknown, right: unknown): boolean { + return areNormalizedValuesEqual( + normalizeDurableReviewValue(left), + normalizeDurableReviewValue(right) + ); +} diff --git a/src/features/review-mutations/core/domain/reviewDecisionBatch.ts b/src/features/review-mutations/core/domain/reviewDecisionBatch.ts new file mode 100644 index 0000000000..5db66929b0 --- /dev/null +++ b/src/features/review-mutations/core/domain/reviewDecisionBatch.ts @@ -0,0 +1,125 @@ +import type { + ApplyReviewDiskTransition, + ApplyReviewResult, + FileReviewDecision, + ReviewMutationDiskPostimage, + ReviewPersistedStateSnapshot, +} from '@shared/types/review'; + +export interface ReviewMutationJournalPathTransition { + filePath: string; + beforeContent: string | null; + afterContent: string | null; + operation?: 'replace' | 'delete' | 'move'; + transactionId?: string; + relatedFilePath?: string; +} + +export function assertPersistedStateIncludesDecisions( + state: ReviewPersistedStateSnapshot, + decisions: readonly FileReviewDecision[] +): void { + for (const decision of decisions) { + const reviewKey = decision.reviewKey; + if (!reviewKey) throw new Error('Durable review mutation requires a stable reviewKey'); + const actualFileDecision = + state.fileDecisions[reviewKey] ?? state.fileDecisions[decision.filePath] ?? 'pending'; + if (actualFileDecision !== decision.fileDecision) { + throw new Error('Durable review state does not match the requested file decision'); + } + for (const [index, expected] of Object.entries(decision.hunkDecisions)) { + const actual = + state.hunkDecisions[`${reviewKey}:${index}`] ?? + state.hunkDecisions[`${decision.filePath}:${index}`] ?? + 'pending'; + if (actual !== expected) { + throw new Error('Durable review state does not match the requested hunk decision'); + } + } + } +} + +export function mergeReviewApplyResults( + current: ApplyReviewResult, + next: ApplyReviewResult +): ApplyReviewResult { + return { + applied: current.applied + next.applied, + skipped: current.skipped + next.skipped, + conflicts: current.conflicts + next.conflicts, + errors: [...current.errors, ...next.errors], + }; +} + +export function mergeReviewMutationDiskPostimages( + target: Map, + postimages: readonly ReviewMutationDiskPostimage[], + normalizePath: (filePath: string) => string +): void { + for (const postimage of postimages) { + target.set(normalizePath(postimage.filePath), postimage); + } +} + +export function composeReviewDiskTransitions( + existing: readonly ReviewMutationJournalPathTransition[], + next: readonly ApplyReviewDiskTransition[], + normalizePath: (filePath: string) => string, + mergeText: ( + base: string, + current: string, + incoming: string + ) => { content: string; hasConflicts: boolean } +): ReviewMutationJournalPathTransition[] { + const composed = new Map( + existing.map((transition) => [normalizePath(transition.filePath), transition]) + ); + for (const transition of next) { + const key = normalizePath(transition.filePath); + const previous = composed.get(key); + if (!previous) { + composed.set(key, { ...transition }); + continue; + } + if ( + previous.beforeContent === transition.beforeContent && + previous.afterContent === transition.afterContent + ) { + composed.set(key, { ...previous, ...transition }); + continue; + } + if ( + transition.beforeContent === transition.afterContent && + previous.afterContent === transition.beforeContent + ) { + continue; + } + if ( + typeof previous.afterContent !== 'string' || + typeof transition.beforeContent !== 'string' || + typeof previous.beforeContent !== 'string' + ) { + throw new Error( + `Review mutation file presence changed during recovery; refusing ${transition.filePath}` + ); + } + const merged = mergeText( + previous.afterContent, + transition.beforeContent, + previous.beforeContent + ); + if (merged.hasConflicts) { + throw new Error( + `Review mutation concurrent edits cannot be preserved safely; refusing ${transition.filePath}` + ); + } + composed.set(key, { + ...previous, + ...transition, + filePath: transition.filePath, + beforeContent: merged.content, + afterContent: transition.afterContent, + }); + } + return [...composed.values()]; +} diff --git a/src/features/review-mutations/core/domain/reviewDecisionCommandPolicy.ts b/src/features/review-mutations/core/domain/reviewDecisionCommandPolicy.ts new file mode 100644 index 0000000000..e4c6010754 --- /dev/null +++ b/src/features/review-mutations/core/domain/reviewDecisionCommandPolicy.ts @@ -0,0 +1,172 @@ +import { isDurableReviewEqual } from './durableReviewValue'; +import { getReviewActionDiskSnapshots } from './reviewHistoryDiskSteps'; + +import type { + FileChangeSummary, + FileReviewDecision, + HunkDecision, + ReviewPersistedStateSnapshot, +} from '@shared/types/review'; + +export interface ReviewDecisionCommandCurrentState extends ReviewPersistedStateSnapshot { + revision: number; +} + +export interface ReviewDecisionCommandPolicyContext { + resolveFile(filePath: string): FileChangeSummary; + normalizePath(filePath: string): string; +} + +export function assertCurrentReviewDecisionRevision( + current: ReviewDecisionCommandCurrentState | null, + expectedRevision: number +): void { + if ((current?.revision ?? 0) !== expectedRevision) { + throw new Error('Review decisions changed; refusing stale state overwrite'); + } +} + +export function assertExactApplyReviewHistoryTransition( + state: ReviewPersistedStateSnapshot, + current: ReviewDecisionCommandCurrentState | null, + decisions: readonly (FileReviewDecision & { reviewKey: string })[], + context: ReviewDecisionCommandPolicyContext +): void { + const previousActions = current?.reviewActionHistory ?? []; + const nextActions = state.reviewActionHistory ?? []; + const action = nextActions.at(-1); + const currentRedo = current?.reviewRedoHistory ?? []; + const knownIds = new Set([ + ...previousActions.map((entry) => entry.id), + ...currentRedo.map((entry) => entry.action.id), + ]); + if ( + !action || + action.kind === 'hunk' || + knownIds.has(action.id) || + nextActions.length !== previousActions.length + 1 || + !isDurableReviewEqual(nextActions.slice(0, -1), previousActions) || + (state.reviewRedoHistory?.length ?? 0) !== 0 + ) { + throw new Error('Durable Reject requires exactly one new disk history action'); + } + + const filesByPath = new Map( + decisions.map((decision) => { + const file = context.resolveFile(decision.filePath); + const canonicalKey = file.changeKey ?? file.filePath; + if (decision.reviewKey !== canonicalKey) { + throw new Error('Durable reviewKey does not match the authoritative review identity'); + } + return [context.normalizePath(file.filePath), file] as const; + }) + ); + const actionPaths = getReviewActionDiskSnapshots(action).map((snapshot) => + context.normalizePath(snapshot.filePath) + ); + if ( + actionPaths.length !== filesByPath.size || + new Set(actionPaths).size !== actionPaths.length || + actionPaths.some((filePath) => !filesByPath.has(filePath)) + ) { + throw new Error('Durable Reject history does not match the requested files'); + } + if (action.kind === 'disk' && decisions.length !== 1) { + throw new Error('Durable Reject history action kind does not match the decision batch'); + } + if (action.descriptor) { + const descriptor = action.descriptor; + let descriptorMatches = false; + if (action.kind === 'bulk') { + descriptorMatches = + descriptor.intent === 'reject-all' && descriptor.fileCount === filesByPath.size; + } else if (action.action.originalIndex !== undefined) { + descriptorMatches = + descriptor.intent === 'reject-hunk' && + descriptor.hunkIndex === action.action.originalIndex && + context.normalizePath(descriptor.filePath) === + context.normalizePath(action.action.snapshot.filePath); + } else { + descriptorMatches = + descriptor.intent === 'reject-file' && + context.normalizePath(descriptor.filePath) === + context.normalizePath(action.action.snapshot.filePath); + } + if (!descriptorMatches) { + throw new Error('Durable Reject history descriptor does not match the decision transition'); + } + } + + const currentDecisions = { + hunkDecisions: current?.hunkDecisions ?? {}, + fileDecisions: current?.fileDecisions ?? {}, + }; + const allowedFileKeys = new Set(decisions.map((decision) => decision.reviewKey)); + const allowedHunkKeys = new Set(); + for (const decision of decisions) { + for (const index of Object.keys(decision.hunkDecisions)) { + allowedHunkKeys.add(`${decision.reviewKey}:${index}`); + } + } + const changedKeys = ( + previous: Record, + next: Record, + allowed: ReadonlySet + ): string[] => { + const changed = [...new Set([...Object.keys(previous), ...Object.keys(next)])].filter( + (key) => previous[key] !== next[key] + ); + if (changed.some((key) => !allowed.has(key))) { + throw new Error('Durable Reject state changes decisions outside the requested files'); + } + return changed; + }; + const changedHunks = changedKeys( + currentDecisions.hunkDecisions, + state.hunkDecisions, + allowedHunkKeys + ); + const changedFiles = changedKeys( + currentDecisions.fileDecisions, + state.fileDecisions, + allowedFileKeys + ); + if (changedHunks.length + changedFiles.length === 0) { + throw new Error('Durable Reject history has no matching decision transition'); + } + + if (action.kind === 'bulk') { + if ( + !isDurableReviewEqual(action.decisionSnapshot, currentDecisions) || + decisions.some((decision) => decision.fileDecision !== 'rejected') + ) { + throw new Error('Durable bulk Reject history has invalid decision metadata'); + } + return; + } + + const decision = decisions[0]; + if (!decision) throw new Error('Durable Reject decision is unavailable'); + const originalIndex = action.action.originalIndex; + if (originalIndex !== undefined) { + const decisionKey = `${decision.reviewKey}:${originalIndex}`; + if ( + changedHunks.length !== 1 || + changedHunks[0] !== decisionKey || + changedFiles.length !== 0 || + decision.fileDecision !== 'pending' || + decision.hunkDecisions[originalIndex] !== 'rejected' || + state.hunkDecisions[decisionKey] !== 'rejected' + ) { + throw new Error('Durable hunk Reject history index does not match the decision transition'); + } + return; + } + + if ( + decision.fileDecision !== 'rejected' || + !isDurableReviewEqual(action.action.decisionSnapshot, currentDecisions) + ) { + throw new Error('Durable file Reject history has invalid decision metadata'); + } +} diff --git a/src/features/review-mutations/core/domain/reviewEditableMutationPolicy.ts b/src/features/review-mutations/core/domain/reviewEditableMutationPolicy.ts new file mode 100644 index 0000000000..298f16c8ab --- /dev/null +++ b/src/features/review-mutations/core/domain/reviewEditableMutationPolicy.ts @@ -0,0 +1,34 @@ +export interface SaveEditedFileInput { + filePath: string; + content: string; + expectedCurrentContent: string | null; +} + +export interface DeleteEditedFileInput { + filePath: string; + expectedCurrentContent: string; +} + +export function parseSaveEditedFileInput( + filePath: unknown, + content: unknown, + expectedCurrentContent: unknown +): SaveEditedFileInput | null { + if ( + typeof filePath !== 'string' || + typeof content !== 'string' || + (expectedCurrentContent !== null && typeof expectedCurrentContent !== 'string') + ) { + return null; + } + return { filePath, content, expectedCurrentContent }; +} + +export function parseDeleteEditedFileInput( + filePath: unknown, + expectedCurrentContent: unknown +): DeleteEditedFileInput | null { + return typeof filePath === 'string' && typeof expectedCurrentContent === 'string' + ? { filePath, expectedCurrentContent } + : null; +} diff --git a/src/features/review-mutations/core/domain/reviewHistoryDecisions.ts b/src/features/review-mutations/core/domain/reviewHistoryDecisions.ts index e76467fbb7..bcfd71808e 100644 --- a/src/features/review-mutations/core/domain/reviewHistoryDecisions.ts +++ b/src/features/review-mutations/core/domain/reviewHistoryDecisions.ts @@ -69,6 +69,43 @@ export function restoreReviewDecisionRecordsForFiles( ); } +export function partitionReviewFilesByApplyErrors( + files: readonly FileChangeSummary[], + errorPaths: readonly string[] | null +): { successful: FileChangeSummary[]; failed: FileChangeSummary[] } { + if (errorPaths === null) return { successful: [], failed: [...files] }; + const normalizedErrors = new Set(errorPaths.map(normalizePathForComparison)); + const requestedPaths = new Set(files.map((file) => normalizePathForComparison(file.filePath))); + const hasUnknownError = [...normalizedErrors].some((filePath) => !requestedPaths.has(filePath)); + if (hasUnknownError) { + return { successful: [], failed: [...files] }; + } + return { + successful: files.filter( + (file) => !normalizedErrors.has(normalizePathForComparison(file.filePath)) + ), + failed: files.filter((file) => normalizedErrors.has(normalizePathForComparison(file.filePath))), + }; +} + +/** + * Keeps successful decisions while restoring the exact pre-apply records for + * every failed file. Unknown failures fail closed for the complete request. + */ +export function reconcileReviewDecisionRecordsAfterApply( + files: readonly FileChangeSummary[], + errorPaths: readonly string[] | null, + current: ReviewDecisionRecords, + snapshot: ReviewDecisionRecords +): ReviewDecisionRecords & { + successful: FileChangeSummary[]; + failed: FileChangeSummary[]; +} { + const partition = partitionReviewFilesByApplyErrors(files, errorPaths); + const reconciled = restoreReviewDecisionRecordsForFiles(partition.failed, current, snapshot); + return { ...partition, ...reconciled }; +} + /** Produces the canonical post-Restore decision state for one reviewed file. */ export function buildReviewRestoreDecisionState( file: FileChangeSummary, diff --git a/src/features/review-mutations/core/domain/reviewHistoryDiskSteps.ts b/src/features/review-mutations/core/domain/reviewHistoryDiskSteps.ts index d48335a82c..362f071264 100644 --- a/src/features/review-mutations/core/domain/reviewHistoryDiskSteps.ts +++ b/src/features/review-mutations/core/domain/reviewHistoryDiskSteps.ts @@ -1,7 +1,9 @@ import { countLineChanges } from '@shared/utils/lineDiffStats'; import { normalizePathForComparison } from '@shared/utils/platformPath'; +import { threeWayTextMerge } from '@shared/utils/threeWayTextMerge'; import type { + FileChangeSummary, ReviewDirectDiskMutationStep, ReviewDiskUndoSnapshot, ReviewUndoAction, @@ -30,6 +32,29 @@ export interface ReviewHistoryDiskTransition { const MAX_EXACT_LINE_STATS_TRANSITIONS = 5; const MAX_EXACT_UPDATE_DIFF_CHARACTERS = 512 * 1024; +export function isLedgerRenameReviewFile(file: FileChangeSummary | undefined): boolean { + return Boolean(file?.snippets.some((snippet) => snippet.ledger?.relation?.kind === 'rename')); +} + +/** + * Rebases an Undo preimage onto the content main actually applied. When both + * sides changed the same region, the durable snapshot is retained but fenced. + */ +export function alignReviewDiskUndoSnapshotWithAppliedContent( + snapshot: ReviewDiskUndoSnapshot, + appliedContent: string +): void { + if (snapshot.afterContent === null) return; + const merged = threeWayTextMerge(snapshot.afterContent, appliedContent, snapshot.beforeContent); + snapshot.afterContent = appliedContent; + if (merged.hasConflicts) { + snapshot.restoreConflict = + 'Undo conflicts with edits that were preserved while applying the rejection.'; + return; + } + snapshot.beforeContent = merged.content; +} + export function buildUndoDiskMutationSteps( actionId: string, snapshots: readonly ReviewDiskUndoSnapshot[] diff --git a/src/features/review-mutations/core/domain/reviewHistoryMutationPolicy.ts b/src/features/review-mutations/core/domain/reviewHistoryMutationPolicy.ts new file mode 100644 index 0000000000..7f6090191f --- /dev/null +++ b/src/features/review-mutations/core/domain/reviewHistoryMutationPolicy.ts @@ -0,0 +1,284 @@ +import { isDurableReviewEqual } from './durableReviewValue'; +import { + buildReviewExternalReloadState, + buildReviewRestoreDecisionState, + buildReviewUndoDecisionState, +} from './reviewHistoryDecisions'; +import { + buildForwardDiskMutationSteps, + buildRedoDiskMutationSteps, + buildUndoDiskMutationSteps, + getReviewActionDiskSnapshots, +} from './reviewHistoryDiskSteps'; + +import type { + ExecuteReviewMutationRequest, + FileChangeSummary, + ReviewDiskUndoSnapshot, + ReviewPersistedStateSnapshot, + ReviewUndoAction, +} from '@shared/types/review'; + +export interface ReviewHistoryDecisionState extends ReviewPersistedStateSnapshot { + revision: number; +} + +export interface ReviewHistoryMutationPolicyContext { + resolveFile(filePath: string): FileChangeSummary; + normalizePath(filePath: string): string; + hashContent(content: string): string; +} + +function assertExactReviewDiskSteps( + request: ExecuteReviewMutationRequest, + action: ReviewUndoAction, + direction: 'forward' | 'undo' | 'redo' +): void { + const snapshots = getReviewActionDiskSnapshots(action); + const expectedSteps = + direction === 'forward' + ? buildForwardDiskMutationSteps(action.id, snapshots) + : direction === 'undo' + ? buildUndoDiskMutationSteps(action.id, snapshots) + : buildRedoDiskMutationSteps(action.id, snapshots); + if (!isDurableReviewEqual(request.diskSteps, expectedSteps)) { + const label = direction === 'forward' ? request.kind : direction; + throw new Error( + `Review ${label[0]?.toUpperCase()}${label.slice(1)} disk mutation does not match durable history` + ); + } +} + +export function isAuthoritativelyBoundReviewSnapshot( + snapshot: ReviewDiskUndoSnapshot, + hashContent: (content: string) => string +): boolean { + if (snapshot.authoritativeBeforeSha256 === undefined) return false; + if (snapshot.authoritativeBeforeSha256 === null) { + const mode = + snapshot.restoreMode ?? (snapshot.renameExpectation ? 'restore-rejected-rename' : 'content'); + return ( + mode === 'delete-file' || + mode === 'restore-rejected-rename' || + mode === 'reapply-rejected-rename' + ); + } + return snapshot.authoritativeBeforeSha256 === hashContent(snapshot.beforeContent); +} + +export function assertAuthoritativelyBoundReviewAction( + action: ReviewUndoAction, + hashContent: (content: string) => string +): void { + if ( + getReviewActionDiskSnapshots(action).some( + (snapshot) => !isAuthoritativelyBoundReviewSnapshot(snapshot, hashContent) + ) + ) { + throw new Error('Review history predates authoritative disk snapshots; reload Changes'); + } +} + +export function rebindReviewActionDescriptorPath( + action: ReviewUndoAction, + filePath: string +): ReviewUndoAction['descriptor'] { + return action.descriptor && 'filePath' in action.descriptor + ? { ...action.descriptor, filePath } + : action.descriptor; +} + +export function assertExactReviewHistoryTransition( + request: ExecuteReviewMutationRequest, + current: ReviewHistoryDecisionState | null, + context: ReviewHistoryMutationPolicyContext +): void { + const next = request.persistedState; + if (!Array.isArray(next.reviewActionHistory) || !Array.isArray(next.reviewRedoHistory)) { + throw new Error('Review history transition is incomplete'); + } + + if (request.kind === 'reload-external') { + if (typeof request.externalFilePath !== 'string' || request.diskSteps.length !== 0) { + throw new Error('External review reload requires one reviewed file and no disk mutation'); + } + const file = context.resolveFile(request.externalFilePath); + const expected = buildReviewExternalReloadState(file, { + hunkDecisions: current?.hunkDecisions ?? {}, + fileDecisions: current?.fileDecisions ?? {}, + hunkContextHashesByFile: current?.hunkContextHashesByFile ?? {}, + reviewActionHistory: current?.reviewActionHistory ?? [], + reviewRedoHistory: current?.reviewRedoHistory ?? [], + }); + if (!isDurableReviewEqual(next, expected)) { + throw new Error('Invalid durable external file reload transition'); + } + return; + } + + if (request.kind === 'restore' || request.kind === 'rename') { + const previousActions = current?.reviewActionHistory ?? []; + const action = next.reviewActionHistory.at(-1); + const snapshot = action?.kind === 'disk' ? action.action.snapshot : null; + const restoreMode = + snapshot?.restoreMode ?? + (snapshot?.renameExpectation ? 'restore-rejected-rename' : 'content'); + const isRenameSnapshot = + restoreMode === 'restore-rejected-rename' || restoreMode === 'reapply-rejected-rename'; + const authoritativeFile = snapshot ? context.resolveFile(snapshot.filePath) : null; + const expectedDecisions = authoritativeFile + ? buildReviewRestoreDecisionState(authoritativeFile, { + hunkDecisions: current?.hunkDecisions ?? {}, + fileDecisions: current?.fileDecisions ?? {}, + }) + : null; + const transitionMatches = + action?.kind === 'disk' && + authoritativeFile !== null && + action.action.file?.filePath === authoritativeFile.filePath && + action.action.file.changeKey === authoritativeFile.changeKey && + snapshot?.file?.filePath === authoritativeFile.filePath && + snapshot.file.changeKey === authoritativeFile.changeKey && + (action.descriptor === undefined || + (action.descriptor.intent === + (request.kind === 'rename' ? 'restore-rename' : 'restore-file') && + context.normalizePath(action.descriptor.filePath) === + context.normalizePath(snapshot.filePath))) && + isDurableReviewEqual(next.reviewActionHistory.slice(0, -1), previousActions) && + next.reviewRedoHistory.length === 0 && + isDurableReviewEqual(action.action.decisionSnapshot, { + hunkDecisions: current?.hunkDecisions ?? {}, + fileDecisions: current?.fileDecisions ?? {}, + }) && + isDurableReviewEqual( + next.hunkContextHashesByFile ?? {}, + current?.hunkContextHashesByFile ?? {} + ) && + isDurableReviewEqual(next.hunkDecisions, expectedDecisions?.hunkDecisions) && + isDurableReviewEqual(next.fileDecisions, expectedDecisions?.fileDecisions) && + (request.kind === 'rename') === isRenameSnapshot; + if (!transitionMatches || !action) { + throw new Error( + `Invalid durable ${request.kind === 'rename' ? 'Rename' : 'Restore'} history transition` + ); + } + assertExactReviewDiskSteps(request, action, 'forward'); + return; + } + + if (!current) { + throw new Error( + `Review history changed; refusing stale ${request.kind === 'undo' ? 'Undo' : 'Redo'}` + ); + } + + if (request.kind === 'undo') { + const action = current.reviewActionHistory.at(-1); + if (!request.expectedTopActionId) { + throw new Error('Review Undo requires the expected durable action id'); + } + if (!action || action.id !== request.expectedTopActionId) { + throw new Error('Review history changed; refusing stale Undo'); + } + assertAuthoritativelyBoundReviewAction(action, (content) => context.hashContent(content)); + const redoEntry = next.reviewRedoHistory.at(-1); + const expectedDecisions = buildReviewUndoDecisionState( + action, + { hunkDecisions: current.hunkDecisions, fileDecisions: current.fileDecisions }, + (filePath) => context.resolveFile(filePath) + ); + const transitionMatches = + expectedDecisions !== null && + isDurableReviewEqual(next.reviewActionHistory, current.reviewActionHistory.slice(0, -1)) && + isDurableReviewEqual(next.reviewRedoHistory.slice(0, -1), current.reviewRedoHistory) && + isDurableReviewEqual(redoEntry?.action, action) && + isDurableReviewEqual(redoEntry?.decisionSnapshot, { + hunkDecisions: current.hunkDecisions, + fileDecisions: current.fileDecisions, + }) && + isDurableReviewEqual( + redoEntry?.hunkContextHashesByFile ?? {}, + current.hunkContextHashesByFile ?? {} + ) && + isDurableReviewEqual(next.hunkDecisions, expectedDecisions.hunkDecisions) && + isDurableReviewEqual(next.fileDecisions, expectedDecisions.fileDecisions) && + isDurableReviewEqual( + next.hunkContextHashesByFile ?? {}, + current.hunkContextHashesByFile ?? {} + ); + if (!transitionMatches) { + throw new Error('Invalid durable Undo history transition'); + } + assertExactReviewDiskSteps(request, action, 'undo'); + return; + } + + const redoEntry = current.reviewRedoHistory.at(-1); + if (!request.expectedTopRedoActionId) { + throw new Error('Review Redo requires the expected durable action id'); + } + if (redoEntry?.action.id !== request.expectedTopRedoActionId) { + throw new Error('Review history changed; refusing stale Redo'); + } + assertAuthoritativelyBoundReviewAction(redoEntry.action, (content) => + context.hashContent(content) + ); + const transitionMatches = + isDurableReviewEqual(next.reviewRedoHistory, current.reviewRedoHistory.slice(0, -1)) && + isDurableReviewEqual(next.reviewActionHistory, [ + ...current.reviewActionHistory, + redoEntry.action, + ]) && + isDurableReviewEqual(next.hunkDecisions, redoEntry.decisionSnapshot.hunkDecisions) && + isDurableReviewEqual(next.fileDecisions, redoEntry.decisionSnapshot.fileDecisions) && + isDurableReviewEqual( + next.hunkContextHashesByFile ?? {}, + redoEntry.hunkContextHashesByFile ?? current.hunkContextHashesByFile ?? {} + ); + if (!transitionMatches) { + throw new Error('Invalid durable Redo history transition'); + } + assertExactReviewDiskSteps(request, redoEntry.action, 'redo'); +} + +export function findLatestRestorableDiskSnapshot( + current: ReviewHistoryDecisionState | null, + filePath: string, + context: Pick +): ReviewDiskUndoSnapshot | null { + if (!current) return null; + const normalizedPath = context.normalizePath(filePath); + for (let index = current.reviewActionHistory.length - 1; index >= 0; index--) { + const action = current.reviewActionHistory[index]; + if (!action) continue; + const matchingSnapshot = [...getReviewActionDiskSnapshots(action)] + .reverse() + .find((candidate) => context.normalizePath(candidate.filePath) === normalizedPath); + if (!matchingSnapshot) continue; + if (matchingSnapshot.restoreConflict) throw new Error(matchingSnapshot.restoreConflict); + if (!isAuthoritativelyBoundReviewSnapshot(matchingSnapshot, context.hashContent)) { + throw new Error('Review history predates authoritative disk snapshots; reload Changes'); + } + if (matchingSnapshot.renameExpectation) return null; + if (action.kind === 'disk' && action.action.originalIndex !== undefined) continue; + return matchingSnapshot; + } + return null; +} + +export function isAuthoritativeReviewDeletion(file: FileChangeSummary): boolean { + if (file.ledgerSummary?.latestOperation) { + return file.ledgerSummary.latestOperation === 'delete'; + } + if (file.ledgerSummary?.afterState?.exists !== undefined) { + return !file.ledgerSummary.afterState.exists; + } + const latestLedger = file.snippets + .filter((snippet) => snippet.ledger && !snippet.isError) + .at(-1)?.ledger; + return ( + latestLedger?.operation === 'delete' || + latestLedger?.afterState?.exists === false || + file.ledgerSummary?.deletedInTask === true + ); +} diff --git a/src/features/review-mutations/core/domain/reviewHistoryRestoreTarget.ts b/src/features/review-mutations/core/domain/reviewHistoryRestoreTarget.ts new file mode 100644 index 0000000000..8095cd617c --- /dev/null +++ b/src/features/review-mutations/core/domain/reviewHistoryRestoreTarget.ts @@ -0,0 +1,27 @@ +import type { RestoreReviewHistoryRequest } from '@shared/types/review'; + +export function parseReviewHistoryRestoreTarget( + value: unknown +): RestoreReviewHistoryRequest['target'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid review history restore target'); + } + const target = value as Record; + if (target.kind === 'start') return { kind: 'start' }; + if ( + target.kind !== 'after-action' || + (target.stack !== 'undo' && target.stack !== 'redo') || + typeof target.actionId !== 'string' || + target.actionId.length === 0 || + target.actionId.length > 256 + ) { + throw new Error('Invalid review history restore target'); + } + return { kind: 'after-action', stack: target.stack, actionId: target.actionId }; +} + +export function isDecisionlessReviewRecoveryKind(kind: string): boolean { + return ( + kind === 'undo' || kind === 'redo' || kind === 'reload-external' || kind === 'restore-history' + ); +} diff --git a/src/features/review-mutations/index.ts b/src/features/review-mutations/index.ts index 931a8fb29a..4f59e46c56 100644 --- a/src/features/review-mutations/index.ts +++ b/src/features/review-mutations/index.ts @@ -1,25 +1,46 @@ -export type * from './contracts'; +export * from './contracts'; +export { isDurableReviewEqual } from './core/domain/durableReviewValue'; +export { + assertCurrentReviewDecisionRevision, + assertExactApplyReviewHistoryTransition, + type ReviewDecisionCommandCurrentState, + type ReviewDecisionCommandPolicyContext, +} from './core/domain/reviewDecisionCommandPolicy'; export { buildReviewExternalReloadState, buildReviewHistoryRestorePlan, buildReviewRestoreDecisionState, buildReviewUndoDecisionState, + partitionReviewFilesByApplyErrors, + reconcileReviewDecisionRecordsAfterApply, restoreReviewDecisionRecordsForFile, restoreReviewDecisionRecordsForFiles, type ReviewDecisionRecords, type ReviewHistoryRestorePlan, } from './core/domain/reviewHistoryDecisions'; export { + alignReviewDiskUndoSnapshotWithAppliedContent, buildForwardDiskMutationSteps, buildRedoDiskMutationSteps, buildReviewHistoryRestoreDiskImpact, buildReviewHistoryRestoreDiskSteps, buildUndoDiskMutationSteps, getReviewActionDiskSnapshots, + isLedgerRenameReviewFile, type ReviewHistoryDiskTransition, type ReviewHistoryDiskTransitionKind, type ReviewHistoryLineStatsStatus, } from './core/domain/reviewHistoryDiskSteps'; +export { + assertAuthoritativelyBoundReviewAction, + assertExactReviewHistoryTransition, + findLatestRestorableDiskSnapshot, + isAuthoritativelyBoundReviewSnapshot, + isAuthoritativeReviewDeletion, + rebindReviewActionDescriptorPath, + type ReviewHistoryDecisionState, + type ReviewHistoryMutationPolicyContext, +} from './core/domain/reviewHistoryMutationPolicy'; export { assertReviewMutationTransition, getNextReviewMutationPhase, diff --git a/src/features/review-mutations/main/adapters/input/ipc/registerReviewMutationRecoveryIpc.ts b/src/features/review-mutations/main/adapters/input/ipc/registerReviewMutationRecoveryIpc.ts new file mode 100644 index 0000000000..e6039c45e7 --- /dev/null +++ b/src/features/review-mutations/main/adapters/input/ipc/registerReviewMutationRecoveryIpc.ts @@ -0,0 +1,78 @@ +import { + REVIEW_EXECUTE_MUTATION, + REVIEW_MUTATION_RECOVERY_IPC_CHANNELS, + REVIEW_RESTORE_HISTORY, + REVIEW_RETRY_MUTATION_RECOVERY, +} from '../../../../contracts'; +import { MAX_REVIEW_MUTATION_STEPS } from '../../../application/ReviewMutationRecoveryApplication'; + +import type { ReviewMutationRecoveryApplication } from '../../../application/ReviewMutationRecoveryApplication'; +import type { IpcResult } from '@shared/types/ipc'; +import type { + ExecuteReviewMutationRequest, + RestoreReviewHistoryRequest, + RetryReviewMutationRecoveryRequest, +} from '@shared/types/review'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +export type ReviewMutationIpcHandlerWrapper = ( + operationName: string, + operation: () => Promise +) => Promise>; + +export function registerReviewMutationRecoveryIpc( + ipcMain: IpcMain, + application: ReviewMutationRecoveryApplication, + wrapHandler: ReviewMutationIpcHandlerWrapper +): void { + ipcMain.handle( + REVIEW_EXECUTE_MUTATION, + async (_event: IpcMainInvokeEvent, requestValue: unknown) => { + if (!isExecuteReviewMutationRequest(requestValue)) { + return { success: false, error: 'Invalid review mutation request' }; + } + return wrapHandler('executeMutation', () => application.execute(requestValue)); + } + ); + ipcMain.handle( + REVIEW_RETRY_MUTATION_RECOVERY, + (_event: IpcMainInvokeEvent, requestValue: unknown) => + wrapHandler('retryMutationRecovery', () => { + if (!requestValue || typeof requestValue !== 'object' || Array.isArray(requestValue)) { + throw new Error('Invalid review mutation recovery request'); + } + return application.retryRecovery(requestValue as RetryReviewMutationRecoveryRequest); + }) + ); + ipcMain.handle(REVIEW_RESTORE_HISTORY, (_event: IpcMainInvokeEvent, requestValue: unknown) => + wrapHandler('restoreHistory', () => { + if (!requestValue || typeof requestValue !== 'object' || Array.isArray(requestValue)) { + throw new Error('Invalid review history restore request'); + } + return application.restoreHistory(requestValue as RestoreReviewHistoryRequest); + }) + ); +} + +export function removeReviewMutationRecoveryIpc(ipcMain: IpcMain): void { + for (const channel of REVIEW_MUTATION_RECOVERY_IPC_CHANNELS) { + ipcMain.removeHandler(channel); + } +} + +function isExecuteReviewMutationRequest(value: unknown): value is ExecuteReviewMutationRequest { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const request = value as Partial; + const allowsEmptyDiskMutation = + request.kind === 'undo' || request.kind === 'redo' || request.kind === 'reload-external'; + return ( + (request.kind === 'restore' || + request.kind === 'rename' || + request.kind === 'undo' || + request.kind === 'redo' || + request.kind === 'reload-external') && + Array.isArray(request.diskSteps) && + (allowsEmptyDiskMutation || request.diskSteps.length > 0) && + request.diskSteps.length <= MAX_REVIEW_MUTATION_STEPS + ); +} diff --git a/src/features/review-mutations/main/application/ReviewDecisionBatchApplication.ts b/src/features/review-mutations/main/application/ReviewDecisionBatchApplication.ts new file mode 100644 index 0000000000..0859dbf1f9 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewDecisionBatchApplication.ts @@ -0,0 +1,471 @@ +import { threeWayTextMerge } from '@shared/utils/threeWayTextMerge'; + +import { ReviewMutationApplyResultError } from '../../core/application/ReviewMutationApplyResultError'; +import { + assertPersistedStateIncludesDecisions, + composeReviewDiskTransitions, + mergeReviewApplyResults, +} from '../../core/domain/reviewDecisionBatch'; + +import type { + ReviewMutationJournalPathPostimage, + ReviewMutationJournalPathTransition, + ReviewMutationJournalRecord, +} from '../../core/application/ReviewMutationJournalTypes'; +import type { + ReviewDecisionBatchDependencies, + ReviewDecisionBatchFileTransaction, +} from './ReviewMutationRecoveryPorts'; +import type { + ApplyReviewResult, + FileChangeWithContent, + FileReviewDecision, + ReviewDiskUndoSnapshot, + ReviewMutationDiskPostimage, + ReviewPersistedStateSnapshot, +} from '@shared/types/review'; + +export class ReviewDecisionBatchApplication { + constructor(private readonly dependencies: ReviewDecisionBatchDependencies) {} + + assertPersistedStateIncludesDecisions( + state: ReviewPersistedStateSnapshot, + decisions: readonly FileReviewDecision[] + ): void { + assertPersistedStateIncludesDecisions(state, decisions); + } + + async applyDisk( + record: ReviewMutationJournalRecord, + onResult?: (result: ApplyReviewResult) => void, + onPostimages?: (postimages: readonly ReviewMutationDiskPostimage[]) => void + ): Promise { + let current = record; + let aggregate: ApplyReviewResult = { applied: 0, skipped: 0, conflicts: 0, errors: [] }; + const scope = this.dependencies.scope.parse(current.reviewScope); + const initialStatuses = + current.decisionStatuses ?? current.decisions.map(() => 'pending' as const); + + try { + for (const [index, status] of initialStatuses.entries()) { + if (status !== 'applied') continue; + const postimages = current.decisionPostimages?.[index]; + if (!postimages) { + throw new Error('Applied review mutation is missing durable postimage evidence'); + } + await this.assertPathPostimages(postimages); + await this.dependencies.applier + .finalizeReviewDiskTransitions?.(current.decisionTransitions?.[index] ?? []) + .catch((error) => { + this.dependencies.logger.warn( + 'Unable to finalize applied review file transaction:', + error + ); + }); + } + } catch (error) { + await this.dependencies.journal.markFailed(current, error).catch((journalError) => { + this.dependencies.logger.error( + 'Unable to preserve drifted review mutation journal:', + journalError + ); + }); + throw error; + } + + for (let index = 0; index < current.decisions.length; index++) { + if (initialStatuses[index] === 'applied') continue; + const decision = current.decisions[index]; + const fileContent = current.fileContents[index]; + if (!decision || fileContent?.filePath !== decision.filePath) { + throw new Error('Review mutation recovery content is unavailable'); + } + + let stepResult: ApplyReviewResult; + try { + stepResult = await this.dependencies.applier.applyReviewDecisions( + { + teamName: current.teamName, + ...(scope.taskId ? { taskId: scope.taskId } : {}), + ...(scope.memberName ? { memberName: scope.memberName } : {}), + decisions: [decision], + }, + new Map([[decision.filePath, fileContent]]), + { + initialDiskTransitions: current.decisionTransitions?.[index] ?? undefined, + checkpointDiskTransitions: async (transitions) => { + const decisionTransitions = [ + ...(current.decisionTransitions ?? current.decisions.map(() => null)), + ]; + const existing = decisionTransitions[index] ?? []; + decisionTransitions[index] = composeReviewDiskTransitions( + existing, + transitions, + (filePath) => this.dependencies.scope.normalizeIdentityPath(filePath), + threeWayTextMerge + ); + current = await this.dependencies.journal.checkpoint({ + ...current, + decisionTransitions, + }); + }, + } + ); + } catch (error) { + await this.dependencies.journal.markFailed(current, error).catch((journalError) => { + this.dependencies.logger.error( + 'Unable to mark failed review mutation journal:', + journalError + ); + }); + throw error; + } + + aggregate = mergeReviewApplyResults(aggregate, stepResult); + onResult?.(aggregate); + if (stepResult.errors.length > 0) { + const transitionEvidence = current.decisionTransitions?.[index]; + if ( + initialStatuses[index] === 'pending' && + (!transitionEvidence || transitionEvidence.length === 0) + ) { + await this.dependencies.journal.remove(current).catch((error) => { + this.dependencies.logger.error( + 'Unable to remove cleanly-conflicted review mutation journal:', + error + ); + }); + } else { + await this.dependencies.journal + .markFailed(current, stepResult.errors[0]?.error) + .catch((error) => { + this.dependencies.logger.error( + 'Unable to preserve failed review mutation journal:', + error + ); + }); + } + throw new ReviewMutationApplyResultError(aggregate); + } + + try { + const decisionStatuses = [...(current.decisionStatuses ?? initialStatuses)]; + decisionStatuses[index] = 'applied'; + const pathPostimages = await this.readPathPostimages(fileContent); + const decisionPostimages = [ + ...(current.decisionPostimages ?? current.decisions.map(() => null)), + ]; + decisionPostimages[index] = pathPostimages.durable; + const decisionTransitions = [ + ...(current.decisionTransitions ?? current.decisions.map(() => null)), + ]; + const mutatedPaths = new Set(); + for (const transition of decisionTransitions[index] ?? []) { + if (transition.beforeContent === transition.afterContent && !transition.operation) { + continue; + } + mutatedPaths.add(this.dependencies.scope.normalizeIdentityPath(transition.filePath)); + if (transition.relatedFilePath) { + mutatedPaths.add( + this.dependencies.scope.normalizeIdentityPath(transition.relatedFilePath) + ); + } + } + onPostimages?.( + [...pathPostimages.contents] + .filter(([filePath]) => + mutatedPaths.has(this.dependencies.scope.normalizeIdentityPath(filePath)) + ) + .map(([filePath, content]) => ({ filePath, content })) + ); + let persistedState = current.persistedState; + if (persistedState) { + persistedState = await this.reconcileLatestActionPostimages( + persistedState, + pathPostimages.contents, + decisionTransitions[index] ?? [] + ); + } + current = await this.dependencies.journal.checkpoint({ + ...current, + decisionStatuses, + decisionPostimages, + decisionTransitions, + persistedState, + }); + await this.dependencies.applier + .finalizeReviewDiskTransitions?.(decisionTransitions[index] ?? []) + .catch((error) => { + this.dependencies.logger.warn('Unable to finalize review file transaction:', error); + }); + } catch (error) { + await this.dependencies.journal.markFailed(current, error).catch((journalError) => { + this.dependencies.logger.error( + 'Unable to checkpoint review mutation postimage:', + journalError + ); + }); + throw error; + } + this.dependencies.cache.invalidateAuthoritativeContent(fileContent); + } + + return current; + } + + async commit(record: ReviewMutationJournalRecord): Promise { + const { teamName, persistenceScope } = record; + if (record.persistedState) { + await this.dependencies.persistence.save(teamName, persistenceScope.scopeKey, { + scopeToken: persistenceScope.scopeToken, + ...record.persistedState, + expectedRevision: record.expectedDecisionRevision, + mutationId: record.id, + }); + return; + } + // Version-1 journal compatibility. Once recovered, the record is completed and removed. + for (const decision of record.decisions) { + await this.dependencies.persistence.mergeFileDecisionPatch( + teamName, + persistenceScope.scopeKey, + persistenceScope.scopeToken, + decision + ); + } + } + + private async readPathPostimages(fileContent: FileChangeWithContent): Promise<{ + durable: ReviewMutationJournalPathPostimage[]; + contents: Map; + }> { + const paths = new Map(); + for (const filePath of [ + fileContent.filePath, + ...fileContent.snippets.map((snippet) => snippet.filePath), + ]) { + paths.set(this.dependencies.scope.normalizeIdentityPath(filePath), filePath); + } + const durable: ReviewMutationJournalPathPostimage[] = []; + const contents = new Map(); + for (const filePath of paths.values()) { + try { + const content = await this.dependencies.files.readText(filePath); + contents.set(filePath, content); + durable.push({ filePath, sha256: await this.hashContent(content) }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + contents.set(filePath, null); + durable.push({ filePath, sha256: null }); + } else { + throw error; + } + } + } + return { durable, contents }; + } + + private async assertPathPostimages( + postimages: readonly ReviewMutationJournalPathPostimage[] + ): Promise { + if (postimages.length === 0) { + throw new Error('Applied review mutation has no durable postimage evidence'); + } + for (const postimage of postimages) { + let currentSha256: string | null; + try { + currentSha256 = await this.hashContent( + await this.dependencies.files.readText(postimage.filePath) + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') currentSha256 = null; + else throw error; + } + if (currentSha256 !== postimage.sha256) { + throw new Error( + `Review mutation postimage changed after crash; refusing recovery for ${postimage.filePath}` + ); + } + } + } + + private async reconcileLatestActionPostimages( + state: ReviewPersistedStateSnapshot, + postimages: ReadonlyMap, + transitions: readonly ReviewMutationJournalPathTransition[] + ): Promise { + const latest = state.reviewActionHistory.at(-1); + if (!latest) return state; + const resolvePostimage = (filePath: string): string | null | undefined => { + for (const [candidatePath, content] of postimages) { + if ( + this.dependencies.scope.normalizeIdentityPath(candidatePath) === + this.dependencies.scope.normalizeIdentityPath(filePath) + ) { + return content; + } + } + return undefined; + }; + const resolveTransition = (filePath: string): ReviewMutationJournalPathTransition | undefined => + transitions.find( + (transition) => + this.dependencies.scope.normalizeIdentityPath(transition.filePath) === + this.dependencies.scope.normalizeIdentityPath(filePath) + ); + const getTransaction = ( + transition: ReviewMutationJournalPathTransition + ): ReviewDecisionBatchFileTransaction | null => { + const { operation, transactionId, beforeContent, afterContent } = transition; + if (!operation || !transactionId || beforeContent === null) return null; + if (operation === 'move') { + if (!transition.relatedFilePath || afterContent === null) return null; + return { + id: transactionId, + kind: 'move' as const, + sourcePath: transition.filePath, + targetPath: transition.relatedFilePath, + expectedContent: beforeContent, + nextContent: afterContent, + }; + } + return { + id: transactionId, + kind: operation, + sourcePath: transition.filePath, + targetPath: transition.filePath, + expectedContent: beforeContent, + nextContent: operation === 'delete' ? null : afterContent, + }; + }; + const hasPublishedTransaction = async ( + transition: ReviewMutationJournalPathTransition | undefined + ): Promise => { + if (!transition) return false; + const transaction = getTransaction(transition); + return transaction + ? (await this.dependencies.files.inspectTransaction(transaction)) === 'published' + : transition.operation === undefined; + }; + const reconcileSnapshot = async ( + snapshot: ReviewDiskUndoSnapshot + ): Promise => { + if (snapshot.renameExpectation) { + const transition = + resolveTransition(snapshot.filePath) ?? + transitions.find( + (candidate) => + candidate.transactionId && + candidate.operation && + this.dependencies.scope.normalizeIdentityPath(candidate.relatedFilePath ?? '') === + this.dependencies.scope.normalizeIdentityPath(snapshot.filePath) + ); + if (!(await hasPublishedTransaction(transition))) { + return { + ...snapshot, + restoreConflict: + 'Reject rename provenance is unavailable; refusing an unsafe Undo or Restore.', + }; + } + return { ...snapshot, restoreConflict: undefined }; + } + + const actual = resolvePostimage(snapshot.filePath); + if (actual === undefined) return snapshot; + const transition = resolveTransition(snapshot.filePath); + if (!transition) { + return { + ...snapshot, + afterContent: actual, + restoreConflict: + 'Reject lock preimage is unavailable; refusing an unsafe Undo or Restore.', + }; + } + if ( + transition.beforeContent === transition.afterContent && + (snapshot.restoreMode === 'create-file' || snapshot.restoreMode === 'delete-file') + ) { + return { + ...snapshot, + afterContent: actual, + restoreConflict: + 'Reject did not prove this file-presence change; refusing an unsafe Undo or Restore.', + }; + } + if (!(await hasPublishedTransaction(transition))) { + return { + ...snapshot, + afterContent: actual, + restoreConflict: + 'Reject filesystem transaction is not durably published; refusing an unsafe Undo or Restore.', + }; + } + + let beforeContent = transition.beforeContent; + if (actual !== transition.afterContent) { + if ( + typeof actual !== 'string' || + typeof transition.afterContent !== 'string' || + typeof transition.beforeContent !== 'string' + ) { + return { + ...snapshot, + afterContent: actual, + restoreConflict: + 'Reject postimage changed across a file-presence transition; refusing an unsafe Undo or Restore.', + }; + } + const merged = threeWayTextMerge(transition.afterContent, actual, transition.beforeContent); + if (!merged.hasConflicts) { + beforeContent = merged.content; + } else { + return { + ...snapshot, + afterContent: actual, + restoreConflict: + 'Reject preserved concurrent edits that cannot be reconstructed safely; refusing Undo or Restore.', + }; + } + } + return { + ...snapshot, + beforeContent: beforeContent ?? '', + afterContent: actual, + authoritativeBeforeSha256: + beforeContent === null ? null : await this.hashContent(beforeContent), + restoreConflict: undefined, + }; + }; + + let reconciled = latest; + if (latest.kind === 'disk') { + const snapshot = await reconcileSnapshot(latest.action.snapshot); + if (snapshot !== latest.action.snapshot) { + reconciled = { + ...latest, + action: { + ...latest.action, + snapshot, + }, + }; + } + } else if (latest.kind === 'bulk') { + reconciled = { + ...latest, + diskSnapshots: await Promise.all(latest.diskSnapshots.map(reconcileSnapshot)), + }; + } + if (reconciled === latest) return state; + return { + ...state, + reviewActionHistory: [...state.reviewActionHistory.slice(0, -1), reconciled], + }; + } + + private async hashContent(content: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + } +} diff --git a/src/features/review-mutations/main/application/ReviewDecisionCommandApplication.ts b/src/features/review-mutations/main/application/ReviewDecisionCommandApplication.ts new file mode 100644 index 0000000000..55fe145acc --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewDecisionCommandApplication.ts @@ -0,0 +1,389 @@ +import { ReviewMutationApplyResultError } from '../../core/application/ReviewMutationApplyResultError'; +import { mergeReviewMutationDiskPostimages } from '../../core/domain/reviewDecisionBatch'; +import { + assertCurrentReviewDecisionRevision, + assertExactApplyReviewHistoryTransition, +} from '../../core/domain/reviewDecisionCommandPolicy'; + +import type { ReviewDecisionCommandDependencies } from './ReviewDecisionCommandPorts'; +import type { ReviewMutationPathAuthorization } from './ReviewMutationRecoveryPorts'; +import type { + ApplyReviewRequest, + ApplyReviewResult, + ConflictCheckResult, + FileChangeWithContent, + FileReviewDecision, + RejectResult, + ReviewDecisionPersistenceScope, + ReviewFileScope, + ReviewMutationDiskPostimage, + ReviewPersistedStateSnapshot, + SnippetDiff, +} from '@shared/types/review'; + +interface DisplayedReviewSnapshot { + teamName: string; + filePath: string; + snippetFingerprint: string; + content: FileChangeWithContent; + expiresAt: number; +} + +const REVIEW_SNAPSHOT_TTL_MS = 60 * 60 * 1000; +const MAX_DISPLAYED_REVIEW_SNAPSHOTS = 2_000; + +export class ReviewDecisionCommandApplication { + private readonly displayedReviewSnapshots = new Map(); + + constructor(private readonly dependencies: ReviewDecisionCommandDependencies) {} + + registerDisplayedReviewSnapshot( + teamName: string, + filePath: string, + snippets: SnippetDiff[], + content: FileChangeWithContent + ): FileChangeWithContent { + const now = this.dependencies.snapshots.now(); + for (const [token, snapshot] of this.displayedReviewSnapshots) { + if (snapshot.expiresAt <= now) this.displayedReviewSnapshots.delete(token); + } + while (this.displayedReviewSnapshots.size >= MAX_DISPLAYED_REVIEW_SNAPSHOTS) { + const oldestToken = this.displayedReviewSnapshots.keys().next().value; + if (!oldestToken) break; + this.displayedReviewSnapshots.delete(oldestToken); + } + + const token = this.dependencies.snapshots.createToken(); + const snapshotContent = { ...content, reviewSnapshotToken: token }; + this.displayedReviewSnapshots.set(token, { + teamName, + filePath: this.dependencies.scope.normalizeIdentityPath(filePath), + snippetFingerprint: this.dependencies.snapshots.fingerprintSnippets(snippets), + content: snapshotContent, + expiresAt: now + REVIEW_SNAPSHOT_TTL_MS, + }); + return snapshotContent; + } + + async checkConflict( + scopeValue: unknown, + filePathValue: unknown, + expectedModified: string + ): Promise { + const { authorization } = await this.dependencies.scope.resolve(scopeValue, { + requireIdentity: true, + }); + const filePath = await this.dependencies.scope.validateFilePath(authorization, filePathValue, { + requireReviewedFile: true, + rejectHardlinks: true, + }); + return this.dependencies.applier.checkConflict(filePath, expectedModified); + } + + async rejectHunks( + scopeValue: unknown, + filePathValue: unknown, + hunkIndices: number[] + ): Promise { + const { scope, authorization } = await this.dependencies.scope.resolve(scopeValue, { + requireIdentity: true, + }); + const filePath = await this.dependencies.scope.validateFilePath(authorization, filePathValue, { + requireReviewedFile: true, + rejectHardlinks: true, + }); + const authoritativeContent = await this.requireAuthoritativeContents( + scope, + authorization, + filePath + ); + return this.dependencies.applier.rejectHunks( + scope.teamName, + filePath, + authoritativeContent.originalFullContent, + authoritativeContent.modifiedFullContent, + hunkIndices, + authoritativeContent.snippets + ); + } + + async rejectFile(scopeValue: unknown, filePathValue: unknown): Promise { + const { scope, authorization } = await this.dependencies.scope.resolve(scopeValue, { + requireIdentity: true, + }); + const filePath = await this.dependencies.scope.validateFilePath(authorization, filePathValue, { + requireReviewedFile: true, + rejectHardlinks: true, + }); + const authoritativeContent = await this.requireAuthoritativeContents( + scope, + authorization, + filePath + ); + return this.dependencies.applier.rejectFile( + scope.teamName, + filePath, + authoritativeContent.originalFullContent, + authoritativeContent.modifiedFullContent + ); + } + + previewReject( + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ): Promise<{ preview: string; hasConflicts: boolean }> { + return this.dependencies.applier.previewReject( + filePath, + original, + modified, + hunkIndices, + snippets + ); + } + + async applyDecisions(request: ApplyReviewRequest): Promise { + const { scope, authorization } = await this.dependencies.scope.resolve(request, { + requireIdentity: true, + }); + const persistenceScope = this.dependencies.scope.parsePersistenceScope( + request.decisionPersistenceScope, + scope + ); + const validatedDecisions: FileReviewDecision[] = []; + const fileContents = new Map(); + const decisionPaths = new Set(); + const decisionReviewKeys = new Set(); + for (const decision of request.decisions) { + this.dependencies.scope.assertDecisionShape(decision); + const filePath = await this.dependencies.scope.validateFilePath( + authorization, + decision.filePath, + { requireReviewedFile: true, rejectHardlinks: true } + ); + const authoritativeFile = this.dependencies.scope.getAuthoritativeFile( + authorization, + filePath + ); + const authoritativeReviewKey = authoritativeFile.changeKey ?? authoritativeFile.filePath; + const normalizedDecisionPath = this.dependencies.scope.normalizeIdentityPath(filePath); + if ( + decisionPaths.has(normalizedDecisionPath) || + decisionReviewKeys.has(authoritativeReviewKey) + ) { + throw new Error('Duplicate reviewed file in Apply decisions'); + } + decisionPaths.add(normalizedDecisionPath); + decisionReviewKeys.add(authoritativeReviewKey); + if (persistenceScope && decision.reviewKey !== authoritativeReviewKey) { + throw new Error('Durable reviewKey does not match the authoritative review identity'); + } + this.dependencies.scope.assertSnippetShapes(authoritativeFile.snippets); + await this.dependencies.scope.validateSnippets(authorization, authoritativeFile.snippets, { + requireReviewedFile: true, + rejectHardlinks: true, + }); + const hasLedgerSnapshot = authoritativeFile.snippets.some( + (snippet) => !!snippet.ledger && !snippet.isError + ); + fileContents.set( + filePath, + hasLedgerSnapshot + ? await this.dependencies.scope.resolveAuthoritativeContent( + scope, + authorization, + filePath + ) + : this.resolveDisplayedReviewSnapshot( + decision.contentSnapshotToken, + scope.teamName, + filePath, + authoritativeFile.snippets + ) + ); + validatedDecisions.push({ + filePath, + ...(decision.reviewKey ? { reviewKey: decision.reviewKey } : {}), + fileDecision: decision.fileDecision, + hunkDecisions: decision.hunkDecisions, + ...(decision.hunkContextHashes ? { hunkContextHashes: decision.hunkContextHashes } : {}), + }); + } + const validatedRequest: ApplyReviewRequest = { + teamName: scope.teamName, + ...(scope.taskId ? { taskId: scope.taskId } : {}), + ...(authorization.resolutionMemberName + ? { memberName: authorization.resolutionMemberName } + : {}), + ...(persistenceScope ? { decisionPersistenceScope: persistenceScope } : {}), + decisions: validatedDecisions, + }; + + let result: ApplyReviewResult; + if (!persistenceScope) { + result = await this.dependencies.applier.applyReviewDecisions(validatedRequest, fileContents); + } else { + if (validatedDecisions.some((decision) => !decision.reviewKey)) { + throw new Error('Durable review mutation requires a stable reviewKey'); + } + if (!request.persistedState) { + throw new Error('Durable review mutation requires an exact post-operation state'); + } + if ( + !Number.isSafeInteger(request.expectedDecisionRevision) || + request.expectedDecisionRevision! < 0 + ) { + throw new Error('Durable review mutation requires an exact decision revision'); + } + this.dependencies.persistence.assertValidSnapshot(request.persistedState); + this.dependencies.batch.assertPersistedStateIncludesDecisions( + request.persistedState, + validatedDecisions + ); + result = await this.applyDecisionsWithDurableJournal( + scope, + authorization, + persistenceScope, + validatedDecisions as (FileReviewDecision & { reviewKey: string })[], + fileContents, + request.persistedState, + request.expectedDecisionRevision! + ); + } + + try { + for (const decision of validatedRequest.decisions) { + this.dependencies.cache.invalidateFile(decision.filePath); + } + } catch (error) { + this.dependencies.logger.debug('applyDecisions cache invalidation failed:', error); + } + return result; + } + + private resolveDisplayedReviewSnapshot( + token: string | undefined, + teamName: string, + filePath: string, + authoritativeSnippets: SnippetDiff[] + ): FileChangeWithContent { + if (!token) { + throw new Error('Displayed review snapshot is unavailable; reload Changes before rejecting.'); + } + const snapshot = this.displayedReviewSnapshots.get(token); + if ( + !snapshot || + snapshot.expiresAt <= this.dependencies.snapshots.now() || + snapshot.teamName !== teamName || + snapshot.filePath !== this.dependencies.scope.normalizeIdentityPath(filePath) || + snapshot.snippetFingerprint !== + this.dependencies.snapshots.fingerprintSnippets(authoritativeSnippets) + ) { + this.displayedReviewSnapshots.delete(token); + throw new Error('Displayed review snapshot is stale; reload Changes before rejecting.'); + } + snapshot.expiresAt = this.dependencies.snapshots.now() + REVIEW_SNAPSHOT_TTL_MS; + return { + ...snapshot.content, + filePath, + snippets: authoritativeSnippets, + }; + } + + private async requireAuthoritativeContents( + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization, + filePath: string + ): Promise { + const content = await this.dependencies.scope.resolveAuthoritativeContent( + scope, + authorization, + filePath + ); + if (content.originalFullContent === null || content.modifiedFullContent === null) { + throw new Error('Authoritative review contents are unavailable'); + } + return content as FileChangeWithContent & { + originalFullContent: string; + modifiedFullContent: string; + }; + } + + private async applyDecisionsWithDurableJournal( + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization, + persistenceScope: ReviewDecisionPersistenceScope, + decisions: (FileReviewDecision & { reviewKey: string })[], + fileContents: Map, + persistedState: ReviewPersistedStateSnapshot, + expectedDecisionRevision: number + ): Promise { + const normalizePath = (filePath: string): string => + this.dependencies.scope.normalizeIdentityPath(filePath); + return this.dependencies.persistence.withLock(scope.teamName, persistenceScope, async () => { + const diskPostimages = new Map(); + try { + await this.dependencies.recovery.recoverPending(scope.teamName, persistenceScope); + const current = await this.dependencies.persistence.load(scope.teamName, persistenceScope); + assertCurrentReviewDecisionRevision(current, expectedDecisionRevision); + assertExactApplyReviewHistoryTransition(persistedState, current, decisions, { + resolveFile: (filePath) => + this.dependencies.scope.getAuthoritativeFile(authorization, filePath), + normalizePath, + }); + const boundPersistedState = await this.dependencies.history.bindNewHistorySnapshots( + persistedState, + current, + scope, + authorization + ); + let result: ApplyReviewResult | null = null; + await this.dependencies.coordinator.execute( + { + teamName: scope.teamName, + persistenceScope, + reviewScope: scope, + kind: decisions.length > 1 ? 'bulk' : 'reject', + decisions, + fileContents: decisions.map((decision) => { + const content = fileContents.get(decision.filePath); + if (!content) throw new Error('Review mutation content is unavailable'); + return content; + }), + persistedState: boundPersistedState, + expectedDecisionRevision, + }, + { + applyDisk: (record) => + this.dependencies.batch.applyDisk( + record, + (nextResult) => { + result = nextResult; + }, + (postimages) => + mergeReviewMutationDiskPostimages(diskPostimages, postimages, normalizePath) + ), + commitDecisions: (record) => this.dependencies.batch.commit(record), + } + ); + const committed = await this.dependencies.persistence.load( + scope.teamName, + persistenceScope + ); + return { + ...(result ?? { applied: 0, skipped: 0, conflicts: 0, errors: [] }), + decisionRevision: committed?.revision ?? expectedDecisionRevision, + committedReviewAction: committed?.reviewActionHistory.at(-1), + diskPostimages: [...diskPostimages.values()], + }; + } catch (error) { + if (error instanceof ReviewMutationApplyResultError) { + return { ...error.result, diskPostimages: [...diskPostimages.values()] }; + } + throw error; + } + }); + } +} diff --git a/src/features/review-mutations/main/application/ReviewDecisionCommandPorts.ts b/src/features/review-mutations/main/application/ReviewDecisionCommandPorts.ts new file mode 100644 index 0000000000..84ffa0bc52 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewDecisionCommandPorts.ts @@ -0,0 +1,156 @@ +import type { ReviewMutationSteps } from '../../core/application/ReviewMutationCoordinator'; +import type { + PrepareReviewMutationInput, + ReviewMutationJournalRecord, +} from '../../core/application/ReviewMutationJournalTypes'; +import type { ReviewDecisionCommandCurrentState } from '../../core/domain/reviewDecisionCommandPolicy'; +import type { ReviewMutationPathAuthorization } from './ReviewMutationRecoveryPorts'; +import type { + ApplyReviewRequest, + ApplyReviewResult, + ConflictCheckResult, + FileChangeSummary, + FileChangeWithContent, + FileReviewDecision, + RejectResult, + ReviewDecisionPersistenceScope, + ReviewFileScope, + ReviewMutationDiskPostimage, + ReviewPersistedStateSnapshot, + SnippetDiff, +} from '@shared/types/review'; + +export interface ReviewDecisionCommandScopePort { + resolve( + value: unknown, + options: { requireIdentity: true } + ): Promise<{ scope: ReviewFileScope; authorization: ReviewMutationPathAuthorization }>; + parsePersistenceScope( + value: unknown, + scope: ReviewFileScope + ): ReviewDecisionPersistenceScope | null; + validateFilePath( + authorization: ReviewMutationPathAuthorization, + filePath: unknown, + options: { requireReviewedFile: boolean; rejectHardlinks: boolean } + ): Promise; + validateSnippets( + authorization: ReviewMutationPathAuthorization, + snippets: SnippetDiff[], + options: { requireReviewedFile: boolean; rejectHardlinks: boolean } + ): Promise; + assertDecisionShape(value: unknown): asserts value is FileReviewDecision; + assertSnippetShapes(value: unknown): asserts value is SnippetDiff[]; + getAuthoritativeFile( + authorization: ReviewMutationPathAuthorization, + filePath: string + ): FileChangeSummary; + resolveAuthoritativeContent( + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization, + filePath: string + ): Promise; + normalizeIdentityPath(filePath: string): string; +} + +export interface ReviewDecisionCommandApplierPort { + checkConflict(filePath: string, expectedModified: string): Promise; + rejectHunks( + teamName: string, + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ): Promise; + rejectFile( + teamName: string, + filePath: string, + original: string, + modified: string + ): Promise; + previewReject( + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ): Promise<{ preview: string; hasConflicts: boolean }>; + applyReviewDecisions( + request: ApplyReviewRequest, + fileContents: Map + ): Promise; +} + +export interface ReviewDecisionCommandPersistencePort { + withLock( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope, + operation: () => Promise + ): Promise; + assertValidSnapshot(value: ReviewPersistedStateSnapshot): void; + load( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope + ): Promise; +} + +export interface ReviewDecisionCommandBatchPort { + assertPersistedStateIncludesDecisions( + state: ReviewPersistedStateSnapshot, + decisions: readonly FileReviewDecision[] + ): void; + applyDisk( + record: ReviewMutationJournalRecord, + onResult?: (result: ApplyReviewResult) => void, + onPostimages?: (postimages: readonly ReviewMutationDiskPostimage[]) => void + ): Promise; + commit(record: ReviewMutationJournalRecord): Promise; +} + +export interface ReviewDecisionCommandHistoryPort { + bindNewHistorySnapshots( + state: ReviewPersistedStateSnapshot, + current: ReviewDecisionCommandCurrentState | null, + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization + ): Promise; +} + +export interface ReviewDecisionCommandRecoveryPort { + recoverPending(teamName: string, persistenceScope: ReviewDecisionPersistenceScope): Promise; +} + +export interface ReviewDecisionCommandCoordinatorPort { + execute( + input: PrepareReviewMutationInput, + steps: ReviewMutationSteps + ): Promise; +} + +export interface ReviewDecisionCommandSnapshotIdentityPort { + now(): number; + createToken(): string; + fingerprintSnippets(snippets: SnippetDiff[]): string; +} + +export interface ReviewDecisionCommandCachePort { + invalidateFile(filePath: string): void; +} + +export interface ReviewDecisionCommandLoggerPort { + debug(message: string, error: unknown): void; +} + +export interface ReviewDecisionCommandDependencies { + scope: ReviewDecisionCommandScopePort; + applier: ReviewDecisionCommandApplierPort; + persistence: ReviewDecisionCommandPersistencePort; + batch: ReviewDecisionCommandBatchPort; + history: ReviewDecisionCommandHistoryPort; + recovery: ReviewDecisionCommandRecoveryPort; + coordinator: ReviewDecisionCommandCoordinatorPort; + snapshots: ReviewDecisionCommandSnapshotIdentityPort; + cache: ReviewDecisionCommandCachePort; + logger: ReviewDecisionCommandLoggerPort; +} diff --git a/src/features/review-mutations/main/application/ReviewDirectMutationDiskService.ts b/src/features/review-mutations/main/application/ReviewDirectMutationDiskService.ts new file mode 100644 index 0000000000..58768d6127 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewDirectMutationDiskService.ts @@ -0,0 +1,339 @@ +import type { + ReviewMutationJournalDiskStep, + ReviewMutationJournalRecord, +} from '../../core/application/ReviewMutationJournalTypes'; +import type { + DirectReviewMutationState, + ReviewDirectMutationDiskDependencies, + ReviewDirectMutationDiskPort, + ReviewMutationPathAuthorization, +} from './ReviewMutationRecoveryPorts'; +import type { + ReviewDirectDiskMutationStep, + ReviewFileScope, + ReviewMutationDiskPostimage, +} from '@shared/types/review'; + +export class ReviewDirectMutationDiskService implements ReviewDirectMutationDiskPort { + constructor(private readonly dependencies: ReviewDirectMutationDiskDependencies) {} + + async normalize( + steps: readonly ReviewDirectDiskMutationStep[], + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization + ): Promise { + const ids = new Set(); + const normalized: ReviewMutationJournalDiskStep[] = []; + for (const step of steps) { + if ( + !step || + typeof step.id !== 'string' || + step.id.length === 0 || + step.id.length > 256 || + ids.has(step.id) + ) { + throw new Error('Invalid or duplicate review mutation step id'); + } + ids.add(step.id); + const filePath = await this.dependencies.scope.validateFilePath( + authorization, + step.filePath, + { requireReviewedFile: true, rejectHardlinks: true } + ); + if (step.type === 'write') { + if ( + typeof step.content !== 'string' || + (step.expectedContent !== null && typeof step.expectedContent !== 'string') + ) { + throw new Error('Invalid review write mutation'); + } + normalized.push({ ...step, filePath, status: 'pending' }); + continue; + } + if (step.type === 'delete') { + if (typeof step.expectedContent !== 'string') { + throw new Error('Invalid review delete mutation'); + } + normalized.push({ ...step, filePath, status: 'pending' }); + continue; + } + if (step.type !== 'restore-rejected-rename' && step.type !== 'reapply-rejected-rename') { + throw new Error('Invalid review mutation step'); + } + const expectation = this.dependencies.scope.parseRenameExpectation(step.expectation); + const authoritativeContent = await this.dependencies.scope.resolveAuthoritativeContent( + scope, + authorization, + filePath + ); + await this.dependencies.scope.validateSnippets(authorization, authoritativeContent.snippets, { + requireReviewedFile: true, + rejectHardlinks: true, + }); + this.dependencies.scope.assertExpectedRename(authoritativeContent, expectation); + normalized.push({ + ...step, + filePath, + expectation, + authoritativeContent, + status: 'pending', + }); + } + return normalized; + } + + async buildPostimages( + steps: readonly ReviewMutationJournalDiskStep[] + ): Promise { + const postimages = new Map(); + for (const step of steps) { + if (step.type === 'write') { + this.mergePostimages(postimages, [{ filePath: step.filePath, content: step.content }]); + continue; + } + if (step.type === 'delete') { + this.mergePostimages(postimages, [{ filePath: step.filePath, content: null }]); + continue; + } + const content = step.authoritativeContent; + if (!content) throw new Error('Rename recovery content is unavailable'); + this.mergePostimages( + postimages, + await this.dependencies.applier.getRejectedRenamePostimages( + content.originalFullContent, + content.modifiedFullContent, + content.snippets, + step.type === 'restore-rejected-rename' ? 'restore' : 'reapply' + ) + ); + } + return [...postimages.values()]; + } + + async buildRecoveryPostimages( + record: ReviewMutationJournalRecord + ): Promise { + if (record.diskSteps) return this.buildPostimages(record.diskSteps); + + const postimages = new Map(); + for (const [index, content] of record.fileContents.entries()) { + const transitions = (record.decisionTransitions?.[index] ?? []).filter( + (transition) => transition.beforeContent !== transition.afterContent || transition.operation + ); + const hasRename = content.snippets.some( + (snippet) => snippet.ledger?.relation?.kind === 'rename' && !snippet.isError + ); + if (hasRename && transitions.length > 0) { + this.mergePostimages( + postimages, + await this.dependencies.applier.getRejectedRenamePostimages( + content.originalFullContent, + content.modifiedFullContent, + content.snippets, + 'reapply' + ) + ); + continue; + } + for (const transition of transitions) { + if (transition.operation === 'move' && transition.relatedFilePath) { + this.mergePostimages(postimages, [ + { filePath: transition.filePath, content: null }, + { filePath: transition.relatedFilePath, content: transition.afterContent }, + ]); + } else { + this.mergePostimages(postimages, [ + { filePath: transition.filePath, content: transition.afterContent }, + ]); + } + } + } + return [...postimages.values()]; + } + + async classify(step: ReviewMutationJournalDiskStep): Promise { + if (step.type === 'write') { + return this.dependencies.applier.classifyEditedFileTransition( + step.filePath, + step.expectedContent, + step.content + ); + } + if (step.type === 'delete') { + return this.dependencies.applier.classifyEditedFileTransition( + step.filePath, + step.expectedContent, + null + ); + } + const content = step.authoritativeContent; + if (!content) throw new Error('Rename recovery content is unavailable'); + const state = await this.dependencies.applier.classifyRejectedRenameTransition( + step.filePath, + content.originalFullContent, + content.modifiedFullContent, + content.snippets + ); + if (state === 'both') return 'both'; + const beforeState = step.type === 'restore-rejected-rename' ? 'rejected' : 'accepted'; + const afterState = step.type === 'restore-rejected-rename' ? 'accepted' : 'rejected'; + if (state === beforeState) return 'before'; + if (state === afterState) return 'after'; + const recoverableIntermediate = + (step.type === 'restore-rejected-rename' && state === 'restoring') || + (step.type === 'reapply-rejected-rename' && + (state === 'reapplying' || state === 'legacy-reapplying')); + if (recoverableIntermediate) return 'intermediate'; + throw new Error('Ledger rename is not in the expected durable mutation state'); + } + + async assertPreimages(steps: readonly ReviewMutationJournalDiskStep[]): Promise { + for (const step of steps) { + const state = await this.classify(step); + if (state !== 'before' && state !== 'both') { + throw new Error('Review mutation preflight failed; no files were changed'); + } + } + } + + async apply(record: ReviewMutationJournalRecord): Promise { + let current = record; + const steps = current.diskSteps; + if (!steps?.length) return current; + + try { + const alreadyAtPostimage = new Set(); + for (const [index, step] of steps.entries()) { + const state = await this.classify(step); + if (step.status === 'applied') { + if (state !== 'after' && state !== 'both') { + throw new Error('Applied review mutation changed after crash; refusing recovery'); + } + } else if (state === 'after' || state === 'both') { + alreadyAtPostimage.add(index); + } + } + if (alreadyAtPostimage.size > 0) { + current = await this.dependencies.journal.checkpoint({ + ...current, + diskSteps: current.diskSteps!.map((step, index) => + alreadyAtPostimage.has(index) ? { ...step, status: 'applied' as const } : step + ), + }); + for (const index of alreadyAtPostimage) { + const appliedStep = current.diskSteps?.[index]; + if (appliedStep) await this.finalizeArtifacts(appliedStep); + } + } + } catch (error) { + await this.dependencies.journal.markFailed(current, error).catch((journalError) => { + this.dependencies.logger.error( + 'Unable to preserve drifted direct review mutation:', + journalError + ); + }); + throw error; + } + + for (let index = 0; index < steps.length; index++) { + const step = current.diskSteps?.[index]; + if (!step || step.status === 'applied') continue; + try { + await this.applyStep(step); + const postState = await this.classify(step); + if (postState !== 'after' && postState !== 'both') { + throw new Error('Review mutation did not reach its durable postimage'); + } + } catch (error) { + await this.dependencies.journal.markFailed(current, error).catch((journalError) => { + this.dependencies.logger.error( + 'Unable to mark failed direct review mutation:', + journalError + ); + }); + throw error; + } + current = await this.dependencies.journal.checkpoint({ + ...current, + diskSteps: current.diskSteps!.map((candidate, candidateIndex) => + candidateIndex === index ? { ...candidate, status: 'applied' as const } : candidate + ), + }); + await this.finalizeArtifacts(step); + if (step.authoritativeContent) { + this.dependencies.cache.invalidateAuthoritativeContent(step.authoritativeContent); + } else { + this.dependencies.cache.invalidateFile(step.filePath); + } + } + return current; + } + + private async applyStep(step: ReviewMutationJournalDiskStep): Promise { + if (step.type === 'write') { + await this.dependencies.applier.saveEditedFile( + step.filePath, + step.content, + step.expectedContent + ); + return; + } + if (step.type === 'delete') { + await this.dependencies.applier.deleteEditedFile(step.filePath, step.expectedContent); + return; + } + const content = step.authoritativeContent; + if (!content) throw new Error('Rename recovery content is unavailable'); + if (step.type === 'restore-rejected-rename') { + await this.dependencies.applier.restoreRejectedRename( + step.filePath, + content.originalFullContent, + content.modifiedFullContent, + content.snippets + ); + } else { + await this.dependencies.applier.reapplyRejectedRename( + step.filePath, + content.originalFullContent, + content.snippets + ); + } + } + + private async finalizeArtifacts(step: ReviewMutationJournalDiskStep): Promise { + if (step.type === 'write') { + await this.dependencies.applier.finalizeEditedFileTransaction?.( + step.filePath, + step.expectedContent, + step.content + ); + return; + } + if (step.type === 'delete') { + await this.dependencies.applier.finalizeEditedFileTransaction?.( + step.filePath, + step.expectedContent, + null + ); + return; + } + const content = step.authoritativeContent; + if (!content) return; + await this.dependencies.applier.finalizeRejectedRenameTransaction?.( + step.filePath, + content.originalFullContent, + content.modifiedFullContent, + content.snippets, + step.type === 'restore-rejected-rename' ? 'restore' : 'reapply' + ); + } + + private mergePostimages( + target: Map, + postimages: readonly ReviewMutationDiskPostimage[] + ): void { + for (const postimage of postimages) { + target.set(this.dependencies.scope.normalizeIdentityPath(postimage.filePath), postimage); + } + } +} diff --git a/src/features/review-mutations/main/application/ReviewEditableMutationApplication.ts b/src/features/review-mutations/main/application/ReviewEditableMutationApplication.ts new file mode 100644 index 0000000000..2b7e59945f --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewEditableMutationApplication.ts @@ -0,0 +1,132 @@ +import type { + DeleteEditedFileInput, + SaveEditedFileInput, +} from '../../core/domain/reviewEditableMutationPolicy'; +import type { ReviewEditableMutationDependencies } from './ReviewEditableMutationPorts'; +import type { FileChangeWithContent } from '@shared/types/review'; + +interface AuthorizedRenameMutation { + filePath: string; + content: FileChangeWithContent; +} + +export class ReviewEditableMutationApplication { + constructor(private readonly dependencies: ReviewEditableMutationDependencies) {} + + async saveEditedFile( + scopeValue: unknown, + input: SaveEditedFileInput + ): Promise<{ success: boolean }> { + const filePath = await this.authorizeMutableFile(scopeValue, input.filePath); + const result = await this.dependencies.applier.saveEditedFile( + filePath, + input.content, + input.expectedCurrentContent + ); + this.dependencies.content.invalidateFile(filePath); + return result; + } + + async deleteEditedFile( + scopeValue: unknown, + input: DeleteEditedFileInput + ): Promise<{ success: boolean }> { + const filePath = await this.authorizeMutableFile(scopeValue, input.filePath); + const result = await this.dependencies.applier.deleteEditedFile( + filePath, + input.expectedCurrentContent + ); + this.dependencies.content.invalidateFile(filePath); + return result; + } + + async restoreRejectedRename( + scopeValue: unknown, + filePathValue: unknown, + expectationValue: unknown + ): Promise<{ success: boolean }> { + const authorized = await this.authorizeRenameMutation( + scopeValue, + filePathValue, + expectationValue + ); + try { + return await this.dependencies.applier.restoreRejectedRename( + authorized.filePath, + authorized.content.originalFullContent, + authorized.content.modifiedFullContent, + authorized.content.snippets + ); + } finally { + this.dependencies.scope.invalidateAuthoritativeReviewContent(authorized.content); + } + } + + async reapplyRejectedRename( + scopeValue: unknown, + filePathValue: unknown, + expectationValue: unknown + ): Promise<{ success: boolean }> { + const authorized = await this.authorizeRenameMutation( + scopeValue, + filePathValue, + expectationValue + ); + try { + return await this.dependencies.applier.reapplyRejectedRename( + authorized.filePath, + authorized.content.originalFullContent, + authorized.content.snippets + ); + } finally { + this.dependencies.scope.invalidateAuthoritativeReviewContent(authorized.content); + } + } + + private async authorizeMutableFile(scopeValue: unknown, filePathValue: unknown): Promise { + const { authorization } = await this.dependencies.scope.resolveReviewPathAuthorization( + scopeValue, + { + requireIdentity: true, + } + ); + return this.dependencies.scope.validateAuthorizedReviewFilePath(authorization, filePathValue, { + requireReviewedFile: true, + rejectHardlinks: true, + }); + } + + private async authorizeRenameMutation( + scopeValue: unknown, + filePathValue: unknown, + expectationValue: unknown + ): Promise { + const expectation = + this.dependencies.scope.parseReviewRenameRecoveryExpectation(expectationValue); + const { scope, authorization } = await this.dependencies.scope.resolveReviewPathAuthorization( + scopeValue, + { + requireIdentity: true, + } + ); + const filePath = await this.dependencies.scope.validateAuthorizedReviewFilePath( + authorization, + filePathValue, + { + requireReviewedFile: true, + rejectHardlinks: true, + } + ); + const content = await this.dependencies.scope.resolveAuthoritativeFileContent( + scope, + authorization, + filePath + ); + await this.dependencies.scope.validateSnippetPaths(authorization, content.snippets, { + requireReviewedFile: true, + rejectHardlinks: true, + }); + this.dependencies.scope.assertExpectedAuthoritativeRename(content, expectation); + return { filePath, content }; + } +} diff --git a/src/features/review-mutations/main/application/ReviewEditableMutationPorts.ts b/src/features/review-mutations/main/application/ReviewEditableMutationPorts.ts new file mode 100644 index 0000000000..8016b33118 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewEditableMutationPorts.ts @@ -0,0 +1,65 @@ +import type { ReviewMutationPathAuthorization } from './ReviewMutationRecoveryPorts'; +import type { + FileChangeWithContent, + ReviewFileScope, + ReviewRenameRecoveryExpectation, + SnippetDiff, +} from '@shared/types/review'; + +export interface ReviewEditableMutationScopePort { + parseReviewRenameRecoveryExpectation(value: unknown): ReviewRenameRecoveryExpectation; + resolveReviewPathAuthorization( + value: unknown, + options: { requireIdentity: true } + ): Promise<{ scope: ReviewFileScope; authorization: ReviewMutationPathAuthorization }>; + validateAuthorizedReviewFilePath( + authorization: ReviewMutationPathAuthorization, + filePath: unknown, + options: { requireReviewedFile: true; rejectHardlinks: true } + ): Promise; + resolveAuthoritativeFileContent( + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization, + filePath: string + ): Promise; + validateSnippetPaths( + authorization: ReviewMutationPathAuthorization, + snippets: SnippetDiff[], + options: { requireReviewedFile: true; rejectHardlinks: true } + ): Promise; + assertExpectedAuthoritativeRename( + content: FileChangeWithContent, + expectation: ReviewRenameRecoveryExpectation + ): void; + invalidateAuthoritativeReviewContent(content: FileChangeWithContent): void; +} + +export interface ReviewEditableMutationApplierPort { + saveEditedFile( + filePath: string, + content: string, + expectedCurrentContent: string | null + ): Promise<{ success: boolean }>; + deleteEditedFile(filePath: string, expectedCurrentContent: string): Promise<{ success: boolean }>; + restoreRejectedRename( + filePath: string, + original: string | null, + modified: string | null, + snippets: SnippetDiff[] + ): Promise<{ success: boolean }>; + reapplyRejectedRename( + filePath: string, + original: string | null, + snippets: SnippetDiff[] + ): Promise<{ success: boolean }>; +} + +export interface ReviewEditableMutationContentPort { + invalidateFile(filePath: string): void; +} + +export interface ReviewEditableMutationDependencies { + scope: ReviewEditableMutationScopePort; + applier: ReviewEditableMutationApplierPort; + content: ReviewEditableMutationContentPort; +} diff --git a/src/features/review-mutations/main/application/ReviewHistoryMutationApplication.ts b/src/features/review-mutations/main/application/ReviewHistoryMutationApplication.ts new file mode 100644 index 0000000000..65ad2dfe10 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewHistoryMutationApplication.ts @@ -0,0 +1,364 @@ +import { createHash } from 'node:crypto'; + +import { threeWayTextMerge } from '@shared/utils/threeWayTextMerge'; + +import { + assertAuthoritativelyBoundReviewAction, + assertExactReviewHistoryTransition, + findLatestRestorableDiskSnapshot, + isAuthoritativeReviewDeletion, + rebindReviewActionDescriptorPath, +} from '../../core/domain/reviewHistoryMutationPolicy'; + +import type { + ReviewHistoryMutationCurrentState, + ReviewHistoryMutationDependencies, +} from './ReviewHistoryMutationPorts'; +import type { ReviewMutationPathAuthorization } from './ReviewMutationRecoveryPorts'; +import type { + ExecuteReviewMutationRequest, + ReviewDiskUndoSnapshot, + ReviewFileScope, + ReviewPersistedStateSnapshot, + ReviewRedoAction, + ReviewUndoAction, +} from '@shared/types/review'; + +function hasDiskPostimage( + snapshot: ReviewDiskUndoSnapshot | null +): snapshot is ReviewDiskUndoSnapshot & { afterContent: string } { + return typeof snapshot?.afterContent === 'string'; +} + +export class ReviewHistoryMutationApplication { + constructor(private readonly dependencies: ReviewHistoryMutationDependencies) {} + + assertExactTransition( + request: ExecuteReviewMutationRequest, + current: ReviewHistoryMutationCurrentState, + authorization: ReviewMutationPathAuthorization + ): void { + assertExactReviewHistoryTransition(request, current, { + resolveFile: (filePath) => + this.dependencies.scope.getAuthoritativeFile(authorization, filePath), + normalizePath: (filePath) => this.dependencies.scope.normalizeIdentityPath(filePath), + hashContent: (content) => this.hashContent(content), + }); + } + + assertAuthoritativelyBoundAction(action: ReviewUndoAction): void { + assertAuthoritativelyBoundReviewAction(action, (content) => this.hashContent(content)); + } + + async bindNewHistorySnapshots( + state: ReviewPersistedStateSnapshot, + current: ReviewHistoryMutationCurrentState, + scope: ReviewFileScope | null, + authorization: ReviewMutationPathAuthorization | null + ): Promise { + const trustedActions = new Map(); + const trustedRedo = new Map(); + for (const action of current?.reviewActionHistory ?? []) trustedActions.set(action.id, action); + for (const entry of current?.reviewRedoHistory ?? []) { + trustedActions.set(entry.action.id, entry.action); + trustedRedo.set(entry.action.id, entry); + } + const bindAction = (action: ReviewUndoAction): Promise => { + const trusted = trustedActions.get(action.id); + return trusted + ? Promise.resolve(trusted) + : this.bindNewAction(action, current, scope, authorization); + }; + return { + ...state, + reviewActionHistory: await Promise.all((state.reviewActionHistory ?? []).map(bindAction)), + reviewRedoHistory: await Promise.all( + (state.reviewRedoHistory ?? []).map(async (entry) => { + const trusted = trustedRedo.get(entry.action.id); + return trusted ?? { ...entry, action: await bindAction(entry.action) }; + }) + ), + }; + } + + async bindAuthoritativeForwardMutation( + request: ExecuteReviewMutationRequest, + current: ReviewHistoryMutationCurrentState, + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization + ): Promise { + if (request.kind !== 'restore' && request.kind !== 'rename') return request.persistedState; + const action = request.persistedState.reviewActionHistory.at(-1); + if (action?.kind !== 'disk' || action.action.originalIndex !== undefined) { + throw new Error(`Invalid durable ${request.kind === 'rename' ? 'Rename' : 'Restore'} action`); + } + const snapshot = action.action.snapshot; + const filePath = await this.dependencies.scope.validateFilePath( + authorization, + snapshot.filePath, + { + requireReviewedFile: true, + rejectHardlinks: true, + } + ); + const authoritativeFile = this.dependencies.scope.getAuthoritativeFile( + authorization, + snapshot.filePath + ); + const restoreMode = + snapshot.restoreMode ?? (snapshot.renameExpectation ? 'restore-rejected-rename' : 'content'); + + if (request.kind === 'rename') { + if (restoreMode !== 'reapply-rejected-rename' || !snapshot.renameExpectation) { + throw new Error('Review Rename mode does not match authoritative rename recovery'); + } + const boundSnapshot = await this.bindNewDiskSnapshot(snapshot, scope, authorization); + return { + ...request.persistedState, + reviewActionHistory: [ + ...request.persistedState.reviewActionHistory.slice(0, -1), + { + ...action, + descriptor: rebindReviewActionDescriptorPath(action, boundSnapshot.filePath), + action: { ...action.action, snapshot: boundSnapshot, file: authoritativeFile }, + }, + ], + }; + } + + if (snapshot.renameExpectation || restoreMode.includes('rename')) { + throw new Error('Review Restore cannot use rename recovery metadata'); + } + const authoritativeContent = await this.dependencies.scope.resolveAuthoritativeContent( + scope, + authorization, + filePath + ); + const previous = findLatestRestorableDiskSnapshot(current, filePath, { + normalizePath: (candidatePath) => + this.dependencies.scope.normalizeIdentityPath(candidatePath), + hashContent: (content) => this.hashContent(content), + }); + const observedBeforeContent = await this.readDiskContent(filePath); + + let expectedAfterContent: string | null; + if (isAuthoritativeReviewDeletion(authoritativeFile)) { + if (restoreMode !== 'create-file' || observedBeforeContent === null) { + throw new Error('Review Restore deletion preimage or mode is not authoritative'); + } + expectedAfterContent = null; + } else if (authoritativeContent.isNewFile) { + if (!hasDiskPostimage(previous)) { + if (restoreMode !== 'delete-file' || observedBeforeContent !== null) { + throw new Error('A file now exists at this reviewed new-file path; refusing Restore'); + } + expectedAfterContent = previous?.beforeContent ?? authoritativeContent.modifiedFullContent; + if (expectedAfterContent === null) { + throw new Error('Authoritative agent content is unavailable; refusing Restore'); + } + } else { + if (restoreMode !== 'content' || observedBeforeContent === null) { + throw new Error('Review Restore new-file preimage or mode is not authoritative'); + } + const merged = threeWayTextMerge( + previous.afterContent, + observedBeforeContent, + previous.beforeContent + ); + if (merged.hasConflicts) { + throw new Error('Agent changes conflict with edits made after rejection.'); + } + expectedAfterContent = merged.content; + } + } else { + if (restoreMode !== 'content' || observedBeforeContent === null) { + throw new Error('Review Restore content preimage or mode is not authoritative'); + } + const desiredContent = previous?.beforeContent ?? authoritativeContent.modifiedFullContent; + if (desiredContent === null) { + throw new Error('Authoritative agent content is unavailable; refusing Restore'); + } + const rejectedBaseline = previous?.afterContent ?? authoritativeContent.originalFullContent; + if (rejectedBaseline === null) { + throw new Error('Authoritative rejected baseline is unavailable; refusing Restore'); + } + const merged = threeWayTextMerge(rejectedBaseline, observedBeforeContent, desiredContent); + if (merged.hasConflicts) { + throw new Error('Agent changes conflict with edits made after rejection.'); + } + expectedAfterContent = merged.content; + } + + const expectedBeforeContent = observedBeforeContent ?? ''; + if (snapshot.beforeContent !== expectedBeforeContent) { + throw new Error('Review Restore preimage does not match the current reviewed file'); + } + if (snapshot.afterContent !== expectedAfterContent) { + throw new Error('Review Restore content does not match authoritative review history'); + } + const boundSnapshot: ReviewDiskUndoSnapshot = { + ...snapshot, + filePath, + beforeContent: expectedBeforeContent, + authoritativeBeforeSha256: + observedBeforeContent === null ? null : this.hashContent(observedBeforeContent), + file: authoritativeFile, + restoreMode, + renameExpectation: undefined, + restoreConflict: undefined, + }; + return { + ...request.persistedState, + reviewActionHistory: [ + ...request.persistedState.reviewActionHistory.slice(0, -1), + { + ...action, + descriptor: rebindReviewActionDescriptorPath(action, boundSnapshot.filePath), + action: { ...action.action, snapshot: boundSnapshot, file: authoritativeFile }, + }, + ], + }; + } + + private async bindNewAction( + action: ReviewUndoAction, + current: ReviewHistoryMutationCurrentState, + scope: ReviewFileScope | null, + authorization: ReviewMutationPathAuthorization | null + ): Promise { + if (action.kind === 'hunk') return action; + const decisionSnapshot = { + hunkDecisions: { ...(current?.hunkDecisions ?? {}) }, + fileDecisions: { ...(current?.fileDecisions ?? {}) }, + }; + if (action.kind === 'bulk') { + if (action.diskSnapshots.length === 0) return action; + if (!scope || !authorization) { + throw new Error('Review scope is unavailable for a new disk history action'); + } + return { + ...action, + decisionSnapshot, + diskSnapshots: await Promise.all( + action.diskSnapshots.map((snapshot) => + this.bindNewDiskSnapshot(snapshot, scope, authorization) + ) + ), + }; + } + if (!scope || !authorization) { + throw new Error('Review scope is unavailable for a new disk history action'); + } + const snapshot = await this.bindNewDiskSnapshot(action.action.snapshot, scope, authorization); + return { + ...action, + descriptor: rebindReviewActionDescriptorPath(action, snapshot.filePath), + action: { + ...action.action, + snapshot, + file: snapshot.file, + ...(action.action.originalIndex === undefined ? { decisionSnapshot } : {}), + }, + }; + } + + private async bindNewDiskSnapshot( + snapshot: ReviewDiskUndoSnapshot, + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization + ): Promise { + const filePath = await this.dependencies.scope.validateFilePath( + authorization, + snapshot.filePath, + { + requireReviewedFile: true, + rejectHardlinks: true, + } + ); + const file = this.dependencies.scope.getAuthoritativeFile(authorization, filePath); + const restoreMode = + snapshot.restoreMode ?? (snapshot.renameExpectation ? 'restore-rejected-rename' : 'content'); + const isRenameMode = + restoreMode === 'restore-rejected-rename' || restoreMode === 'reapply-rejected-rename'; + + if (isRenameMode || snapshot.renameExpectation) { + if (!isRenameMode || !snapshot.renameExpectation) { + throw new Error('Rename recovery metadata does not match the review history mode'); + } + const expectation = this.dependencies.scope.parseRenameExpectation( + snapshot.renameExpectation + ); + const authoritativeContent = await this.dependencies.scope.resolveAuthoritativeContent( + scope, + authorization, + filePath + ); + this.dependencies.scope.assertExpectedRename(authoritativeContent, expectation); + return { + ...snapshot, + filePath, + beforeContent: '', + afterContent: null, + authoritativeBeforeSha256: null, + file, + restoreMode, + renameExpectation: expectation, + restoreConflict: undefined, + }; + } + + const beforeContent = await this.readDiskContent(filePath); + if (beforeContent === null && restoreMode !== 'delete-file') { + throw new Error('Review history preimage is missing; refusing an unsafe disk action'); + } + const authoritativeContent = await this.dependencies.scope.resolveAuthoritativeContent( + scope, + authorization, + filePath + ); + if (restoreMode === 'create-file' && !authoritativeContent.isNewFile) { + throw new Error('Create-file review history does not match an authoritative new file'); + } + if (restoreMode === 'delete-file' && !isAuthoritativeReviewDeletion(file)) { + throw new Error('Delete-file review history does not match an authoritative deletion'); + } + + let afterContent: string | null; + if (restoreMode === 'create-file') { + afterContent = null; + } else if (restoreMode === 'delete-file') { + afterContent = authoritativeContent.originalFullContent; + if (afterContent === null) { + throw new Error('Authoritative deleted-file baseline is unavailable'); + } + } else { + afterContent = beforeContent; + } + + return { + ...snapshot, + filePath, + beforeContent: beforeContent ?? '', + afterContent, + authoritativeBeforeSha256: beforeContent === null ? null : this.hashContent(beforeContent), + file, + restoreMode, + renameExpectation: undefined, + restoreConflict: undefined, + }; + } + + private async readDiskContent(filePath: string): Promise { + try { + return await this.dependencies.files.readText(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return null; + throw error; + } + } + + private hashContent(content: string): string { + return createHash('sha256').update(content).digest('hex'); + } +} diff --git a/src/features/review-mutations/main/application/ReviewHistoryMutationPorts.ts b/src/features/review-mutations/main/application/ReviewHistoryMutationPorts.ts new file mode 100644 index 0000000000..6ef63a6ce7 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewHistoryMutationPorts.ts @@ -0,0 +1,44 @@ +import type { + LoadedReviewMutationDecisions, + ReviewMutationPathAuthorization, +} from './ReviewMutationRecoveryPorts'; +import type { + FileChangeSummary, + FileChangeWithContent, + ReviewFileScope, + ReviewRenameRecoveryExpectation, +} from '@shared/types/review'; + +export interface ReviewHistoryMutationScopePort { + validateFilePath( + authorization: ReviewMutationPathAuthorization, + filePath: string, + options: { requireReviewedFile: boolean; rejectHardlinks: boolean } + ): Promise; + getAuthoritativeFile( + authorization: ReviewMutationPathAuthorization, + filePath: string + ): FileChangeSummary; + resolveAuthoritativeContent( + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization, + filePath: string + ): Promise; + parseRenameExpectation(value: unknown): ReviewRenameRecoveryExpectation; + assertExpectedRename( + content: FileChangeWithContent, + expectation: ReviewRenameRecoveryExpectation + ): void; + normalizeIdentityPath(filePath: string): string; +} + +export interface ReviewHistoryMutationFilePort { + readText(filePath: string): Promise; +} + +export interface ReviewHistoryMutationDependencies { + scope: ReviewHistoryMutationScopePort; + files: ReviewHistoryMutationFilePort; +} + +export type ReviewHistoryMutationCurrentState = LoadedReviewMutationDecisions | null; diff --git a/src/features/review-mutations/main/application/ReviewMutationRecoveryApplication.ts b/src/features/review-mutations/main/application/ReviewMutationRecoveryApplication.ts new file mode 100644 index 0000000000..0934c883a4 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewMutationRecoveryApplication.ts @@ -0,0 +1,456 @@ +import { isDurableReviewEqual } from '../../core/domain/durableReviewValue'; +import { buildReviewHistoryRestorePlan } from '../../core/domain/reviewHistoryDecisions'; +import { buildReviewHistoryRestoreDiskSteps } from '../../core/domain/reviewHistoryDiskSteps'; +import { + isDecisionlessReviewRecoveryKind, + parseReviewHistoryRestoreTarget, +} from '../../core/domain/reviewHistoryRestoreTarget'; + +import type { ReviewMutationJournalRecord } from '../../core/application/ReviewMutationJournalTypes'; +import type { + LoadedReviewMutationDecisions, + ReviewMutationPathAuthorization, + ReviewMutationRecoveryDependencies, +} from './ReviewMutationRecoveryPorts'; +import type { + ExecuteReviewMutationRequest, + ExecuteReviewMutationResult, + RestoreReviewHistoryRequest, + RestoreReviewHistoryResult, + RetryReviewMutationRecoveryRequest, + RetryReviewMutationRecoveryResult, + ReviewDecisionPersistenceScope, + ReviewMutationDiskPostimage, + ReviewPersistedStateSnapshot, +} from '@shared/types/review'; + +export const MAX_REVIEW_MUTATION_STEPS = 2_000; + +export class ReviewMutationRecoveryApplication { + constructor(private readonly dependencies: ReviewMutationRecoveryDependencies) {} + + async execute(request: ExecuteReviewMutationRequest): Promise { + const { scope, authorization } = await this.dependencies.scope.resolve(request.scope, { + requireIdentity: true, + }); + const persistenceScope = this.dependencies.scope.parsePersistenceScope( + request.decisionPersistenceScope, + scope + ); + if (!persistenceScope) throw new Error('Review mutation requires an exact decision scope'); + this.dependencies.decisions.assertValidSnapshot(request.persistedState); + if ( + !Number.isSafeInteger(request.expectedDecisionRevision) || + request.expectedDecisionRevision < 0 + ) { + throw new Error('Review mutation requires an exact decision revision'); + } + + return this.dependencies.decisions.withLock(scope.teamName, persistenceScope, async () => { + await this.recoverPending(scope.teamName, persistenceScope); + await this.dependencies.decisions.assertCurrentRevision( + scope.teamName, + persistenceScope, + request.expectedDecisionRevision + ); + const current = await this.dependencies.decisions.load(scope.teamName, persistenceScope); + this.dependencies.decisions.assertExactTransition(request, current, authorization); + const persistedState = await this.dependencies.decisions.bindAuthoritativeForwardMutation( + request, + current, + scope, + authorization + ); + const diskSteps = await this.dependencies.disk.normalize( + request.diskSteps, + scope, + authorization + ); + const diskPostimages = await this.dependencies.disk.buildPostimages(diskSteps); + await this.dependencies.disk.assertPreimages(diskSteps); + await this.dependencies.coordinator.execute( + { + teamName: scope.teamName, + persistenceScope, + reviewScope: scope, + kind: request.kind, + decisions: [], + fileContents: [], + diskSteps, + persistedState, + expectedDecisionRevision: request.expectedDecisionRevision, + }, + { + applyDisk: (record) => this.dependencies.disk.apply(record), + commitDecisions: (record) => this.dependencies.decisions.commit(record), + } + ); + const committed = await this.dependencies.decisions.load(scope.teamName, persistenceScope); + return { + decisionRevision: committed?.revision ?? request.expectedDecisionRevision, + diskPostimages, + ...(request.kind === 'restore' || request.kind === 'rename' + ? { committedReviewAction: committed?.reviewActionHistory.at(-1) } + : {}), + }; + }); + } + + async restoreHistory(request: RestoreReviewHistoryRequest): Promise { + const target = parseReviewHistoryRestoreTarget(request.target); + if ( + !Number.isSafeInteger(request.expectedDecisionRevision) || + request.expectedDecisionRevision < 0 + ) { + throw new Error('Review history restore requires an exact decision revision'); + } + const { scope, authorization } = await this.dependencies.scope.resolve(request.scope, { + requireIdentity: true, + }); + const persistenceScope = this.dependencies.scope.parsePersistenceScope( + request.decisionPersistenceScope, + scope + ); + if (!persistenceScope) { + throw new Error('Review history restore requires an exact decision scope'); + } + + return this.dependencies.decisions.withLock(scope.teamName, persistenceScope, async () => { + await this.recoverPending(scope.teamName, persistenceScope); + await this.dependencies.decisions.assertCurrentRevision( + scope.teamName, + persistenceScope, + request.expectedDecisionRevision + ); + const current = await this.dependencies.decisions.load(scope.teamName, persistenceScope); + if (!current) throw new Error('Review history is unavailable'); + const currentState = this.toPersistedState(current); + const plan = buildReviewHistoryRestorePlan(currentState, target, (filePath) => + this.dependencies.scope.getAuthoritativeFile(authorization, filePath) + ); + if (plan.actionCount === 0) { + return { + decisionRevision: current.revision, + persistedState: currentState, + direction: 'none', + actionCount: 0, + diskPostimages: [], + }; + } + if (plan.direction === 'none') { + throw new Error('Review history restore plan is inconsistent'); + } + const direction = plan.direction; + for (const action of plan.orderedActions) { + this.dependencies.decisions.assertAuthoritativelyBoundAction(action); + } + this.dependencies.decisions.assertValidSnapshot(plan.persistedState); + const plannedDiskSteps = buildReviewHistoryRestoreDiskSteps( + plan.orderedActions.map((action) => ({ direction, action })) + ); + const diskSteps = await this.dependencies.disk.normalize( + plannedDiskSteps, + scope, + authorization + ); + const diskPostimages = await this.dependencies.disk.buildPostimages(diskSteps); + await this.dependencies.disk.assertPreimages(diskSteps); + await this.dependencies.coordinator.execute( + { + teamName: scope.teamName, + persistenceScope, + reviewScope: scope, + kind: 'restore-history', + decisions: [], + fileContents: [], + diskSteps, + persistedState: plan.persistedState, + expectedDecisionRevision: request.expectedDecisionRevision, + }, + { + applyDisk: (record) => this.dependencies.disk.apply(record), + commitDecisions: (record) => this.dependencies.decisions.commit(record), + } + ); + const committed = await this.dependencies.decisions.load(scope.teamName, persistenceScope); + if (!committed) throw new Error('Restored review history was not committed'); + return { + decisionRevision: committed.revision, + persistedState: this.toPersistedState(committed), + direction, + actionCount: plan.actionCount, + diskPostimages, + }; + }); + } + + async retryRecovery( + request: RetryReviewMutationRecoveryRequest + ): Promise { + const { scope, authorization } = await this.dependencies.scope.resolve(request.scope, { + requireIdentity: true, + }); + const persistenceScope = this.dependencies.scope.parsePersistenceScope( + request.decisionPersistenceScope, + scope + ); + if (!persistenceScope) { + throw new Error('Review mutation recovery requires an exact decision scope'); + } + const expectedRestore = request.expectedRestore; + if (expectedRestore) { + if ( + !Number.isSafeInteger(expectedRestore.expectedDecisionRevision) || + expectedRestore.expectedDecisionRevision < 0 || + !Array.isArray(expectedRestore.diskSteps) || + expectedRestore.diskSteps.length > MAX_REVIEW_MUTATION_STEPS + ) { + throw new Error('Invalid expected review history Restore recovery'); + } + this.dependencies.decisions.assertValidSnapshot(expectedRestore.persistedState); + } + + return this.dependencies.decisions.withLock(scope.teamName, persistenceScope, async () => { + const records = await this.dependencies.journal.list(scope.teamName, persistenceScope); + if (records.length > 1) { + throw new Error('Multiple review mutations require manual recovery'); + } + const record = records[0]; + const recordDiskSteps = (record?.diskSteps ?? []).map( + ({ status: _status, authoritativeContent: _authoritativeContent, ...step }) => step + ); + const matchesExpectedRestore = + !record || + !expectedRestore || + (record.kind === 'restore-history' && + record.expectedDecisionRevision === expectedRestore.expectedDecisionRevision && + isDurableReviewEqual(record.persistedState, expectedRestore.persistedState) && + isDurableReviewEqual(recordDiskSteps, expectedRestore.diskSteps)); + if (!matchesExpectedRestore) { + const committed = await this.dependencies.decisions.load(scope.teamName, persistenceScope); + return { + decisionRevision: committed?.revision ?? 0, + recoveredMutation: false, + recoveredRestoreHistory: false, + differentMutationPending: true, + persistedState: committed ? this.toPersistedState(committed) : null, + expectedRestoreCompleted: false, + diskPostimages: [], + retried: false, + }; + } + + let diskPostimages: ReviewMutationDiskPostimage[] = []; + let postimagesResolved = false; + if (record) { + try { + diskPostimages = await this.dependencies.disk.buildRecoveryPostimages(record); + postimagesResolved = true; + } catch (error) { + this.dependencies.logger.warn( + 'Unable to resolve interrupted review mutation postimages:', + error + ); + } + } + const retried = Boolean(record?.blocked); + if (record?.blocked) await this.dependencies.journal.unblock(record); + await this.recoverPending(scope.teamName, persistenceScope); + const committed = await this.dependencies.decisions.load(scope.teamName, persistenceScope); + const persistedState = committed ? this.toPersistedState(committed) : null; + const expectedRestoreStateCompleted = Boolean( + expectedRestore && + committed?.revision === expectedRestore.expectedDecisionRevision + 1 && + persistedState && + isDurableReviewEqual(persistedState, expectedRestore.persistedState) && + (!record || record.kind === 'restore-history') + ); + if (expectedRestoreStateCompleted && !record && expectedRestore) { + try { + const normalizedSteps = await this.dependencies.disk.normalize( + expectedRestore.diskSteps, + scope, + authorization + ); + const postimageStates = await Promise.all( + normalizedSteps.map((step) => this.dependencies.disk.classify(step)) + ); + if (postimageStates.some((state) => state !== 'after' && state !== 'both')) { + throw new Error('Completed Restore disk postimage is no longer present'); + } + diskPostimages = await this.dependencies.disk.buildPostimages(normalizedSteps); + postimagesResolved = true; + } catch (error) { + this.dependencies.logger.warn('Unable to verify completed Restore postimages:', error); + diskPostimages = []; + } + } + const expectedRestoreCompleted = Boolean( + expectedRestoreStateCompleted && + expectedRestore && + (expectedRestore.diskSteps.length === 0 || postimagesResolved) + ); + return { + decisionRevision: committed?.revision ?? 0, + recoveredMutation: Boolean(record), + recoveredRestoreHistory: record?.kind === 'restore-history', + differentMutationPending: false, + persistedState, + expectedRestoreCompleted, + diskPostimages: + expectedRestoreCompleted || (Boolean(record) && postimagesResolved) ? diskPostimages : [], + retried, + }; + }); + } + + async recoverPending( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope + ): Promise { + const records = await this.dependencies.journal.list(teamName, persistenceScope); + for (const record of records) { + if (record.blocked) { + throw new Error( + 'A previous review update did not finish safely. Retry recovery or discard saved review state.' + ); + } + this.assertRecoverableJournalContent(record); + const scope = this.dependencies.scope.parse(record.reviewScope); + if (!scope.taskId && !scope.memberName) { + throw new Error('Review mutation recovery requires taskId or memberName'); + } + if (scope.teamName !== teamName) { + throw new Error('Review mutation recovery scope mismatch'); + } + this.dependencies.scope.parsePersistenceScope(persistenceScope, scope); + + if ( + !record.diskSteps?.length && + record.decisions.length === 0 && + isDecisionlessReviewRecoveryKind(record.kind) + ) { + await this.resumeDirectRecord(record); + continue; + } + if (record.diskSteps?.length) { + const { authorization } = await this.dependencies.scope.resolve(scope, { + requireIdentity: true, + }); + await this.assertAuthorizedRecoverySteps(record, authorization); + await this.resumeDirectRecord(record); + continue; + } + await this.dependencies.coordinator.resume(record, { + applyDisk: async (current) => { + const { authorization } = await this.dependencies.scope.resolve(scope, { + requireIdentity: true, + }); + for (const [index, savedDecision] of current.decisions.entries()) { + const savedContent = current.fileContents[index]; + const filePath = await this.dependencies.scope.validateFilePath( + authorization, + savedDecision.filePath, + { requireReviewedFile: false, rejectHardlinks: true } + ); + await this.dependencies.scope.validateSnippets(authorization, savedContent.snippets, { + requireReviewedFile: false, + rejectHardlinks: true, + }); + if ( + filePath !== this.dependencies.scope.normalizeFilesystemPath(savedContent.filePath) + ) { + throw new Error('Review mutation recovery file mismatch'); + } + } + return this.dependencies.applyDecisionBatchDisk(current); + }, + commitDecisions: (current) => this.dependencies.decisions.commit(current), + }); + } + } + + private async assertAuthorizedRecoverySteps( + record: ReviewMutationJournalRecord, + authorization: ReviewMutationPathAuthorization + ): Promise { + for (const step of record.diskSteps ?? []) { + const filePath = await this.dependencies.scope.validateFilePath( + authorization, + step.filePath, + { requireReviewedFile: false, rejectHardlinks: true } + ); + if (filePath !== this.dependencies.scope.normalizeFilesystemPath(step.filePath)) { + throw new Error('Review mutation recovery file mismatch'); + } + if (step.authoritativeContent) { + await this.dependencies.scope.validateSnippets( + authorization, + step.authoritativeContent.snippets, + { requireReviewedFile: false, rejectHardlinks: true } + ); + } + } + } + + private async resumeDirectRecord(record: ReviewMutationJournalRecord): Promise { + await this.dependencies.coordinator.resume(record, { + applyDisk: (current) => this.dependencies.disk.apply(current), + commitDecisions: (current) => this.dependencies.decisions.commit(current), + }); + } + + private assertRecoverableJournalContent(record: ReviewMutationJournalRecord): void { + if ( + record.persistedState && + (!Number.isSafeInteger(record.expectedDecisionRevision) || + record.expectedDecisionRevision! < 0) + ) { + throw new Error('Review mutation recovery revision is unavailable'); + } + if (record.diskSteps?.length) { + if (!record.persistedState) { + throw new Error('Review mutation recovery state is unavailable'); + } + this.dependencies.decisions.assertValidSnapshot(record.persistedState); + return; + } + if ( + isDecisionlessReviewRecoveryKind(record.kind) && + record.decisions.length === 0 && + record.fileContents.length === 0 && + record.persistedState + ) { + this.dependencies.decisions.assertValidSnapshot(record.persistedState); + return; + } + if (record.decisions.length === 0 || record.decisions.length !== record.fileContents.length) { + throw new Error('Invalid review mutation recovery batch'); + } + for (const [index, decision] of record.decisions.entries()) { + const fileContent = record.fileContents[index]; + this.dependencies.scope.assertDecisionShape(decision); + this.dependencies.scope.assertSnippetShapes(fileContent?.snippets); + if ( + fileContent?.filePath !== decision.filePath || + (fileContent.originalFullContent !== null && + typeof fileContent.originalFullContent !== 'string') || + (fileContent.modifiedFullContent !== null && + typeof fileContent.modifiedFullContent !== 'string') || + typeof fileContent.isNewFile !== 'boolean' + ) { + throw new Error('Invalid review mutation recovery content'); + } + } + } + + private toPersistedState(committed: LoadedReviewMutationDecisions): ReviewPersistedStateSnapshot { + return { + hunkDecisions: committed.hunkDecisions, + fileDecisions: committed.fileDecisions, + hunkContextHashesByFile: committed.hunkContextHashesByFile, + reviewActionHistory: committed.reviewActionHistory, + reviewRedoHistory: committed.reviewRedoHistory, + }; + } +} diff --git a/src/features/review-mutations/main/application/ReviewMutationRecoveryPorts.ts b/src/features/review-mutations/main/application/ReviewMutationRecoveryPorts.ts new file mode 100644 index 0000000000..9894c2b6d0 --- /dev/null +++ b/src/features/review-mutations/main/application/ReviewMutationRecoveryPorts.ts @@ -0,0 +1,283 @@ +import type { ReviewMutationSteps } from '../../core/application/ReviewMutationCoordinator'; +import type { + PrepareReviewMutationInput, + ReviewMutationJournalDiskStep, + ReviewMutationJournalRecord, +} from '../../core/application/ReviewMutationJournalTypes'; +import type { + ApplyReviewDiskTransition, + ApplyReviewRequest, + ApplyReviewResult, + ExecuteReviewMutationRequest, + FileChangeSummary, + FileChangeWithContent, + FileReviewDecision, + ReviewDecisionPersistenceScope, + ReviewDirectDiskMutationStep, + ReviewFileScope, + ReviewMutationDiskPostimage, + ReviewPersistedStateSnapshot, + ReviewRenameRecoveryExpectation, + ReviewUndoAction, + SnippetDiff, +} from '@shared/types/review'; + +export interface ReviewMutationPathAuthorization { + roots: { lexicalPath: string; realPath: string }[]; + reviewedFiles: Map | null; + resolutionMemberName: string; +} + +export interface LoadedReviewMutationDecisions extends ReviewPersistedStateSnapshot { + revision: number; +} + +export interface ReviewMutationScopePort { + parse(value: unknown): ReviewFileScope; + resolve( + value: unknown, + options: { requireIdentity: true } + ): Promise<{ scope: ReviewFileScope; authorization: ReviewMutationPathAuthorization }>; + parsePersistenceScope( + value: unknown, + scope: ReviewFileScope + ): ReviewDecisionPersistenceScope | null; + validateFilePath( + authorization: ReviewMutationPathAuthorization, + filePath: string, + options: { requireReviewedFile: boolean; rejectHardlinks: boolean } + ): Promise; + validateSnippets( + authorization: ReviewMutationPathAuthorization, + snippets: SnippetDiff[], + options: { requireReviewedFile: boolean; rejectHardlinks: boolean } + ): Promise; + resolveAuthoritativeContent( + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization, + filePath: string + ): Promise; + assertExpectedRename( + content: FileChangeWithContent, + expectation: ReviewRenameRecoveryExpectation + ): void; + parseRenameExpectation(value: unknown): ReviewRenameRecoveryExpectation; + assertDecisionShape(value: unknown): void; + assertSnippetShapes(value: unknown): asserts value is SnippetDiff[]; + getAuthoritativeFile( + authorization: ReviewMutationPathAuthorization, + filePath: string + ): FileChangeSummary; + normalizeIdentityPath(filePath: string): string; + normalizeFilesystemPath(filePath: string): string; +} + +export interface ReviewMutationDecisionPort { + withLock( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope, + operation: () => Promise + ): Promise; + assertValidSnapshot(value: ReviewPersistedStateSnapshot): void; + assertCurrentRevision( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope, + expectedRevision: number + ): Promise; + load( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope + ): Promise; + commit(record: ReviewMutationJournalRecord): Promise; + assertExactTransition( + request: ExecuteReviewMutationRequest, + current: LoadedReviewMutationDecisions | null, + authorization: ReviewMutationPathAuthorization + ): void; + bindAuthoritativeForwardMutation( + request: ExecuteReviewMutationRequest, + current: LoadedReviewMutationDecisions | null, + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization + ): Promise; + assertAuthoritativelyBoundAction(action: ReviewUndoAction): void; +} + +export interface ReviewMutationJournalRepositoryPort { + list( + teamName: string, + persistenceScope: ReviewDecisionPersistenceScope + ): Promise; + checkpoint(record: ReviewMutationJournalRecord): Promise; + markFailed(record: ReviewMutationJournalRecord, error: unknown): Promise; + unblock(record: ReviewMutationJournalRecord): Promise; + remove(record: ReviewMutationJournalRecord): Promise; +} + +export interface ReviewMutationCoordinatorPort { + execute( + input: PrepareReviewMutationInput, + steps: ReviewMutationSteps + ): Promise; + resume( + record: ReviewMutationJournalRecord, + steps: ReviewMutationSteps + ): Promise; +} + +export type DirectReviewMutationState = 'before' | 'after' | 'both' | 'intermediate'; + +export interface ReviewMutationDiskApplierPort { + getRejectedRenamePostimages( + original: string | null, + modified: string | null, + snippets: SnippetDiff[], + direction: 'restore' | 'reapply' + ): Promise; + classifyEditedFileTransition( + filePath: string, + beforeContent: string | null, + afterContent: string | null + ): Promise>; + classifyRejectedRenameTransition( + filePath: string, + original: string | null, + modified: string | null, + snippets: SnippetDiff[] + ): Promise<'accepted' | 'rejected' | 'both' | 'restoring' | 'reapplying' | 'legacy-reapplying'>; + saveEditedFile( + filePath: string, + content: string, + expectedContent: string | null + ): Promise; + deleteEditedFile(filePath: string, expectedContent: string): Promise; + restoreRejectedRename( + filePath: string, + original: string | null, + modified: string | null, + snippets: SnippetDiff[] + ): Promise; + reapplyRejectedRename( + filePath: string, + original: string | null, + snippets: SnippetDiff[] + ): Promise; + finalizeEditedFileTransaction?( + filePath: string, + expectedContent: string | null, + nextContent: string | null + ): Promise; + finalizeRejectedRenameTransaction?( + filePath: string, + original: string | null, + modified: string | null, + snippets: SnippetDiff[], + direction: 'restore' | 'reapply' + ): Promise; +} + +export interface ReviewMutationContentCachePort { + invalidateAuthoritativeContent(content: FileChangeWithContent): void; + invalidateFile(filePath: string): void; +} + +export interface ReviewMutationLoggerPort { + warn(message: string, error: unknown): void; + error(message: string, error: unknown): void; +} + +export interface ReviewDecisionBatchScopePort { + parse(value: unknown): ReviewFileScope; + normalizeIdentityPath(filePath: string): string; +} + +export interface ReviewDecisionBatchApplierPort { + applyReviewDecisions( + request: ApplyReviewRequest, + fileContents: Map, + hooks: { + initialDiskTransitions?: readonly ApplyReviewDiskTransition[]; + checkpointDiskTransitions(transitions: readonly ApplyReviewDiskTransition[]): Promise; + } + ): Promise; + finalizeReviewDiskTransitions?(transitions: readonly ApplyReviewDiskTransition[]): Promise; +} + +export interface ReviewDecisionBatchPersistencePort { + save( + teamName: string, + scopeKey: string, + state: ReviewPersistedStateSnapshot & { + scopeToken: string; + expectedRevision?: number; + mutationId?: string; + } + ): Promise; + mergeFileDecisionPatch( + teamName: string, + scopeKey: string, + scopeToken: string, + decision: FileReviewDecision & { reviewKey: string } + ): Promise; +} + +export interface ReviewDecisionBatchFileTransaction { + id: string; + kind: 'replace' | 'delete' | 'move'; + sourcePath: string; + targetPath: string; + expectedContent: string; + nextContent: string | null; +} + +export interface ReviewDecisionBatchFilePort { + readText(filePath: string): Promise; + inspectTransaction( + transaction: ReviewDecisionBatchFileTransaction + ): Promise<'missing' | 'prepared' | 'detached' | 'published' | 'conflict'>; +} + +export interface ReviewDecisionBatchDependencies { + scope: ReviewDecisionBatchScopePort; + journal: Pick; + applier: ReviewDecisionBatchApplierPort; + persistence: ReviewDecisionBatchPersistencePort; + files: ReviewDecisionBatchFilePort; + cache: Pick; + logger: ReviewMutationLoggerPort; +} + +export interface ReviewDirectMutationDiskDependencies { + scope: ReviewMutationScopePort; + journal: ReviewMutationJournalRepositoryPort; + applier: ReviewMutationDiskApplierPort; + cache: ReviewMutationContentCachePort; + logger: ReviewMutationLoggerPort; +} + +export interface ReviewDirectMutationDiskPort { + normalize( + steps: readonly ReviewDirectDiskMutationStep[], + scope: ReviewFileScope, + authorization: ReviewMutationPathAuthorization + ): Promise; + buildPostimages( + steps: readonly ReviewMutationJournalDiskStep[] + ): Promise; + buildRecoveryPostimages( + record: ReviewMutationJournalRecord + ): Promise; + classify(step: ReviewMutationJournalDiskStep): Promise; + assertPreimages(steps: readonly ReviewMutationJournalDiskStep[]): Promise; + apply(record: ReviewMutationJournalRecord): Promise; +} + +export interface ReviewMutationRecoveryDependencies { + scope: ReviewMutationScopePort; + decisions: ReviewMutationDecisionPort; + journal: ReviewMutationJournalRepositoryPort; + coordinator: ReviewMutationCoordinatorPort; + disk: ReviewDirectMutationDiskPort; + applyDecisionBatchDisk(record: ReviewMutationJournalRecord): Promise; + logger: ReviewMutationLoggerPort; +} diff --git a/src/features/review-mutations/main/composition/createReviewDecisionBatchFeature.ts b/src/features/review-mutations/main/composition/createReviewDecisionBatchFeature.ts new file mode 100644 index 0000000000..e3626a9681 --- /dev/null +++ b/src/features/review-mutations/main/composition/createReviewDecisionBatchFeature.ts @@ -0,0 +1,11 @@ +import { ReviewDecisionBatchApplication } from '../application/ReviewDecisionBatchApplication'; + +import type { ReviewDecisionBatchDependencies } from '../application/ReviewMutationRecoveryPorts'; + +export type ReviewDecisionBatchFeatureDependencies = ReviewDecisionBatchDependencies; + +export function createReviewDecisionBatchFeature( + dependencies: ReviewDecisionBatchFeatureDependencies +): ReviewDecisionBatchApplication { + return new ReviewDecisionBatchApplication(dependencies); +} diff --git a/src/features/review-mutations/main/composition/createReviewDecisionCommandFeature.ts b/src/features/review-mutations/main/composition/createReviewDecisionCommandFeature.ts new file mode 100644 index 0000000000..87b283e44a --- /dev/null +++ b/src/features/review-mutations/main/composition/createReviewDecisionCommandFeature.ts @@ -0,0 +1,18 @@ +import { ReviewDecisionCommandApplication } from '../application/ReviewDecisionCommandApplication'; +import { nodeReviewDecisionCommandSnapshotIdentity } from '../infrastructure/nodeReviewDecisionCommandSnapshotIdentity'; + +import type { ReviewDecisionCommandDependencies } from '../application/ReviewDecisionCommandPorts'; + +export type ReviewDecisionCommandFeatureDependencies = Omit< + ReviewDecisionCommandDependencies, + 'snapshots' +>; + +export function createReviewDecisionCommandFeature( + dependencies: ReviewDecisionCommandFeatureDependencies +): ReviewDecisionCommandApplication { + return new ReviewDecisionCommandApplication({ + ...dependencies, + snapshots: nodeReviewDecisionCommandSnapshotIdentity, + }); +} diff --git a/src/features/review-mutations/main/composition/createReviewEditableMutationFeature.ts b/src/features/review-mutations/main/composition/createReviewEditableMutationFeature.ts new file mode 100644 index 0000000000..d3d54825ba --- /dev/null +++ b/src/features/review-mutations/main/composition/createReviewEditableMutationFeature.ts @@ -0,0 +1,11 @@ +import { ReviewEditableMutationApplication } from '../application/ReviewEditableMutationApplication'; + +import type { ReviewEditableMutationDependencies } from '../application/ReviewEditableMutationPorts'; + +export type ReviewEditableMutationFeatureDependencies = ReviewEditableMutationDependencies; + +export function createReviewEditableMutationFeature( + dependencies: ReviewEditableMutationFeatureDependencies +): ReviewEditableMutationApplication { + return new ReviewEditableMutationApplication(dependencies); +} diff --git a/src/features/review-mutations/main/composition/createReviewHistoryMutationFeature.ts b/src/features/review-mutations/main/composition/createReviewHistoryMutationFeature.ts new file mode 100644 index 0000000000..61651a4b24 --- /dev/null +++ b/src/features/review-mutations/main/composition/createReviewHistoryMutationFeature.ts @@ -0,0 +1,11 @@ +import { ReviewHistoryMutationApplication } from '../application/ReviewHistoryMutationApplication'; + +import type { ReviewHistoryMutationDependencies } from '../application/ReviewHistoryMutationPorts'; + +export type ReviewHistoryMutationFeatureDependencies = ReviewHistoryMutationDependencies; + +export function createReviewHistoryMutationFeature( + dependencies: ReviewHistoryMutationFeatureDependencies +): ReviewHistoryMutationApplication { + return new ReviewHistoryMutationApplication(dependencies); +} diff --git a/src/features/review-mutations/main/composition/createReviewMutationRecoveryFeature.ts b/src/features/review-mutations/main/composition/createReviewMutationRecoveryFeature.ts new file mode 100644 index 0000000000..9e96cf3f43 --- /dev/null +++ b/src/features/review-mutations/main/composition/createReviewMutationRecoveryFeature.ts @@ -0,0 +1,45 @@ +import { ReviewDirectMutationDiskService } from '../application/ReviewDirectMutationDiskService'; +import { ReviewMutationRecoveryApplication } from '../application/ReviewMutationRecoveryApplication'; + +import type { + ReviewMutationContentCachePort, + ReviewMutationCoordinatorPort, + ReviewMutationDecisionPort, + ReviewMutationDiskApplierPort, + ReviewMutationJournalRepositoryPort, + ReviewMutationLoggerPort, + ReviewMutationRecoveryDependencies, + ReviewMutationScopePort, +} from '../application/ReviewMutationRecoveryPorts'; + +export interface ReviewMutationRecoveryFeatureDependencies { + scope: ReviewMutationScopePort; + decisions: ReviewMutationDecisionPort; + journal: ReviewMutationJournalRepositoryPort; + coordinator: ReviewMutationCoordinatorPort; + applier: ReviewMutationDiskApplierPort; + cache: ReviewMutationContentCachePort; + applyDecisionBatchDisk: ReviewMutationRecoveryDependencies['applyDecisionBatchDisk']; + logger: ReviewMutationLoggerPort; +} + +export function createReviewMutationRecoveryFeature( + dependencies: ReviewMutationRecoveryFeatureDependencies +): ReviewMutationRecoveryApplication { + const disk = new ReviewDirectMutationDiskService({ + scope: dependencies.scope, + journal: dependencies.journal, + applier: dependencies.applier, + cache: dependencies.cache, + logger: dependencies.logger, + }); + return new ReviewMutationRecoveryApplication({ + scope: dependencies.scope, + decisions: dependencies.decisions, + journal: dependencies.journal, + coordinator: dependencies.coordinator, + disk, + applyDecisionBatchDisk: dependencies.applyDecisionBatchDisk, + logger: dependencies.logger, + }); +} diff --git a/src/features/review-mutations/main/index.ts b/src/features/review-mutations/main/index.ts index e299fdb37c..87d2fc8547 100644 --- a/src/features/review-mutations/main/index.ts +++ b/src/features/review-mutations/main/index.ts @@ -1,9 +1,36 @@ +export { ReviewMutationApplyResultError } from '../core/application/ReviewMutationApplyResultError'; export { ReviewMutationCoordinator, type ReviewMutationJournalPort, type ReviewMutationPhaseObserver, type ReviewMutationSteps, } from '../core/application/ReviewMutationCoordinator'; +export type { + PrepareReviewMutationInput, + ReviewMutationJournalDiskStep, + ReviewMutationJournalPathPostimage, + ReviewMutationJournalPathTransition, + ReviewMutationJournalRecord, +} from '../core/application/ReviewMutationJournalTypes'; +export { isDurableReviewEqual } from '../core/domain/durableReviewValue'; +export { + assertPersistedStateIncludesDecisions, + composeReviewDiskTransitions, + mergeReviewApplyResults, + mergeReviewMutationDiskPostimages, +} from '../core/domain/reviewDecisionBatch'; +export { + assertCurrentReviewDecisionRevision, + assertExactApplyReviewHistoryTransition, + type ReviewDecisionCommandCurrentState, + type ReviewDecisionCommandPolicyContext, +} from '../core/domain/reviewDecisionCommandPolicy'; +export { + type DeleteEditedFileInput, + parseDeleteEditedFileInput, + parseSaveEditedFileInput, + type SaveEditedFileInput, +} from '../core/domain/reviewEditableMutationPolicy'; export { buildReviewExternalReloadState, buildReviewHistoryRestorePlan, @@ -25,3 +52,91 @@ export { type ReviewHistoryDiskTransitionKind, type ReviewHistoryLineStatsStatus, } from '../core/domain/reviewHistoryDiskSteps'; +export { + assertAuthoritativelyBoundReviewAction, + assertExactReviewHistoryTransition, + findLatestRestorableDiskSnapshot, + isAuthoritativelyBoundReviewSnapshot, + isAuthoritativeReviewDeletion, + rebindReviewActionDescriptorPath, + type ReviewHistoryDecisionState, + type ReviewHistoryMutationPolicyContext, +} from '../core/domain/reviewHistoryMutationPolicy'; +export { + isDecisionlessReviewRecoveryKind, + parseReviewHistoryRestoreTarget, +} from '../core/domain/reviewHistoryRestoreTarget'; +export { ReviewDecisionCommandApplication } from './application/ReviewDecisionCommandApplication'; +export type { + ReviewDecisionCommandApplierPort, + ReviewDecisionCommandBatchPort, + ReviewDecisionCommandCachePort, + ReviewDecisionCommandCoordinatorPort, + ReviewDecisionCommandDependencies, + ReviewDecisionCommandHistoryPort, + ReviewDecisionCommandLoggerPort, + ReviewDecisionCommandPersistencePort, + ReviewDecisionCommandRecoveryPort, + ReviewDecisionCommandScopePort, + ReviewDecisionCommandSnapshotIdentityPort, +} from './application/ReviewDecisionCommandPorts'; +export { ReviewDirectMutationDiskService } from './application/ReviewDirectMutationDiskService'; +export { ReviewEditableMutationApplication } from './application/ReviewEditableMutationApplication'; +export type { + ReviewEditableMutationApplierPort, + ReviewEditableMutationContentPort, + ReviewEditableMutationDependencies, + ReviewEditableMutationScopePort, +} from './application/ReviewEditableMutationPorts'; +export { ReviewHistoryMutationApplication } from './application/ReviewHistoryMutationApplication'; +export type { + ReviewHistoryMutationCurrentState, + ReviewHistoryMutationDependencies, + ReviewHistoryMutationFilePort, + ReviewHistoryMutationScopePort, +} from './application/ReviewHistoryMutationPorts'; +export { + MAX_REVIEW_MUTATION_STEPS, + ReviewMutationRecoveryApplication, +} from './application/ReviewMutationRecoveryApplication'; +export type { + DirectReviewMutationState, + LoadedReviewMutationDecisions, + ReviewDecisionBatchApplierPort, + ReviewDecisionBatchDependencies, + ReviewDecisionBatchFilePort, + ReviewDecisionBatchFileTransaction, + ReviewDecisionBatchPersistencePort, + ReviewDecisionBatchScopePort, + ReviewDirectMutationDiskDependencies, + ReviewDirectMutationDiskPort, + ReviewMutationContentCachePort, + ReviewMutationCoordinatorPort, + ReviewMutationDecisionPort, + ReviewMutationDiskApplierPort, + ReviewMutationJournalRepositoryPort, + ReviewMutationLoggerPort, + ReviewMutationPathAuthorization, + ReviewMutationRecoveryDependencies, + ReviewMutationScopePort, +} from './application/ReviewMutationRecoveryPorts'; +export { + createReviewDecisionBatchFeature, + type ReviewDecisionBatchFeatureDependencies, +} from './composition/createReviewDecisionBatchFeature'; +export { + createReviewDecisionCommandFeature, + type ReviewDecisionCommandFeatureDependencies, +} from './composition/createReviewDecisionCommandFeature'; +export { + createReviewEditableMutationFeature, + type ReviewEditableMutationFeatureDependencies, +} from './composition/createReviewEditableMutationFeature'; +export { + createReviewHistoryMutationFeature, + type ReviewHistoryMutationFeatureDependencies, +} from './composition/createReviewHistoryMutationFeature'; +export { + createReviewMutationRecoveryFeature, + type ReviewMutationRecoveryFeatureDependencies, +} from './composition/createReviewMutationRecoveryFeature'; diff --git a/src/features/review-mutations/main/infrastructure/nodeReviewDecisionCommandSnapshotIdentity.ts b/src/features/review-mutations/main/infrastructure/nodeReviewDecisionCommandSnapshotIdentity.ts new file mode 100644 index 0000000000..538290b904 --- /dev/null +++ b/src/features/review-mutations/main/infrastructure/nodeReviewDecisionCommandSnapshotIdentity.ts @@ -0,0 +1,11 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import type { ReviewDecisionCommandSnapshotIdentityPort } from '../application/ReviewDecisionCommandPorts'; + +export const nodeReviewDecisionCommandSnapshotIdentity: ReviewDecisionCommandSnapshotIdentityPort = + { + now: Date.now, + createToken: randomUUID, + fingerprintSnippets: (snippets) => + createHash('sha256').update(JSON.stringify(snippets)).digest('hex'), + }; diff --git a/src/features/running-teams/renderer/hooks/useRunningTeamsSection.ts b/src/features/running-teams/renderer/hooks/useRunningTeamsSection.ts index 8c5bb38312..e0db1aabb4 100644 --- a/src/features/running-teams/renderer/hooks/useRunningTeamsSection.ts +++ b/src/features/running-teams/renderer/hooks/useRunningTeamsSection.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; -import { api } from '@renderer/api'; +import { createTeamAliveListReadPort } from '@renderer/composition/team/createTeamAliveListReadPort'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, @@ -27,6 +27,8 @@ interface RunningTeamsSectionState { openRunningTeam: (row: RunningTeamRowModel) => void; } +const teamAliveListReadPort = createTeamAliveListReadPort(); + function toCandidate(input: { team: TeamSummary; aliveTeams: string[]; @@ -110,8 +112,8 @@ export function useRunningTeamsSection(searchQuery: string): RunningTeamsSection } let cancelled = false; - void api.teams - .aliveList() + void teamAliveListReadPort + .listAliveTeams() .then((teamNames) => { if (!cancelled) { setAliveTeams(teamNames); diff --git a/src/features/runtime-instance-context/contracts/index.ts b/src/features/runtime-instance-context/contracts/index.ts new file mode 100644 index 0000000000..f0b13b2405 --- /dev/null +++ b/src/features/runtime-instance-context/contracts/index.ts @@ -0,0 +1,6 @@ +export type { + RuntimeInstanceContext, + RuntimeRootKind, + RuntimeRootReference, + RuntimeRootReferenceValue, +} from './runtime-instance-context'; diff --git a/src/features/runtime-instance-context/contracts/runtime-instance-context.ts b/src/features/runtime-instance-context/contracts/runtime-instance-context.ts new file mode 100644 index 0000000000..1f37fca27b --- /dev/null +++ b/src/features/runtime-instance-context/contracts/runtime-instance-context.ts @@ -0,0 +1,24 @@ +import type { BootId, DeploymentId } from '@shared/contracts/hosted'; + +declare const runtimeRootReferenceBrand: unique symbol; + +export type RuntimeRootReferenceValue = string & { + readonly [runtimeRootReferenceBrand]: 'RuntimeRootReferenceValue'; +}; + +export type RuntimeRootKind = 'claude' | 'app-data' | 'workspace' | 'temp' | 'logs'; + +export interface RuntimeRootReference { + readonly kind: Kind; + readonly reference: RuntimeRootReferenceValue; +} + +export interface RuntimeInstanceContext { + readonly deploymentId: DeploymentId; + readonly bootId: BootId; + readonly claudeRoot: RuntimeRootReference<'claude'>; + readonly appDataRoot: RuntimeRootReference<'app-data'>; + readonly workspaceRoots: readonly RuntimeRootReference<'workspace'>[]; + readonly tempRoot: RuntimeRootReference<'temp'>; + readonly logsRoot: RuntimeRootReference<'logs'>; +} diff --git a/src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts b/src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts new file mode 100644 index 0000000000..dcc3739373 --- /dev/null +++ b/src/features/runtime-instance-context/core/domain/RuntimeInstanceContext.ts @@ -0,0 +1,132 @@ +import { parseBootId, parseDeploymentId } from '@shared/contracts/hosted'; + +import type { + RuntimeInstanceContext, + RuntimeRootKind, + RuntimeRootReference, + RuntimeRootReferenceValue, +} from '../../contracts/runtime-instance-context'; + +const CONTEXT_KEYS = [ + 'deploymentId', + 'bootId', + 'claudeRoot', + 'appDataRoot', + 'workspaceRoots', + 'tempRoot', + 'logsRoot', +] as const; +const ROOT_REFERENCE_KEYS = ['kind', 'reference'] as const; +const MAX_ROOT_REFERENCE_LENGTH = 4_096; +const MAX_WORKSPACE_ROOTS = 1_000; +const INVALID_CONTEXT = 'runtime-instance-context-invalid'; + +function invalidContext(): TypeError { + return new TypeError(INVALID_CONTEXT); +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 31 || code === 127) return true; + } + return false; +} + +function readExactRecord(value: unknown, keys: readonly string[]): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidContext(); + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw invalidContext(); + } + + const record = value as Record; + const ownKeys = Reflect.ownKeys(record); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key)) + ) { + throw invalidContext(); + } + + const snapshot: Record = Object.create(null) as Record; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw invalidContext(); + } + snapshot[key] = descriptor.value; + } + + return snapshot; +} + +function parseRootReference( + value: unknown, + expectedKind: Kind +): RuntimeRootReference { + const record = readExactRecord(value, ROOT_REFERENCE_KEYS); + const reference = record.reference; + if ( + record.kind !== expectedKind || + typeof reference !== 'string' || + reference.length === 0 || + reference.length > MAX_ROOT_REFERENCE_LENGTH || + reference.trim() !== reference || + hasControlCharacter(reference) + ) { + throw invalidContext(); + } + + return Object.freeze({ + kind: expectedKind, + reference: reference as RuntimeRootReferenceValue, + }); +} + +function parseWorkspaceRoots(value: unknown): readonly RuntimeRootReference<'workspace'>[] { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + value.length > MAX_WORKSPACE_ROOTS + ) { + throw invalidContext(); + } + + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length !== value.length + 1 || !ownKeys.includes('length')) { + throw invalidContext(); + } + + const roots: RuntimeRootReference<'workspace'>[] = []; + roots.length = value.length; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw invalidContext(); + } + roots[index] = parseRootReference(descriptor.value, 'workspace'); + } + + return Object.freeze(roots); +} + +export function createRuntimeInstanceContext(value: unknown): RuntimeInstanceContext { + try { + const record = readExactRecord(value, CONTEXT_KEYS); + return Object.freeze({ + deploymentId: parseDeploymentId(record.deploymentId), + bootId: parseBootId(record.bootId), + claudeRoot: parseRootReference(record.claudeRoot, 'claude'), + appDataRoot: parseRootReference(record.appDataRoot, 'app-data'), + workspaceRoots: parseWorkspaceRoots(record.workspaceRoots), + tempRoot: parseRootReference(record.tempRoot, 'temp'), + logsRoot: parseRootReference(record.logsRoot, 'logs'), + }); + } catch { + throw invalidContext(); + } +} diff --git a/src/features/runtime-instance-context/index.ts b/src/features/runtime-instance-context/index.ts new file mode 100644 index 0000000000..1dbbe3c733 --- /dev/null +++ b/src/features/runtime-instance-context/index.ts @@ -0,0 +1,7 @@ +export type { + RuntimeInstanceContext, + RuntimeRootKind, + RuntimeRootReference, + RuntimeRootReferenceValue, +} from './contracts'; +export { createRuntimeInstanceContext } from './core/domain/RuntimeInstanceContext'; diff --git a/src/features/runtime-provider-management/core/domain/providerManagementView.ts b/src/features/runtime-provider-management/core/domain/providerManagementView.ts index ff2da0ec82..638845aaab 100644 --- a/src/features/runtime-provider-management/core/domain/providerManagementView.ts +++ b/src/features/runtime-provider-management/core/domain/providerManagementView.ts @@ -1,5 +1,3 @@ -import runtimeLock from '../../../../../runtime.lock.json'; - import type { RuntimeProviderActionDescriptorDto, RuntimeProviderActionIdDto, @@ -107,7 +105,7 @@ export function getProjectPathName(projectPath: string | null | undefined): stri export function supportsScopedDefaultModelInheritance( view: RuntimeProviderManagementViewDto | null, - bundledRuntimeVersion: string = runtimeLock.version + bundledRuntimeVersion: string = '' ): boolean { if ( !view || diff --git a/src/features/runtime-provider-management/main/infrastructure/runtimeProviderCliCommand.ts b/src/features/runtime-provider-management/main/infrastructure/runtimeProviderCliCommand.ts index 5fca716758..d8823b6e68 100644 --- a/src/features/runtime-provider-management/main/infrastructure/runtimeProviderCliCommand.ts +++ b/src/features/runtime-provider-management/main/infrastructure/runtimeProviderCliCommand.ts @@ -5,9 +5,7 @@ import { getHomeDir } from '@main/utils/pathDecoder'; export const RUNTIME_PROVIDER_COMMAND_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const SPAWN_OUTPUT_TRUNCATED_MARKER = '...[truncated runtime provider command output]'; -export function normalizeProjectPath( - projectPath: string | null | undefined -): string | null { +export function normalizeProjectPath(projectPath: string | null | undefined): string | null { const normalized = projectPath?.trim(); return normalized ? normalized : null; } @@ -56,10 +54,7 @@ export function createBoundedSpawnOutputBuffer(): BoundedSpawnOutputBuffer { return { chunks: [], bytes: 0, truncated: false }; } -export function appendBoundedSpawnOutput( - buffer: BoundedSpawnOutputBuffer, - chunk: Buffer -): void { +export function appendBoundedSpawnOutput(buffer: BoundedSpawnOutputBuffer, chunk: Buffer): void { if (buffer.bytes >= RUNTIME_PROVIDER_COMMAND_MAX_BUFFER_BYTES) { buffer.truncated = true; return; diff --git a/src/features/runtime-provider-management/renderer/RuntimeLocalProviderSetupDialog.tsx b/src/features/runtime-provider-management/renderer/RuntimeLocalProviderSetupDialog.tsx index 79b9622a56..c58096e501 100644 --- a/src/features/runtime-provider-management/renderer/RuntimeLocalProviderSetupDialog.tsx +++ b/src/features/runtime-provider-management/renderer/RuntimeLocalProviderSetupDialog.tsx @@ -25,6 +25,7 @@ import { SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; +import { createRuntimeProviderProvisioningReadinessTransport } from '@renderer/composition/team/createRuntimeProviderProvisioningReadinessTransport'; import { normalizePathForMatching } from '@renderer/utils/pathNormalize'; import { AlertTriangle, @@ -81,13 +82,13 @@ import type { ProjectPathProject } from '@renderer/components/team/dialogs/proje import type { ComboboxOption } from '@renderer/components/ui/combobox'; import type { JSX, ReactNode } from 'react'; +const provisioningReadinessPort = createRuntimeProviderProvisioningReadinessTransport(); type SetupErrorScope = 'server' | 'project' | 'model' | 'setup'; interface SetupErrorState { readonly scope: SetupErrorScope; readonly message: string; } - interface SetupStepProps { readonly number: number; readonly title: string; @@ -660,13 +661,9 @@ export const RuntimeLocalProviderSetupDialog = ({ // Check model/runtime capacity before asking OpenCode to execute a model turn. // This rejects known-incompatible local models from metadata in milliseconds // instead of waiting for a doomed execution probe to time out. - const readiness = await api.teams.prepareProvisioning( + const readiness = await provisioningReadinessPort.checkReadiness( getLocalModelVerificationCwd(configuration, targetProjectPath), - 'opencode', - ['opencode'], - [configuration.modelRoute], - false, - 'compatibility' + configuration.modelRoute ); if (dialogSessionRef.current !== sessionId) return; if (!readiness.ready) { diff --git a/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx b/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx index d548bfcd0a..b05273dc44 100644 --- a/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx +++ b/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx @@ -7,6 +7,8 @@ import { import { useStore } from '@renderer/store'; import { useShallow } from 'zustand/react/shallow'; +import runtimeLock from '../../../../runtime.lock.json'; + import { type RuntimeProviderChangeKind, useRuntimeProviderManagement, @@ -117,6 +119,7 @@ export const RuntimeProviderManagementPanel = ({ projectPath: effectiveProjectPath, initialProviderId, initialProviderAction, + bundledRuntimeVersion: runtimeLock.version, onProviderChanged, }); const activeAuthOption = state.setupForm?.authOptions?.find( @@ -181,6 +184,7 @@ export const RuntimeProviderManagementPanel = ({ projectContextProjects={projectContextProjects} projectContextLoading={projectContextLoading || !projectContextResolved} projectContextError={projectContextError} + bundledRuntimeVersion={runtimeLock.version} onProjectContextChange={setActiveProjectPath} /> diff --git a/src/features/runtime-provider-management/renderer/hooks/runtimeProviderManagementPresentation.ts b/src/features/runtime-provider-management/renderer/hooks/runtimeProviderManagementPresentation.ts index f8257f95e3..2853b98d71 100644 --- a/src/features/runtime-provider-management/renderer/hooks/runtimeProviderManagementPresentation.ts +++ b/src/features/runtime-provider-management/renderer/hooks/runtimeProviderManagementPresentation.ts @@ -55,7 +55,8 @@ export function presentSetupForm( title: replaceProviderNameInText(form.title, form.displayName, displayName) ?? form.title, description: replaceProviderNameInText(form.description, form.displayName, displayName), submitLabel: - replaceProviderNameInText(form.submitLabel, form.displayName, displayName) ?? form.submitLabel, + replaceProviderNameInText(form.submitLabel, form.displayName, displayName) ?? + form.submitLabel, }; } diff --git a/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.test.tsx b/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.test.tsx index bfb4f47b3e..9a9639dbd6 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.test.tsx +++ b/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.test.tsx @@ -9,11 +9,9 @@ import type { OpenCodeLocalModelSetupTarget } from '../openCodeLocalModelSetup'; const apiMock = vi.hoisted(() => ({ runtimeProviderManagement: { configureLocalProvider: vi.fn() }, - teams: { prepareProvisioning: vi.fn() }, + readiness: { checkReadiness: vi.fn() }, })); -vi.mock('@renderer/api', () => ({ api: apiMock })); - const target: OpenCodeLocalModelSetupTarget = { providerId: 'ollama', modelId: 'qwen3-30b-32k', @@ -44,6 +42,11 @@ const HookProbe = ({ projectPath: '/workspace/sandbox', addingMessage: 'Adding', chooseProjectMessage: 'Choose a project', + dependencies: { + configureLocalProvider: (input) => + apiMock.runtimeProviderManagement.configureLocalProvider(input), + checkReadiness: (cwd, modelRoute) => apiMock.readiness.checkReadiness(cwd, modelRoute), + }, autoSelectContextKey, onConfigured: () => undefined, onReady, @@ -56,7 +59,7 @@ describe('useOpenCodeLocalModelSetup', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); apiMock.runtimeProviderManagement.configureLocalProvider.mockReset(); - apiMock.teams.prepareProvisioning.mockReset(); + apiMock.readiness.checkReadiness.mockReset(); }); afterEach(() => { @@ -80,7 +83,7 @@ describe('useOpenCodeLocalModelSetup', () => { setAsDefault: false, }, }); - apiMock.teams.prepareProvisioning.mockReturnValue(readiness.promise); + apiMock.readiness.checkReadiness.mockReturnValue(readiness.promise); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -105,7 +108,7 @@ describe('useOpenCodeLocalModelSetup', () => { setup = addAndTest(target); await Promise.resolve(); }); - await vi.waitFor(() => expect(apiMock.teams.prepareProvisioning).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(apiMock.readiness.checkReadiness).toHaveBeenCalledOnce()); await act(async () => { root.render( diff --git a/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.ts b/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.ts index 2ada469063..dd85fd6018 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useOpenCodeLocalModelSetup.ts @@ -1,9 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { api } from '@renderer/api'; - import { addAndTestOpenCodeLocalModel, + type OpenCodeLocalModelSetupDependencies, type OpenCodeLocalModelSetupResult, type OpenCodeLocalModelSetupTarget, } from '../openCodeLocalModelSetup'; @@ -16,6 +15,7 @@ export function useOpenCodeLocalModelSetup({ projectPath, addingMessage, chooseProjectMessage, + dependencies, autoSelectContextKey, onConfigured, onReady, @@ -23,6 +23,7 @@ export function useOpenCodeLocalModelSetup({ projectPath: string | null; addingMessage: string; chooseProjectMessage: string; + dependencies: OpenCodeLocalModelSetupDependencies; autoSelectContextKey: string; onConfigured: (projectPath: string) => void | Promise; onReady: (modelRoute: string) => void; @@ -83,11 +84,7 @@ export function useOpenCodeLocalModelSetup({ const result = await addAndTestOpenCodeLocalModel({ projectPath: actionScope, target, - dependencies: { - configureLocalProvider: (input) => - api.runtimeProviderManagement.configureLocalProvider(input), - prepareProvisioning: (...args) => api.teams.prepareProvisioning(...args), - }, + dependencies, onConfigured: () => onConfigured(actionScope), }); inFlightActionsRef.current.delete(actionKey); @@ -104,7 +101,14 @@ export function useOpenCodeLocalModelSetup({ onReady(target.modelRoute); } }, - [addingMessage, chooseProjectMessage, normalizedProjectPath, onConfigured, onReady] + [ + addingMessage, + chooseProjectMessage, + dependencies, + normalizedProjectPath, + onConfigured, + onReady, + ] ); return { actionByRoute, addAndTest }; diff --git a/src/features/runtime-provider-management/renderer/index.ts b/src/features/runtime-provider-management/renderer/index.ts index 0260d6a7d6..d7305af745 100644 --- a/src/features/runtime-provider-management/renderer/index.ts +++ b/src/features/runtime-provider-management/renderer/index.ts @@ -23,6 +23,7 @@ export { type OpenCodeLocalModelSetupResult, type OpenCodeLocalModelSetupTarget, } from './openCodeLocalModelSetup'; +export type { RuntimeProviderProvisioningReadinessPort } from './ports/RuntimeProviderProvisioningReadinessPort'; export type { RuntimeProviderDirectoryCacheSnapshot } from './runtimeProviderDirectoryCache'; export { getRuntimeProviderDirectoryCacheSnapshot, diff --git a/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.test.ts b/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.test.ts index 9cded378d4..b431c8535e 100644 --- a/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.test.ts +++ b/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.test.ts @@ -34,7 +34,7 @@ function dependencies( setAsDefault: false, }, })), - prepareProvisioning: vi.fn(async () => ({ ready: true, message: 'Ready.' })), + checkReadiness: vi.fn(async () => ({ ready: true, message: 'Ready.' })), ...overrides, }; } @@ -63,15 +63,8 @@ describe('addAndTestOpenCodeLocalModel', () => { allowPrivateNetwork: false, }) ); - expect(deps.prepareProvisioning).toHaveBeenCalledWith( - projectPath, - 'opencode', - ['opencode'], - ['ollama/qwen3-30b-32k'], - false, - 'deep' - ); - expect(deps.prepareProvisioning).toHaveBeenCalledOnce(); + expect(deps.checkReadiness).toHaveBeenCalledWith(projectPath, 'ollama/qwen3-30b-32k'); + expect(deps.checkReadiness).toHaveBeenCalledOnce(); expect(onConfigured).toHaveBeenCalledOnce(); }); @@ -91,13 +84,13 @@ describe('addAndTestOpenCodeLocalModel', () => { }) ).resolves.toEqual({ status: 'ready', message: 'Ready.' }); - expect(deps.prepareProvisioning).toHaveBeenCalledOnce(); + expect(deps.checkReadiness).toHaveBeenCalledOnce(); finishRefresh(); }); it('returns the exact hard compatibility reason from the deep check', async () => { const deps = dependencies({ - prepareProvisioning: vi.fn(async () => ({ + checkReadiness: vi.fn(async () => ({ ready: false, message: 'Not ready.', issues: [ @@ -123,12 +116,12 @@ describe('addAndTestOpenCodeLocalModel', () => { status: 'incompatible', message: 'gemma3:27b does not support tool calls required by Agent Teams.', }); - expect(deps.prepareProvisioning).toHaveBeenCalledOnce(); + expect(deps.checkReadiness).toHaveBeenCalledOnce(); }); it('preserves an experimental override offered by the deep check', async () => { const deps = dependencies({ - prepareProvisioning: vi.fn(async () => ({ + checkReadiness: vi.fn(async () => ({ ready: false, message: 'Not ready.', issues: [ @@ -155,12 +148,12 @@ describe('addAndTestOpenCodeLocalModel', () => { status: 'experimental', message: 'The execution probe failed, but an experimental override is available.', }); - expect(deps.prepareProvisioning).toHaveBeenCalledOnce(); + expect(deps.checkReadiness).toHaveBeenCalledOnce(); }); it('keeps a warning-only deep check in needs verification', async () => { const deps = dependencies({ - prepareProvisioning: vi.fn(async () => ({ + checkReadiness: vi.fn(async () => ({ ready: true, message: 'Ready with warning.', warnings: ['Coordination probe was unavailable and will be retried before launch.'], @@ -177,7 +170,7 @@ describe('addAndTestOpenCodeLocalModel', () => { status: 'needs_verification', message: 'Coordination probe was unavailable and will be retried before launch.', }); - expect(deps.prepareProvisioning).toHaveBeenCalledOnce(); + expect(deps.checkReadiness).toHaveBeenCalledOnce(); }); it('forwards approval only when the caller confirms the exact project target', async () => { diff --git a/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.ts b/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.ts index 706a50ac0c..fc159c9a98 100644 --- a/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.ts +++ b/src/features/runtime-provider-management/renderer/openCodeLocalModelSetup.ts @@ -3,7 +3,7 @@ import type { RuntimeLocalProviderConfigureResponse, RuntimeLocalProviderPresetIdDto, } from '../contracts'; -import type { TeamsAPI } from '@shared/types/api'; +import type { RuntimeProviderProvisioningReadinessPort } from './ports/RuntimeProviderProvisioningReadinessPort'; export interface OpenCodeLocalModelSetupTarget { providerId: string; @@ -24,7 +24,7 @@ export interface OpenCodeLocalModelSetupDependencies { configureLocalProvider: ( input: RuntimeLocalProviderConfigureInput ) => Promise; - prepareProvisioning: TeamsAPI['prepareProvisioning']; + checkReadiness: RuntimeProviderProvisioningReadinessPort['checkReadiness']; } export async function addAndTestOpenCodeLocalModel({ @@ -71,14 +71,7 @@ export async function addAndTestOpenCodeLocalModel({ // The deep verification remains authoritative if the surrounding catalog refresh fails. } - const readiness = await dependencies.prepareProvisioning( - normalizedProjectPath, - 'opencode', - ['opencode'], - [target.modelRoute], - false, - 'deep' - ); + const readiness = await dependencies.checkReadiness(normalizedProjectPath, target.modelRoute); if (!readiness.ready) { const issue = readiness.issues?.find( diff --git a/src/features/runtime-provider-management/renderer/ports/RuntimeProviderProvisioningReadinessPort.ts b/src/features/runtime-provider-management/renderer/ports/RuntimeProviderProvisioningReadinessPort.ts new file mode 100644 index 0000000000..d824586041 --- /dev/null +++ b/src/features/runtime-provider-management/renderer/ports/RuntimeProviderProvisioningReadinessPort.ts @@ -0,0 +1,5 @@ +import type { TeamProvisioningPrepareResult } from '@shared/types'; + +export interface RuntimeProviderProvisioningReadinessPort { + checkReadiness(cwd: string, modelRoute: string): Promise; +} diff --git a/src/features/task-board-commands/core/application/TaskBoardCommandFacade.ts b/src/features/task-board-commands/core/application/TaskBoardCommandFacade.ts index 8192a9dac0..151cd432f5 100644 --- a/src/features/task-board-commands/core/application/TaskBoardCommandFacade.ts +++ b/src/features/task-board-commands/core/application/TaskBoardCommandFacade.ts @@ -17,7 +17,7 @@ export interface TaskBoardCreateTaskDestination { findById(taskId: string): TeamTask | null; findByIdempotencyKey(idempotencyKey: string): TeamTask[]; create(input: Record): TeamTask | Promise; - reconcile(input: Record): TeamTask | null | Promise; + reconcile?(input: Record): TeamTask | null | Promise; } export interface TaskBoardCreateTaskCommand { @@ -332,13 +332,17 @@ async function reconcileDestination( payload: JsonObject ): Promise { let task: TeamTask | null; - try { - task = await destination.reconcile(makeDestinationInput(record, payload)); - } catch (error) { - if (isDestinationConflictError(error)) { - throw new TaskBoardCreateDestinationConflictError(error); + if (destination.reconcile) { + try { + task = await destination.reconcile(makeDestinationInput(record, payload)); + } catch (error) { + if (isDestinationConflictError(error)) { + throw new TaskBoardCreateDestinationConflictError(error); + } + throw error; } - throw error; + } else { + task = destination.findById(record.commandId); } if (!task) { throw new Error(`Task disappeared during command reconciliation: ${record.commandId}`); diff --git a/src/features/task-log-observability/contracts/channels.ts b/src/features/task-log-observability/contracts/channels.ts new file mode 100644 index 0000000000..f75cdef326 --- /dev/null +++ b/src/features/task-log-observability/contracts/channels.ts @@ -0,0 +1,6 @@ +export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity'; +export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail'; +export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; +export const TEAM_GET_TASK_LOG_STREAM_SUMMARY = 'team:getTaskLogStreamSummary'; +export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'; +export const TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail'; diff --git a/src/features/task-log-observability/contracts/index.ts b/src/features/task-log-observability/contracts/index.ts new file mode 100644 index 0000000000..ee323854ed --- /dev/null +++ b/src/features/task-log-observability/contracts/index.ts @@ -0,0 +1,8 @@ +export { + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_ACTIVITY_DETAIL, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_LOG_STREAM_SUMMARY, +} from './channels'; diff --git a/src/features/task-log-observability/core/application/models/TaskLogObservabilityModels.ts b/src/features/task-log-observability/core/application/models/TaskLogObservabilityModels.ts new file mode 100644 index 0000000000..c5d6794646 --- /dev/null +++ b/src/features/task-log-observability/core/application/models/TaskLogObservabilityModels.ts @@ -0,0 +1,150 @@ +export interface TaskRef { + taskId: string; + displayId: string; + teamName: string; +} + +export type BoardTaskRefKind = 'canonical' | 'display' | 'unknown'; +export type BoardTaskResolution = 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'; +export type BoardTaskActivityLinkKind = 'execution' | 'lifecycle' | 'board_action'; +export type BoardTaskActivityTargetRole = 'subject' | 'related'; +export type BoardTaskActorRelation = 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'; + +export interface BoardTaskLocator { + ref: string; + refKind: BoardTaskRefKind; + canonicalId?: string; +} + +export interface BoardTaskActivityTaskRef { + locator: BoardTaskLocator; + resolution: BoardTaskResolution; + taskRef?: TaskRef; +} + +export interface BoardTaskActivityActor { + role: 'member' | 'lead' | 'unknown'; + sessionId: string; + isSidechain: boolean; +} + +export interface BoardTaskActivityActorContext { + relation: BoardTaskActorRelation; +} + +export interface BoardTaskActivityEntry { + id: string; + timestamp: string; + task: BoardTaskActivityTaskRef; + linkKind: BoardTaskActivityLinkKind; + targetRole: BoardTaskActivityTargetRole; + actor: BoardTaskActivityActor; + actorContext: BoardTaskActivityActorContext; + source: { + messageUuid: string; + filePath: string; + sourceOrder: number; + }; +} + +export interface BoardTaskActivityDetailMetadataRow { + label: string; + value: string; +} + +export interface BoardTaskActivityDetail { + entryId: string; + summaryLabel: string; + actorLabel: string; + timestamp: string; + contextLines: string[]; + metadataRows: BoardTaskActivityDetailMetadataRow[]; +} + +export type BoardTaskActivityDetailResult = + | { status: 'ok'; detail: BoardTaskActivityDetail } + | { status: 'missing' }; + +export interface BoardTaskExactLogActor { + role: 'member' | 'lead' | 'unknown'; + sessionId: string; + isSidechain: boolean; +} + +export interface BoardTaskExactLogSource { + filePath: string; + messageUuid: string; + sourceOrder: number; +} + +interface BoardTaskExactLogSummaryBase { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + anchorKind: 'tool' | 'message'; + actionLabel: string; + linkKinds: BoardTaskActivityLinkKind[]; +} + +export type BoardTaskExactLogSummary = + | (BoardTaskExactLogSummaryBase & { + canLoadDetail: true; + sourceGeneration: string; + }) + | (BoardTaskExactLogSummaryBase & { + canLoadDetail: false; + }); + +export interface BoardTaskExactLogSummariesResponse { + items: BoardTaskExactLogSummary[]; +} + +/** + * Core forwards parsed runtime chunks without inspecting their main-process shape. + * The surrounding DTO remains feature-owned while this nested payload stays opaque. + */ +export type BoardTaskLogChunk = ReturnType; + +export interface BoardTaskExactLogDetail { + id: string; + chunks: BoardTaskLogChunk[]; +} + +export type BoardTaskExactLogDetailResult = + | { status: 'ok'; detail: BoardTaskExactLogDetail } + | { status: 'stale' } + | { status: 'missing' }; + +export interface BoardTaskLogActor { + role: 'member' | 'lead' | 'unknown'; + sessionId: string; + isSidechain: boolean; +} + +export interface BoardTaskLogParticipant { + key: string; + label: string; + role: 'member' | 'lead' | 'unknown'; + isLead: boolean; + isSidechain: boolean; +} + +export interface BoardTaskLogSegment { + id: string; + participantKey: string; + actor: BoardTaskLogActor; + startTimestamp: string; + endTimestamp: string; + chunks: BoardTaskLogChunk[]; +} + +export interface BoardTaskLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: 'all' | string; + segments: BoardTaskLogSegment[]; +} + +export interface BoardTaskLogStreamSummary { + segmentCount: number; +} diff --git a/src/features/task-log-observability/core/application/ports/TaskLogObservabilityReaders.ts b/src/features/task-log-observability/core/application/ports/TaskLogObservabilityReaders.ts new file mode 100644 index 0000000000..525aeb5004 --- /dev/null +++ b/src/features/task-log-observability/core/application/ports/TaskLogObservabilityReaders.ts @@ -0,0 +1,49 @@ +import type { + BoardTaskActivityDetailResult, + BoardTaskActivityEntry, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, +} from '../models/TaskLogObservabilityModels'; + +export interface TaskActivityReader { + getTaskActivity(teamName: string, taskId: string): Promise; +} + +export interface TaskActivityDetailReader { + getTaskActivityDetail( + teamName: string, + taskId: string, + activityId: string + ): Promise; +} + +export interface TaskLogStreamReader { + getTaskLogStream(teamName: string, taskId: string): Promise; + getTaskLogStreamSummary(teamName: string, taskId: string): Promise; +} + +export interface TaskExactLogSummaryReader { + getTaskExactLogSummaries( + teamName: string, + taskId: string + ): Promise; +} + +export interface TaskExactLogDetailReader { + getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ): Promise; +} + +export interface TaskLogObservabilityReaders { + activity: TaskActivityReader; + activityDetail: TaskActivityDetailReader; + stream: TaskLogStreamReader; + exactLogSummaries: TaskExactLogSummaryReader; + exactLogDetail: TaskExactLogDetailReader; +} diff --git a/src/features/task-log-observability/main/adapters/input/ipc/registerTaskLogObservabilityIpc.ts b/src/features/task-log-observability/main/adapters/input/ipc/registerTaskLogObservabilityIpc.ts new file mode 100644 index 0000000000..a126989070 --- /dev/null +++ b/src/features/task-log-observability/main/adapters/input/ipc/registerTaskLogObservabilityIpc.ts @@ -0,0 +1,202 @@ +import { + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_ACTIVITY_DETAIL, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_LOG_STREAM_SUMMARY, +} from '@features/task-log-observability/contracts'; +import { validateTaskId, validateTeamName } from '@main/ipc/guards'; + +import type { + TaskLogObservabilityIpcDependencies, + TaskLogObservabilityIpcEvent, + TaskLogObservabilityIpcRegistrar, +} from '../../../composition/TaskLogObservabilityIpcBoundary'; +import type { + BoardTaskActivityDetailResult, + BoardTaskActivityEntry, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, + IpcResult, +} from '@shared/types'; + +async function executeQuery( + dependencies: TaskLogObservabilityIpcDependencies, + operation: string, + query: () => Promise +): Promise> { + try { + return { success: true, data: await query() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + dependencies.logger.error(`[teams:${operation}] ${message}`); + return { success: false, error: message }; + } +} + +function validateTaskLocator( + teamName: unknown, + taskId: unknown +): { valid: true; teamName: string; taskId: string } | { valid: false; result: IpcResult } { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { + valid: false, + result: { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }, + }; + } + + const validatedTaskId = validateTaskId(taskId); + if (!validatedTaskId.valid) { + return { + valid: false, + result: { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }, + }; + } + + return { + valid: true, + teamName: validatedTeamName.value!, + taskId: validatedTaskId.value!, + }; +} + +export function registerTaskLogObservabilityIpc( + ipcMain: TaskLogObservabilityIpcRegistrar, + dependencies: TaskLogObservabilityIpcDependencies +): void { + ipcMain.handle( + TEAM_GET_TASK_ACTIVITY, + async ( + _event: TaskLogObservabilityIpcEvent, + teamName: unknown, + taskId: unknown + ): Promise> => { + const locator = validateTaskLocator(teamName, taskId); + if (!locator.valid) return locator.result; + + return executeQuery(dependencies, 'getTaskActivity', () => + dependencies.readers.activity.getTaskActivity(locator.teamName, locator.taskId) + ); + } + ); + + ipcMain.handle( + TEAM_GET_TASK_ACTIVITY_DETAIL, + async ( + _event: TaskLogObservabilityIpcEvent, + teamName: unknown, + taskId: unknown, + activityId: unknown + ): Promise> => { + const locator = validateTaskLocator(teamName, taskId); + if (!locator.valid) return locator.result; + if (typeof activityId !== 'string' || activityId.trim().length === 0) { + return { success: false, error: 'activityId must be a non-empty string' }; + } + + return executeQuery(dependencies, 'getTaskActivityDetail', () => + dependencies.readers.activityDetail.getTaskActivityDetail( + locator.teamName, + locator.taskId, + activityId.trim() + ) + ); + } + ); + + ipcMain.handle( + TEAM_GET_TASK_LOG_STREAM_SUMMARY, + async ( + _event: TaskLogObservabilityIpcEvent, + teamName: unknown, + taskId: unknown + ): Promise> => { + const locator = validateTaskLocator(teamName, taskId); + if (!locator.valid) return locator.result; + + return executeQuery(dependencies, 'getTaskLogStreamSummary', () => + dependencies.readers.stream.getTaskLogStreamSummary(locator.teamName, locator.taskId) + ); + } + ); + + ipcMain.handle( + TEAM_GET_TASK_LOG_STREAM, + async ( + _event: TaskLogObservabilityIpcEvent, + teamName: unknown, + taskId: unknown + ): Promise> => { + const locator = validateTaskLocator(teamName, taskId); + if (!locator.valid) return locator.result; + + return executeQuery(dependencies, 'getTaskLogStream', () => + dependencies.readers.stream.getTaskLogStream(locator.teamName, locator.taskId) + ); + } + ); + + ipcMain.handle( + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + async ( + _event: TaskLogObservabilityIpcEvent, + teamName: unknown, + taskId: unknown + ): Promise> => { + const locator = validateTaskLocator(teamName, taskId); + if (!locator.valid) return locator.result; + + return executeQuery(dependencies, 'getTaskExactLogSummaries', () => + dependencies.readers.exactLogSummaries.getTaskExactLogSummaries( + locator.teamName, + locator.taskId + ) + ); + } + ); + + ipcMain.handle( + TEAM_GET_TASK_EXACT_LOG_DETAIL, + async ( + _event: TaskLogObservabilityIpcEvent, + teamName: unknown, + taskId: unknown, + exactLogId: unknown, + expectedSourceGeneration: unknown + ): Promise> => { + const locator = validateTaskLocator(teamName, taskId); + if (!locator.valid) return locator.result; + if (typeof exactLogId !== 'string' || exactLogId.trim().length === 0) { + return { success: false, error: 'exactLogId must be a non-empty string' }; + } + if ( + typeof expectedSourceGeneration !== 'string' || + expectedSourceGeneration.trim().length === 0 + ) { + return { success: false, error: 'expectedSourceGeneration must be a non-empty string' }; + } + + return executeQuery(dependencies, 'getTaskExactLogDetail', () => + dependencies.readers.exactLogDetail.getTaskExactLogDetail( + locator.teamName, + locator.taskId, + exactLogId.trim(), + expectedSourceGeneration.trim() + ) + ); + } + ); +} + +export function removeTaskLogObservabilityIpc(ipcMain: TaskLogObservabilityIpcRegistrar): void { + ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); + ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL); + ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM_SUMMARY); + ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); +} diff --git a/src/features/task-log-observability/main/composition/TaskLogObservabilityIpcBoundary.ts b/src/features/task-log-observability/main/composition/TaskLogObservabilityIpcBoundary.ts new file mode 100644 index 0000000000..125dde4e7e --- /dev/null +++ b/src/features/task-log-observability/main/composition/TaskLogObservabilityIpcBoundary.ts @@ -0,0 +1,34 @@ +import { + registerTaskLogObservabilityIpc as registerObservabilityIpc, + removeTaskLogObservabilityIpc as removeObservabilityIpc, +} from '../adapters/input/ipc/registerTaskLogObservabilityIpc'; + +import type { TaskLogObservabilityReaders } from '../../core/application/ports/TaskLogObservabilityReaders'; + +export interface TaskLogObservabilityIpcLogger { + error(message: string): void; +} + +export interface TaskLogObservabilityIpcDependencies { + readers: TaskLogObservabilityReaders; + logger: TaskLogObservabilityIpcLogger; +} + +export interface TaskLogObservabilityIpcRegistrar { + readonly handle: CallableFunction; + readonly removeHandler: CallableFunction; +} + +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Named IPC boundary contract intentionally remains Electron-free. +export type TaskLogObservabilityIpcEvent = unknown; + +export function registerTaskLogObservabilityIpc( + ipcMain: TaskLogObservabilityIpcRegistrar, + dependencies: TaskLogObservabilityIpcDependencies +): void { + registerObservabilityIpc(ipcMain, dependencies); +} + +export function removeTaskLogObservabilityIpc(ipcMain: TaskLogObservabilityIpcRegistrar): void { + removeObservabilityIpc(ipcMain); +} diff --git a/src/features/task-log-observability/main/index.ts b/src/features/task-log-observability/main/index.ts new file mode 100644 index 0000000000..4f5f4fec3c --- /dev/null +++ b/src/features/task-log-observability/main/index.ts @@ -0,0 +1,6 @@ +export { + registerTaskLogObservabilityIpc, + removeTaskLogObservabilityIpc, + type TaskLogObservabilityIpcDependencies, + type TaskLogObservabilityIpcLogger, +} from './composition/TaskLogObservabilityIpcBoundary'; diff --git a/src/features/task-log-observability/renderer/index.ts b/src/features/task-log-observability/renderer/index.ts new file mode 100644 index 0000000000..c535dd1339 --- /dev/null +++ b/src/features/task-log-observability/renderer/index.ts @@ -0,0 +1,4 @@ +export type { + TaskLogObservabilityRendererPort, + TaskLogObservabilityTeamChangeListener, +} from './ports/TaskLogObservabilityRendererPort'; diff --git a/src/features/task-log-observability/renderer/ports/TaskLogObservabilityRendererPort.ts b/src/features/task-log-observability/renderer/ports/TaskLogObservabilityRendererPort.ts new file mode 100644 index 0000000000..6a1995198b --- /dev/null +++ b/src/features/task-log-observability/renderer/ports/TaskLogObservabilityRendererPort.ts @@ -0,0 +1,37 @@ +import type { + BoardTaskActivityDetailResult, + BoardTaskActivityEntry, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, + TeamChangeEvent, +} from '@shared/types'; + +export type TaskLogObservabilityTeamChangeListener = (event: TeamChangeEvent) => void; + +export interface TaskLogObservabilityRendererPort { + getTaskActivity(teamName: string, taskId: string): Promise; + getTaskActivityDetail( + teamName: string, + taskId: string, + activityId: string + ): Promise; + getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ): Promise; + getTaskExactLogSummaries( + teamName: string, + taskId: string + ): Promise; + getTaskLogStream(teamName: string, taskId: string): Promise; + getTaskLogStreamSummary?: ( + teamName: string, + taskId: string + ) => Promise; + setTaskLogStreamTracking?: (teamName: string, enabled: boolean) => Promise; + subscribeToTeamChanges(listener: TaskLogObservabilityTeamChangeListener): () => void; +} diff --git a/src/features/team-approvals/contracts/api.ts b/src/features/team-approvals/contracts/api.ts new file mode 100644 index 0000000000..f336e9a35a --- /dev/null +++ b/src/features/team-approvals/contracts/api.ts @@ -0,0 +1,25 @@ +import type { + ToolApprovalEvent, + ToolApprovalFileContent, + ToolApprovalSettings, +} from '@shared/types/team'; + +export interface ToolApprovalFileReadRequest { + teamName: string; + runId: string; + requestId: string; + filePath: string; +} + +export interface TeamApprovalsElectronApi { + respondToToolApproval( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ): Promise; + onToolApprovalEvent(callback: (event: unknown, data: ToolApprovalEvent) => void): () => void; + updateToolApprovalSettings(teamName: string, settings: ToolApprovalSettings): Promise; + readFileForToolApproval(request: ToolApprovalFileReadRequest): Promise; +} diff --git a/src/features/team-approvals/contracts/channels.ts b/src/features/team-approvals/contracts/channels.ts new file mode 100644 index 0000000000..011fc0a532 --- /dev/null +++ b/src/features/team-approvals/contracts/channels.ts @@ -0,0 +1,11 @@ +/** Push event: tool approval request or dismissal (main -> renderer). */ +export const TEAM_TOOL_APPROVAL_EVENT = 'team:toolApprovalEvent'; + +/** Invoke: respond to a tool approval request (renderer -> main). */ +export const TEAM_TOOL_APPROVAL_RESPOND = 'team:toolApprovalRespond'; + +/** Invoke: update tool approval settings (renderer -> main). */ +export const TEAM_TOOL_APPROVAL_SETTINGS = 'team:toolApprovalSettings'; + +/** Invoke: read file content for the tool approval diff preview (renderer -> main). */ +export const TEAM_TOOL_APPROVAL_READ_FILE = 'team:toolApprovalReadFile'; diff --git a/src/features/team-approvals/contracts/hosted.ts b/src/features/team-approvals/contracts/hosted.ts new file mode 100644 index 0000000000..42983671a6 --- /dev/null +++ b/src/features/team-approvals/contracts/hosted.ts @@ -0,0 +1,578 @@ +import { + createSafeAppError, + type Cursor, + HOSTED_SCHEMA_VERSION, + parseCursor, + parseHostedSchemaVersion, + parseRunId, + parseTeamId, + type RunId, + type SafeAppError, + type TeamId, +} from '@shared/contracts/hosted'; + +declare const hostedTeamApprovalBrand: unique symbol; + +export type HostedTeamApprovalId = string & { + readonly [hostedTeamApprovalBrand]: 'HostedTeamApprovalId'; +}; +export type HostedTeamApprovalGeneration = string & { + readonly [hostedTeamApprovalBrand]: 'HostedTeamApprovalGeneration'; +}; +export type HostedTeamApprovalPreviewRef = string & { + readonly [hostedTeamApprovalBrand]: 'HostedTeamApprovalPreviewRef'; +}; +export type HostedTeamApprovalIdempotencyKey = string & { + readonly [hostedTeamApprovalBrand]: 'HostedTeamApprovalIdempotencyKey'; +}; + +export const HOSTED_TEAM_APPROVAL_SCHEMA_VERSION = HOSTED_SCHEMA_VERSION; +export const HOSTED_TEAM_APPROVAL_PAGE_ROUTE = '/api/hosted/v1/team-approvals/page' as const; +export const HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE = '/api/hosted/v1/team-approvals/preview' as const; +export const HOSTED_TEAM_APPROVAL_DECISION_ROUTE = + '/api/hosted/v1/team-approvals/decisions' as const; + +const APPROVAL_ID = /^approval_[0-9a-f]{32}$/; +const APPROVAL_GENERATION = /^generation_[A-Za-z0-9][A-Za-z0-9._-]{0,245}$/; +const PREVIEW_REF = /^approval_preview_[A-Za-z0-9][A-Za-z0-9._-]{0,239}$/; +const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +export function parseHostedTeamApprovalId(value: unknown): HostedTeamApprovalId { + if (typeof value !== 'string' || !APPROVAL_ID.test(value)) { + throw new TypeError('hosted-team-approval-id-invalid'); + } + return value as HostedTeamApprovalId; +} + +export function parseHostedTeamApprovalGeneration(value: unknown): HostedTeamApprovalGeneration { + if (typeof value !== 'string' || !APPROVAL_GENERATION.test(value)) { + throw new TypeError('hosted-team-approval-generation-invalid'); + } + return value as HostedTeamApprovalGeneration; +} + +export function parseHostedTeamApprovalPreviewRef(value: unknown): HostedTeamApprovalPreviewRef { + if (typeof value !== 'string' || !PREVIEW_REF.test(value)) { + throw new TypeError('hosted-team-approval-preview-ref-invalid'); + } + return value as HostedTeamApprovalPreviewRef; +} + +export function parseHostedTeamApprovalIdempotencyKey( + value: unknown +): HostedTeamApprovalIdempotencyKey { + if (typeof value !== 'string' || !IDEMPOTENCY_KEY.test(value)) { + throw new TypeError('hosted-team-approval-idempotency-key-invalid'); + } + return value as HostedTeamApprovalIdempotencyKey; +} + +export const HOSTED_TEAM_APPROVAL_CATEGORIES = Object.freeze([ + 'file_change', + 'command', + 'network', + 'other', +] as const); +export type HostedTeamApprovalCategory = (typeof HOSTED_TEAM_APPROVAL_CATEGORIES)[number]; + +export const HOSTED_TEAM_APPROVAL_DECISIONS = Object.freeze(['allow', 'deny'] as const); +export type HostedTeamApprovalDecision = (typeof HOSTED_TEAM_APPROVAL_DECISIONS)[number]; + +export interface HostedTeamApprovalPageRequest { + readonly schemaVersion: typeof HOSTED_TEAM_APPROVAL_SCHEMA_VERSION; + readonly teamId: TeamId; + readonly expectedRunId: RunId; + readonly cursor: Cursor | null; + readonly limit: number; +} + +/** Browser-safe pending projection. The opaque previewRef is not a path or authorization token. */ +export interface HostedTeamApprovalItem { + readonly teamId: TeamId; + readonly runId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly generation: HostedTeamApprovalGeneration; + readonly category: HostedTeamApprovalCategory; + readonly summary: string; + readonly requestedAtMs: number; + readonly expiresAtMs: number | null; + readonly previewRef: HostedTeamApprovalPreviewRef | null; +} + +export interface HostedTeamApprovalPageBudget { + readonly itemLimit: number; + readonly byteLimit: number; + readonly timeLimitMs: number; + readonly usedItems: number; + readonly usedBytes: number; + readonly elapsedMs: number; +} + +export interface HostedTeamApprovalPage { + readonly schemaVersion: typeof HOSTED_TEAM_APPROVAL_SCHEMA_VERSION; + readonly kind: 'approval_page'; + readonly teamId: TeamId; + readonly items: readonly HostedTeamApprovalItem[]; + readonly nextCursor: Cursor | null; + readonly truncated: boolean; + readonly budget: HostedTeamApprovalPageBudget; +} + +export interface HostedTeamApprovalPreviewRequest { + readonly schemaVersion: typeof HOSTED_TEAM_APPROVAL_SCHEMA_VERSION; + readonly teamId: TeamId; + readonly expectedRunId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly expectedGeneration: HostedTeamApprovalGeneration; + readonly previewRef: HostedTeamApprovalPreviewRef; +} + +export interface HostedTeamApprovalPreview { + readonly schemaVersion: typeof HOSTED_TEAM_APPROVAL_SCHEMA_VERSION; + readonly kind: 'approval_preview'; + readonly teamId: TeamId; + readonly runId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly generation: HostedTeamApprovalGeneration; + readonly content: string; + readonly byteLength: number; + readonly truncated: boolean; + readonly isBinary: boolean; +} + +export interface HostedTeamApprovalDecisionCommand { + readonly schemaVersion: typeof HOSTED_TEAM_APPROVAL_SCHEMA_VERSION; + readonly teamId: TeamId; + readonly expectedRunId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly expectedGeneration: HostedTeamApprovalGeneration; + readonly idempotencyKey: HostedTeamApprovalIdempotencyKey; + readonly decision: HostedTeamApprovalDecision; +} + +interface HostedTeamApprovalDecisionReceiptBase { + readonly schemaVersion: typeof HOSTED_TEAM_APPROVAL_SCHEMA_VERSION; + readonly teamId: TeamId; + readonly runId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly generation: HostedTeamApprovalGeneration; + readonly decision: HostedTeamApprovalDecision; +} + +export interface HostedTeamApprovalCommittedReceipt extends HostedTeamApprovalDecisionReceiptBase { + readonly outcome: 'committed'; +} + +export interface HostedTeamApprovalReplayReceipt extends HostedTeamApprovalDecisionReceiptBase { + readonly outcome: 'idempotent_replay'; +} + +export type HostedTeamApprovalDecisionReceipt = + | HostedTeamApprovalCommittedReceipt + | HostedTeamApprovalReplayReceipt; + +export type GetHostedTeamApprovalPageResult = + | { readonly kind: 'success'; readonly page: HostedTeamApprovalPage } + | { readonly kind: 'invalid_request' } + | { readonly kind: 'not_found' } + | { readonly kind: 'cancelled' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +export type GetHostedTeamApprovalPreviewResult = + | { readonly kind: 'success'; readonly preview: HostedTeamApprovalPreview } + | { readonly kind: 'invalid_request' } + | { + readonly kind: 'stale_generation'; + readonly currentGeneration: HostedTeamApprovalGeneration; + } + | { readonly kind: 'not_found' } + | { readonly kind: 'cancelled' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +export type DecideHostedTeamApprovalResult = + | { readonly kind: 'committed'; readonly receipt: HostedTeamApprovalCommittedReceipt } + | { readonly kind: 'idempotent_replay'; readonly receipt: HostedTeamApprovalReplayReceipt } + | { + readonly kind: 'already_resolved'; + readonly generation: HostedTeamApprovalGeneration; + readonly decision: HostedTeamApprovalDecision; + } + | { readonly kind: 'invalid_request' } + | { + readonly kind: 'stale_generation'; + readonly currentGeneration: HostedTeamApprovalGeneration; + } + | { readonly kind: 'conflict'; readonly reason: 'idempotency_mismatch' } + | { readonly kind: 'expired' } + | { readonly kind: 'not_found' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +export interface HostedTeamApprovalErrorEnvelope { + readonly schemaVersion: typeof HOSTED_TEAM_APPROVAL_SCHEMA_VERSION; + readonly kind: 'error'; + readonly error: SafeAppError; + readonly retryable: boolean; + readonly currentGeneration?: HostedTeamApprovalGeneration; + readonly resolvedDecision?: HostedTeamApprovalDecision; +} + +type ParseResult = { readonly ok: true; readonly value: T } | { readonly ok: false }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value); + return ( + ownKeys.length === keys.length && + ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function parseRequest(operation: () => T): ParseResult { + try { + return Object.freeze({ ok: true, value: operation() }); + } catch { + return Object.freeze({ ok: false }); + } +} + +export function parseHostedTeamApprovalPageRequest( + value: unknown +): ParseResult { + return parseRequest(() => { + if ( + !isRecord(value) || + !hasExactKeys(value, ['schemaVersion', 'teamId', 'expectedRunId', 'cursor', 'limit']) + ) { + throw new TypeError(); + } + const limit = value.limit; + if (!Number.isSafeInteger(limit) || (limit as number) < 1 || (limit as number) > 50) { + throw new TypeError(); + } + return Object.freeze({ + schemaVersion: parseHostedSchemaVersion(value.schemaVersion), + teamId: parseTeamId(value.teamId), + expectedRunId: parseRunId(value.expectedRunId), + cursor: value.cursor === null ? null : parseCursor(value.cursor), + limit: limit as number, + }); + }); +} + +export function parseHostedTeamApprovalPreviewRequest( + value: unknown +): ParseResult { + return parseRequest(() => { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'schemaVersion', + 'teamId', + 'expectedRunId', + 'approvalId', + 'expectedGeneration', + 'previewRef', + ]) + ) { + throw new TypeError(); + } + return Object.freeze({ + schemaVersion: parseHostedSchemaVersion(value.schemaVersion), + teamId: parseTeamId(value.teamId), + expectedRunId: parseRunId(value.expectedRunId), + approvalId: parseHostedTeamApprovalId(value.approvalId), + expectedGeneration: parseHostedTeamApprovalGeneration(value.expectedGeneration), + previewRef: parseHostedTeamApprovalPreviewRef(value.previewRef), + }); + }); +} + +export function parseHostedTeamApprovalDecisionCommand( + value: unknown +): ParseResult { + return parseRequest(() => { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'schemaVersion', + 'teamId', + 'expectedRunId', + 'approvalId', + 'expectedGeneration', + 'idempotencyKey', + 'decision', + ]) || + !HOSTED_TEAM_APPROVAL_DECISIONS.includes(value.decision as HostedTeamApprovalDecision) + ) { + throw new TypeError(); + } + return Object.freeze({ + schemaVersion: parseHostedSchemaVersion(value.schemaVersion), + teamId: parseTeamId(value.teamId), + expectedRunId: parseRunId(value.expectedRunId), + approvalId: parseHostedTeamApprovalId(value.approvalId), + expectedGeneration: parseHostedTeamApprovalGeneration(value.expectedGeneration), + idempotencyKey: parseHostedTeamApprovalIdempotencyKey(value.idempotencyKey), + decision: value.decision as HostedTeamApprovalDecision, + }); + }); +} + +const RECEIPT_KEYS = Object.freeze([ + 'schemaVersion', + 'outcome', + 'teamId', + 'runId', + 'approvalId', + 'generation', + 'decision', +] as const); + +export function parseHostedTeamApprovalDecisionReceipt( + value: unknown +): ParseResult { + return parseRequest(() => { + if ( + !isRecord(value) || + !hasExactKeys(value, RECEIPT_KEYS) || + (value.outcome !== 'committed' && value.outcome !== 'idempotent_replay') || + !HOSTED_TEAM_APPROVAL_DECISIONS.includes(value.decision as HostedTeamApprovalDecision) + ) { + throw new TypeError(); + } + return Object.freeze({ + schemaVersion: parseHostedSchemaVersion(value.schemaVersion), + outcome: value.outcome, + teamId: parseTeamId(value.teamId), + runId: parseRunId(value.runId), + approvalId: parseHostedTeamApprovalId(value.approvalId), + generation: parseHostedTeamApprovalGeneration(value.generation), + decision: value.decision as HostedTeamApprovalDecision, + }); + }); +} + +function parseHostedTeamApprovalItem( + value: unknown, + expectedTeamId: TeamId +): HostedTeamApprovalItem { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'teamId', + 'runId', + 'approvalId', + 'generation', + 'category', + 'summary', + 'requestedAtMs', + 'expiresAtMs', + 'previewRef', + ]) + ) { + throw new TypeError(); + } + const teamId = parseTeamId(value.teamId); + const runId = parseRunId(value.runId); + const summary = value.summary; + const requestedAtMs = value.requestedAtMs; + const expiresAtMs = value.expiresAtMs; + if ( + teamId !== expectedTeamId || + !HOSTED_TEAM_APPROVAL_CATEGORIES.includes(value.category as HostedTeamApprovalCategory) || + typeof summary !== 'string' || + summary.length < 1 || + summary.length > 512 || + summary.trim() !== summary || + !Number.isSafeInteger(requestedAtMs) || + (requestedAtMs as number) < 0 || + (expiresAtMs !== null && (!Number.isSafeInteger(expiresAtMs) || (expiresAtMs as number) < 0)) || + (typeof expiresAtMs === 'number' && expiresAtMs <= (requestedAtMs as number)) + ) { + throw new TypeError(); + } + return Object.freeze({ + teamId, + runId, + approvalId: parseHostedTeamApprovalId(value.approvalId), + generation: parseHostedTeamApprovalGeneration(value.generation), + category: value.category as HostedTeamApprovalCategory, + summary, + requestedAtMs: requestedAtMs as number, + expiresAtMs: expiresAtMs as number | null, + previewRef: + value.previewRef === null ? null : parseHostedTeamApprovalPreviewRef(value.previewRef), + }); +} + +export function parseHostedTeamApprovalPage(value: unknown): ParseResult { + return parseRequest(() => { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'schemaVersion', + 'kind', + 'teamId', + 'items', + 'nextCursor', + 'truncated', + 'budget', + ]) || + value.kind !== 'approval_page' || + !Array.isArray(value.items) || + value.items.length > 50 || + typeof value.truncated !== 'boolean' || + !isRecord(value.budget) || + !hasExactKeys(value.budget, [ + 'itemLimit', + 'byteLimit', + 'timeLimitMs', + 'usedItems', + 'usedBytes', + 'elapsedMs', + ]) + ) { + throw new TypeError(); + } + const teamId = parseTeamId(value.teamId); + const items = Object.freeze( + value.items.map((item) => parseHostedTeamApprovalItem(item, teamId)) + ); + const approvalIds = new Set(items.map(({ approvalId }) => approvalId)); + const budget = value.budget; + const numericFields = [ + budget.itemLimit, + budget.byteLimit, + budget.timeLimitMs, + budget.usedItems, + budget.usedBytes, + budget.elapsedMs, + ]; + if ( + approvalIds.size !== items.length || + numericFields.some((field) => !Number.isSafeInteger(field) || (field as number) < 0) || + (budget.itemLimit as number) < 1 || + (budget.itemLimit as number) > 50 || + (budget.byteLimit as number) < 1 || + (budget.byteLimit as number) > 128 * 1024 || + (budget.timeLimitMs as number) < 1 || + (budget.timeLimitMs as number) > 250 || + budget.usedItems !== items.length || + budget.usedItems > (budget.itemLimit as number) || + (budget.usedBytes as number) > (budget.byteLimit as number) || + (value.truncated && value.nextCursor === null) || + (!value.truncated && value.nextCursor !== null) + ) { + throw new TypeError(); + } + return Object.freeze({ + schemaVersion: parseHostedSchemaVersion(value.schemaVersion), + kind: 'approval_page' as const, + teamId, + items, + nextCursor: value.nextCursor === null ? null : parseCursor(value.nextCursor), + truncated: value.truncated, + budget: Object.freeze({ + itemLimit: budget.itemLimit as number, + byteLimit: budget.byteLimit as number, + timeLimitMs: budget.timeLimitMs as number, + usedItems: budget.usedItems, + usedBytes: budget.usedBytes as number, + elapsedMs: budget.elapsedMs as number, + }), + }); + }); +} + +export function parseHostedTeamApprovalPreview( + value: unknown +): ParseResult { + return parseRequest(() => { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'schemaVersion', + 'kind', + 'teamId', + 'runId', + 'approvalId', + 'generation', + 'content', + 'byteLength', + 'truncated', + 'isBinary', + ]) || + value.kind !== 'approval_preview' || + typeof value.content !== 'string' || + typeof value.truncated !== 'boolean' || + typeof value.isBinary !== 'boolean' || + !Number.isSafeInteger(value.byteLength) || + (value.byteLength as number) < 0 || + (value.byteLength as number) > 64 * 1024 || + new TextEncoder().encode(value.content).byteLength > (value.byteLength as number) || + (value.isBinary && value.content !== '') + ) { + throw new TypeError(); + } + return Object.freeze({ + schemaVersion: parseHostedSchemaVersion(value.schemaVersion), + kind: 'approval_preview' as const, + teamId: parseTeamId(value.teamId), + runId: parseRunId(value.runId), + approvalId: parseHostedTeamApprovalId(value.approvalId), + generation: parseHostedTeamApprovalGeneration(value.generation), + content: value.content, + byteLength: value.byteLength as number, + truncated: value.truncated, + isBinary: value.isBinary, + }); + }); +} + +export function parseHostedTeamApprovalErrorEnvelope( + value: unknown +): ParseResult { + return parseRequest(() => { + if (!isRecord(value)) throw new TypeError(); + const optionalKeys = ['currentGeneration', 'resolvedDecision']; + const requiredKeys = ['schemaVersion', 'kind', 'error', 'retryable']; + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.some( + (key) => + typeof key !== 'string' || (!requiredKeys.includes(key) && !optionalKeys.includes(key)) + ) || + !requiredKeys.every((key) => Object.hasOwn(value, key)) || + value.kind !== 'error' || + typeof value.retryable !== 'boolean' + ) { + throw new TypeError(); + } + const currentGeneration = Object.hasOwn(value, 'currentGeneration') + ? parseHostedTeamApprovalGeneration(value.currentGeneration) + : undefined; + const resolvedDecision = Object.hasOwn(value, 'resolvedDecision') + ? value.resolvedDecision + : undefined; + if ( + resolvedDecision !== undefined && + !HOSTED_TEAM_APPROVAL_DECISIONS.includes(resolvedDecision as HostedTeamApprovalDecision) + ) { + throw new TypeError(); + } + return Object.freeze({ + schemaVersion: parseHostedSchemaVersion(value.schemaVersion), + kind: 'error' as const, + error: createSafeAppError(value.error), + retryable: value.retryable, + ...(currentGeneration === undefined ? {} : { currentGeneration }), + ...(resolvedDecision === undefined + ? {} + : { resolvedDecision: resolvedDecision as HostedTeamApprovalDecision }), + }); + }); +} diff --git a/src/features/team-approvals/contracts/index.ts b/src/features/team-approvals/contracts/index.ts new file mode 100644 index 0000000000..439db0aa71 --- /dev/null +++ b/src/features/team-approvals/contracts/index.ts @@ -0,0 +1,50 @@ +export type { TeamApprovalsElectronApi, ToolApprovalFileReadRequest } from './api'; +export { + TEAM_TOOL_APPROVAL_EVENT, + TEAM_TOOL_APPROVAL_READ_FILE, + TEAM_TOOL_APPROVAL_RESPOND, + TEAM_TOOL_APPROVAL_SETTINGS, +} from './channels'; +export { + type DecideHostedTeamApprovalResult, + type GetHostedTeamApprovalPageResult, + type GetHostedTeamApprovalPreviewResult, + HOSTED_TEAM_APPROVAL_CATEGORIES, + HOSTED_TEAM_APPROVAL_DECISION_ROUTE, + HOSTED_TEAM_APPROVAL_DECISIONS, + HOSTED_TEAM_APPROVAL_PAGE_ROUTE, + HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE, + HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + type HostedTeamApprovalCategory, + type HostedTeamApprovalCommittedReceipt, + type HostedTeamApprovalDecision, + type HostedTeamApprovalDecisionCommand, + type HostedTeamApprovalDecisionReceipt, + type HostedTeamApprovalErrorEnvelope, + type HostedTeamApprovalGeneration, + type HostedTeamApprovalId, + type HostedTeamApprovalIdempotencyKey, + type HostedTeamApprovalItem, + type HostedTeamApprovalPage, + type HostedTeamApprovalPageBudget, + type HostedTeamApprovalPageRequest, + type HostedTeamApprovalPreview, + type HostedTeamApprovalPreviewRef, + type HostedTeamApprovalPreviewRequest, + type HostedTeamApprovalReplayReceipt, + parseHostedTeamApprovalDecisionCommand, + parseHostedTeamApprovalDecisionReceipt, + parseHostedTeamApprovalErrorEnvelope, + parseHostedTeamApprovalGeneration, + parseHostedTeamApprovalId, + parseHostedTeamApprovalIdempotencyKey, + parseHostedTeamApprovalPage, + parseHostedTeamApprovalPageRequest, + parseHostedTeamApprovalPreview, + parseHostedTeamApprovalPreviewRef, + parseHostedTeamApprovalPreviewRequest, +} from './hosted'; +export { + parseRuntimePermissionApprovalIngressAuthority, + type RuntimePermissionApprovalIngressAuthority, +} from '@features/team-runtime-control/contracts'; diff --git a/src/features/team-approvals/core/application/models/HostedTeamApprovalModels.ts b/src/features/team-approvals/core/application/models/HostedTeamApprovalModels.ts new file mode 100644 index 0000000000..0adbe3a3ce --- /dev/null +++ b/src/features/team-approvals/core/application/models/HostedTeamApprovalModels.ts @@ -0,0 +1,202 @@ +import { parseRunId, parseTeamId, type RunId, type TeamId } from '@shared/contracts/hosted'; + +import { + HOSTED_TEAM_APPROVAL_CATEGORIES, + HOSTED_TEAM_APPROVAL_DECISIONS, + HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + type HostedTeamApprovalDecision, + type HostedTeamApprovalDecisionReceipt, + type HostedTeamApprovalItem, + type HostedTeamApprovalPreview, + parseHostedTeamApprovalDecisionReceipt, + parseHostedTeamApprovalGeneration, + parseHostedTeamApprovalId, + parseHostedTeamApprovalPreviewRef, +} from '../../../contracts/hosted'; + +export const HOSTED_TEAM_APPROVAL_MAX_PAGE_ITEMS = 50; +export const HOSTED_TEAM_APPROVAL_MAX_SOURCE_ITEMS = 51; +export const HOSTED_TEAM_APPROVAL_MAX_PAGE_BYTES = 128 * 1024; +export const HOSTED_TEAM_APPROVAL_MAX_PAGE_TIME_MS = 250; +export const HOSTED_TEAM_APPROVAL_MAX_PREVIEW_BYTES = 64 * 1024; +export const HOSTED_TEAM_APPROVAL_MAX_PREVIEW_TIME_MS = 250; + +const ITEM_KEYS = Object.freeze([ + 'teamId', + 'runId', + 'approvalId', + 'generation', + 'category', + 'summary', + 'requestedAtMs', + 'expiresAtMs', + 'previewRef', +] as const); +const PREVIEW_KEYS = Object.freeze([ + 'teamId', + 'runId', + 'approvalId', + 'generation', + 'content', + 'byteLength', + 'truncated', + 'isBinary', +] as const); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value); + return ( + ownKeys.length === keys.length && + ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function isSafeDisplayText(value: unknown): value is string { + if ( + typeof value !== 'string' || + value.length < 1 || + value.length > 512 || + value.trim() !== value + ) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.charCodeAt(index); + if ((codePoint <= 31 && codePoint !== 9) || codePoint === 127) return false; + } + return true; +} + +function isTimestamp(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +export function normalizeHostedTeamApprovalItem( + value: unknown, + expectedTeamId: TeamId +): HostedTeamApprovalItem | null { + try { + if (!isRecord(value) || !hasExactKeys(value, ITEM_KEYS)) return null; + const teamId = parseTeamId(value.teamId); + const runId = parseRunId(value.runId); + const category = value.category; + const requestedAtMs = value.requestedAtMs; + const expiresAtMs = value.expiresAtMs; + if ( + teamId !== expectedTeamId || + !HOSTED_TEAM_APPROVAL_CATEGORIES.includes(category as never) || + !isSafeDisplayText(value.summary) || + !isTimestamp(requestedAtMs) || + (expiresAtMs !== null && !isTimestamp(expiresAtMs)) || + (typeof expiresAtMs === 'number' && expiresAtMs <= requestedAtMs) + ) { + return null; + } + return Object.freeze({ + teamId, + runId, + approvalId: parseHostedTeamApprovalId(value.approvalId), + generation: parseHostedTeamApprovalGeneration(value.generation), + category: category as HostedTeamApprovalItem['category'], + summary: value.summary, + requestedAtMs, + expiresAtMs, + previewRef: + value.previewRef === null ? null : parseHostedTeamApprovalPreviewRef(value.previewRef), + }); + } catch { + return null; + } +} + +export function normalizeHostedTeamApprovalPreview( + value: unknown, + expected: { + readonly teamId: TeamId; + readonly runId: RunId; + readonly approvalId: ReturnType; + } +): HostedTeamApprovalPreview | null { + try { + if (!isRecord(value) || !hasExactKeys(value, PREVIEW_KEYS)) return null; + const teamId = parseTeamId(value.teamId); + const runId = parseRunId(value.runId); + const approvalId = parseHostedTeamApprovalId(value.approvalId); + const content = value.content; + const byteLength = value.byteLength; + if ( + teamId !== expected.teamId || + runId !== expected.runId || + approvalId !== expected.approvalId || + typeof content !== 'string' || + typeof value.truncated !== 'boolean' || + typeof value.isBinary !== 'boolean' || + !Number.isSafeInteger(byteLength) || + (byteLength as number) < 0 || + (byteLength as number) > HOSTED_TEAM_APPROVAL_MAX_PREVIEW_BYTES || + new TextEncoder().encode(content).byteLength > (byteLength as number) || + (value.isBinary && content !== '') + ) { + return null; + } + return Object.freeze({ + schemaVersion: HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + kind: 'approval_preview' as const, + teamId, + runId, + approvalId, + generation: parseHostedTeamApprovalGeneration(value.generation), + content, + byteLength: byteLength as number, + truncated: value.truncated, + isBinary: value.isBinary, + }); + } catch { + return null; + } +} + +export function normalizeHostedTeamApprovalReceipt( + value: unknown, + expected: { + readonly outcome: HostedTeamApprovalDecisionReceipt['outcome']; + readonly teamId: TeamId; + readonly runId: RunId; + readonly approvalId: ReturnType; + readonly generation: ReturnType; + readonly decision: HostedTeamApprovalDecision; + } +): HostedTeamApprovalDecisionReceipt | null { + const parsed = parseHostedTeamApprovalDecisionReceipt(value); + if ( + !parsed.ok || + parsed.value.outcome !== expected.outcome || + parsed.value.teamId !== expected.teamId || + parsed.value.runId !== expected.runId || + parsed.value.approvalId !== expected.approvalId || + parsed.value.generation !== expected.generation || + parsed.value.decision !== expected.decision + ) { + return null; + } + return parsed.value; +} + +export function normalizeHostedTeamApprovalDecision( + value: unknown +): HostedTeamApprovalDecision | null { + return HOSTED_TEAM_APPROVAL_DECISIONS.includes(value as HostedTeamApprovalDecision) + ? (value as HostedTeamApprovalDecision) + : null; +} + +export function normalizeHostedTeamApprovalRetryAfterMs(value: unknown): number | undefined { + return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= 60_000 + ? (value as number) + : undefined; +} diff --git a/src/features/team-approvals/core/application/models/TeamApprovalsModels.ts b/src/features/team-approvals/core/application/models/TeamApprovalsModels.ts new file mode 100644 index 0000000000..71b10423cc --- /dev/null +++ b/src/features/team-approvals/core/application/models/TeamApprovalsModels.ts @@ -0,0 +1,19 @@ +export type ToolApprovalTimeoutAction = 'allow' | 'deny' | 'wait'; + +/** Settings consumed by the approvals use cases, independent of the shared UI DTO barrel. */ +export interface ToolApprovalSettings { + autoAllowAll: boolean; + autoAllowFileEdits: boolean; + autoAllowSafeBash: boolean; + timeoutAction: ToolApprovalTimeoutAction; + timeoutSeconds: number; +} + +/** File preview returned by the feature-owned approval reader port. */ +export interface ToolApprovalFileContent { + content: string; + exists: boolean; + truncated: boolean; + isBinary: boolean; + error?: string; +} diff --git a/src/features/team-approvals/core/application/ports/HostedTeamApprovalPorts.ts b/src/features/team-approvals/core/application/ports/HostedTeamApprovalPorts.ts new file mode 100644 index 0000000000..fb11550adf --- /dev/null +++ b/src/features/team-approvals/core/application/ports/HostedTeamApprovalPorts.ts @@ -0,0 +1,110 @@ +import type { + HostedTeamApprovalDecision, + HostedTeamApprovalDecisionCommand, + HostedTeamApprovalDecisionReceipt, + HostedTeamApprovalGeneration, + HostedTeamApprovalId, + HostedTeamApprovalItem, + HostedTeamApprovalPreviewRef, +} from '../../../contracts/hosted'; +import type { Cursor, QueryContext, RunId, TeamId } from '@shared/contracts/hosted'; + +export interface HostedTeamApprovalPageSourceRequest { + readonly teamId: TeamId; + readonly expectedRunId: RunId; + readonly cursor: Cursor | null; + readonly itemLimit: number; + readonly byteLimit: number; + readonly deadlineAtMs: number; +} + +export interface HostedTeamApprovalPageCandidate { + readonly item: HostedTeamApprovalItem; + readonly cursorAfter: Cursor; +} + +export type HostedTeamApprovalPageSourceResult = + | { + readonly kind: 'found'; + readonly teamId: TeamId; + readonly candidates: readonly HostedTeamApprovalPageCandidate[]; + readonly hasMore: boolean; + } + | { readonly kind: 'not_found' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +export interface HostedTeamApprovalPageSourcePort { + readPage( + request: HostedTeamApprovalPageSourceRequest, + context: QueryContext + ): Promise; +} + +export interface HostedTeamApprovalPreviewSourceRequest { + readonly teamId: TeamId; + readonly expectedRunId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly expectedGeneration: HostedTeamApprovalGeneration; + readonly previewRef: HostedTeamApprovalPreviewRef; + readonly byteLimit: number; + readonly deadlineAtMs: number; +} + +export type HostedTeamApprovalPreviewSourceResult = + | { + readonly kind: 'found'; + readonly preview: { + readonly teamId: TeamId; + readonly runId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly generation: HostedTeamApprovalGeneration; + readonly content: string; + readonly byteLength: number; + readonly truncated: boolean; + readonly isBinary: boolean; + }; + } + | { + readonly kind: 'stale_generation'; + readonly currentGeneration: HostedTeamApprovalGeneration; + } + | { readonly kind: 'not_found' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +export interface HostedTeamApprovalPreviewSourcePort { + readPreview( + request: HostedTeamApprovalPreviewSourceRequest, + context: QueryContext + ): Promise; +} + +export type HostedTeamApprovalDecisionAdmissionResult = + | { + readonly kind: 'committed' | 'idempotent_replay'; + readonly receipt: HostedTeamApprovalDecisionReceipt; + } + | { + readonly kind: 'already_resolved'; + readonly generation: HostedTeamApprovalGeneration; + readonly decision: HostedTeamApprovalDecision; + } + | { + readonly kind: 'stale_generation'; + readonly currentGeneration: HostedTeamApprovalGeneration; + } + | { readonly kind: 'conflict'; readonly reason: 'idempotency_mismatch' } + | { readonly kind: 'expired' } + | { readonly kind: 'not_found' } + | { readonly kind: 'unavailable'; readonly retryAfterMs?: number }; + +/** Owns atomic generation compare, idempotency matching, one-decision claim, and audit commit. */ +export interface HostedTeamApprovalDecisionAdmissionPort { + admit( + command: HostedTeamApprovalDecisionCommand, + context: QueryContext + ): Promise; +} + +export interface HostedTeamApprovalClockPort { + now(): number; +} diff --git a/src/features/team-approvals/core/application/ports/TeamApprovalsPorts.ts b/src/features/team-approvals/core/application/ports/TeamApprovalsPorts.ts new file mode 100644 index 0000000000..e5b3fdb584 --- /dev/null +++ b/src/features/team-approvals/core/application/ports/TeamApprovalsPorts.ts @@ -0,0 +1,48 @@ +import type { ToolApprovalFileContent, ToolApprovalSettings } from '../models/TeamApprovalsModels'; +import type { ToolApprovalFileReadRequest } from '@features/team-approvals/contracts'; + +export interface RespondToToolApprovalCommand { + teamName: string; + runId: string; + requestId: string; + allow: boolean; + message?: string; +} + +export interface UpdateToolApprovalSettingsCommand { + teamName: string; + settings: ToolApprovalSettings; +} + +/** Commands consumed by the team approvals feature. */ +export interface TeamApprovalsCommandPort { + respond(command: RespondToToolApprovalCommand): Promise; + updateSettings(command: UpdateToolApprovalSettingsCommand): void; +} + +/** Read-only filesystem capability used by the approval diff preview. */ +export interface ToolApprovalFileReaderPort { + read(filePath: string): Promise; +} + +export interface PendingToolApprovalFileTarget { + /** Opaque generation of the active approval and its workspace binding. */ + authorizationGeneration: string; + /** Exact path string carried by the authorized tool request. */ + authorizationPath: string; + /** Absolute path resolved from the owning run's project directory. */ + readPath: string; +} + +export interface PendingToolApprovalFileTargetPort { + getFileTarget( + teamName: string, + runId: string, + requestId: string + ): PendingToolApprovalFileTarget | null; +} + +/** Application capability that authorizes and reads one active approval preview. */ +export interface ToolApprovalPreviewReaderPort { + read(request: ToolApprovalFileReadRequest): Promise; +} diff --git a/src/features/team-approvals/core/application/use-cases/DecideHostedTeamApproval.ts b/src/features/team-approvals/core/application/use-cases/DecideHostedTeamApproval.ts new file mode 100644 index 0000000000..fbe9ffaecd --- /dev/null +++ b/src/features/team-approvals/core/application/use-cases/DecideHostedTeamApproval.ts @@ -0,0 +1,87 @@ +import { + type DecideHostedTeamApprovalResult, + parseHostedTeamApprovalDecisionCommand, + parseHostedTeamApprovalGeneration, +} from '../../../contracts/hosted'; +import { + normalizeHostedTeamApprovalDecision, + normalizeHostedTeamApprovalReceipt, + normalizeHostedTeamApprovalRetryAfterMs, +} from '../models/HostedTeamApprovalModels'; + +import type { HostedTeamApprovalDecisionAdmissionPort } from '../ports/HostedTeamApprovalPorts'; +import type { QueryContext } from '@shared/contracts/hosted'; + +function unavailable(retryAfterMs?: number): DecideHostedTeamApprovalResult { + return retryAfterMs === undefined + ? Object.freeze({ kind: 'unavailable' }) + : Object.freeze({ kind: 'unavailable', retryAfterMs }); +} + +export class DecideHostedTeamApproval { + constructor(private readonly admission: HostedTeamApprovalDecisionAdmissionPort) {} + + async execute( + commandValue: unknown, + context: QueryContext + ): Promise { + const command = parseHostedTeamApprovalDecisionCommand(commandValue); + if (!command.ok) return Object.freeze({ kind: 'invalid_request' }); + if (context.signal.aborted) return unavailable(); + + try { + const result = await this.admission.admit(command.value, context); + switch (result.kind) { + case 'committed': { + const receipt = normalizeHostedTeamApprovalReceipt(result.receipt, { + outcome: 'committed', + teamId: command.value.teamId, + runId: command.value.expectedRunId, + approvalId: command.value.approvalId, + generation: command.value.expectedGeneration, + decision: command.value.decision, + }); + if (receipt === null || receipt.outcome !== 'committed') return unavailable(); + return Object.freeze({ kind: 'committed', receipt }); + } + case 'idempotent_replay': { + const receipt = normalizeHostedTeamApprovalReceipt(result.receipt, { + outcome: 'idempotent_replay', + teamId: command.value.teamId, + runId: command.value.expectedRunId, + approvalId: command.value.approvalId, + generation: command.value.expectedGeneration, + decision: command.value.decision, + }); + if (receipt === null || receipt.outcome !== 'idempotent_replay') return unavailable(); + return Object.freeze({ kind: 'idempotent_replay', receipt }); + } + case 'already_resolved': { + const generation = parseHostedTeamApprovalGeneration(result.generation); + const decision = normalizeHostedTeamApprovalDecision(result.decision); + if (decision === null) return unavailable(); + return Object.freeze({ kind: result.kind, generation, decision }); + } + case 'stale_generation': { + const currentGeneration = parseHostedTeamApprovalGeneration(result.currentGeneration); + return currentGeneration === command.value.expectedGeneration + ? unavailable() + : Object.freeze({ kind: result.kind, currentGeneration }); + } + case 'conflict': + return result.reason === 'idempotency_mismatch' + ? Object.freeze({ kind: result.kind, reason: result.reason }) + : unavailable(); + case 'expired': + case 'not_found': + return Object.freeze({ kind: result.kind }); + case 'unavailable': + return unavailable(normalizeHostedTeamApprovalRetryAfterMs(result.retryAfterMs)); + default: + return unavailable(); + } + } catch { + return unavailable(); + } + } +} diff --git a/src/features/team-approvals/core/application/use-cases/GetHostedTeamApprovalPage.ts b/src/features/team-approvals/core/application/use-cases/GetHostedTeamApprovalPage.ts new file mode 100644 index 0000000000..21eea5fad3 --- /dev/null +++ b/src/features/team-approvals/core/application/use-cases/GetHostedTeamApprovalPage.ts @@ -0,0 +1,169 @@ +import { parseCursor, parseTeamId, type QueryContext } from '@shared/contracts/hosted'; + +import { + type GetHostedTeamApprovalPageResult, + HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + type HostedTeamApprovalItem, + parseHostedTeamApprovalPageRequest, +} from '../../../contracts/hosted'; +import { + HOSTED_TEAM_APPROVAL_MAX_PAGE_BYTES, + HOSTED_TEAM_APPROVAL_MAX_PAGE_TIME_MS, + HOSTED_TEAM_APPROVAL_MAX_SOURCE_ITEMS, + normalizeHostedTeamApprovalItem, + normalizeHostedTeamApprovalRetryAfterMs, +} from '../models/HostedTeamApprovalModels'; + +import type { + HostedTeamApprovalClockPort, + HostedTeamApprovalPageCandidate, + HostedTeamApprovalPageSourcePort, +} from '../ports/HostedTeamApprovalPorts'; + +interface NormalizedCandidate { + readonly item: HostedTeamApprovalItem; + readonly cursorAfter: ReturnType; + readonly bytes: number; +} + +function unavailable(retryAfterMs?: number): GetHostedTeamApprovalPageResult { + return retryAfterMs === undefined + ? Object.freeze({ kind: 'unavailable' }) + : Object.freeze({ kind: 'unavailable', retryAfterMs }); +} + +function normalizeCandidates( + candidates: readonly HostedTeamApprovalPageCandidate[], + expectedTeamId: ReturnType, + requestCursor: ReturnType | null +): readonly NormalizedCandidate[] | null { + try { + const cursors = new Set(); + const approvalIds = new Set(); + const normalized: NormalizedCandidate[] = []; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object') return null; + const item = normalizeHostedTeamApprovalItem(candidate.item, expectedTeamId); + const cursorAfter = parseCursor(candidate.cursorAfter); + if ( + item === null || + cursorAfter === requestCursor || + cursors.has(cursorAfter) || + approvalIds.has(item.approvalId) + ) { + return null; + } + cursors.add(cursorAfter); + approvalIds.add(item.approvalId); + normalized.push( + Object.freeze({ + item, + cursorAfter, + bytes: new TextEncoder().encode(JSON.stringify(item)).byteLength, + }) + ); + } + return Object.freeze(normalized); + } catch { + return null; + } +} + +export class GetHostedTeamApprovalPage { + constructor( + private readonly source: HostedTeamApprovalPageSourcePort, + private readonly clock: HostedTeamApprovalClockPort + ) {} + + async execute( + requestValue: unknown, + context: QueryContext + ): Promise { + const request = parseHostedTeamApprovalPageRequest(requestValue); + if (!request.ok) return Object.freeze({ kind: 'invalid_request' }); + if (context.signal.aborted) return Object.freeze({ kind: 'cancelled' }); + + const startedAtMs = this.clock.now(); + const deadlineAtMs = Math.min( + context.deadlineAtMs, + startedAtMs + HOSTED_TEAM_APPROVAL_MAX_PAGE_TIME_MS + ); + + try { + const result = await this.source.readPage( + Object.freeze({ + teamId: request.value.teamId, + expectedRunId: request.value.expectedRunId, + cursor: request.value.cursor, + itemLimit: Math.min(request.value.limit + 1, HOSTED_TEAM_APPROVAL_MAX_SOURCE_ITEMS), + byteLimit: HOSTED_TEAM_APPROVAL_MAX_PAGE_BYTES, + deadlineAtMs, + }), + context + ); + if (context.signal.aborted) return Object.freeze({ kind: 'cancelled' }); + if (result.kind === 'not_found') return Object.freeze({ kind: 'not_found' }); + if (result.kind === 'unavailable') { + return unavailable(normalizeHostedTeamApprovalRetryAfterMs(result.retryAfterMs)); + } + if ( + result.kind !== 'found' || + parseTeamId(result.teamId) !== request.value.teamId || + !Array.isArray(result.candidates) || + result.candidates.length > HOSTED_TEAM_APPROVAL_MAX_SOURCE_ITEMS || + typeof result.hasMore !== 'boolean' + ) { + return unavailable(); + } + const candidates = normalizeCandidates( + result.candidates, + request.value.teamId, + request.value.cursor + ); + if (candidates === null) return unavailable(); + if (candidates.some(({ item }) => item.runId !== request.value.expectedRunId)) { + return unavailable(); + } + + const selected: NormalizedCandidate[] = []; + let usedBytes = 0; + for (const candidate of candidates) { + if ( + selected.length >= request.value.limit || + usedBytes + candidate.bytes > HOSTED_TEAM_APPROVAL_MAX_PAGE_BYTES || + this.clock.now() >= deadlineAtMs + ) { + break; + } + selected.push(candidate); + usedBytes += candidate.bytes; + } + + const truncated = selected.length < candidates.length || result.hasMore; + const nextCursor = truncated ? (selected.at(-1)?.cursorAfter ?? null) : null; + if (truncated && nextCursor === null) return unavailable(); + + return Object.freeze({ + kind: 'success', + page: Object.freeze({ + schemaVersion: HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + kind: 'approval_page', + teamId: request.value.teamId, + items: Object.freeze(selected.map(({ item }) => item)), + nextCursor, + truncated, + budget: Object.freeze({ + itemLimit: request.value.limit, + byteLimit: HOSTED_TEAM_APPROVAL_MAX_PAGE_BYTES, + timeLimitMs: HOSTED_TEAM_APPROVAL_MAX_PAGE_TIME_MS, + usedItems: selected.length, + usedBytes, + elapsedMs: Math.max(0, this.clock.now() - startedAtMs), + }), + }), + }); + } catch { + return unavailable(); + } + } +} diff --git a/src/features/team-approvals/core/application/use-cases/GetHostedTeamApprovalPreview.ts b/src/features/team-approvals/core/application/use-cases/GetHostedTeamApprovalPreview.ts new file mode 100644 index 0000000000..e9e1b282ee --- /dev/null +++ b/src/features/team-approvals/core/application/use-cases/GetHostedTeamApprovalPreview.ts @@ -0,0 +1,87 @@ +import { + type GetHostedTeamApprovalPreviewResult, + parseHostedTeamApprovalGeneration, + parseHostedTeamApprovalPreviewRequest, +} from '../../../contracts/hosted'; +import { + HOSTED_TEAM_APPROVAL_MAX_PREVIEW_BYTES, + HOSTED_TEAM_APPROVAL_MAX_PREVIEW_TIME_MS, + normalizeHostedTeamApprovalPreview, + normalizeHostedTeamApprovalRetryAfterMs, +} from '../models/HostedTeamApprovalModels'; + +import type { + HostedTeamApprovalClockPort, + HostedTeamApprovalPreviewSourcePort, +} from '../ports/HostedTeamApprovalPorts'; +import type { QueryContext } from '@shared/contracts/hosted'; + +function unavailable(retryAfterMs?: number): GetHostedTeamApprovalPreviewResult { + return retryAfterMs === undefined + ? Object.freeze({ kind: 'unavailable' }) + : Object.freeze({ kind: 'unavailable', retryAfterMs }); +} + +export class GetHostedTeamApprovalPreview { + constructor( + private readonly source: HostedTeamApprovalPreviewSourcePort, + private readonly clock: HostedTeamApprovalClockPort + ) {} + + async execute( + requestValue: unknown, + context: QueryContext + ): Promise { + const request = parseHostedTeamApprovalPreviewRequest(requestValue); + if (!request.ok) return Object.freeze({ kind: 'invalid_request' }); + if (context.signal.aborted) return Object.freeze({ kind: 'cancelled' }); + + const startedAtMs = this.clock.now(); + const deadlineAtMs = Math.min( + context.deadlineAtMs, + startedAtMs + HOSTED_TEAM_APPROVAL_MAX_PREVIEW_TIME_MS + ); + try { + const result = await this.source.readPreview( + Object.freeze({ + teamId: request.value.teamId, + expectedRunId: request.value.expectedRunId, + approvalId: request.value.approvalId, + expectedGeneration: request.value.expectedGeneration, + previewRef: request.value.previewRef, + byteLimit: HOSTED_TEAM_APPROVAL_MAX_PREVIEW_BYTES, + deadlineAtMs, + }), + context + ); + if (context.signal.aborted) return Object.freeze({ kind: 'cancelled' }); + if (result.kind === 'not_found') return Object.freeze({ kind: 'not_found' }); + if (result.kind === 'unavailable') { + return unavailable(normalizeHostedTeamApprovalRetryAfterMs(result.retryAfterMs)); + } + if (result.kind === 'stale_generation') { + const currentGeneration = parseHostedTeamApprovalGeneration(result.currentGeneration); + return currentGeneration === request.value.expectedGeneration + ? unavailable() + : Object.freeze({ kind: result.kind, currentGeneration }); + } + if (result.kind !== 'found') return unavailable(); + + const preview = normalizeHostedTeamApprovalPreview(result.preview, { + teamId: request.value.teamId, + runId: request.value.expectedRunId, + approvalId: request.value.approvalId, + }); + if (preview === null) return unavailable(); + if (preview.generation !== request.value.expectedGeneration) { + return Object.freeze({ + kind: 'stale_generation', + currentGeneration: preview.generation, + }); + } + return Object.freeze({ kind: 'success', preview }); + } catch { + return unavailable(); + } + } +} diff --git a/src/features/team-approvals/core/application/use-cases/ReadToolApprovalFilePreview.test.ts b/src/features/team-approvals/core/application/use-cases/ReadToolApprovalFilePreview.test.ts new file mode 100644 index 0000000000..e4bb91383d --- /dev/null +++ b/src/features/team-approvals/core/application/use-cases/ReadToolApprovalFilePreview.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ReadToolApprovalFilePreview } from './ReadToolApprovalFilePreview'; + +const REQUEST = { + teamName: 'team-one', + runId: 'run-1', + requestId: 'request-1', + filePath: '/workspace/approved.txt', +}; + +describe('ReadToolApprovalFilePreview', () => { + it('reads the exact path returned by the active approval capability', async () => { + const files = { + read: vi.fn(async () => ({ + content: 'approved', + exists: true, + truncated: false, + isBinary: false, + })), + }; + const useCase = new ReadToolApprovalFilePreview({ + pendingApprovals: { + getFileTarget: vi.fn(() => ({ + authorizationGeneration: 'approval-generation-1', + authorizationPath: REQUEST.filePath, + readPath: REQUEST.filePath, + })), + }, + files, + }); + + await expect(useCase.read(REQUEST)).resolves.toMatchObject({ content: 'approved' }); + expect(files.read).toHaveBeenCalledWith(REQUEST.filePath); + }); + + it('rejects lexical aliases instead of reading an untrusted equivalent path', async () => { + const files = { read: vi.fn() }; + const useCase = new ReadToolApprovalFilePreview({ + pendingApprovals: { + getFileTarget: vi.fn(() => ({ + authorizationGeneration: 'approval-generation-1', + authorizationPath: '/workspace/safe/target.txt', + readPath: '/workspace/safe/target.txt', + })), + }, + files, + }); + + await expect( + useCase.read({ + ...REQUEST, + filePath: '/workspace/pivot/../safe/target.txt', + }) + ).resolves.toBeNull(); + expect(files.read).not.toHaveBeenCalled(); + }); + + it('rejects missing or stale approval identities before filesystem access', async () => { + const files = { read: vi.fn() }; + const useCase = new ReadToolApprovalFilePreview({ + pendingApprovals: { getFileTarget: vi.fn(() => null) }, + files, + }); + + await expect(useCase.read(REQUEST)).resolves.toBeNull(); + expect(files.read).not.toHaveBeenCalled(); + }); + + it('preserves relative authorization identity while reading the run-resolved target', async () => { + const files = { + read: vi.fn(async () => ({ + content: 'approved', + exists: true, + truncated: false, + isBinary: false, + })), + }; + const useCase = new ReadToolApprovalFilePreview({ + pendingApprovals: { + getFileTarget: vi.fn(() => ({ + authorizationGeneration: 'approval-generation-1', + authorizationPath: 'src/approved.txt', + readPath: '/workspace/project/src/approved.txt', + })), + }, + files, + }); + + await expect( + useCase.read({ + ...REQUEST, + filePath: 'src/approved.txt', + }) + ).resolves.toMatchObject({ content: 'approved' }); + expect(files.read).toHaveBeenCalledWith('/workspace/project/src/approved.txt'); + }); + + it('discards content when the approval is revoked during the filesystem read', async () => { + let active = true; + const files = { + read: vi.fn(async () => { + active = false; + return { + content: 'stale content', + exists: true, + truncated: false, + isBinary: false, + }; + }), + }; + const useCase = new ReadToolApprovalFilePreview({ + pendingApprovals: { + getFileTarget: vi.fn(() => + active + ? { + authorizationGeneration: 'approval-generation-1', + authorizationPath: REQUEST.filePath, + readPath: REQUEST.filePath, + } + : null + ), + }, + files, + }); + + await expect(useCase.read(REQUEST)).resolves.toBeNull(); + expect(files.read).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/team-approvals/core/application/use-cases/ReadToolApprovalFilePreview.ts b/src/features/team-approvals/core/application/use-cases/ReadToolApprovalFilePreview.ts new file mode 100644 index 0000000000..58cf428e3c --- /dev/null +++ b/src/features/team-approvals/core/application/use-cases/ReadToolApprovalFilePreview.ts @@ -0,0 +1,43 @@ +import type { ToolApprovalFileContent } from '../models/TeamApprovalsModels'; +import type { + PendingToolApprovalFileTargetPort, + ToolApprovalFileReaderPort, + ToolApprovalPreviewReaderPort, +} from '../ports/TeamApprovalsPorts'; +import type { ToolApprovalFileReadRequest } from '@features/team-approvals/contracts'; + +export interface ReadToolApprovalFilePreviewDependencies { + pendingApprovals: PendingToolApprovalFileTargetPort; + files: ToolApprovalFileReaderPort; +} + +export class ReadToolApprovalFilePreview implements ToolApprovalPreviewReaderPort { + constructor(private readonly dependencies: ReadToolApprovalFilePreviewDependencies) {} + + async read(request: ToolApprovalFileReadRequest): Promise { + const target = this.dependencies.pendingApprovals.getFileTarget( + request.teamName, + request.runId, + request.requestId + ); + if (request.filePath !== target?.authorizationPath) { + return null; + } + + const content = await this.dependencies.files.read(target.readPath); + const currentTarget = this.dependencies.pendingApprovals.getFileTarget( + request.teamName, + request.runId, + request.requestId + ); + if ( + currentTarget?.authorizationGeneration !== target.authorizationGeneration || + currentTarget.authorizationPath !== target.authorizationPath || + currentTarget.readPath !== target.readPath + ) { + return null; + } + + return content; + } +} diff --git a/src/features/team-approvals/core/domain/ToolApprovalPreviewPathPolicy.test.ts b/src/features/team-approvals/core/domain/ToolApprovalPreviewPathPolicy.test.ts new file mode 100644 index 0000000000..4cc9f39d49 --- /dev/null +++ b/src/features/team-approvals/core/domain/ToolApprovalPreviewPathPolicy.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { isToolApprovalPreviewPathLexicallyUnsafe } from './ToolApprovalPreviewPathPolicy'; + +describe('ToolApprovalPreviewPathPolicy', () => { + it('rejects POSIX parent components without rejecting dot-containing names', () => { + expect(isToolApprovalPreviewPathLexicallyUnsafe('link/../target.txt', 'posix')).toBe(true); + expect(isToolApprovalPreviewPathLexicallyUnsafe('../target.txt', 'posix')).toBe(true); + expect(isToolApprovalPreviewPathLexicallyUnsafe('safe/..cache/target.txt', 'posix')).toBe( + false + ); + expect(isToolApprovalPreviewPathLexicallyUnsafe('safe/.../target.txt', 'posix')).toBe(false); + }); + + it('rejects Windows parent aliases across both separator forms', () => { + for (const candidate of [ + String.raw`link\..\target.txt`, + String.raw`link\.. \target.txt`, + String.raw`link/.. ./target.txt`, + ]) { + expect(isToolApprovalPreviewPathLexicallyUnsafe(candidate, 'win32')).toBe(true); + } + for (const candidate of [ + String.raw`C:..\target.txt`, + 'C:../target.txt', + String.raw`D:target.txt`, + String.raw`\current-drive-rooted.txt`, + '/current-drive-rooted.txt', + String.raw`\\server-only`, + '///not-a-unc-path.txt', + String.raw`\\?\C:relative\target.txt`, + String.raw`\\.\pipe\preview`, + ]) { + expect(isToolApprovalPreviewPathLexicallyUnsafe(candidate, 'win32')).toBe(true); + } + for (const candidate of [ + String.raw`C:\safe\target.txt`, + 'C:/safe/target.txt', + String.raw`\\server\share\target.txt`, + '//server/share/target.txt', + String.raw`\\?\C:\safe\target.txt`, + String.raw`\\?\UNC\server\share\target.txt`, + ]) { + expect(isToolApprovalPreviewPathLexicallyUnsafe(candidate, 'win32')).toBe(false); + } + expect( + isToolApprovalPreviewPathLexicallyUnsafe(String.raw`safe\..cache\target.txt`, 'win32') + ).toBe(false); + expect(isToolApprovalPreviewPathLexicallyUnsafe(String.raw`safe\...\target.txt`, 'win32')).toBe( + false + ); + }); +}); diff --git a/src/features/team-approvals/core/domain/ToolApprovalPreviewPathPolicy.ts b/src/features/team-approvals/core/domain/ToolApprovalPreviewPathPolicy.ts new file mode 100644 index 0000000000..3174551f5a --- /dev/null +++ b/src/features/team-approvals/core/domain/ToolApprovalPreviewPathPolicy.ts @@ -0,0 +1,41 @@ +export type ToolApprovalPreviewPathPlatform = 'posix' | 'win32'; + +function isWindowsParentPathComponent(component: string): boolean { + if (!component.startsWith('..')) return false; + + let leadingPeriodCount = 0; + while (component[leadingPeriodCount] === '.') leadingPeriodCount += 1; + if (leadingPeriodCount !== 2) return false; + + for (let index = leadingPeriodCount; index < component.length; index += 1) { + const character = component[index]; + if (character !== ' ' && character !== '.') return false; + } + return true; +} + +function isFullyQualifiedWindowsPath(filePath: string): boolean { + if (/^[a-zA-Z]:[\\/]/.test(filePath)) return true; + if (/^[\\/]{2}\?[\\/][a-zA-Z]:[\\/]/.test(filePath)) return true; + if (/^[\\/]{2}\?[\\/]UNC[\\/][^\\/]+[\\/]+[^\\/]+(?:[\\/]|$)/i.test(filePath)) { + return true; + } + return /^[\\/]{2}(?![?.](?:[\\/]|$))[^\\/]+[\\/]+[^\\/]+(?:[\\/]|$)/.test(filePath); +} + +function isPartiallyQualifiedWindowsPath(filePath: string): boolean { + if (/^[a-zA-Z]:/.test(filePath)) return !isFullyQualifiedWindowsPath(filePath); + if (/^[\\/]/.test(filePath)) return !isFullyQualifiedWindowsPath(filePath); + return false; +} + +export function isToolApprovalPreviewPathLexicallyUnsafe( + filePath: string, + platform: ToolApprovalPreviewPathPlatform +): boolean { + if (platform === 'win32') { + if (isPartiallyQualifiedWindowsPath(filePath)) return true; + return filePath.split(/[\\/]+/).some(isWindowsParentPathComponent); + } + return filePath.split('/').includes('..'); +} diff --git a/src/features/team-approvals/core/domain/index.ts b/src/features/team-approvals/core/domain/index.ts new file mode 100644 index 0000000000..e559f94396 --- /dev/null +++ b/src/features/team-approvals/core/domain/index.ts @@ -0,0 +1,2 @@ +export type { ToolApprovalPreviewPathPlatform } from './ToolApprovalPreviewPathPolicy'; +export { isToolApprovalPreviewPathLexicallyUnsafe } from './ToolApprovalPreviewPathPolicy'; diff --git a/src/features/team-approvals/index.ts b/src/features/team-approvals/index.ts new file mode 100644 index 0000000000..714bfbfd6a --- /dev/null +++ b/src/features/team-approvals/index.ts @@ -0,0 +1,2 @@ +export * from './contracts'; +export { isToolApprovalPreviewPathLexicallyUnsafe } from './core/domain'; diff --git a/src/features/team-approvals/main/adapters/input/http/hostedTeamApprovalRoutes.ts b/src/features/team-approvals/main/adapters/input/http/hostedTeamApprovalRoutes.ts new file mode 100644 index 0000000000..fbcbfc8e39 --- /dev/null +++ b/src/features/team-approvals/main/adapters/input/http/hostedTeamApprovalRoutes.ts @@ -0,0 +1,64 @@ +import { + HOSTED_TEAM_APPROVAL_DECISION_ROUTE, + HOSTED_TEAM_APPROVAL_PAGE_ROUTE, + HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE, +} from '../../../../contracts/hosted'; + +import type { RouteDescriptor } from '@main/composition/hosted/routing'; + +export { + HOSTED_TEAM_APPROVAL_DECISION_ROUTE, + HOSTED_TEAM_APPROVAL_PAGE_ROUTE, + HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE, +}; + +const READ_READINESS = Object.freeze(['serve', 'auth', 'read'] as const); +const DECISION_READINESS = Object.freeze(['serve', 'auth', 'mutation'] as const); + +export const HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS = Object.freeze([ + Object.freeze({ + id: 'team-approvals.page.v1', + method: 'POST', + path: HOSTED_TEAM_APPROVAL_PAGE_ROUTE, + owner: 'team-approvals', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session', + readiness: READ_READINESS, + requestSchemaId: 'team-approvals.page.request.v1', + responseSchemaId: 'team-approvals.page.response.v1', + handlerId: 'team-approvals.page.handler.v1', + clientId: 'team-approvals.page.client.v1', + semanticTestId: 'team-approvals.page.semantic.v1', + testOnly: false, + } satisfies RouteDescriptor), + Object.freeze({ + id: 'team-approvals.preview.v1', + method: 'POST', + path: HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE, + owner: 'team-approvals', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session', + readiness: READ_READINESS, + requestSchemaId: 'team-approvals.preview.request.v1', + responseSchemaId: 'team-approvals.preview.response.v1', + handlerId: 'team-approvals.preview.handler.v1', + clientId: 'team-approvals.preview.client.v1', + semanticTestId: 'team-approvals.preview.semantic.v1', + testOnly: false, + } satisfies RouteDescriptor), + Object.freeze({ + id: 'team-approvals.decision.v1', + method: 'POST', + path: HOSTED_TEAM_APPROVAL_DECISION_ROUTE, + owner: 'team-approvals', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session', + readiness: DECISION_READINESS, + requestSchemaId: 'team-approvals.decision.request.v1', + responseSchemaId: 'team-approvals.decision.response.v1', + handlerId: 'team-approvals.decision.handler.v1', + clientId: 'team-approvals.decision.client.v1', + semanticTestId: 'team-approvals.decision.semantic.v1', + testOnly: false, + } satisfies RouteDescriptor), +] satisfies readonly RouteDescriptor[]); diff --git a/src/features/team-approvals/main/adapters/input/http/registerHostedTeamApprovalsHttp.ts b/src/features/team-approvals/main/adapters/input/http/registerHostedTeamApprovalsHttp.ts new file mode 100644 index 0000000000..a701fdd835 --- /dev/null +++ b/src/features/team-approvals/main/adapters/input/http/registerHostedTeamApprovalsHttp.ts @@ -0,0 +1,252 @@ +import { createSafeAppError, type QueryContext } from '@shared/contracts/hosted'; + +import { + type DecideHostedTeamApprovalResult, + type GetHostedTeamApprovalPageResult, + type GetHostedTeamApprovalPreviewResult, + HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + type HostedTeamApprovalDecision, + type HostedTeamApprovalErrorEnvelope, + type HostedTeamApprovalGeneration, +} from '../../../../contracts/hosted'; + +import { HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS } from './hostedTeamApprovalRoutes'; + +import type { + HostedRouteAdmission, + HostedRouteContribution, +} from '@main/composition/hosted/application'; +import type { RouteDescriptor } from '@main/composition/hosted/routing'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export interface HostedTeamApprovalsHttpFacade { + getPage(request: unknown, context: QueryContext): Promise; + getPreview(request: unknown, context: QueryContext): Promise; + decide(request: unknown, context: QueryContext): Promise; +} + +export type HostedTeamApprovalsContextFactory = ( + descriptor: RouteDescriptor, + request: FastifyRequest, + signal: AbortSignal +) => QueryContext | Promise; + +function errorEnvelope( + code: 'conflict' | 'invalid_request' | 'not_found' | 'unavailable', + reason: string, + retryable: boolean, + metadata: { + readonly retryAfterMs?: number; + readonly currentGeneration?: HostedTeamApprovalGeneration; + readonly resolvedDecision?: HostedTeamApprovalDecision; + } = {} +): HostedTeamApprovalErrorEnvelope { + const error = createSafeAppError({ + code, + reason, + ...(metadata.retryAfterMs === undefined ? {} : { retryAfterMs: metadata.retryAfterMs }), + }); + return Object.freeze({ + schemaVersion: HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + kind: 'error', + error, + retryable, + ...(metadata.currentGeneration === undefined + ? {} + : { currentGeneration: metadata.currentGeneration }), + ...(metadata.resolvedDecision === undefined + ? {} + : { resolvedDecision: metadata.resolvedDecision }), + }); +} + +function sendUnavailable(reply: FastifyReply, retryAfterMs?: number): FastifyReply { + if (retryAfterMs !== undefined) { + void reply.header('Retry-After', String(Math.max(1, Math.ceil(retryAfterMs / 1_000)))); + } + return reply + .status(503) + .send(errorEnvelope('unavailable', 'team_approval_unavailable', true, { retryAfterMs })); +} + +function sendPageResult( + reply: FastifyReply, + result: GetHostedTeamApprovalPageResult +): FastifyReply { + switch (result.kind) { + case 'success': + return reply.status(200).send(result.page); + case 'invalid_request': + return reply + .status(400) + .send(errorEnvelope('invalid_request', 'approval_page_request_invalid', false)); + case 'not_found': + return reply.status(404).send(errorEnvelope('not_found', 'team_not_found', false)); + case 'cancelled': + return sendUnavailable(reply); + case 'unavailable': + return sendUnavailable(reply, result.retryAfterMs); + } +} + +function sendPreviewResult( + reply: FastifyReply, + result: GetHostedTeamApprovalPreviewResult +): FastifyReply { + switch (result.kind) { + case 'success': + return reply.status(200).send(result.preview); + case 'invalid_request': + return reply + .status(400) + .send(errorEnvelope('invalid_request', 'approval_preview_request_invalid', false)); + case 'stale_generation': + return reply.status(409).send( + errorEnvelope('conflict', 'stale_generation', false, { + currentGeneration: result.currentGeneration, + }) + ); + case 'not_found': + return reply.status(404).send(errorEnvelope('not_found', 'approval_not_found', false)); + case 'cancelled': + return sendUnavailable(reply); + case 'unavailable': + return sendUnavailable(reply, result.retryAfterMs); + } +} + +function sendDecisionResult( + reply: FastifyReply, + result: DecideHostedTeamApprovalResult +): FastifyReply { + switch (result.kind) { + case 'committed': + case 'idempotent_replay': + return reply.status(200).send(result.receipt); + case 'already_resolved': + return reply.status(409).send( + errorEnvelope('conflict', 'approval_already_resolved', false, { + currentGeneration: result.generation, + resolvedDecision: result.decision, + }) + ); + case 'invalid_request': + return reply + .status(400) + .send(errorEnvelope('invalid_request', 'approval_decision_invalid', false)); + case 'stale_generation': + return reply.status(409).send( + errorEnvelope('conflict', 'stale_generation', false, { + currentGeneration: result.currentGeneration, + }) + ); + case 'conflict': + return reply.status(409).send(errorEnvelope('conflict', result.reason, false)); + case 'expired': + return reply.status(410).send(errorEnvelope('conflict', 'approval_expired', false)); + case 'not_found': + return reply.status(404).send(errorEnvelope('not_found', 'approval_not_found', false)); + case 'unavailable': + return sendUnavailable(reply, result.retryAfterMs); + } +} + +async function withRequestSignal( + request: FastifyRequest, + reply: FastifyReply, + operation: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + request.raw.once('aborted', abort); + request.raw.socket.once('close', abort); + reply.raw.once('close', abort); + if (request.raw.aborted || request.raw.socket.destroyed || reply.raw.destroyed) abort(); + try { + return await operation(controller.signal); + } finally { + request.raw.removeListener('aborted', abort); + request.raw.socket.removeListener('close', abort); + reply.raw.removeListener('close', abort); + } +} + +async function handle( + request: FastifyRequest, + reply: FastifyReply, + descriptor: RouteDescriptor, + routeAdmission: HostedRouteAdmission, + createContext: HostedTeamApprovalsContextFactory, + operation: (context: QueryContext) => Promise, + send: (reply: FastifyReply, result: T) => FastifyReply +): Promise { + void reply.header('Cache-Control', 'no-store'); + try { + return await withRequestSignal(request, reply, async (signal) => { + const invocation = await routeAdmission.invoke(descriptor.id, async () => { + const context = await createContext(descriptor, request, signal); + if (signal.aborted || context.signal !== signal) return null; + return operation(context); + }); + return invocation.admitted && invocation.value !== null + ? send(reply, invocation.value) + : sendUnavailable(reply); + }); + } catch { + return sendUnavailable(reply); + } +} + +export function registerHostedTeamApprovalsHttp( + app: FastifyInstance, + contribution: HostedRouteContribution, + routeAdmission: HostedRouteAdmission, + createContext: HostedTeamApprovalsContextFactory +): void { + if (contribution.id !== 'team-approvals.hosted.v1') { + throw new TypeError('hosted-team-approvals-route-contribution-invalid'); + } + if ( + contribution.routes.length !== HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS.length || + contribution.routes.some( + (descriptor, index) => descriptor !== HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS[index] + ) + ) { + throw new TypeError('hosted-team-approvals-route-contribution-invalid'); + } + const descriptors = HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS; + const facade = contribution.facade; + app.post<{ Body: unknown }>(descriptors[0].path, (request, reply) => + handle( + request, + reply, + descriptors[0], + routeAdmission, + createContext, + (context) => facade.getPage(request.body, context), + sendPageResult + ) + ); + app.post<{ Body: unknown }>(descriptors[1].path, (request, reply) => + handle( + request, + reply, + descriptors[1], + routeAdmission, + createContext, + (context) => facade.getPreview(request.body, context), + sendPreviewResult + ) + ); + app.post<{ Body: unknown }>(descriptors[2].path, (request, reply) => + handle( + request, + reply, + descriptors[2], + routeAdmission, + createContext, + (context) => facade.decide(request.body, context), + sendDecisionResult + ) + ); +} diff --git a/src/features/team-approvals/main/adapters/input/ipc/registerTeamApprovalsIpc.test.ts b/src/features/team-approvals/main/adapters/input/ipc/registerTeamApprovalsIpc.test.ts new file mode 100644 index 0000000000..fa1d05338f --- /dev/null +++ b/src/features/team-approvals/main/adapters/input/ipc/registerTeamApprovalsIpc.test.ts @@ -0,0 +1,301 @@ +import path from 'node:path'; + +import { + TEAM_TOOL_APPROVAL_READ_FILE, + TEAM_TOOL_APPROVAL_RESPOND, + TEAM_TOOL_APPROVAL_SETTINGS, +} from '@features/team-approvals/contracts'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerTeamApprovalsIpc, removeTeamApprovalsIpc } from './registerTeamApprovalsIpc'; + +import type { TeamApprovalsIpcDependencies } from './registerTeamApprovalsIpc'; +import type { ToolApprovalSettings } from '@shared/types'; + +type Handler = (...args: unknown[]) => unknown; + +const CHANNELS = [ + TEAM_TOOL_APPROVAL_RESPOND, + TEAM_TOOL_APPROVAL_READ_FILE, + TEAM_TOOL_APPROVAL_SETTINGS, +]; +const APPROVAL_PREVIEW_PATH = path.resolve('approval preview.txt'); +const APPROVAL_FILE_PATH = path.resolve('approval.txt'); +const VALID_FILE_READ_REQUEST = { + teamName: 'team-one', + runId: 'run-1', + requestId: 'request-1', + filePath: APPROVAL_PREVIEW_PATH, +}; +const VALID_SETTINGS: ToolApprovalSettings = { + autoAllowAll: false, + autoAllowFileEdits: true, + autoAllowSafeBash: false, + timeoutAction: 'deny', + timeoutSeconds: 30, +}; + +describe('team approvals IPC', () => { + const handlers = new Map(); + const ipcMain = { + handle: vi.fn((channel: string, handler: Handler) => { + if (handlers.has(channel)) throw new Error(`Duplicate IPC registration: ${channel}`); + handlers.set(channel, handler); + }), + removeHandler: vi.fn((channel: string) => { + handlers.delete(channel); + }), + }; + const commands = { + respond: vi.fn(async () => undefined), + updateSettings: vi.fn(() => undefined), + }; + const previewReader = { + read: vi.fn(async () => ({ + content: 'preview', + exists: true, + truncated: false, + isBinary: false, + })), + }; + const logger = { error: vi.fn() }; + const dependencies: TeamApprovalsIpcDependencies = { + commands, + previewReader, + logger, + }; + + beforeEach(() => { + handlers.clear(); + vi.clearAllMocks(); + registerTeamApprovalsIpc(ipcMain as never, dependencies); + }); + + it('owns and removes the exact three stable invoke channels', () => { + expect(CHANNELS).toEqual([ + 'team:toolApprovalRespond', + 'team:toolApprovalReadFile', + 'team:toolApprovalSettings', + ]); + expect(ipcMain.handle).toHaveBeenCalledTimes(CHANNELS.length); + expect([...handlers.keys()]).toEqual(CHANNELS); + + removeTeamApprovalsIpc(ipcMain as never); + expect(ipcMain.removeHandler.mock.calls.map(([channel]) => channel)).toEqual(CHANNELS); + expect(handlers.size).toBe(0); + }); + + it('validates a response and preserves non-team string arguments exactly', async () => { + await expect( + handlers.get(TEAM_TOOL_APPROVAL_RESPOND)!( + {}, + ' team-one ', + ' run-1 ', + ' request-1 ', + true, + ' ok ' + ) + ).resolves.toEqual({ success: true, data: undefined }); + + expect(commands.respond).toHaveBeenCalledWith({ + teamName: 'team-one', + runId: ' run-1 ', + requestId: ' request-1 ', + allow: true, + message: ' ok ', + }); + }); + + it.each([ + ['bad team', ['../team', 'run-1', 'request-1', true], 'teamName contains invalid characters'], + ['blank run', ['team-one', ' ', 'request-1', true], 'runId must be a non-empty string'], + ['blank request', ['team-one', 'run-1', '', true], 'requestId must be a non-empty string'], + ['invalid allow', ['team-one', 'run-1', 'request-1', 'yes'], 'allow must be a boolean'], + ])('rejects %s before command execution', async (_label, args, error) => { + await expect(handlers.get(TEAM_TOOL_APPROVAL_RESPOND)!({}, ...args)).resolves.toEqual({ + success: false, + error, + }); + expect(commands.respond).not.toHaveBeenCalled(); + }); + + it('maps response command failures through the legacy teams envelope and logger', async () => { + commands.respond.mockRejectedValueOnce(new Error('runtime rejected')); + + await expect( + handlers.get(TEAM_TOOL_APPROVAL_RESPOND)!({}, 'team-one', 'run-1', 'request-1', false) + ).resolves.toEqual({ success: false, error: 'runtime rejected' }); + expect(logger.error).toHaveBeenCalledWith('[teams:toolApprovalRespond] runtime rejected'); + }); + + it('validates settings and preserves the original team name', async () => { + await expect( + handlers.get(TEAM_TOOL_APPROVAL_SETTINGS)!({}, ' team-one ', VALID_SETTINGS) + ).resolves.toEqual({ success: true, data: undefined }); + expect(commands.updateSettings).toHaveBeenCalledWith({ + teamName: ' team-one ', + settings: VALID_SETTINGS, + }); + }); + + it.each([ + ['blank team', '', VALID_SETTINGS, 'teamName must be a non-empty string'], + ['non-object', 'team-one', null, 'Settings must be an object'], + [ + 'auto allow all', + 'team-one', + { ...VALID_SETTINGS, autoAllowAll: 1 }, + 'autoAllowAll must be a boolean', + ], + [ + 'file edits', + 'team-one', + { ...VALID_SETTINGS, autoAllowFileEdits: 1 }, + 'autoAllowFileEdits must be a boolean', + ], + [ + 'safe bash', + 'team-one', + { ...VALID_SETTINGS, autoAllowSafeBash: 1 }, + 'autoAllowSafeBash must be a boolean', + ], + [ + 'timeout action', + 'team-one', + { ...VALID_SETTINGS, timeoutAction: 'later' }, + 'timeoutAction must be "allow", "deny", or "wait"', + ], + [ + 'timeout seconds type', + 'team-one', + { ...VALID_SETTINGS, timeoutSeconds: Number.NaN }, + 'timeoutSeconds must be a number between 5 and 300', + ], + [ + 'timeout seconds minimum', + 'team-one', + { ...VALID_SETTINGS, timeoutSeconds: 4 }, + 'timeoutSeconds must be a number between 5 and 300', + ], + [ + 'timeout seconds maximum', + 'team-one', + { ...VALID_SETTINGS, timeoutSeconds: 301 }, + 'timeoutSeconds must be a number between 5 and 300', + ], + ])('rejects invalid settings: %s', async (_label, teamName, settings, error) => { + await expect( + handlers.get(TEAM_TOOL_APPROVAL_SETTINGS)!({}, teamName, settings) + ).resolves.toEqual({ success: false, error }); + expect(commands.updateSettings).not.toHaveBeenCalled(); + }); + + it('keeps the synchronous settings failure prefix stable', async () => { + commands.updateSettings.mockImplementationOnce(() => { + throw new Error('settings failed'); + }); + + await expect( + handlers.get(TEAM_TOOL_APPROVAL_SETTINGS)!({}, 'team-one', VALID_SETTINGS) + ).resolves.toEqual({ + success: false, + error: 'Failed to update tool approval settings: settings failed', + }); + }); + + it('validates approval identity and preserves path strings before reading', async () => { + await expect(handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!({}, null)).resolves.toEqual({ + success: false, + error: 'File preview request must be an object', + }); + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!( + {}, + { ...VALID_FILE_READ_REQUEST, teamName: '../team' } + ) + ).resolves.toEqual({ + success: false, + error: 'teamName contains invalid characters', + }); + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!({}, { ...VALID_FILE_READ_REQUEST, runId: '' }) + ).resolves.toEqual({ + success: false, + error: 'runId must be a non-empty string', + }); + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!({}, { ...VALID_FILE_READ_REQUEST, requestId: '' }) + ).resolves.toEqual({ + success: false, + error: 'requestId must be a non-empty string', + }); + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!({}, { ...VALID_FILE_READ_REQUEST, filePath: '' }) + ).resolves.toEqual({ + success: false, + error: 'filePath must be a non-empty string', + }); + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!( + {}, + { ...VALID_FILE_READ_REQUEST, filePath: 'relative.txt' } + ) + ).resolves.toEqual({ + success: true, + data: { content: 'preview', exists: true, truncated: false, isBinary: false }, + }); + expect(previewReader.read).toHaveBeenCalledWith({ + ...VALID_FILE_READ_REQUEST, + filePath: 'relative.txt', + }); + previewReader.read.mockClear(); + + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!({}, VALID_FILE_READ_REQUEST) + ).resolves.toEqual({ + success: true, + data: { content: 'preview', exists: true, truncated: false, isBinary: false }, + }); + expect(previewReader.read).toHaveBeenCalledWith({ + teamName: 'team-one', + runId: 'run-1', + requestId: 'request-1', + filePath: APPROVAL_PREVIEW_PATH, + }); + }); + + it('rejects file reads that do not match the active approval', async () => { + previewReader.read.mockResolvedValueOnce(null); + + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!( + {}, + { ...VALID_FILE_READ_REQUEST, filePath: APPROVAL_FILE_PATH } + ) + ).resolves.toEqual({ + success: false, + error: 'File preview is not authorized for this approval request', + }); + expect(previewReader.read).toHaveBeenCalledOnce(); + }); + + it('contains an unexpected reader rejection in the legacy success envelope', async () => { + previewReader.read.mockRejectedValueOnce(new Error('read failed')); + + await expect( + handlers.get(TEAM_TOOL_APPROVAL_READ_FILE)!( + {}, + { ...VALID_FILE_READ_REQUEST, filePath: APPROVAL_FILE_PATH } + ) + ).resolves.toEqual({ + success: true, + data: { + content: '', + exists: true, + truncated: false, + isBinary: false, + error: 'read failed', + }, + }); + }); +}); diff --git a/src/features/team-approvals/main/adapters/input/ipc/registerTeamApprovalsIpc.ts b/src/features/team-approvals/main/adapters/input/ipc/registerTeamApprovalsIpc.ts new file mode 100644 index 0000000000..213ff61ef9 --- /dev/null +++ b/src/features/team-approvals/main/adapters/input/ipc/registerTeamApprovalsIpc.ts @@ -0,0 +1,208 @@ +import { + TEAM_TOOL_APPROVAL_READ_FILE, + TEAM_TOOL_APPROVAL_RESPOND, + TEAM_TOOL_APPROVAL_SETTINGS, + type ToolApprovalFileReadRequest, +} from '@features/team-approvals/contracts'; +import { validateTeamName } from '@main/ipc/guards'; + +import type { + TeamApprovalsIpcDependencies, + TeamApprovalsIpcEvent, + TeamApprovalsIpcRegistrar, +} from '../../../composition/TeamApprovalsIpcBoundary'; +import type { IpcResult, ToolApprovalFileContent, ToolApprovalSettings } from '@shared/types'; + +export type { + TeamApprovalsIpcDependencies, + TeamApprovalsIpcLogger, +} from '../../../composition/TeamApprovalsIpcBoundary'; + +async function executeCommand( + dependencies: TeamApprovalsIpcDependencies, + operation: string, + command: () => Promise +): Promise> { + try { + await command(); + return { success: true, data: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + dependencies.logger.error(`[teams:${operation}] ${message}`); + return { success: false, error: message }; + } +} + +function validateSettings(settings: unknown): IpcResult | ToolApprovalSettings { + if (typeof settings !== 'object' || settings === null) { + return { success: false, error: 'Settings must be an object' }; + } + + const candidate = settings as Record; + if (typeof candidate.autoAllowAll !== 'boolean') { + return { success: false, error: 'autoAllowAll must be a boolean' }; + } + if (typeof candidate.autoAllowFileEdits !== 'boolean') { + return { success: false, error: 'autoAllowFileEdits must be a boolean' }; + } + if (typeof candidate.autoAllowSafeBash !== 'boolean') { + return { success: false, error: 'autoAllowSafeBash must be a boolean' }; + } + if ( + typeof candidate.timeoutAction !== 'string' || + !['allow', 'deny', 'wait'].includes(candidate.timeoutAction) + ) { + return { success: false, error: 'timeoutAction must be "allow", "deny", or "wait"' }; + } + if ( + typeof candidate.timeoutSeconds !== 'number' || + !Number.isFinite(candidate.timeoutSeconds) || + candidate.timeoutSeconds < 5 || + candidate.timeoutSeconds > 300 + ) { + return { success: false, error: 'timeoutSeconds must be a number between 5 and 300' }; + } + + return candidate as unknown as ToolApprovalSettings; +} + +function validateFileReadRequest(request: unknown): IpcResult | ToolApprovalFileReadRequest { + if (typeof request !== 'object' || request === null) { + return { success: false, error: 'File preview request must be an object' }; + } + + const candidate = request as Record; + const validatedTeamName = validateTeamName(candidate.teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + if (typeof candidate.runId !== 'string' || candidate.runId.trim().length === 0) { + return { success: false, error: 'runId must be a non-empty string' }; + } + if (typeof candidate.requestId !== 'string' || candidate.requestId.trim().length === 0) { + return { success: false, error: 'requestId must be a non-empty string' }; + } + if (typeof candidate.filePath !== 'string' || candidate.filePath.trim().length === 0) { + return { success: false, error: 'filePath must be a non-empty string' }; + } + return { + teamName: validatedTeamName.value!, + runId: candidate.runId, + requestId: candidate.requestId, + filePath: candidate.filePath, + }; +} + +export function registerTeamApprovalsIpc( + ipcMain: TeamApprovalsIpcRegistrar, + dependencies: TeamApprovalsIpcDependencies +): void { + ipcMain.handle( + TEAM_TOOL_APPROVAL_RESPOND, + async ( + _event: TeamApprovalsIpcEvent, + teamName: unknown, + runId: unknown, + requestId: unknown, + allow: unknown, + message?: unknown + ): Promise> => { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + if (typeof runId !== 'string' || runId.trim().length === 0) { + return { success: false, error: 'runId must be a non-empty string' }; + } + if (typeof requestId !== 'string' || requestId.trim().length === 0) { + return { success: false, error: 'requestId must be a non-empty string' }; + } + if (typeof allow !== 'boolean') { + return { success: false, error: 'allow must be a boolean' }; + } + + return executeCommand(dependencies, 'toolApprovalRespond', () => + dependencies.commands.respond({ + teamName: validatedTeamName.value!, + runId, + requestId, + allow, + message: typeof message === 'string' ? message : undefined, + }) + ); + } + ); + + ipcMain.handle( + TEAM_TOOL_APPROVAL_READ_FILE, + async ( + _event: TeamApprovalsIpcEvent, + request: unknown + ): Promise> => { + const validatedRequest = validateFileReadRequest(request); + if ('success' in validatedRequest) return validatedRequest; + + try { + const preview = await dependencies.previewReader.read(validatedRequest); + if (preview === null) { + return { + success: false, + error: 'File preview is not authorized for this approval request', + }; + } + return { + success: true, + data: preview, + }; + } catch (error) { + return { + success: true, + data: { + content: '', + exists: true, + truncated: false, + isBinary: false, + error: error instanceof Error ? error.message : String(error), + }, + }; + } + } + ); + + ipcMain.handle( + TEAM_TOOL_APPROVAL_SETTINGS, + async ( + _event: TeamApprovalsIpcEvent, + teamName: unknown, + settings: unknown + ): Promise> => { + if (typeof teamName !== 'string' || teamName.trim().length === 0) { + return { success: false, error: 'teamName must be a non-empty string' }; + } + + const validatedSettings = validateSettings(settings); + if ('success' in validatedSettings) return validatedSettings; + + try { + dependencies.commands.updateSettings({ + teamName, + settings: validatedSettings, + }); + return { success: true, data: undefined }; + } catch (error) { + return { + success: false, + error: `Failed to update tool approval settings: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + } + ); +} + +export function removeTeamApprovalsIpc(ipcMain: TeamApprovalsIpcRegistrar): void { + ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND); + ipcMain.removeHandler(TEAM_TOOL_APPROVAL_READ_FILE); + ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS); +} diff --git a/src/features/team-approvals/main/adapters/input/runtime-ingress/HostedRuntimePermissionRequestProjector.ts b/src/features/team-approvals/main/adapters/input/runtime-ingress/HostedRuntimePermissionRequestProjector.ts new file mode 100644 index 0000000000..a11a16843a --- /dev/null +++ b/src/features/team-approvals/main/adapters/input/runtime-ingress/HostedRuntimePermissionRequestProjector.ts @@ -0,0 +1,208 @@ +import { + RuntimeIngressPermissionOutbox, + type RuntimeIngressPermissionOutboxRecord, +} from '@features/team-runtime-control'; +import { + deriveRuntimePermissionApprovalIdentity, + parseRuntimePermissionApprovalPayload, +} from '@features/team-runtime-control/contracts'; + +import type { HostedTeamApprovalPendingIngressPort } from '../../../ports/HostedTeamApprovalAuthorityStoragePort'; +import type { + HostedRuntimePermissionIngressAuthorityPort, + HostedTeamApprovalRuntimeBridgeClockPort, +} from '../../../ports/HostedTeamApprovalRuntimeBridgePorts'; +import type { HostedTeamApprovalPendingStorageRecord } from '@features/internal-storage/contracts'; + +const MAX_BATCH_SIZE = 100; + +export interface HostedRuntimePermissionProjectionRequest { + readonly ownerId: string; + readonly leaseToken: string; + readonly leaseDurationMs: number; + readonly limit: number; + readonly deadlineAtMs: number; +} + +export interface HostedRuntimePermissionProjectionResult { + readonly claimed: number; + readonly projected: number; + readonly acknowledged: number; + readonly retained: number; +} + +function currentTime(clock: HostedTeamApprovalRuntimeBridgeClockPort): number | null { + try { + const value = clock.now(); + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } catch { + return null; + } +} + +function isIdentifier(value: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/.test(value); +} + +function isOpenRequest(request: HostedRuntimePermissionProjectionRequest, now: number): boolean { + return ( + isIdentifier(request.ownerId) && + isIdentifier(request.leaseToken) && + Number.isSafeInteger(request.leaseDurationMs) && + request.leaseDurationMs > 0 && + Number.isSafeInteger(request.limit) && + request.limit > 0 && + request.limit <= MAX_BATCH_SIZE && + Number.isSafeInteger(request.deadlineAtMs) && + request.deadlineAtMs > now + ); +} + +function ownsOpenLease( + record: RuntimeIngressPermissionOutboxRecord, + request: HostedRuntimePermissionProjectionRequest, + now: number +): boolean { + const lease = record.lease; + return ( + lease !== null && + lease.ownerId === request.ownerId && + lease.leaseToken === request.leaseToken && + Date.parse(lease.leaseExpiresAtIso) > now + ); +} + +function pendingRecordFor( + record: RuntimeIngressPermissionOutboxRecord, + scope: HostedTeamApprovalPendingStorageRecord['scope'], + deadlineAtMs: number +): HostedTeamApprovalPendingStorageRecord { + const payload = parseRuntimePermissionApprovalPayload(JSON.parse(record.payloadJson) as unknown); + const identity = deriveRuntimePermissionApprovalIdentity({ + teamId: record.authority.teamId, + runId: record.authority.runId, + requestId: record.commandId, + effectRef: record.effectRef, + }); + const requestedAtMs = Date.parse(record.observedAtIso); + const observedAtMs = Date.parse(record.acceptedAtIso); + if ( + !Number.isSafeInteger(requestedAtMs) || + requestedAtMs < 0 || + !Number.isSafeInteger(observedAtMs) || + observedAtMs < requestedAtMs + ) { + throw new TypeError('runtime-permission-projection-observed-at-invalid'); + } + return Object.freeze({ + scope, + runId: identity.runId, + requestId: identity.requestId, + approvalId: identity.approvalId, + approvalGeneration: identity.approvalGeneration, + category: payload.category, + summary: payload.summary, + requestedAtMs, + expiresAtMs: payload.expiresAtMs, + preview: payload.preview, + deliveryRef: payload.deliveryRef, + observedAtMs, + deadlineAtMs, + }); +} + +/** + * Projects committed runtime.permission-request effects through the durable + * pending ingress. It never calls a provider and acknowledges an effect only + * after the idempotent pending record has been persisted. + */ +export class HostedRuntimePermissionRequestProjector { + constructor( + private readonly outbox: RuntimeIngressPermissionOutbox, + private readonly pendingIngress: HostedTeamApprovalPendingIngressPort, + private readonly authority: HostedRuntimePermissionIngressAuthorityPort, + private readonly clock: HostedTeamApprovalRuntimeBridgeClockPort + ) {} + + async project( + request: HostedRuntimePermissionProjectionRequest + ): Promise { + const startedAtMs = currentTime(this.clock); + if (startedAtMs === null || !isOpenRequest(request, startedAtMs)) { + throw new Error('hosted-runtime-permission-projection-unavailable'); + } + const records = await this.outbox.claim({ + ownerId: request.ownerId, + leaseToken: request.leaseToken, + leaseDurationMs: request.leaseDurationMs, + limit: request.limit, + }); + let projected = 0; + let acknowledged = 0; + for (const record of records) { + const beforeProjection = currentTime(this.clock); + if ( + beforeProjection === null || + beforeProjection >= request.deadlineAtMs || + !ownsOpenLease(record, request, beforeProjection) + ) { + continue; + } + try { + const payload = parseRuntimePermissionApprovalPayload( + JSON.parse(record.payloadJson) as unknown + ); + if (payload.expiresAtMs !== null && payload.expiresAtMs <= beforeProjection) continue; + const resolved = await this.authority.resolvePersistedIngressAuthority(record.authority); + if (resolved.status !== 'resolved' || resolved.scope.teamId !== record.authority.teamId) { + continue; + } + const beforeObserve = currentTime(this.clock); + if ( + beforeObserve === null || + beforeObserve >= request.deadlineAtMs || + !ownsOpenLease(record, request, beforeObserve) + ) { + continue; + } + const pending = pendingRecordFor(record, resolved.scope, request.deadlineAtMs); + const observed = await this.pendingIngress.observePending(pending); + if ( + observed.runId !== pending.runId || + observed.requestId !== pending.requestId || + observed.approvalId !== pending.approvalId || + observed.approvalGeneration !== pending.approvalGeneration + ) { + continue; + } + projected += 1; + const beforeAcknowledge = currentTime(this.clock); + if ( + beforeAcknowledge === null || + beforeAcknowledge >= request.deadlineAtMs || + !ownsOpenLease(record, request, beforeAcknowledge) || + record.lease === null + ) { + continue; + } + const result = await this.outbox.acknowledge({ + outboxId: record.outboxId, + generation: record.lease.generation, + ownerId: request.ownerId, + leaseToken: request.leaseToken, + }); + if (result.status === 'acknowledged' || result.status === 'already_acknowledged') { + acknowledged += 1; + } + } catch { + // Preserve the lease-backed effect for recovery; no partial projection is acknowledged. + } + } + return Object.freeze({ + claimed: records.length, + projected, + acknowledged, + retained: records.length - acknowledged, + }); + } +} diff --git a/src/features/team-approvals/main/adapters/output/HostedTeamApprovalAuthorityAdapter.ts b/src/features/team-approvals/main/adapters/output/HostedTeamApprovalAuthorityAdapter.ts new file mode 100644 index 0000000000..b5adf28403 --- /dev/null +++ b/src/features/team-approvals/main/adapters/output/HostedTeamApprovalAuthorityAdapter.ts @@ -0,0 +1,417 @@ +import { parseCursor, parseRunId, parseTeamId } from '@shared/contracts/hosted'; + +import { + type HostedTeamApprovalDecisionCommand, + parseHostedTeamApprovalDecisionCommand, + parseHostedTeamApprovalGeneration, + parseHostedTeamApprovalId, + parseHostedTeamApprovalPreviewRef, +} from '../../../contracts/hosted'; +import { + HOSTED_TEAM_APPROVAL_MAX_PAGE_BYTES, + HOSTED_TEAM_APPROVAL_MAX_PREVIEW_BYTES, + HOSTED_TEAM_APPROVAL_MAX_SOURCE_ITEMS, + normalizeHostedTeamApprovalDecision, + normalizeHostedTeamApprovalItem, + normalizeHostedTeamApprovalPreview, + normalizeHostedTeamApprovalReceipt, + normalizeHostedTeamApprovalRetryAfterMs, +} from '../../../core/application/models/HostedTeamApprovalModels'; + +import type { + HostedTeamApprovalClockPort, + HostedTeamApprovalDecisionAdmissionPort, + HostedTeamApprovalDecisionAdmissionResult, + HostedTeamApprovalPageCandidate, + HostedTeamApprovalPageSourcePort, + HostedTeamApprovalPageSourceRequest, + HostedTeamApprovalPageSourceResult, + HostedTeamApprovalPreviewSourcePort, + HostedTeamApprovalPreviewSourceRequest, + HostedTeamApprovalPreviewSourceResult, +} from '../../../core/application/ports/HostedTeamApprovalPorts'; +import type { HostedTeamApprovalAuthorityPort } from '../../ports/HostedTeamApprovalAuthorityPort'; +import type { QueryContext } from '@shared/contracts/hosted'; + +type UnknownRecord = Record; + +const PAGE_REQUEST_KEYS = Object.freeze([ + 'teamId', + 'expectedRunId', + 'cursor', + 'itemLimit', + 'byteLimit', + 'deadlineAtMs', +] as const); +const PREVIEW_REQUEST_KEYS = Object.freeze([ + 'teamId', + 'expectedRunId', + 'approvalId', + 'expectedGeneration', + 'previewRef', + 'byteLimit', + 'deadlineAtMs', +] as const); + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: UnknownRecord, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value); + return ( + ownKeys.length === keys.length && + ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function isSafeIntegerInRange(value: unknown, minimum: number, maximum: number): value is number { + return ( + Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum + ); +} + +function unavailable(retryAfterMs?: number): { + readonly kind: 'unavailable'; + readonly retryAfterMs?: number; +} { + return retryAfterMs === undefined + ? Object.freeze({ kind: 'unavailable' }) + : Object.freeze({ kind: 'unavailable', retryAfterMs }); +} + +function normalizeUnavailable(value: UnknownRecord): ReturnType { + if (hasExactKeys(value, ['kind'])) return unavailable(); + if (!hasExactKeys(value, ['kind', 'retryAfterMs'])) return unavailable(); + const retryAfterMs = normalizeHostedTeamApprovalRetryAfterMs(value.retryAfterMs); + return retryAfterMs === undefined ? unavailable() : unavailable(retryAfterMs); +} + +function normalizePageRequest( + value: HostedTeamApprovalPageSourceRequest, + context: QueryContext +): HostedTeamApprovalPageSourceRequest | null { + try { + if (!isRecord(value) || !hasExactKeys(value, PAGE_REQUEST_KEYS)) return null; + const teamId = parseTeamId(value.teamId); + const expectedRunId = parseRunId(value.expectedRunId); + const cursor = value.cursor === null ? null : parseCursor(value.cursor); + const itemLimit = value.itemLimit; + const byteLimit = value.byteLimit; + const deadlineAtMs = value.deadlineAtMs; + if ( + !isSafeIntegerInRange(itemLimit, 1, HOSTED_TEAM_APPROVAL_MAX_SOURCE_ITEMS) || + !isSafeIntegerInRange(byteLimit, 1, HOSTED_TEAM_APPROVAL_MAX_PAGE_BYTES) || + !Number.isSafeInteger(deadlineAtMs) || + deadlineAtMs < 0 || + deadlineAtMs > context.deadlineAtMs + ) { + return null; + } + return Object.freeze({ teamId, expectedRunId, cursor, itemLimit, byteLimit, deadlineAtMs }); + } catch { + return null; + } +} + +function normalizePreviewRequest( + value: HostedTeamApprovalPreviewSourceRequest, + context: QueryContext +): HostedTeamApprovalPreviewSourceRequest | null { + try { + if (!isRecord(value) || !hasExactKeys(value, PREVIEW_REQUEST_KEYS)) return null; + const teamId = parseTeamId(value.teamId); + const expectedRunId = parseRunId(value.expectedRunId); + const approvalId = parseHostedTeamApprovalId(value.approvalId); + const expectedGeneration = parseHostedTeamApprovalGeneration(value.expectedGeneration); + const previewRef = parseHostedTeamApprovalPreviewRef(value.previewRef); + const byteLimit = value.byteLimit; + const deadlineAtMs = value.deadlineAtMs; + if ( + !isSafeIntegerInRange(byteLimit, 1, HOSTED_TEAM_APPROVAL_MAX_PREVIEW_BYTES) || + !Number.isSafeInteger(deadlineAtMs) || + deadlineAtMs < 0 || + deadlineAtMs > context.deadlineAtMs + ) { + return null; + } + return Object.freeze({ + teamId, + expectedRunId, + approvalId, + expectedGeneration, + previewRef, + byteLimit, + deadlineAtMs, + }); + } catch { + return null; + } +} + +function normalizeDecisionCommand( + command: HostedTeamApprovalDecisionCommand +): HostedTeamApprovalDecisionCommand | null { + const parsed = parseHostedTeamApprovalDecisionCommand(command); + return parsed.ok ? parsed.value : null; +} + +function normalizeCandidates( + value: unknown, + request: HostedTeamApprovalPageSourceRequest +): readonly HostedTeamApprovalPageCandidate[] | null { + if (!Array.isArray(value) || value.length > request.itemLimit) return null; + + const approvalIds = new Set(); + const cursors = new Set(); + const candidates: HostedTeamApprovalPageCandidate[] = []; + let usedBytes = 0; + + for (const candidateValue of value) { + if (!isRecord(candidateValue) || !hasExactKeys(candidateValue, ['item', 'cursorAfter'])) { + return null; + } + const item = normalizeHostedTeamApprovalItem(candidateValue.item, request.teamId); + if (item === null || item.runId !== request.expectedRunId) return null; + + let cursorAfter: ReturnType; + try { + cursorAfter = parseCursor(candidateValue.cursorAfter); + } catch { + return null; + } + if ( + cursorAfter === request.cursor || + cursors.has(cursorAfter) || + approvalIds.has(item.approvalId) + ) { + return null; + } + + usedBytes += new TextEncoder().encode(JSON.stringify(item)).byteLength; + if (usedBytes > request.byteLimit) return null; + cursors.add(cursorAfter); + approvalIds.add(item.approvalId); + candidates.push(Object.freeze({ item, cursorAfter })); + } + + return Object.freeze(candidates); +} + +function normalizePageResult( + value: unknown, + request: HostedTeamApprovalPageSourceRequest +): HostedTeamApprovalPageSourceResult { + if (!isRecord(value) || typeof value.kind !== 'string') return unavailable(); + if (value.kind === 'not_found') { + return hasExactKeys(value, ['kind']) ? Object.freeze({ kind: 'not_found' }) : unavailable(); + } + if (value.kind === 'unavailable') return normalizeUnavailable(value); + if (value.kind !== 'found' || !hasExactKeys(value, ['kind', 'teamId', 'candidates', 'hasMore'])) { + return unavailable(); + } + + try { + if (parseTeamId(value.teamId) !== request.teamId || typeof value.hasMore !== 'boolean') { + return unavailable(); + } + const candidates = normalizeCandidates(value.candidates, request); + if (candidates === null || (value.hasMore && candidates.length === 0)) return unavailable(); + return Object.freeze({ + kind: 'found', + teamId: request.teamId, + candidates, + hasMore: value.hasMore, + }); + } catch { + return unavailable(); + } +} + +function normalizePreviewResult( + value: unknown, + request: HostedTeamApprovalPreviewSourceRequest +): HostedTeamApprovalPreviewSourceResult { + if (!isRecord(value) || typeof value.kind !== 'string') return unavailable(); + if (value.kind === 'not_found') { + return hasExactKeys(value, ['kind']) ? Object.freeze({ kind: 'not_found' }) : unavailable(); + } + if (value.kind === 'unavailable') return normalizeUnavailable(value); + if (value.kind === 'stale_generation') { + if (!hasExactKeys(value, ['kind', 'currentGeneration'])) return unavailable(); + try { + const currentGeneration = parseHostedTeamApprovalGeneration(value.currentGeneration); + return currentGeneration === request.expectedGeneration + ? unavailable() + : Object.freeze({ kind: 'stale_generation', currentGeneration }); + } catch { + return unavailable(); + } + } + if (value.kind !== 'found' || !hasExactKeys(value, ['kind', 'preview'])) return unavailable(); + + const preview = normalizeHostedTeamApprovalPreview(value.preview, { + teamId: request.teamId, + runId: request.expectedRunId, + approvalId: request.approvalId, + }); + if ( + preview === null || + preview.generation !== request.expectedGeneration || + preview.byteLength > request.byteLimit + ) { + return unavailable(); + } + return Object.freeze({ + kind: 'found', + preview: Object.freeze({ + teamId: preview.teamId, + runId: preview.runId, + approvalId: preview.approvalId, + generation: preview.generation, + content: preview.content, + byteLength: preview.byteLength, + truncated: preview.truncated, + isBinary: preview.isBinary, + }), + }); +} + +function normalizeDecisionResult( + value: unknown, + command: HostedTeamApprovalDecisionCommand +): HostedTeamApprovalDecisionAdmissionResult { + if (!isRecord(value) || typeof value.kind !== 'string') return unavailable(); + if (value.kind === 'unavailable') return normalizeUnavailable(value); + if (value.kind === 'committed' || value.kind === 'idempotent_replay') { + if (!hasExactKeys(value, ['kind', 'receipt'])) return unavailable(); + const receipt = normalizeHostedTeamApprovalReceipt(value.receipt, { + outcome: value.kind, + teamId: command.teamId, + runId: command.expectedRunId, + approvalId: command.approvalId, + generation: command.expectedGeneration, + decision: command.decision, + }); + return receipt === null ? unavailable() : Object.freeze({ kind: value.kind, receipt }); + } + if (value.kind === 'already_resolved') { + if (!hasExactKeys(value, ['kind', 'generation', 'decision'])) return unavailable(); + try { + const generation = parseHostedTeamApprovalGeneration(value.generation); + const decision = normalizeHostedTeamApprovalDecision(value.decision); + return generation !== command.expectedGeneration || decision === null + ? unavailable() + : Object.freeze({ kind: 'already_resolved', generation, decision }); + } catch { + return unavailable(); + } + } + if (value.kind === 'stale_generation') { + if (!hasExactKeys(value, ['kind', 'currentGeneration'])) return unavailable(); + try { + const currentGeneration = parseHostedTeamApprovalGeneration(value.currentGeneration); + return currentGeneration === command.expectedGeneration + ? unavailable() + : Object.freeze({ kind: 'stale_generation', currentGeneration }); + } catch { + return unavailable(); + } + } + if (value.kind === 'conflict') { + return hasExactKeys(value, ['kind', 'reason']) && value.reason === 'idempotency_mismatch' + ? Object.freeze({ kind: 'conflict', reason: 'idempotency_mismatch' }) + : unavailable(); + } + if (value.kind === 'expired' || value.kind === 'not_found') { + return hasExactKeys(value, ['kind']) ? Object.freeze({ kind: value.kind }) : unavailable(); + } + return unavailable(); +} + +export class HostedTeamApprovalAuthorityAdapter + implements + HostedTeamApprovalPageSourcePort, + HostedTeamApprovalPreviewSourcePort, + HostedTeamApprovalDecisionAdmissionPort +{ + constructor( + private readonly authority: HostedTeamApprovalAuthorityPort, + private readonly clock: HostedTeamApprovalClockPort + ) {} + + private isActive(context: QueryContext, deadlineAtMs: number): boolean { + try { + const now = this.clock.now(); + return ( + context.signal instanceof AbortSignal && + !context.signal.aborted && + Number.isSafeInteger(context.deadlineAtMs) && + context.deadlineAtMs >= 0 && + Number.isSafeInteger(deadlineAtMs) && + deadlineAtMs >= 0 && + deadlineAtMs <= context.deadlineAtMs && + Number.isSafeInteger(now) && + now >= 0 && + now < deadlineAtMs + ); + } catch { + return false; + } + } + + async readPage( + requestValue: HostedTeamApprovalPageSourceRequest, + context: QueryContext + ): Promise { + const request = normalizePageRequest(requestValue, context); + if (request === null || !this.isActive(context, request.deadlineAtMs)) { + return unavailable(); + } + try { + const result = await this.authority.readPendingPage(request, context); + return this.isActive(context, request.deadlineAtMs) + ? normalizePageResult(result, request) + : unavailable(); + } catch { + return unavailable(); + } + } + + async readPreview( + requestValue: HostedTeamApprovalPreviewSourceRequest, + context: QueryContext + ): Promise { + const request = normalizePreviewRequest(requestValue, context); + if (request === null || !this.isActive(context, request.deadlineAtMs)) { + return unavailable(); + } + try { + const result = await this.authority.readPreviewByOpaqueRef(request, context); + return this.isActive(context, request.deadlineAtMs) + ? normalizePreviewResult(result, request) + : unavailable(); + } catch { + return unavailable(); + } + } + + async admit( + commandValue: HostedTeamApprovalDecisionCommand, + context: QueryContext + ): Promise { + const command = normalizeDecisionCommand(commandValue); + if (command === null || !this.isActive(context, context.deadlineAtMs)) { + return unavailable(); + } + try { + const result = await this.authority.compareAndClaimDecision(command, context); + return this.isActive(context, context.deadlineAtMs) + ? normalizeDecisionResult(result, command) + : unavailable(); + } catch { + return unavailable(); + } + } +} diff --git a/src/features/team-approvals/main/adapters/output/InternalStorageHostedTeamApprovalAuthority.ts b/src/features/team-approvals/main/adapters/output/InternalStorageHostedTeamApprovalAuthority.ts new file mode 100644 index 0000000000..ccdfeb6158 --- /dev/null +++ b/src/features/team-approvals/main/adapters/output/InternalStorageHostedTeamApprovalAuthority.ts @@ -0,0 +1,404 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { parseCursor, parseRunId, parseTeamId, type QueryContext } from '@shared/contracts/hosted'; + +import { + parseHostedTeamApprovalGeneration, + parseHostedTeamApprovalId, + parseHostedTeamApprovalPreviewRef, +} from '../../../contracts/hosted'; + +import type { + HostedTeamApprovalDecision, + HostedTeamApprovalDecisionCommand, + HostedTeamApprovalDecisionReceipt, +} from '../../../contracts/hosted'; +import type { + HostedTeamApprovalClockPort, + HostedTeamApprovalDecisionAdmissionResult, + HostedTeamApprovalPageSourceRequest, + HostedTeamApprovalPageSourceResult, + HostedTeamApprovalPreviewSourceRequest, + HostedTeamApprovalPreviewSourceResult, +} from '../../../core/application/ports/HostedTeamApprovalPorts'; +import type { HostedTeamApprovalAuthorityPort } from '../../ports/HostedTeamApprovalAuthorityPort'; +import type { + HostedTeamApprovalAuthorityScopeResolverPort, + HostedTeamApprovalDeliveryOutboxPort, + HostedTeamApprovalPendingIngressPort, +} from '../../ports/HostedTeamApprovalAuthorityStoragePort'; +import type { + HostedTeamApprovalAuthorityScope, + HostedTeamApprovalAuthorityStorageGateway, + HostedTeamApprovalDeliveryAcknowledgeRequest, + HostedTeamApprovalDeliveryClaimRequest, + HostedTeamApprovalDeliveryOperatorRequiredRequest, + HostedTeamApprovalDeliveryReconciliationReadResult, + HostedTeamApprovalDeliveryReconciliationRequest, + HostedTeamApprovalDeliveryReconciliationSettleRequest, + HostedTeamApprovalDeliveryRecord, + HostedTeamApprovalPendingReadRecord, + HostedTeamApprovalPendingStorageRecord, +} from '@features/internal-storage/contracts'; + +const CURSOR_PREFIX = 'cursor_'; + +export interface HostedTeamApprovalAuthorityIdFactory { + nextAuditId(): string; + nextDeliveryId(): string; +} + +export interface InternalStorageHostedTeamApprovalAuthorityDependencies { + readonly storage: HostedTeamApprovalAuthorityStorageGateway; + readonly scopeResolver: HostedTeamApprovalAuthorityScopeResolverPort; + readonly clock?: HostedTeamApprovalClockPort; + readonly ids?: HostedTeamApprovalAuthorityIdFactory; + readonly onDecisionCommitted?: () => void; +} + +function defaultIds(): HostedTeamApprovalAuthorityIdFactory { + return Object.freeze({ + nextAuditId: () => `approval_audit_${randomUUID().replaceAll('-', '')}`, + nextDeliveryId: () => `approval_delivery_${randomUUID().replaceAll('-', '')}`, + }); +} + +function unavailable(): HostedTeamApprovalDecisionAdmissionResult { + return Object.freeze({ kind: 'unavailable' }); +} + +function pageUnavailable(): HostedTeamApprovalPageSourceResult { + return Object.freeze({ kind: 'unavailable' }); +} + +function previewUnavailable(): HostedTeamApprovalPreviewSourceResult { + return Object.freeze({ kind: 'unavailable' }); +} + +function contextOpen( + context: QueryContext, + clock: HostedTeamApprovalClockPort, + deadlineAtMs: number +): boolean { + try { + const now = clock.now(); + return ( + context.signal instanceof AbortSignal && + !context.signal.aborted && + Number.isSafeInteger(context.deadlineAtMs) && + context.deadlineAtMs >= 0 && + Number.isSafeInteger(deadlineAtMs) && + deadlineAtMs >= 0 && + deadlineAtMs <= context.deadlineAtMs && + Number.isSafeInteger(now) && + now >= 0 && + now < deadlineAtMs + ); + } catch { + return false; + } +} + +function generationHash(generation: string): string { + return createHash('sha256').update(generation).digest('hex'); +} + +function parseAfterApprovalCursor(cursor: string | null): { + readonly approvalId: string | null; + readonly approvalGenerationHash: string | null; +} { + if (cursor === null) return { approvalId: null, approvalGenerationHash: null }; + const parsed = parseCursor(cursor); + if (!parsed.startsWith(CURSOR_PREFIX)) { + throw new TypeError('hosted-team-approval-authority-cursor-invalid'); + } + const separator = parsed.lastIndexOf('.'); + const approvalId = parseHostedTeamApprovalId(parsed.slice(CURSOR_PREFIX.length, separator)); + const approvalGenerationHash = parsed.slice(separator + 1); + if ( + !/^[a-f0-9]{64}$/.test(approvalGenerationHash) || + `${CURSOR_PREFIX}${approvalId}.${approvalGenerationHash}` !== parsed + ) { + throw new TypeError('hosted-team-approval-authority-cursor-invalid'); + } + return { approvalId, approvalGenerationHash }; +} + +function cursorForApproval( + approvalId: string, + approvalGeneration: string +): ReturnType { + return parseCursor( + `${CURSOR_PREFIX}${parseHostedTeamApprovalId(approvalId)}.${generationHash(approvalGeneration)}` + ); +} + +function receipt( + outcome: 'committed' | 'idempotent_replay', + command: HostedTeamApprovalDecisionCommand +): HostedTeamApprovalDecisionReceipt { + return Object.freeze({ + schemaVersion: command.schemaVersion, + outcome, + teamId: command.teamId, + runId: command.expectedRunId, + approvalId: command.approvalId, + generation: command.expectedGeneration, + decision: command.decision, + }); +} + +function parseDecision(value: unknown): HostedTeamApprovalDecision { + if (value !== 'allow' && value !== 'deny') { + throw new TypeError('hosted-team-approval-authority-decision-invalid'); + } + return value; +} + +function browserDecisionIntentHash( + scope: HostedTeamApprovalAuthorityScope, + command: HostedTeamApprovalDecisionCommand +): string { + return createHash('sha256') + .update( + JSON.stringify({ + schemaVersion: 1, + teamId: scope.teamId, + expectedRunId: command.expectedRunId, + approvalId: command.approvalId, + approvalGeneration: command.expectedGeneration, + decision: command.decision, + }) + ) + .digest('hex'); +} + +function scopeMatches( + scope: HostedTeamApprovalAuthorityScope, + teamId: string, + context: QueryContext +): boolean { + return scope.principalId === context.actorId && scope.teamId === teamId; +} + +/** + * Concrete durable authority. It binds browser context to a trusted scope, + * while lifecycle ingress and outbox delivery remain explicit external-owner + * operations. It deliberately has no process or provider capability. + */ +export class InternalStorageHostedTeamApprovalAuthority + implements + HostedTeamApprovalAuthorityPort, + HostedTeamApprovalPendingIngressPort, + HostedTeamApprovalDeliveryOutboxPort +{ + private readonly clock: HostedTeamApprovalClockPort; + private readonly ids: HostedTeamApprovalAuthorityIdFactory; + + constructor( + private readonly dependencies: InternalStorageHostedTeamApprovalAuthorityDependencies + ) { + this.clock = dependencies.clock ?? Object.freeze({ now: Date.now }); + this.ids = dependencies.ids ?? defaultIds(); + } + + async observePending( + record: HostedTeamApprovalPendingStorageRecord + ): Promise { + return this.dependencies.storage.hostedTeamApprovalObserve(record); + } + + async claimDeliveries( + request: HostedTeamApprovalDeliveryClaimRequest + ): Promise { + return this.dependencies.storage.hostedTeamApprovalClaimDeliveries(request); + } + + async acknowledgeDelivery(request: HostedTeamApprovalDeliveryAcknowledgeRequest): Promise { + await this.dependencies.storage.hostedTeamApprovalAcknowledgeDelivery(request); + } + + async markDeliveryOperatorRequired( + request: HostedTeamApprovalDeliveryOperatorRequiredRequest + ): Promise { + await this.dependencies.storage.hostedTeamApprovalMarkDeliveryOperatorRequired(request); + } + + async readDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationRequest + ): Promise { + return this.dependencies.storage.hostedTeamApprovalReadDeliveryReconciliation(request); + } + + async settleDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationSettleRequest + ): Promise { + await this.dependencies.storage.hostedTeamApprovalSettleDeliveryReconciliation(request); + } + + async readPendingPage( + request: HostedTeamApprovalPageSourceRequest, + context: QueryContext + ): Promise { + if (!contextOpen(context, this.clock, request.deadlineAtMs)) return pageUnavailable(); + try { + const scope = await this.resolveScope(request.teamId, context, request.deadlineAtMs); + if (scope === null) return Object.freeze({ kind: 'not_found' }); + const after = parseAfterApprovalCursor(request.cursor); + const result = await this.dependencies.storage.hostedTeamApprovalReadPending({ + scope, + expectedRunId: request.expectedRunId, + afterApprovalId: after.approvalId, + afterApprovalGenerationHash: after.approvalGenerationHash, + limit: request.itemLimit, + deadlineAtMs: request.deadlineAtMs, + }); + if (!contextOpen(context, this.clock, request.deadlineAtMs)) return pageUnavailable(); + return Object.freeze({ + kind: 'found', + teamId: request.teamId, + candidates: Object.freeze( + result.records.map((record) => + Object.freeze({ + item: Object.freeze({ + teamId: request.teamId, + runId: parseRunId(record.runId), + approvalId: parseHostedTeamApprovalId(record.approvalId), + generation: parseHostedTeamApprovalGeneration(record.approvalGeneration), + category: record.category, + summary: record.summary, + requestedAtMs: record.requestedAtMs, + expiresAtMs: record.expiresAtMs, + previewRef: + record.previewRef === null + ? null + : parseHostedTeamApprovalPreviewRef(record.previewRef), + }), + cursorAfter: cursorForApproval(record.approvalId, record.approvalGeneration), + }) + ) + ), + hasMore: result.hasMore, + }); + } catch { + return pageUnavailable(); + } + } + + async readPreviewByOpaqueRef( + request: HostedTeamApprovalPreviewSourceRequest, + context: QueryContext + ): Promise { + if (!contextOpen(context, this.clock, request.deadlineAtMs)) return previewUnavailable(); + try { + const scope = await this.resolveScope(request.teamId, context, request.deadlineAtMs); + if (scope === null) return Object.freeze({ kind: 'not_found' }); + const result = await this.dependencies.storage.hostedTeamApprovalReadPreview({ + scope, + expectedRunId: request.expectedRunId, + approvalId: request.approvalId, + expectedApprovalGeneration: request.expectedGeneration, + previewRef: request.previewRef, + deadlineAtMs: request.deadlineAtMs, + }); + if (!contextOpen(context, this.clock, request.deadlineAtMs)) return previewUnavailable(); + if (result.kind === 'not_found') return result; + if (result.kind === 'stale_generation') { + return Object.freeze({ + kind: 'stale_generation', + currentGeneration: parseHostedTeamApprovalGeneration(result.currentApprovalGeneration), + }); + } + return Object.freeze({ + kind: 'found', + preview: Object.freeze({ + teamId: request.teamId, + runId: request.expectedRunId, + approvalId: request.approvalId, + generation: request.expectedGeneration, + content: result.preview.content, + byteLength: result.preview.byteLength, + truncated: result.preview.truncated, + isBinary: result.preview.isBinary, + }), + }); + } catch { + return previewUnavailable(); + } + } + + async compareAndClaimDecision( + command: HostedTeamApprovalDecisionCommand, + context: QueryContext + ): Promise { + if (!contextOpen(context, this.clock, context.deadlineAtMs)) return unavailable(); + try { + const scope = await this.resolveScope(command.teamId, context, context.deadlineAtMs); + if (scope === null) return Object.freeze({ kind: 'not_found' }); + if (!contextOpen(context, this.clock, context.deadlineAtMs)) return unavailable(); + const result = await this.dependencies.storage.hostedTeamApprovalDecide({ + scope, + expectedRunId: command.expectedRunId, + approvalId: command.approvalId, + expectedApprovalGeneration: command.expectedGeneration, + idempotencyKey: command.idempotencyKey, + decision: command.decision, + payloadHash: browserDecisionIntentHash(scope, command), + audit: { + auditId: this.ids.nextAuditId(), + principalId: context.actorId, + sessionId: context.sessionId, + }, + delivery: { + deliveryId: this.ids.nextDeliveryId(), + }, + deadlineAtMs: context.deadlineAtMs, + }); + if (!contextOpen(context, this.clock, context.deadlineAtMs)) return unavailable(); + if (result.kind === 'committed' || result.kind === 'idempotent_replay') { + if ( + result.receipt.approvalGeneration !== command.expectedGeneration || + result.receipt.decision !== command.decision + ) { + return unavailable(); + } + if (result.kind === 'committed') this.dependencies.onDecisionCommitted?.(); + return Object.freeze({ kind: result.kind, receipt: receipt(result.kind, command) }); + } + if (result.kind === 'already_resolved') { + return Object.freeze({ + kind: 'already_resolved', + generation: parseHostedTeamApprovalGeneration(result.approvalGeneration), + decision: parseDecision(result.decision), + }); + } + if (result.kind === 'stale_generation') { + return Object.freeze({ + kind: 'stale_generation', + currentGeneration: parseHostedTeamApprovalGeneration(result.currentApprovalGeneration), + }); + } + return result; + } catch { + return unavailable(); + } + } + + private async resolveScope( + teamId: string, + context: QueryContext, + deadlineAtMs: number + ): Promise { + if (!contextOpen(context, this.clock, deadlineAtMs)) return null; + const parsedTeamId = parseTeamId(teamId); + const scope = await this.dependencies.scopeResolver.resolveScope(parsedTeamId, context); + if ( + scope === null || + !scopeMatches(scope, parsedTeamId, context) || + !contextOpen(context, this.clock, deadlineAtMs) + ) { + return null; + } + return scope; + } +} diff --git a/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalDecisionDeliveryCoordinator.ts b/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalDecisionDeliveryCoordinator.ts new file mode 100644 index 0000000000..2b5b0bfd68 --- /dev/null +++ b/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalDecisionDeliveryCoordinator.ts @@ -0,0 +1,216 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import type { HostedTeamApprovalDeliveryOutboxPort } from '../../../ports/HostedTeamApprovalAuthorityStoragePort'; +import type { + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedTeamApprovalRuntimeBridgeClockPort, +} from '../../../ports/HostedTeamApprovalRuntimeBridgePorts'; +import type { HostedTeamApprovalDeliveryRecord } from '@features/internal-storage/contracts'; + +const MAX_LEASE_DURATION_MS = 5 * 60 * 1_000; +const MAX_BATCH_SIZE = 100; +/** Longer than the runtime wire's maximum 60 second exchange timeout. */ +const BOUNDARY_LEASE_DURATION_MS = 2 * 60 * 1_000; + +export interface HostedApprovalDecisionDeliveryRequest { + readonly workspaceId: string; + readonly teamId: string; + readonly authorityGeneration: string; + readonly restoreGeneration: number; + readonly ownerId: string; + readonly leaseToken: string; + readonly leaseDurationMs: number; + readonly limit: number; + readonly deadlineAtMs: number; +} + +export interface HostedApprovalDecisionDeliveryResult { + readonly claimed: number; + readonly delivered: number; + readonly acknowledged: number; + readonly retained: number; + readonly operatorRequired: number; +} + +export type HostedApprovalDeliveryClaimTokenFactory = () => string; + +function createClaimToken(): string { + return `approval-claim_${randomBytes(24).toString('hex')}`; +} + +function currentTime(clock: HostedTeamApprovalRuntimeBridgeClockPort): number | null { + try { + const value = clock.now(); + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } catch { + return null; + } +} + +function isIdentifier(value: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/.test(value); +} + +function isOpenRequest(request: HostedApprovalDecisionDeliveryRequest, now: number): boolean { + return ( + isIdentifier(request.ownerId) && + isIdentifier(request.leaseToken) && + Number.isSafeInteger(request.leaseDurationMs) && + request.leaseDurationMs > 0 && + request.leaseDurationMs <= MAX_LEASE_DURATION_MS && + Number.isSafeInteger(request.limit) && + request.limit > 0 && + request.limit <= MAX_BATCH_SIZE && + Number.isSafeInteger(request.deadlineAtMs) && + request.deadlineAtMs > now + ); +} + +function ownsOpenLease( + record: HostedTeamApprovalDeliveryRecord, + request: HostedApprovalDecisionDeliveryRequest, + now: number +): boolean { + return ( + record.ownerId === request.ownerId && + record.leaseToken === request.leaseToken && + record.leaseExpiresAtMs > now + ); +} + +function reconciliationRef(record: HostedTeamApprovalDeliveryRecord): string { + const digest = createHash('sha256') + .update(`${record.deliveryId}\0${record.deliveryGeneration}`) + .digest('hex'); + return `approval-reconciliation_${digest}`; +} + +/** + * Delivers only records claimed from the durable approval decision outbox. + * Its provider-facing conversation is a narrow external lifecycle port; it + * never starts, stops, or otherwise owns a runtime lifecycle. + */ +export class HostedApprovalDecisionDeliveryCoordinator { + constructor( + private readonly deliveryOutbox: HostedTeamApprovalDeliveryOutboxPort, + private readonly externalDelivery: HostedApprovalDecisionExternalLifecycleDeliveryPort, + private readonly clock: HostedTeamApprovalRuntimeBridgeClockPort, + private readonly claimTokenFactory: HostedApprovalDeliveryClaimTokenFactory = createClaimToken + ) {} + + async deliver( + request: HostedApprovalDecisionDeliveryRequest + ): Promise { + const startedAtMs = currentTime(this.clock); + if (startedAtMs === null || !isOpenRequest(request, startedAtMs)) { + throw new Error('hosted-approval-delivery-unavailable'); + } + const claimLeaseToken = this.claimTokenFactory(); + if (!isIdentifier(claimLeaseToken) || claimLeaseToken === request.leaseToken) { + throw new Error('hosted-approval-delivery-claim-token-invalid'); + } + const claimRequest = Object.freeze({ ...request, leaseToken: claimLeaseToken }); + let records: readonly HostedTeamApprovalDeliveryRecord[]; + try { + records = await this.deliveryOutbox.claimDeliveries({ + workspaceId: request.workspaceId, + teamId: request.teamId, + authorityGeneration: request.authorityGeneration, + restoreGeneration: request.restoreGeneration, + ownerId: request.ownerId, + leaseToken: claimLeaseToken, + leaseDurationMs: request.leaseDurationMs, + limit: request.limit, + deadlineAtMs: request.deadlineAtMs, + }); + } catch (error) { + throw new Error('hosted-approval-delivery-claim-unavailable', { cause: error }); + } + let delivered = 0; + let acknowledged = 0; + let operatorRequired = 0; + for (const record of records) { + let boundaryFenced = false; + const beforeDelivery = currentTime(this.clock); + if ( + beforeDelivery === null || + beforeDelivery >= request.deadlineAtMs || + record.partition.teamId !== request.teamId || + !ownsOpenLease(record, claimRequest, beforeDelivery) + ) { + continue; + } + try { + const stableReconciliationRef = reconciliationRef(record); + await this.deliveryOutbox.markDeliveryOperatorRequired({ + workspaceId: record.workspaceId, + authorityGeneration: record.authorityGeneration, + restoreGeneration: record.restoreGeneration, + partition: record.partition, + deliveryId: record.deliveryId, + approvalGeneration: record.approvalGeneration, + deliveryGeneration: record.deliveryGeneration, + ownerId: request.ownerId, + leaseToken: claimLeaseToken, + deadlineAtMs: request.deadlineAtMs, + reconciliationRef: stableReconciliationRef, + boundaryLeaseDurationMs: BOUNDARY_LEASE_DURATION_MS, + }); + boundaryFenced = true; + const fencedGeneration = record.deliveryGeneration + 1; + const delivery = await this.externalDelivery.deliverRuntimePermissionDecision({ + providerDeliveryId: record.deliveryId, + reconciliationRef: stableReconciliationRef, + principal: record.principal, + deliveryRef: record.deliveryRef, + approvalId: record.approvalId, + approvalGeneration: record.approvalGeneration, + decision: record.decision, + partition: record.partition, + requestId: record.requestId, + }); + if (delivery.status === 'operator_required') { + if (delivery.reconciliationRef !== stableReconciliationRef) { + throw new Error('hosted-approval-delivery-reconciliation-reference-mismatch'); + } + operatorRequired += 1; + continue; + } + if (delivery.status !== 'delivered' && delivery.status !== 'idempotent_replay') { + operatorRequired += 1; + continue; + } + delivered += 1; + const beforeAcknowledge = currentTime(this.clock); + if (beforeAcknowledge === null || beforeAcknowledge >= request.deadlineAtMs) { + continue; + } + await this.deliveryOutbox.settleDeliveryReconciliation({ + workspaceId: record.workspaceId, + authorityGeneration: record.authorityGeneration, + restoreGeneration: record.restoreGeneration, + partition: record.partition, + deliveryId: record.deliveryId, + approvalGeneration: record.approvalGeneration, + deliveryGeneration: fencedGeneration, + reconciliationRef: stableReconciliationRef, + ownerId: request.ownerId, + leaseToken: claimLeaseToken, + deadlineAtMs: request.deadlineAtMs, + outcome: 'delivered', + }); + acknowledged += 1; + } catch { + // Once fenced, every failure is an ambiguous provider effect and remains quarantined. + if (boundaryFenced) operatorRequired += 1; + } + } + return Object.freeze({ + claimed: records.length, + delivered, + acknowledged, + retained: records.length - acknowledged, + operatorRequired, + }); + } +} diff --git a/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalDecisionReconciliationCoordinator.ts b/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalDecisionReconciliationCoordinator.ts new file mode 100644 index 0000000000..691955e8c6 --- /dev/null +++ b/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalDecisionReconciliationCoordinator.ts @@ -0,0 +1,109 @@ +import type { HostedTeamApprovalDeliveryOutboxPort } from '../../../ports/HostedTeamApprovalAuthorityStoragePort'; +import type { + HostedApprovalDecisionReconciliationPort, + HostedTeamApprovalRuntimeBridgeClockPort, +} from '../../../ports/HostedTeamApprovalRuntimeBridgePorts'; +import type { HostedTeamApprovalPartition } from '@features/internal-storage/contracts'; + +export interface HostedApprovalDecisionReconciliationRequest { + readonly workspaceId: string; + readonly authorityGeneration: string; + readonly restoreGeneration: number; + readonly partition: HostedTeamApprovalPartition; + readonly providerDeliveryId: string; + readonly approvalGeneration: string; + readonly deliveryGeneration: number; + readonly reconciliationRef: string; + readonly ownerId: string; + readonly leaseToken: string; + readonly deadlineAtMs: number; +} + +const RECONCILIATION_LEASE_DURATION_MS = 2 * 60 * 1_000; + +export type HostedApprovalDecisionReconciliationResult = + | { readonly status: 'delivered' | 'retry_authorized' } + | { + readonly status: 'operator_required' | 'unavailable'; + readonly claim?: Readonly<{ deliveryGeneration: number; operationId: string }>; + } + | { readonly status: 'stale_binding' | 'not_found' }; + +/** + * Applies the only recovery policy for ambiguous provider delivery: a proven + * `not_delivered` result reopens the outbox; every other unresolved outcome + * remains durably quarantined under the same exact generation binding. + */ +export class HostedApprovalDecisionReconciliationCoordinator { + constructor( + private readonly deliveryOutbox: HostedTeamApprovalDeliveryOutboxPort, + private readonly externalReconciliation: HostedApprovalDecisionReconciliationPort, + private readonly clock: HostedTeamApprovalRuntimeBridgeClockPort + ) {} + + async reconcile( + request: HostedApprovalDecisionReconciliationRequest + ): Promise { + let now: number; + try { + now = this.clock.now(); + } catch { + return Object.freeze({ status: 'unavailable' }); + } + if (!Number.isSafeInteger(now) || now < 0 || now >= request.deadlineAtMs) { + return Object.freeze({ status: 'unavailable' }); + } + const binding = { + workspaceId: request.workspaceId, + authorityGeneration: request.authorityGeneration, + restoreGeneration: request.restoreGeneration, + partition: request.partition, + deliveryId: request.providerDeliveryId, + approvalGeneration: request.approvalGeneration, + deliveryGeneration: request.deliveryGeneration, + reconciliationRef: request.reconciliationRef, + ownerId: request.ownerId, + leaseToken: request.leaseToken, + leaseDurationMs: RECONCILIATION_LEASE_DURATION_MS, + deadlineAtMs: request.deadlineAtMs, + } as const; + try { + const admission = await this.deliveryOutbox.readDeliveryReconciliation(binding); + if (admission.kind === 'unavailable') return Object.freeze({ status: 'unavailable' }); + if (admission.kind !== 'claimed') return Object.freeze({ status: admission.kind }); + const result = await this.externalReconciliation.reconcileRuntimePermissionDecision({ + reconciliationRef: request.reconciliationRef, + providerDeliveryId: request.providerDeliveryId, + partition: request.partition, + }); + if (result.status === 'operator_required' || result.status === 'unavailable') { + return Object.freeze({ + status: result.status, + claim: Object.freeze({ + deliveryGeneration: admission.deliveryGeneration, + operationId: request.leaseToken, + }), + }); + } + await this.deliveryOutbox.settleDeliveryReconciliation({ + workspaceId: binding.workspaceId, + authorityGeneration: binding.authorityGeneration, + restoreGeneration: binding.restoreGeneration, + partition: binding.partition, + deliveryId: binding.deliveryId, + approvalGeneration: binding.approvalGeneration, + reconciliationRef: binding.reconciliationRef, + ownerId: binding.ownerId, + leaseToken: binding.leaseToken, + deadlineAtMs: binding.deadlineAtMs, + deliveryGeneration: admission.deliveryGeneration, + outcome: result.status, + }); + return Object.freeze({ + status: result.status === 'delivered' ? 'delivered' : 'retry_authorized', + }); + } catch { + return Object.freeze({ status: 'unavailable' }); + } + } +} diff --git a/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalRuntimeOrchestratorRouter.ts b/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalRuntimeOrchestratorRouter.ts new file mode 100644 index 0000000000..32ef637aed --- /dev/null +++ b/src/features/team-approvals/main/adapters/output/runtime-ingress/HostedApprovalRuntimeOrchestratorRouter.ts @@ -0,0 +1,158 @@ +import type { + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedApprovalDecisionReconciliationPort, + HostedRuntimePermissionIngressAuthorityPort, + HostedRuntimePermissionIngressEffectPort, +} from '../../../ports/HostedTeamApprovalRuntimeBridgePorts'; +import type { TeamId } from '@shared/contracts/hosted'; + +export interface HostedApprovalRuntimeOrchestratorRoute { + readonly teamId: TeamId; + readonly authority: HostedRuntimePermissionIngressEffectPort & + HostedRuntimePermissionIngressAuthorityPort & + HostedApprovalDecisionExternalLifecycleDeliveryPort & + HostedApprovalDecisionReconciliationPort; +} + +/** + * Routes every approval effect from the immutable record team, never from UI + * selection or a process-global current team. Claims are spread fairly across + * the signed route catalog and acknowledgements remain bound to the route that + * returned the exact outbox record. + */ +export class HostedApprovalRuntimeOrchestratorRouter + implements + HostedRuntimePermissionIngressEffectPort, + HostedRuntimePermissionIngressAuthorityPort, + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedApprovalDecisionReconciliationPort +{ + private readonly routes: readonly HostedApprovalRuntimeOrchestratorRoute[]; + private readonly byTeam = new Map(); + private readonly claimedOutboxRoutes = new Map(); + private claimCursor = 0; + private closed = false; + + constructor(routes: readonly HostedApprovalRuntimeOrchestratorRoute[]) { + if (routes.length === 0 || routes.length > 256) { + throw new TypeError('hosted-approval-runtime-route-catalog-invalid'); + } + for (const route of routes) { + if (this.byTeam.has(route.teamId)) { + throw new TypeError('hosted-approval-runtime-route-team-duplicate'); + } + this.byTeam.set(route.teamId, route); + } + this.routes = Object.freeze([...routes]); + } + + async claimPermissionApprovalIngressEffects( + request: Parameters< + HostedRuntimePermissionIngressEffectPort['claimPermissionApprovalIngressEffects'] + >[0] + ): ReturnType { + this.assertOpen(); + const routeCount = Math.min(this.routes.length, request.limit); + const selected = Array.from( + { length: routeCount }, + (_, index) => this.routes[(this.claimCursor + index) % this.routes.length] + ); + this.claimCursor = (this.claimCursor + routeCount) % this.routes.length; + let remaining = request.limit; + const allocations = selected.map((_, index) => { + const allocation = Math.ceil(remaining / (selected.length - index)); + remaining -= allocation; + return allocation; + }); + const batches = await Promise.all( + selected.map((route, index) => + route.authority.claimPermissionApprovalIngressEffects({ + ...request, + limit: allocations[index], + }) + ) + ); + const records = batches.flatMap((batch, index) => { + const route = selected[index]; + return batch.filter((record) => { + if (record.authority.teamId !== route.teamId) return false; + this.claimedOutboxRoutes.set(record.outboxId, route.teamId); + return true; + }); + }); + return Object.freeze(records.slice(0, request.limit)); + } + + acknowledgePermissionApprovalIngressEffect( + request: Parameters< + HostedRuntimePermissionIngressEffectPort['acknowledgePermissionApprovalIngressEffect'] + >[0] + ): ReturnType< + HostedRuntimePermissionIngressEffectPort['acknowledgePermissionApprovalIngressEffect'] + > { + this.assertOpen(); + const teamId = this.claimedOutboxRoutes.get(request.outboxId); + if (teamId === undefined) return Promise.resolve(Object.freeze({ status: 'unavailable' })); + const route = this.byTeam.get(teamId); + if (route === undefined) return Promise.resolve(Object.freeze({ status: 'unavailable' })); + return route.authority.acknowledgePermissionApprovalIngressEffect(request).finally(() => { + this.claimedOutboxRoutes.delete(request.outboxId); + }); + } + + resolvePersistedIngressAuthority( + authority: Parameters< + HostedRuntimePermissionIngressAuthorityPort['resolvePersistedIngressAuthority'] + >[0] + ): ReturnType { + this.assertOpen(); + const route = this.byTeam.get(authority.teamId); + if (route === undefined) return Promise.resolve(Object.freeze({ status: 'unavailable' })); + return route.authority.resolvePersistedIngressAuthority(authority); + } + + deliverRuntimePermissionDecision( + request: Parameters< + HostedApprovalDecisionExternalLifecycleDeliveryPort['deliverRuntimePermissionDecision'] + >[0] + ): ReturnType< + HostedApprovalDecisionExternalLifecycleDeliveryPort['deliverRuntimePermissionDecision'] + > { + this.assertOpen(); + const route = this.byTeam.get(request.partition.teamId); + if (route === undefined) { + return Promise.resolve( + Object.freeze({ + status: 'operator_required', + reconciliationRef: request.reconciliationRef, + }) + ); + } + return route.authority.deliverRuntimePermissionDecision(request); + } + + reconcileRuntimePermissionDecision( + request: Parameters< + HostedApprovalDecisionReconciliationPort['reconcileRuntimePermissionDecision'] + >[0] + ): ReturnType { + this.assertOpen(); + const route = this.byTeam.get(request.partition.teamId); + if (route === undefined) return Promise.resolve(Object.freeze({ status: 'unavailable' })); + return route.authority.reconcileRuntimePermissionDecision(request); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.claimedOutboxRoutes.clear(); + for (const route of this.routes) { + const closable = route.authority as { close?: () => void }; + closable.close?.(); + } + } + + private assertOpen(): void { + if (this.closed) throw new Error('hosted-approval-runtime-route-catalog-closed'); + } +} diff --git a/src/features/team-approvals/main/adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorAuthority.ts b/src/features/team-approvals/main/adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorAuthority.ts new file mode 100644 index 0000000000..223814bf20 --- /dev/null +++ b/src/features/team-approvals/main/adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorAuthority.ts @@ -0,0 +1,402 @@ +import { randomBytes } from 'node:crypto'; +import { connect as connectSocket, type Socket } from 'node:net'; + +import { + inspectOrchestratorLifecycleSocketIdentity, + type OrchestratorLifecycleOwnerBinding, + type OrchestratorLifecycleOwnerProofKey, + type OrchestratorSocketIdentity, + parseOrchestratorLifecycleOwnerBinding, + parseStrictOrchestratorSignedJsonFrame, + sameOrchestratorLifecycleOwnerBinding, + sameOrchestratorSocketIdentity, +} from '@features/team-lifecycle/main/hosted'; +import { isExactRuntimePermissionApprovalIngressAuthority } from '@features/team-runtime-control/contracts'; + +import { + createHostedApprovalRuntimeOwnerProof, + HOSTED_APPROVAL_RUNTIME_WIRE_SCHEMA_VERSION, + type HostedApprovalDecisionDeliveryRequest, + type HostedApprovalRuntimeOperation, + hostedApprovalRuntimeOwnerProofMatches, + type HostedApprovalRuntimeRequestPayloadByOperation, + type HostedApprovalRuntimeResponsePayloadByOperation, + type HostedApprovalRuntimeWireAuthority, + parseHostedApprovalRuntimeExchangeId, + parseHostedApprovalRuntimeRequestPayload, + parseHostedApprovalRuntimeResponsePayload, + parseHostedApprovalRuntimeWireAuthority, + sameHostedApprovalRuntimeWireAuthority, +} from './hostedApprovalRuntimeOrchestratorWire'; + +import type { + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedApprovalDecisionReconciliationPort, + HostedRuntimePermissionIngressAuthorityPort, + HostedRuntimePermissionIngressEffectPort, +} from '../../../ports/HostedTeamApprovalRuntimeBridgePorts'; +import type { RuntimePermissionApprovalIngressAuthority } from '@features/team-runtime-control/contracts'; +import type { RunId, TeamId } from '@shared/contracts/hosted'; + +const DEFAULT_TIMEOUT_MS = 5_000; +export const HOSTED_APPROVAL_RUNTIME_MAXIMUM_FRAME_BYTES = 8 * 1024 * 1024; + +export interface HostedApprovalRuntimeOwnerLeasePort { + readonly socketPath: string; + currentBinding(): OrchestratorLifecycleOwnerBinding | null; + invalidate(): void; +} + +export interface HostedApprovalRuntimeOrchestratorAuthorityOptions { + readonly lease: HostedApprovalRuntimeOwnerLeasePort; + readonly ownerProofKey: OrchestratorLifecycleOwnerProofKey; + readonly authority: HostedApprovalRuntimeWireAuthority; + readonly timeoutMs?: number; + readonly generateExchangeId?: () => string; + readonly connect?: (path: string) => Socket; + readonly inspectSocketIdentity?: (path: string) => Promise; + /** Trusted lifecycle admission lookup; null or any field mismatch fails closed. */ + readonly getAdmittedIngressAuthority: ( + authority: RuntimePermissionApprovalIngressAuthority + ) => Promise; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +/** + * Approval-only transport over the already admitted lifecycle-owner socket. It owns no runtime, + * provider, browser, storage, or authority policy and invalidates the shared lease on a proof, + * binding, or socket-identity mismatch. + */ +export class HostedApprovalRuntimeOrchestratorAuthority + implements + HostedRuntimePermissionIngressEffectPort, + HostedRuntimePermissionIngressAuthorityPort, + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedApprovalDecisionReconciliationPort +{ + private readonly timeoutMs: number; + private readonly generateExchangeId: () => string; + private readonly connect: (path: string) => Socket; + private readonly inspectSocketIdentity: (path: string) => Promise; + private readonly authority: HostedApprovalRuntimeWireAuthority; + private readonly activeSockets = new Set(); + private epoch = 0; + private closed = false; + + constructor(private readonly options: HostedApprovalRuntimeOrchestratorAuthorityOptions) { + this.authority = parseHostedApprovalRuntimeWireAuthority(options.authority); + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 60_000) { + throw new TypeError('hosted-approval-runtime-timeout-invalid'); + } + this.generateExchangeId = + options.generateExchangeId ?? (() => `approval-request_${randomBytes(16).toString('hex')}`); + this.connect = options.connect ?? connectSocket; + this.inspectSocketIdentity = + options.inspectSocketIdentity ?? inspectOrchestratorLifecycleSocketIdentity; + } + + claimPermissionApprovalIngressEffects( + request: Parameters< + HostedRuntimePermissionIngressEffectPort['claimPermissionApprovalIngressEffects'] + >[0] + ): ReturnType { + return this.exchange('approval_ingress_claim', request); + } + + acknowledgePermissionApprovalIngressEffect( + request: Parameters< + HostedRuntimePermissionIngressEffectPort['acknowledgePermissionApprovalIngressEffect'] + >[0] + ): ReturnType< + HostedRuntimePermissionIngressEffectPort['acknowledgePermissionApprovalIngressEffect'] + > { + return this.exchange('approval_ingress_ack', request); + } + + resolvePersistedIngressAuthority( + authority: RuntimePermissionApprovalIngressAuthority + ): ReturnType { + return this.exchangeWithPrivateAuthority('approval_ingress_authority_resolve', authority); + } + + deliverRuntimePermissionDecision( + request: HostedApprovalDecisionDeliveryRequest + ): ReturnType< + HostedApprovalDecisionExternalLifecycleDeliveryPort['deliverRuntimePermissionDecision'] + > { + return this.exchange('approval_decision_deliver', request); + } + + reconcileRuntimePermissionDecision(request: { + readonly reconciliationRef: string; + readonly providerDeliveryId: string; + readonly partition: Readonly<{ readonly teamId: TeamId; readonly runId: RunId }>; + }): Promise { + return this.exchange('approval_decision_reconcile', request); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.revokeSockets(false); + } + + private async exchange( + operation: Operation, + unparsedPayload: HostedApprovalRuntimeRequestPayloadByOperation[Operation] + ): Promise { + const epoch = this.epoch; + this.assertActive(epoch); + const ownerBinding = this.options.lease.currentBinding(); + if (ownerBinding === null) throw new Error('hosted-approval-runtime-owner-unavailable'); + const payload = parseHostedApprovalRuntimeRequestPayload(operation, unparsedPayload); + const exchangeId = parseHostedApprovalRuntimeExchangeId(this.generateExchangeId()); + await this.assertSocketIdentity(ownerBinding); + this.assertCurrentOwner(epoch, ownerBinding); + const unsignedRequest = Object.freeze({ + schemaVersion: HOSTED_APPROVAL_RUNTIME_WIRE_SCHEMA_VERSION, + exchangeId, + operation, + ownerBinding, + authority: this.authority, + payload, + }); + const serializedUnsignedEnvelope = JSON.stringify(unsignedRequest); + const body = Buffer.from( + `${serializedUnsignedEnvelope.slice(0, -1)},"ownerProof":"${createHostedApprovalRuntimeOwnerProof( + this.options.ownerProofKey, + 'request', + serializedUnsignedEnvelope + )}"}\n`, + 'utf8' + ); + if (body.byteLength > HOSTED_APPROVAL_RUNTIME_MAXIMUM_FRAME_BYTES) { + throw new Error('hosted-approval-runtime-request-too-large'); + } + const responseFrame = await this.request(body, epoch, ownerBinding); + let signed: ReturnType; + try { + signed = parseStrictOrchestratorSignedJsonFrame(responseFrame); + } catch (error) { + this.ownerMismatch(); + throw error; + } + const response = signed.value; + if ( + !hasExactKeys(response, [ + 'schemaVersion', + 'exchangeId', + 'operation', + 'ownerBinding', + 'authority', + 'payload', + 'ownerProof', + ]) || + response.schemaVersion !== HOSTED_APPROVAL_RUNTIME_WIRE_SCHEMA_VERSION || + response.exchangeId !== exchangeId || + response.operation !== operation + ) { + this.ownerMismatch(); + throw new Error('hosted-approval-runtime-response-invalid'); + } + let responseBinding: OrchestratorLifecycleOwnerBinding; + let responseAuthority: HostedApprovalRuntimeWireAuthority; + try { + responseBinding = parseOrchestratorLifecycleOwnerBinding(response.ownerBinding); + responseAuthority = parseHostedApprovalRuntimeWireAuthority(response.authority); + } catch (error) { + this.ownerMismatch(); + throw error; + } + if ( + !sameOrchestratorLifecycleOwnerBinding(ownerBinding, responseBinding) || + !sameHostedApprovalRuntimeWireAuthority(this.authority, responseAuthority) || + !hostedApprovalRuntimeOwnerProofMatches( + createHostedApprovalRuntimeOwnerProof( + this.options.ownerProofKey, + 'response', + signed.serializedUnsignedEnvelope + ), + signed.ownerProof + ) + ) { + this.ownerMismatch(); + throw new Error('hosted-approval-runtime-response-proof-invalid'); + } + const result = parseHostedApprovalRuntimeResponsePayload( + operation, + response.payload, + payload, + this.authority + ); + if (operation === 'approval_ingress_claim') { + for (const record of result as readonly { + authority: RuntimePermissionApprovalIngressAuthority; + }[]) { + await this.assertPrivateAuthority(record.authority); + } + } + await this.assertSocketIdentity(ownerBinding); + this.assertCurrentOwner(epoch, ownerBinding); + return result; + } + + private async exchangeWithPrivateAuthority( + operation: 'approval_ingress_authority_resolve', + authority: RuntimePermissionApprovalIngressAuthority + ): Promise< + HostedApprovalRuntimeResponsePayloadByOperation['approval_ingress_authority_resolve'] + > { + await this.assertPrivateAuthority(authority); + return this.exchange(operation, authority); + } + + private async assertPrivateAuthority( + authority: RuntimePermissionApprovalIngressAuthority + ): Promise { + const admitted = await this.options.getAdmittedIngressAuthority(authority); + if ( + admitted === null || + !isExactRuntimePermissionApprovalIngressAuthority(admitted, authority) + ) { + throw new Error('hosted-approval-runtime-private-authority-unavailable'); + } + } + + private request( + body: Buffer, + epoch: number, + ownerBinding: OrchestratorLifecycleOwnerBinding + ): Promise { + return new Promise((resolve, reject) => { + this.assertCurrentOwner(epoch, ownerBinding); + const socket = this.connect(this.options.lease.socketPath); + this.activeSockets.add(socket); + const responseChunks: Buffer[] = []; + let responseBytes = 0; + let settled = false; + let readableEnded = false; + const finish = (error?: unknown): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + this.activeSockets.delete(socket); + socket.removeAllListeners(); + socket.destroy(); + if (error === undefined) { + try { + resolve( + new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode( + Buffer.concat(responseChunks) + ) + ); + } catch { + this.ownerMismatch(); + reject(new Error('hosted-approval-runtime-response-utf8-invalid')); + } + } else reject(error instanceof Error ? error : new Error('hosted-approval-runtime-error')); + }; + const timer = setTimeout( + () => finish(new Error('hosted-approval-runtime-timeout')), + this.timeoutMs + ); + timer.unref?.(); + socket.once('connect', () => { + void (async () => { + this.assertCurrentOwner(epoch, ownerBinding); + await this.assertSocketIdentity(ownerBinding); + this.assertCurrentOwner(epoch, ownerBinding); + socket.end(body); + })().catch(finish); + }); + socket.on('data', (chunk: Buffer) => { + responseBytes += chunk.byteLength; + if (responseBytes > HOSTED_APPROVAL_RUNTIME_MAXIMUM_FRAME_BYTES) { + this.ownerMismatch(); + finish(new Error('hosted-approval-runtime-response-too-large')); + return; + } + responseChunks.push(chunk); + const response = Buffer.concat(responseChunks); + const newline = response.indexOf(0x0a); + if (newline >= 0 && newline !== response.length - 1) { + this.ownerMismatch(); + finish(new Error('hosted-approval-runtime-response-trailing-data')); + } + }); + socket.once('error', finish); + socket.once('end', () => { + readableEnded = true; + const response = Buffer.concat(responseChunks); + if (response.at(-1) !== 0x0a || response.indexOf(0x0a) !== response.length - 1) { + this.ownerMismatch(); + finish(new Error('hosted-approval-runtime-response-incomplete')); + return; + } + finish(); + }); + socket.once('close', () => { + if (!settled) { + finish( + new Error( + readableEnded + ? 'hosted-approval-runtime-response-invalid-close' + : 'hosted-approval-runtime-response-closed' + ) + ); + } + }); + }); + } + + private async assertSocketIdentity( + ownerBinding: OrchestratorLifecycleOwnerBinding + ): Promise { + let identity: OrchestratorSocketIdentity; + try { + identity = await this.inspectSocketIdentity(this.options.lease.socketPath); + } catch (error) { + this.ownerMismatch(); + throw error; + } + if (!sameOrchestratorSocketIdentity(identity, ownerBinding.socketIdentity)) { + this.ownerMismatch(); + throw new Error('hosted-approval-runtime-owner-socket-changed'); + } + } + + private assertActive(epoch: number): void { + if (this.closed || this.epoch !== epoch) { + throw new Error('hosted-approval-runtime-unavailable'); + } + } + + private assertCurrentOwner(epoch: number, ownerBinding: OrchestratorLifecycleOwnerBinding): void { + this.assertActive(epoch); + const current = this.options.lease.currentBinding(); + if (current === null || !sameOrchestratorLifecycleOwnerBinding(current, ownerBinding)) { + this.ownerMismatch(); + throw new Error('hosted-approval-runtime-owner-changed'); + } + } + + private ownerMismatch(): void { + this.revokeSockets(true); + } + + private revokeSockets(invalidateLease: boolean): void { + this.epoch += 1; + for (const socket of this.activeSockets) socket.destroy(); + this.activeSockets.clear(); + if (invalidateLease) this.options.lease.invalidate(); + } +} diff --git a/src/features/team-approvals/main/adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorWire.ts b/src/features/team-approvals/main/adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorWire.ts new file mode 100644 index 0000000000..21cea50d41 --- /dev/null +++ b/src/features/team-approvals/main/adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorWire.ts @@ -0,0 +1,572 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { + isRuntimeIngressPermissionOutboxAcknowledgeRequest, + isRuntimeIngressPermissionOutboxClaimRequest, + isRuntimeIngressPermissionOutboxRecord, + RUNTIME_INGRESS_PERMISSION_OUTBOX_MAX_CLAIM_BYTES, + RUNTIME_INGRESS_PERMISSION_OUTBOX_MAX_RECORD_BYTES, + type RuntimeIngressPermissionOutboxAcknowledgeRequest, + type RuntimeIngressPermissionOutboxAcknowledgeResult, + type RuntimeIngressPermissionOutboxClaimRequest, + type RuntimeIngressPermissionOutboxRecord, +} from '@features/team-runtime-control'; +import { + parseRuntimePermissionApprovalIngressAuthority, + type RuntimePermissionApprovalIngressAuthority, +} from '@features/team-runtime-control/contracts'; +import { + type ActorId, + type BootId, + type DeploymentId, + parseActorId, + parseBootId, + parseDeploymentId, + parseRunId, + parseTeamId, + parseWorkspaceId, + type TeamId, + type WorkspaceId, +} from '@shared/contracts/hosted'; +export { + HOSTED_APPROVAL_RUNTIME_WIRE_CAPABILITY, + HOSTED_APPROVAL_RUNTIME_WIRE_CAPABILITY_DIGEST, +} from '@shared/contracts/hostedApprovalWireCapability'; + +import type { + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedRuntimePermissionIngressAuthorityPort, +} from '../../../ports/HostedTeamApprovalRuntimeBridgePorts'; +import type { + HostedTeamApprovalAuthorityScope, + HostedTeamApprovalStorageDecision, +} from '@features/internal-storage/contracts'; +import type { OrchestratorLifecycleOwnerProofKey } from '@features/team-lifecycle/main/hosted'; + +export const HOSTED_APPROVAL_RUNTIME_WIRE_SCHEMA_VERSION = 4 as const; +export const HOSTED_APPROVAL_RUNTIME_OWNER_PROOF_DOMAIN = + 'agent-teams.hosted-runtime-approval.owner-proof/v1' as const; + +export const HOSTED_APPROVAL_RUNTIME_OPERATIONS = Object.freeze([ + 'approval_ingress_claim', + 'approval_ingress_ack', + 'approval_ingress_authority_resolve', + 'approval_decision_deliver', + 'approval_decision_reconcile', +] as const); + +export type HostedApprovalRuntimeOperation = (typeof HOSTED_APPROVAL_RUNTIME_OPERATIONS)[number]; + +export interface HostedApprovalRuntimeMountBinding { + readonly mountGeneration: number; + readonly declaredRootHash: string; +} + +/** Server-only authority copied from the admitted lifecycle-owner bootstrap. */ +export interface HostedApprovalRuntimeWireAuthority { + readonly actorId: ActorId; + readonly deploymentId: DeploymentId; + readonly bootId: BootId; + readonly restoreGeneration: number; + readonly workspaceId: WorkspaceId; + readonly mountBinding: HostedApprovalRuntimeMountBinding; +} + +export type HostedApprovalDecisionDeliveryRequest = Parameters< + HostedApprovalDecisionExternalLifecycleDeliveryPort['deliverRuntimePermissionDecision'] +>[0]; +export type HostedApprovalDecisionDeliveryResult = Awaited< + ReturnType< + HostedApprovalDecisionExternalLifecycleDeliveryPort['deliverRuntimePermissionDecision'] + > +>; +export type HostedApprovalIngressAuthorityResult = Awaited< + ReturnType +>; +export interface HostedApprovalDecisionReconciliationRequest { + readonly reconciliationRef: string; + readonly providerDeliveryId: string; + readonly partition: Readonly<{ readonly teamId: TeamId; readonly runId: string }>; +} +export type HostedApprovalDecisionReconciliationResult = + | Readonly<{ readonly status: 'delivered' | 'not_delivered' }> + | Readonly<{ readonly status: 'operator_required' | 'unavailable' }>; + +export interface HostedApprovalRuntimeRequestPayloadByOperation { + readonly approval_ingress_claim: RuntimeIngressPermissionOutboxClaimRequest; + readonly approval_ingress_ack: RuntimeIngressPermissionOutboxAcknowledgeRequest; + readonly approval_ingress_authority_resolve: RuntimePermissionApprovalIngressAuthority; + readonly approval_decision_deliver: HostedApprovalDecisionDeliveryRequest; + readonly approval_decision_reconcile: HostedApprovalDecisionReconciliationRequest; +} + +export interface HostedApprovalRuntimeResponsePayloadByOperation { + readonly approval_ingress_claim: readonly RuntimeIngressPermissionOutboxRecord[]; + readonly approval_ingress_ack: RuntimeIngressPermissionOutboxAcknowledgeResult; + readonly approval_ingress_authority_resolve: HostedApprovalIngressAuthorityResult; + readonly approval_decision_deliver: HostedApprovalDecisionDeliveryResult; + readonly approval_decision_reconcile: HostedApprovalDecisionReconciliationResult; +} + +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/; +const APPROVAL_ID = /^approval_[0-9a-f]{32}$/; +const APPROVAL_GENERATION = /^generation_runtime-permission-[0-9a-f]{64}$/; +const AUTHORITY_GENERATION = /^generation_[A-Za-z0-9][A-Za-z0-9._-]{0,245}$/; +const DELIVERY_REF = /^delivery_ref_[A-Za-z0-9][A-Za-z0-9._-]{0,239}$/; +const EXCHANGE_ID = /^approval-request_[0-9a-f]{32}$/; +const RECONCILIATION_REF = /^approval-reconciliation_[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/; +const HASH = /^[0-9a-f]{64}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function parseIdentifier(value: unknown, diagnostic: string, pattern = IDENTIFIER): string { + if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(diagnostic); + return value; +} + +function parsePositiveInteger(value: unknown, diagnostic: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) throw new TypeError(diagnostic); + return value as number; +} + +export function parseHostedApprovalRuntimeWireAuthority( + value: unknown +): HostedApprovalRuntimeWireAuthority { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'actorId', + 'deploymentId', + 'bootId', + 'restoreGeneration', + 'workspaceId', + 'mountBinding', + ]) || + !Number.isSafeInteger(value.restoreGeneration) || + (value.restoreGeneration as number) < 0 || + !isRecord(value.mountBinding) || + !hasExactKeys(value.mountBinding, ['mountGeneration', 'declaredRootHash']) || + typeof value.mountBinding.declaredRootHash !== 'string' || + !HASH.test(value.mountBinding.declaredRootHash) + ) { + throw new TypeError('hosted-approval-runtime-wire-authority-invalid'); + } + return Object.freeze({ + actorId: parseActorId(value.actorId), + deploymentId: parseDeploymentId(value.deploymentId), + bootId: parseBootId(value.bootId), + restoreGeneration: value.restoreGeneration as number, + workspaceId: parseWorkspaceId(value.workspaceId), + mountBinding: Object.freeze({ + mountGeneration: parsePositiveInteger( + value.mountBinding.mountGeneration, + 'hosted-approval-runtime-wire-mount-generation-invalid' + ), + declaredRootHash: value.mountBinding.declaredRootHash, + }), + }); +} + +export function sameHostedApprovalRuntimeWireAuthority( + left: HostedApprovalRuntimeWireAuthority, + right: HostedApprovalRuntimeWireAuthority +): boolean { + return ( + left.actorId === right.actorId && + left.deploymentId === right.deploymentId && + left.bootId === right.bootId && + left.restoreGeneration === right.restoreGeneration && + left.workspaceId === right.workspaceId && + left.mountBinding.mountGeneration === right.mountBinding.mountGeneration && + left.mountBinding.declaredRootHash === right.mountBinding.declaredRootHash + ); +} + +function parseScope(value: unknown): HostedTeamApprovalAuthorityScope { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'principalId', + 'workspaceId', + 'teamId', + 'authorityGeneration', + 'restoreGeneration', + ]) || + !Number.isSafeInteger(value.restoreGeneration) || + (value.restoreGeneration as number) < 0 + ) { + throw new TypeError('hosted-approval-runtime-scope-invalid'); + } + return Object.freeze({ + principalId: parseActorId(value.principalId), + workspaceId: parseWorkspaceId(value.workspaceId), + teamId: parseTeamId(value.teamId), + authorityGeneration: parseIdentifier( + value.authorityGeneration, + 'hosted-approval-runtime-authority-generation-invalid', + AUTHORITY_GENERATION + ), + restoreGeneration: value.restoreGeneration as number, + }); +} + +function parseDecision(value: unknown): HostedTeamApprovalStorageDecision { + if (value !== 'allow' && value !== 'deny' && value !== 'timeout') { + throw new TypeError('hosted-approval-runtime-decision-invalid'); + } + return value; +} + +export function parseHostedApprovalDecisionDeliveryRequest( + value: unknown +): HostedApprovalDecisionDeliveryRequest { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'providerDeliveryId', + 'reconciliationRef', + 'principal', + 'deliveryRef', + 'approvalId', + 'approvalGeneration', + 'decision', + 'partition', + 'requestId', + ]) || + !isRecord(value.partition) || + !hasExactKeys(value.partition, ['teamId', 'runId']) + ) { + throw new TypeError('hosted-approval-runtime-delivery-request-invalid'); + } + return Object.freeze({ + providerDeliveryId: parseIdentifier( + value.providerDeliveryId, + 'hosted-approval-runtime-provider-delivery-id-invalid', + IDENTIFIER + ), + reconciliationRef: parseIdentifier( + value.reconciliationRef, + 'hosted-approval-runtime-reconciliation-ref-invalid', + RECONCILIATION_REF + ), + principal: parseDeliveryPrincipal(value.principal, value.decision), + deliveryRef: parseIdentifier( + value.deliveryRef, + 'hosted-approval-runtime-delivery-ref-invalid', + DELIVERY_REF + ), + approvalId: parseIdentifier( + value.approvalId, + 'hosted-approval-runtime-approval-id-invalid', + APPROVAL_ID + ), + approvalGeneration: parseIdentifier( + value.approvalGeneration, + 'hosted-approval-runtime-approval-generation-invalid', + APPROVAL_GENERATION + ), + decision: parseDecision(value.decision), + partition: Object.freeze({ + teamId: parseTeamId(value.partition.teamId), + runId: parseRunId(value.partition.runId), + }), + requestId: parseIdentifier(value.requestId, 'hosted-approval-runtime-request-id-invalid'), + }); +} + +function parseReconciliationRequest(value: unknown): HostedApprovalDecisionReconciliationRequest { + if ( + !isRecord(value) || + !hasExactKeys(value, ['reconciliationRef', 'providerDeliveryId', 'partition']) || + !isRecord(value.partition) || + !hasExactKeys(value.partition, ['teamId', 'runId']) + ) { + throw new TypeError('hosted-approval-runtime-reconciliation-request-invalid'); + } + return Object.freeze({ + reconciliationRef: parseIdentifier( + value.reconciliationRef, + 'hosted-approval-runtime-reconciliation-ref-invalid', + RECONCILIATION_REF + ), + providerDeliveryId: parseIdentifier( + value.providerDeliveryId, + 'hosted-approval-runtime-provider-delivery-id-invalid' + ), + partition: Object.freeze({ + teamId: parseTeamId(value.partition.teamId), + runId: parseRunId(value.partition.runId), + }), + }); +} + +function parseDeliveryPrincipal( + value: unknown, + decision: unknown +): HostedApprovalDecisionDeliveryRequest['principal'] { + if (!isRecord(value)) throw new TypeError('hosted-approval-runtime-principal-invalid'); + if ( + value.kind === 'operator' && + hasExactKeys(value, ['kind', 'actorId']) && + decision !== 'timeout' + ) { + return Object.freeze({ kind: 'operator', actorId: parseActorId(value.actorId) }); + } + if (value.kind === 'system_timeout' && hasExactKeys(value, ['kind']) && decision === 'timeout') { + return Object.freeze({ kind: 'system_timeout' }); + } + throw new TypeError('hosted-approval-runtime-principal-invalid'); +} + +export function parseHostedApprovalRuntimeRequestPayload< + Operation extends HostedApprovalRuntimeOperation, +>(operation: Operation, value: unknown): HostedApprovalRuntimeRequestPayloadByOperation[Operation] { + switch (operation) { + case 'approval_ingress_claim': + if (!isRuntimeIngressPermissionOutboxClaimRequest(value)) throw new TypeError(); + return Object.freeze({ + ...value, + }) as HostedApprovalRuntimeRequestPayloadByOperation[Operation]; + case 'approval_ingress_ack': + if (!isRuntimeIngressPermissionOutboxAcknowledgeRequest(value)) throw new TypeError(); + return Object.freeze({ + ...value, + }) as HostedApprovalRuntimeRequestPayloadByOperation[Operation]; + case 'approval_ingress_authority_resolve': + return parseRuntimePermissionApprovalIngressAuthority( + value + ) as HostedApprovalRuntimeRequestPayloadByOperation[Operation]; + case 'approval_decision_deliver': + return parseHostedApprovalDecisionDeliveryRequest( + value + ) as HostedApprovalRuntimeRequestPayloadByOperation[Operation]; + case 'approval_decision_reconcile': + return parseReconciliationRequest( + value + ) as HostedApprovalRuntimeRequestPayloadByOperation[Operation]; + } +} + +export function parseHostedApprovalRuntimeResponsePayload< + Operation extends HostedApprovalRuntimeOperation, +>( + operation: Operation, + value: unknown, + request: HostedApprovalRuntimeRequestPayloadByOperation[Operation], + authority: HostedApprovalRuntimeWireAuthority +): HostedApprovalRuntimeResponsePayloadByOperation[Operation] { + if (operation === 'approval_ingress_claim') { + const claim = request as RuntimeIngressPermissionOutboxClaimRequest; + if (!Array.isArray(value) || value.length > claim.limit || value.length > 100) { + throw new TypeError(); + } + const outboxIds = new Set(); + const records = value.map((record) => { + if ( + !isRuntimeIngressPermissionOutboxRecord(record) || + new TextEncoder().encode(JSON.stringify(record)).byteLength > + RUNTIME_INGRESS_PERMISSION_OUTBOX_MAX_RECORD_BYTES || + record.lease === null || + record.lease.generation < 1 || + record.lease.ownerId !== claim.ownerId || + record.lease.leaseToken !== claim.leaseToken || + record.acknowledgedAtIso !== null || + outboxIds.has(record.outboxId) || + record.authority.deploymentId !== authority.deploymentId + ) { + throw new TypeError(); + } + outboxIds.add(record.outboxId); + return Object.freeze(record); + }); + if ( + new TextEncoder().encode(JSON.stringify(records)).byteLength > + RUNTIME_INGRESS_PERMISSION_OUTBOX_MAX_CLAIM_BYTES + ) { + throw new TypeError(); + } + return Object.freeze(records) as HostedApprovalRuntimeResponsePayloadByOperation[Operation]; + } + if (operation === 'approval_ingress_ack') { + if ( + !isRecord(value) || + !hasExactKeys(value, ['status']) || + !['acknowledged', 'already_acknowledged', 'conflict', 'unavailable'].includes( + value.status as string + ) + ) { + throw new TypeError(); + } + return Object.freeze({ + status: value.status, + }) as HostedApprovalRuntimeResponsePayloadByOperation[Operation]; + } + if (operation === 'approval_ingress_authority_resolve') { + if ( + !isRecord(value) || + !hasExactKeys(value, value.status === 'resolved' ? ['status', 'scope'] : ['status']) + ) { + throw new TypeError(); + } + if (value.status === 'resolved') { + const scope = parseScope(value.scope); + const ingress = request as RuntimePermissionApprovalIngressAuthority; + if ( + scope.teamId !== ingress.teamId || + scope.workspaceId !== authority.workspaceId || + scope.restoreGeneration !== authority.restoreGeneration + ) { + throw new TypeError(); + } + return Object.freeze({ + status: 'resolved', + scope, + }) as HostedApprovalRuntimeResponsePayloadByOperation[Operation]; + } + if (!['stale_generation', 'wrong_lane', 'unavailable'].includes(value.status as string)) { + throw new TypeError(); + } + return Object.freeze({ + status: value.status, + }) as HostedApprovalRuntimeResponsePayloadByOperation[Operation]; + } + if (operation === 'approval_decision_reconcile') { + if ( + !isRecord(value) || + !hasExactKeys(value, ['status']) || + !['delivered', 'not_delivered', 'operator_required', 'unavailable'].includes( + value.status as string + ) + ) { + throw new TypeError(); + } + return Object.freeze({ + status: value.status, + }) as HostedApprovalRuntimeResponsePayloadByOperation[Operation]; + } + if ( + !isRecord(value) || + !hasExactKeys( + value, + value.status === 'operator_required' ? ['status', 'reconciliationRef'] : ['status'] + ) || + ![ + 'delivered', + 'idempotent_replay', + 'stale_generation', + 'expired', + 'wrong_lane', + 'self_approval', + 'unavailable', + 'operator_required', + ].includes(value.status as string) + ) { + throw new TypeError(); + } + if (value.status === 'operator_required') { + const requestRef = (request as HostedApprovalDecisionDeliveryRequest).reconciliationRef; + if (value.reconciliationRef !== requestRef) throw new TypeError(); + return Object.freeze({ + status: 'operator_required', + reconciliationRef: parseIdentifier( + value.reconciliationRef, + 'hosted-approval-runtime-reconciliation-ref-invalid', + RECONCILIATION_REF + ), + }) as HostedApprovalRuntimeResponsePayloadByOperation[Operation]; + } + return Object.freeze({ + status: value.status, + }) as HostedApprovalRuntimeResponsePayloadByOperation[Operation]; +} + +export function createHostedApprovalRuntimeOwnerProof( + key: OrchestratorLifecycleOwnerProofKey, + direction: 'request' | 'response', + serializedUnsignedEnvelope: string +): string { + return createHmac('sha256', Buffer.from(key, 'hex')) + .update( + `${HOSTED_APPROVAL_RUNTIME_OWNER_PROOF_DOMAIN}\u0000${direction}\u0000${serializedUnsignedEnvelope}` + ) + .digest('hex'); +} + +export function hostedApprovalRuntimeOwnerProofMatches(expected: string, actual: unknown): boolean { + return ( + typeof actual === 'string' && + HASH.test(actual) && + timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(actual, 'hex')) + ); +} + +export function parseHostedApprovalRuntimeExchangeId(value: unknown): string { + return parseIdentifier(value, 'hosted-approval-runtime-exchange-id-invalid', EXCHANGE_ID); +} + +/** Product-only candidate fixture; it is not an owner-compatible cross-repository vector. */ +export function hostedApprovalRuntimeProductCandidateRequest(): Readonly<{ + schemaVersion: 4; + exchangeId: string; + operation: 'approval_ingress_ack'; + ownerBinding: Readonly<{ + ownerAuthority: string; + ownerGeneration: number; + ownerSessionId: string; + socketIdentity: Readonly<{ + device: string; + inode: string; + uid: number; + gid: number; + mode: number; + }>; + }>; + authority: HostedApprovalRuntimeWireAuthority; + payload: RuntimeIngressPermissionOutboxAcknowledgeRequest; +}> { + return Object.freeze({ + schemaVersion: HOSTED_APPROVAL_RUNTIME_WIRE_SCHEMA_VERSION, + exchangeId: `approval-request_${'6'.repeat(32)}`, + operation: 'approval_ingress_ack', + ownerBinding: Object.freeze({ + ownerAuthority: 'owner-authority_approval-wire', + ownerGeneration: 7, + ownerSessionId: 'owner-session_approval-wire', + socketIdentity: Object.freeze({ + device: '11', + inode: '12', + uid: 501, + gid: 20, + mode: 0o600, + }), + }), + authority: Object.freeze({ + actorId: parseActorId('actor_approval-wire'), + deploymentId: parseDeploymentId('deployment_approval-wire'), + bootId: parseBootId('boot_approval-wire'), + restoreGeneration: 4, + workspaceId: parseWorkspaceId(`workspace_${'3'.repeat(32)}`), + mountBinding: Object.freeze({ + mountGeneration: 9, + declaredRootHash: 'a'.repeat(64), + }), + }), + payload: Object.freeze({ + outboxId: `runtime_permission:effect:${'5'.repeat(64)}`, + generation: 3, + ownerId: 'owner_approval-wire', + leaseToken: 'lease_approval-wire', + }), + }); +} diff --git a/src/features/team-approvals/main/composition/TeamApprovalsIpcBoundary.ts b/src/features/team-approvals/main/composition/TeamApprovalsIpcBoundary.ts new file mode 100644 index 0000000000..9999da1322 --- /dev/null +++ b/src/features/team-approvals/main/composition/TeamApprovalsIpcBoundary.ts @@ -0,0 +1,41 @@ +import { + registerTeamApprovalsIpc as registerApprovalsIpc, + removeTeamApprovalsIpc as removeApprovalsIpc, +} from '../adapters/input/ipc/registerTeamApprovalsIpc'; + +import type { + TeamApprovalsCommandPort, + ToolApprovalPreviewReaderPort, +} from '../../core/application/ports/TeamApprovalsPorts'; + +export interface TeamApprovalsIpcLogger { + error(message: string): void; +} + +export interface TeamApprovalsFeature { + commands: TeamApprovalsCommandPort; + previewReader: ToolApprovalPreviewReaderPort; +} + +export interface TeamApprovalsIpcDependencies extends TeamApprovalsFeature { + logger: TeamApprovalsIpcLogger; +} + +export interface TeamApprovalsIpcRegistrar { + readonly handle: CallableFunction; + readonly removeHandler: CallableFunction; +} + +// eslint-disable-next-line sonarjs/redundant-type-aliases -- Named IPC boundary contract intentionally remains Electron-free. +export type TeamApprovalsIpcEvent = unknown; + +export function registerTeamApprovalsIpc( + ipcMain: TeamApprovalsIpcRegistrar, + dependencies: TeamApprovalsIpcDependencies +): void { + registerApprovalsIpc(ipcMain, dependencies); +} + +export function removeTeamApprovalsIpc(ipcMain: TeamApprovalsIpcRegistrar): void { + removeApprovalsIpc(ipcMain); +} diff --git a/src/features/team-approvals/main/composition/createDurableHostedTeamApprovalAuthority.ts b/src/features/team-approvals/main/composition/createDurableHostedTeamApprovalAuthority.ts new file mode 100644 index 0000000000..a4385d544e --- /dev/null +++ b/src/features/team-approvals/main/composition/createDurableHostedTeamApprovalAuthority.ts @@ -0,0 +1,35 @@ +import { InternalStorageHostedTeamApprovalAuthority } from '../adapters/output/InternalStorageHostedTeamApprovalAuthority'; + +import { createHostedTeamApprovalOutputAdapters } from './createHostedTeamApprovalOutputAdapters'; + +import type { InternalStorageHostedTeamApprovalAuthorityDependencies } from '../adapters/output/InternalStorageHostedTeamApprovalAuthority'; +import type { HostedTeamApprovalAuthorityPort } from '../ports/HostedTeamApprovalAuthorityPort'; +import type { + HostedTeamApprovalDeliveryOutboxPort, + HostedTeamApprovalPendingIngressPort, +} from '../ports/HostedTeamApprovalAuthorityStoragePort'; +import type { HostedTeamApprovalOutputAdapters } from './createHostedTeamApprovalOutputAdapters'; + +export interface DurableHostedTeamApprovalAuthority { + readonly authority: HostedTeamApprovalAuthorityPort; + readonly ingress: HostedTeamApprovalPendingIngressPort; + readonly deliveryOutbox: HostedTeamApprovalDeliveryOutboxPort; + readonly outputAdapters: HostedTeamApprovalOutputAdapters; +} + +/** + * Builds the durable authority boundary without mounting it in Electron or + * creating a runtime. The external lifecycle owner receives ingress/outbox + * ports explicitly from its own composition. + */ +export function createDurableHostedTeamApprovalAuthority( + dependencies: InternalStorageHostedTeamApprovalAuthorityDependencies +): DurableHostedTeamApprovalAuthority { + const authority = new InternalStorageHostedTeamApprovalAuthority(dependencies); + return Object.freeze({ + authority, + ingress: authority, + deliveryOutbox: authority, + outputAdapters: createHostedTeamApprovalOutputAdapters(authority, dependencies.clock), + }); +} diff --git a/src/features/team-approvals/main/composition/createHostedApprovalAdmissionAuthority.ts b/src/features/team-approvals/main/composition/createHostedApprovalAdmissionAuthority.ts new file mode 100644 index 0000000000..74a4daf11b --- /dev/null +++ b/src/features/team-approvals/main/composition/createHostedApprovalAdmissionAuthority.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto'; + +import { + isExactRuntimePermissionApprovalIngressAuthority, + parseRuntimePermissionApprovalIngressAuthority, +} from '@features/team-runtime-control/contracts'; + +import type { RuntimePermissionApprovalIngressAuthority } from '@features/team-runtime-control/contracts'; +import type { HostedApprovalAdmissionPin } from '@main/composition/hosted/hostedLifecycleProductionOwnerAdmission'; + +export interface HostedApprovalAdmissionSnapshot { + readonly schemaVersion: 1; + readonly approvalGeneration: number; + readonly authorities: readonly RuntimePermissionApprovalIngressAuthority[]; +} + +export interface HostedApprovalAdmissionAuthority { + getAdmittedIngressAuthority( + candidate: RuntimePermissionApprovalIngressAuthority + ): Promise; +} + +/** + * Resolves only authorities whose canonical snapshot is pinned by the launcher-signed lifecycle + * admission. Owner-writable JSON without the signed digest is never an authority source. + */ +export function createHostedApprovalAdmissionAuthority(input: { + readonly pin: HostedApprovalAdmissionPin; + readonly snapshot: unknown; +}): HostedApprovalAdmissionAuthority | null { + if (input.pin.state !== 'active') return null; + const snapshot = parseSnapshot(input.snapshot); + if ( + snapshot.approvalGeneration !== input.pin.approvalGeneration || + canonicalDigest(snapshot) !== input.pin.approvalDigest + ) { + return null; + } + return Object.freeze({ + async getAdmittedIngressAuthority(candidate: RuntimePermissionApprovalIngressAuthority) { + return ( + snapshot.authorities.find((authority) => + isExactRuntimePermissionApprovalIngressAuthority(authority, candidate) + ) ?? null + ); + }, + }); +} + +function parseSnapshot(value: unknown): HostedApprovalAdmissionSnapshot { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('hosted-approval-admission-snapshot-invalid'); + } + const record = value as Record; + if ( + Object.keys(record).length !== 3 || + record.schemaVersion !== 1 || + !Number.isSafeInteger(record.approvalGeneration) || + (record.approvalGeneration as number) < 1 || + !Array.isArray(record.authorities) + ) { + throw new TypeError('hosted-approval-admission-snapshot-invalid'); + } + const authorities = record.authorities.map(parseRuntimePermissionApprovalIngressAuthority); + const identities = new Set(); + for (const authority of authorities) { + const identity = `${authority.teamId}\0${authority.runId}\0${authority.laneId}\0${authority.sessionId}`; + if (identities.has(identity)) + throw new TypeError('hosted-approval-admission-snapshot-duplicate'); + identities.add(identity); + } + return Object.freeze({ + schemaVersion: 1, + approvalGeneration: record.approvalGeneration as number, + authorities: Object.freeze(authorities), + }); +} + +function canonicalDigest(snapshot: HostedApprovalAdmissionSnapshot): `sha256:${string}` { + const body = JSON.stringify(snapshot); + return `sha256:${createHash('sha256').update(body).digest('hex')}`; +} diff --git a/src/features/team-approvals/main/composition/createHostedTeamApprovalOutputAdapters.ts b/src/features/team-approvals/main/composition/createHostedTeamApprovalOutputAdapters.ts new file mode 100644 index 0000000000..8b5ec851b0 --- /dev/null +++ b/src/features/team-approvals/main/composition/createHostedTeamApprovalOutputAdapters.ts @@ -0,0 +1,28 @@ +import { HostedTeamApprovalAuthorityAdapter } from '../adapters/output/HostedTeamApprovalAuthorityAdapter'; + +import type { + HostedTeamApprovalClockPort, + HostedTeamApprovalDecisionAdmissionPort, + HostedTeamApprovalPageSourcePort, + HostedTeamApprovalPreviewSourcePort, +} from '../../core/application/ports/HostedTeamApprovalPorts'; +import type { HostedTeamApprovalAuthorityPort } from '../ports/HostedTeamApprovalAuthorityPort'; + +export interface HostedTeamApprovalOutputAdapters { + readonly pageSource: HostedTeamApprovalPageSourcePort; + readonly previewSource: HostedTeamApprovalPreviewSourcePort; + readonly decisionAdmission: HostedTeamApprovalDecisionAdmissionPort; +} + +/** Creates one authority adapter shared by every hosted approval application port. */ +export function createHostedTeamApprovalOutputAdapters( + authority: HostedTeamApprovalAuthorityPort, + clock: HostedTeamApprovalClockPort = Object.freeze({ now: Date.now }) +): HostedTeamApprovalOutputAdapters { + const adapter = new HostedTeamApprovalAuthorityAdapter(authority, clock); + return Object.freeze({ + pageSource: adapter, + previewSource: adapter, + decisionAdmission: adapter, + }); +} diff --git a/src/features/team-approvals/main/composition/createHostedTeamApprovalRuntimeBridge.ts b/src/features/team-approvals/main/composition/createHostedTeamApprovalRuntimeBridge.ts new file mode 100644 index 0000000000..aed0294d79 --- /dev/null +++ b/src/features/team-approvals/main/composition/createHostedTeamApprovalRuntimeBridge.ts @@ -0,0 +1,82 @@ +import { RuntimeIngressPermissionOutbox } from '@features/team-runtime-control'; + +import { HostedRuntimePermissionRequestProjector } from '../adapters/input/runtime-ingress/HostedRuntimePermissionRequestProjector'; +import { HostedApprovalDecisionDeliveryCoordinator } from '../adapters/output/runtime-ingress/HostedApprovalDecisionDeliveryCoordinator'; +import { HostedApprovalDecisionReconciliationCoordinator } from '../adapters/output/runtime-ingress/HostedApprovalDecisionReconciliationCoordinator'; + +import type { + HostedRuntimePermissionProjectionRequest, + HostedRuntimePermissionProjectionResult, +} from '../adapters/input/runtime-ingress/HostedRuntimePermissionRequestProjector'; +import type { + HostedApprovalDecisionDeliveryRequest, + HostedApprovalDecisionDeliveryResult, +} from '../adapters/output/runtime-ingress/HostedApprovalDecisionDeliveryCoordinator'; +import type { + HostedApprovalDecisionReconciliationRequest, + HostedApprovalDecisionReconciliationResult, +} from '../adapters/output/runtime-ingress/HostedApprovalDecisionReconciliationCoordinator'; +import type { + HostedTeamApprovalDeliveryOutboxPort, + HostedTeamApprovalPendingIngressPort, +} from '../ports/HostedTeamApprovalAuthorityStoragePort'; +import type { + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedApprovalDecisionReconciliationPort, + HostedRuntimePermissionIngressAuthorityPort, + HostedRuntimePermissionIngressEffectPort, + HostedTeamApprovalRuntimeBridgeClockPort, +} from '../ports/HostedTeamApprovalRuntimeBridgePorts'; + +export interface HostedTeamApprovalRuntimeBridgeDependencies { + readonly ingressEffectOutbox: HostedRuntimePermissionIngressEffectPort; + readonly pendingIngress: HostedTeamApprovalPendingIngressPort; + readonly ingressAuthority: HostedRuntimePermissionIngressAuthorityPort; + readonly deliveryOutbox: HostedTeamApprovalDeliveryOutboxPort; + readonly externalDecisionDelivery: HostedApprovalDecisionExternalLifecycleDeliveryPort; + readonly externalDecisionReconciliation: HostedApprovalDecisionReconciliationPort; + readonly clock?: HostedTeamApprovalRuntimeBridgeClockPort; +} + +export interface HostedTeamApprovalRuntimeBridge { + readonly projectRuntimePermissionRequests: ( + request: HostedRuntimePermissionProjectionRequest + ) => Promise; + readonly deliverApprovalDecisions: ( + request: HostedApprovalDecisionDeliveryRequest + ) => Promise; + readonly reconcileApprovalDecision: ( + request: HostedApprovalDecisionReconciliationRequest + ) => Promise; +} + +/** + * Wires durable ingress and decision outboxes without mounting an HTTP route, + * renderer surface, or process lifecycle owner. + */ +export function createHostedTeamApprovalRuntimeBridge( + dependencies: HostedTeamApprovalRuntimeBridgeDependencies +): HostedTeamApprovalRuntimeBridge { + const clock = dependencies.clock ?? Object.freeze({ now: Date.now }); + const projector = new HostedRuntimePermissionRequestProjector( + new RuntimeIngressPermissionOutbox(dependencies.ingressEffectOutbox), + dependencies.pendingIngress, + dependencies.ingressAuthority, + clock + ); + const delivery = new HostedApprovalDecisionDeliveryCoordinator( + dependencies.deliveryOutbox, + dependencies.externalDecisionDelivery, + clock + ); + const reconciliation = new HostedApprovalDecisionReconciliationCoordinator( + dependencies.deliveryOutbox, + dependencies.externalDecisionReconciliation, + clock + ); + return Object.freeze({ + projectRuntimePermissionRequests: projector.project.bind(projector), + deliverApprovalDecisions: delivery.deliver.bind(delivery), + reconcileApprovalDecision: reconciliation.reconcile.bind(reconciliation), + }); +} diff --git a/src/features/team-approvals/main/composition/createHostedTeamApprovalsFeature.ts b/src/features/team-approvals/main/composition/createHostedTeamApprovalsFeature.ts new file mode 100644 index 0000000000..60f57da2ce --- /dev/null +++ b/src/features/team-approvals/main/composition/createHostedTeamApprovalsFeature.ts @@ -0,0 +1,50 @@ +import { DecideHostedTeamApproval } from '../../core/application/use-cases/DecideHostedTeamApproval'; +import { GetHostedTeamApprovalPage } from '../../core/application/use-cases/GetHostedTeamApprovalPage'; +import { GetHostedTeamApprovalPreview } from '../../core/application/use-cases/GetHostedTeamApprovalPreview'; +import { HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS } from '../adapters/input/http/hostedTeamApprovalRoutes'; + +import type { + HostedTeamApprovalClockPort, + HostedTeamApprovalDecisionAdmissionPort, + HostedTeamApprovalPageSourcePort, + HostedTeamApprovalPreviewSourcePort, +} from '../../core/application/ports/HostedTeamApprovalPorts'; +import type { HostedTeamApprovalsHttpFacade } from '../adapters/input/http/registerHostedTeamApprovalsHttp'; +import type { HostedRouteContribution } from '@main/composition/hosted/application'; + +export interface HostedTeamApprovalsFeature extends HostedTeamApprovalsHttpFacade { + readonly routes: typeof HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS; +} + +export interface HostedTeamApprovalsFeatureDependencies { + readonly pageSource: HostedTeamApprovalPageSourcePort; + readonly previewSource: HostedTeamApprovalPreviewSourcePort; + readonly decisionAdmission: HostedTeamApprovalDecisionAdmissionPort; + readonly clock?: HostedTeamApprovalClockPort; +} + +export function createHostedTeamApprovalsFeature( + dependencies: HostedTeamApprovalsFeatureDependencies +): HostedTeamApprovalsFeature { + const clock = dependencies.clock ?? Object.freeze({ now: Date.now }); + const getPage = new GetHostedTeamApprovalPage(dependencies.pageSource, clock); + const getPreview = new GetHostedTeamApprovalPreview(dependencies.previewSource, clock); + const decide = new DecideHostedTeamApproval(dependencies.decisionAdmission); + + return Object.freeze({ + routes: HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS, + getPage: getPage.execute.bind(getPage), + getPreview: getPreview.execute.bind(getPreview), + decide: decide.execute.bind(decide), + }); +} + +export function createHostedTeamApprovalsRouteContribution( + feature: HostedTeamApprovalsFeature +): HostedRouteContribution { + return Object.freeze({ + id: 'team-approvals.hosted.v1', + facade: feature, + routes: feature.routes, + }); +} diff --git a/src/features/team-approvals/main/composition/createTeamApprovalsFeature.test.ts b/src/features/team-approvals/main/composition/createTeamApprovalsFeature.test.ts new file mode 100644 index 0000000000..e0750fe3aa --- /dev/null +++ b/src/features/team-approvals/main/composition/createTeamApprovalsFeature.test.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createTeamApprovalsFeature } from './createTeamApprovalsFeature'; + +import type { TeamToolApprovalCompatibilityApi } from './createTeamApprovalsFeature'; +import type { ToolApprovalSettings } from '@shared/types'; + +describe('createTeamApprovalsFeature', () => { + it('adapts the existing bound capability without losing its receiver', async () => { + const settings: ToolApprovalSettings = { + autoAllowAll: false, + autoAllowFileEdits: false, + autoAllowSafeBash: true, + timeoutAction: 'wait', + timeoutSeconds: 30, + }; + const respondToToolApproval = vi.fn(async function (this: TeamToolApprovalCompatibilityApi) { + expect(this).toBe(api); + }); + const updateToolApprovalSettings = vi.fn(function (this: TeamToolApprovalCompatibilityApi) { + expect(this).toBe(api); + }); + const approvedPath = path.resolve('approved.txt'); + const getPendingToolApprovalFileTarget = vi.fn(function ( + this: TeamToolApprovalCompatibilityApi + ) { + expect(this).toBe(api); + return { + authorizationGeneration: 'approval-generation-1', + authorizationPath: 'approved.txt', + readPath: approvedPath, + }; + }); + const api: TeamToolApprovalCompatibilityApi = { + getPendingToolApprovalFileTarget, + respondToToolApproval, + updateToolApprovalSettings, + }; + const fileReader = { + read: vi.fn(async () => ({ + content: 'approved', + exists: true, + truncated: false, + isBinary: false, + })), + }; + const feature = createTeamApprovalsFeature({ toolApprovalApi: api, fileReader }); + + await feature.commands.respond({ + teamName: 'team-one', + runId: 'run-1', + requestId: 'request-1', + allow: true, + message: 'approved', + }); + feature.commands.updateSettings({ teamName: 'team-one', settings }); + + expect(respondToToolApproval).toHaveBeenCalledWith( + 'team-one', + 'run-1', + 'request-1', + true, + 'approved' + ); + expect(updateToolApprovalSettings).toHaveBeenCalledWith('team-one', settings); + await expect( + feature.previewReader.read({ + teamName: 'team-one', + runId: 'run-1', + requestId: 'request-1', + filePath: 'approved.txt', + }) + ).resolves.toMatchObject({ content: 'approved' }); + await expect( + feature.previewReader.read({ + teamName: 'team-one', + runId: 'run-1', + requestId: 'request-1', + filePath: path.resolve('other.txt'), + }) + ).resolves.toBeNull(); + expect(getPendingToolApprovalFileTarget).toHaveBeenCalledWith('team-one', 'run-1', 'request-1'); + expect(fileReader.read).toHaveBeenCalledWith(approvedPath); + }); +}); diff --git a/src/features/team-approvals/main/composition/createTeamApprovalsFeature.ts b/src/features/team-approvals/main/composition/createTeamApprovalsFeature.ts new file mode 100644 index 0000000000..fdc81e33ac --- /dev/null +++ b/src/features/team-approvals/main/composition/createTeamApprovalsFeature.ts @@ -0,0 +1,61 @@ +import { ReadToolApprovalFilePreview } from '../../core/application/use-cases/ReadToolApprovalFilePreview'; + +import type { TeamApprovalsFeature } from './TeamApprovalsIpcBoundary'; +import type { ToolApprovalFileContent, ToolApprovalSettings } from '@shared/types'; + +export interface TeamApprovalsFileReader { + read(filePath: string): Promise; +} + +export interface TeamToolApprovalCompatibilityApi { + respondToToolApproval( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ): Promise; + updateToolApprovalSettings(teamName: string, settings: ToolApprovalSettings): void; + getPendingToolApprovalFileTarget( + teamName: string, + runId: string, + requestId: string + ): { authorizationGeneration: string; authorizationPath: string; readPath: string } | null; +} + +export type { TeamApprovalsFeature } from './TeamApprovalsIpcBoundary'; + +export interface TeamApprovalsFeatureDependencies { + toolApprovalApi: TeamToolApprovalCompatibilityApi; + fileReader: TeamApprovalsFileReader; +} + +export function createTeamApprovalsFeature( + dependencies: TeamApprovalsFeatureDependencies +): TeamApprovalsFeature { + const previewReader = new ReadToolApprovalFilePreview({ + pendingApprovals: { + getFileTarget: (teamName, runId, requestId) => + dependencies.toolApprovalApi.getPendingToolApprovalFileTarget(teamName, runId, requestId), + }, + files: dependencies.fileReader, + }); + + return { + commands: { + respond: ({ teamName, runId, requestId, allow, message }) => + dependencies.toolApprovalApi.respondToToolApproval( + teamName, + runId, + requestId, + allow, + message + ), + updateSettings: ({ teamName, settings }) => + dependencies.toolApprovalApi.updateToolApprovalSettings(teamName, settings), + }, + previewReader: { + read: (request) => previewReader.read(request), + }, + }; +} diff --git a/src/features/team-approvals/main/hosted.ts b/src/features/team-approvals/main/hosted.ts new file mode 100644 index 0000000000..8443ff2d30 --- /dev/null +++ b/src/features/team-approvals/main/hosted.ts @@ -0,0 +1,106 @@ +export type { + HostedTeamApprovalClockPort, + HostedTeamApprovalDecisionAdmissionPort, + HostedTeamApprovalDecisionAdmissionResult, + HostedTeamApprovalPageCandidate, + HostedTeamApprovalPageSourcePort, + HostedTeamApprovalPageSourceRequest, + HostedTeamApprovalPageSourceResult, + HostedTeamApprovalPreviewSourcePort, + HostedTeamApprovalPreviewSourceRequest, + HostedTeamApprovalPreviewSourceResult, +} from '../core/application/ports/HostedTeamApprovalPorts'; +export { + HOSTED_TEAM_APPROVAL_DECISION_ROUTE, + HOSTED_TEAM_APPROVAL_PAGE_ROUTE, + HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE, + HOSTED_TEAM_APPROVAL_ROUTE_DESCRIPTORS, +} from './adapters/input/http/hostedTeamApprovalRoutes'; +export { + type HostedTeamApprovalsContextFactory, + type HostedTeamApprovalsHttpFacade, + registerHostedTeamApprovalsHttp, +} from './adapters/input/http/registerHostedTeamApprovalsHttp'; +export { + type HostedRuntimePermissionProjectionRequest, + type HostedRuntimePermissionProjectionResult, + HostedRuntimePermissionRequestProjector, +} from './adapters/input/runtime-ingress/HostedRuntimePermissionRequestProjector'; +export { + HostedApprovalDecisionDeliveryCoordinator, + type HostedApprovalDecisionDeliveryRequest, + type HostedApprovalDecisionDeliveryResult, +} from './adapters/output/runtime-ingress/HostedApprovalDecisionDeliveryCoordinator'; +export { + HostedApprovalDecisionReconciliationCoordinator, + type HostedApprovalDecisionReconciliationRequest, + type HostedApprovalDecisionReconciliationResult, +} from './adapters/output/runtime-ingress/HostedApprovalDecisionReconciliationCoordinator'; +export { + HOSTED_APPROVAL_RUNTIME_MAXIMUM_FRAME_BYTES, + HostedApprovalRuntimeOrchestratorAuthority, + type HostedApprovalRuntimeOrchestratorAuthorityOptions, + type HostedApprovalRuntimeOwnerLeasePort, +} from './adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorAuthority'; +export { + type HostedApprovalRuntimeOrchestratorRoute, + HostedApprovalRuntimeOrchestratorRouter, +} from './adapters/output/runtime-ingress/HostedApprovalRuntimeOrchestratorRouter'; +export { + createHostedApprovalRuntimeOwnerProof, + HOSTED_APPROVAL_RUNTIME_OPERATIONS, + HOSTED_APPROVAL_RUNTIME_OWNER_PROOF_DOMAIN, + HOSTED_APPROVAL_RUNTIME_WIRE_CAPABILITY, + HOSTED_APPROVAL_RUNTIME_WIRE_CAPABILITY_DIGEST, + HOSTED_APPROVAL_RUNTIME_WIRE_SCHEMA_VERSION, + type HostedApprovalIngressAuthorityResult, + type HostedApprovalRuntimeMountBinding, + type HostedApprovalRuntimeOperation, + hostedApprovalRuntimeOwnerProofMatches, + type HostedApprovalRuntimeRequestPayloadByOperation, + type HostedApprovalRuntimeResponsePayloadByOperation, + type HostedApprovalRuntimeWireAuthority, + parseHostedApprovalDecisionDeliveryRequest, + parseHostedApprovalRuntimeExchangeId, + parseHostedApprovalRuntimeRequestPayload, + parseHostedApprovalRuntimeResponsePayload, + parseHostedApprovalRuntimeWireAuthority, + sameHostedApprovalRuntimeWireAuthority, +} from './adapters/output/runtime-ingress/hostedApprovalRuntimeOrchestratorWire'; +export { + createDurableHostedTeamApprovalAuthority, + type DurableHostedTeamApprovalAuthority, +} from './composition/createDurableHostedTeamApprovalAuthority'; +export { + createHostedApprovalAdmissionAuthority, + type HostedApprovalAdmissionAuthority, + type HostedApprovalAdmissionSnapshot, +} from './composition/createHostedApprovalAdmissionAuthority'; +export { + createHostedTeamApprovalOutputAdapters, + type HostedTeamApprovalOutputAdapters, +} from './composition/createHostedTeamApprovalOutputAdapters'; +export { + createHostedTeamApprovalRuntimeBridge, + type HostedTeamApprovalRuntimeBridge, + type HostedTeamApprovalRuntimeBridgeDependencies, +} from './composition/createHostedTeamApprovalRuntimeBridge'; +export { + createHostedTeamApprovalsFeature, + createHostedTeamApprovalsRouteContribution, + type HostedTeamApprovalsFeature, + type HostedTeamApprovalsFeatureDependencies, +} from './composition/createHostedTeamApprovalsFeature'; +export type { HostedTeamApprovalAuthorityPort } from './ports/HostedTeamApprovalAuthorityPort'; +export type { + HostedTeamApprovalAuthorityScopeResolverPort, + HostedTeamApprovalDeliveryOutboxPort, + HostedTeamApprovalPendingIngressPort, +} from './ports/HostedTeamApprovalAuthorityStoragePort'; +export type { + HostedApprovalDecisionExternalLifecycleDeliveryPort, + HostedApprovalDecisionReconciliationPort, + HostedRuntimePermissionIngressAuthorityPort, + HostedRuntimePermissionIngressEffectPort, + HostedTeamApprovalRuntimeBridgeClockPort, +} from './ports/HostedTeamApprovalRuntimeBridgePorts'; diff --git a/src/features/team-approvals/main/index.ts b/src/features/team-approvals/main/index.ts new file mode 100644 index 0000000000..b3be8b695b --- /dev/null +++ b/src/features/team-approvals/main/index.ts @@ -0,0 +1,13 @@ +export type { + TeamApprovalsFeature, + TeamApprovalsFeatureDependencies, + TeamApprovalsFileReader, + TeamToolApprovalCompatibilityApi, +} from './composition/createTeamApprovalsFeature'; +export { createTeamApprovalsFeature } from './composition/createTeamApprovalsFeature'; +export { + registerTeamApprovalsIpc, + removeTeamApprovalsIpc, + type TeamApprovalsIpcDependencies, + type TeamApprovalsIpcLogger, +} from './composition/TeamApprovalsIpcBoundary'; diff --git a/src/features/team-approvals/main/ports/HostedTeamApprovalAuthorityPort.ts b/src/features/team-approvals/main/ports/HostedTeamApprovalAuthorityPort.ts new file mode 100644 index 0000000000..ed77efd95f --- /dev/null +++ b/src/features/team-approvals/main/ports/HostedTeamApprovalAuthorityPort.ts @@ -0,0 +1,38 @@ +import type { HostedTeamApprovalDecisionCommand } from '../../contracts/hosted'; +import type { + HostedTeamApprovalDecisionAdmissionResult, + HostedTeamApprovalPageSourceRequest, + HostedTeamApprovalPageSourceResult, + HostedTeamApprovalPreviewSourceRequest, + HostedTeamApprovalPreviewSourceResult, +} from '../../core/application/ports/HostedTeamApprovalPorts'; +import type { QueryContext } from '@shared/contracts/hosted'; + +/** + * Server-owned approval authority consumed by the hosted feature. + * + * Each operation revalidates the exact QueryContext scope within the authoritative observation. A + * pending read selects only currently pending approvals. A preview read binds the opaque reference + * to the same team, approval, and generation before returning its bounded projection. + * + * `compareAndClaimDecision` performs scope revalidation, pending-generation comparison, + * idempotency matching, the one-decision claim, and redacted audit commit atomically. A committed + * claim and its persisted-before-delivery handoff are durable before `committed` is returned, so a + * caller can safely recover through the authority's idempotent receipt. + */ +export interface HostedTeamApprovalAuthorityPort { + readPendingPage( + request: HostedTeamApprovalPageSourceRequest, + context: QueryContext + ): Promise; + + readPreviewByOpaqueRef( + request: HostedTeamApprovalPreviewSourceRequest, + context: QueryContext + ): Promise; + + compareAndClaimDecision( + command: HostedTeamApprovalDecisionCommand, + context: QueryContext + ): Promise; +} diff --git a/src/features/team-approvals/main/ports/HostedTeamApprovalAuthorityStoragePort.ts b/src/features/team-approvals/main/ports/HostedTeamApprovalAuthorityStoragePort.ts new file mode 100644 index 0000000000..b156a41103 --- /dev/null +++ b/src/features/team-approvals/main/ports/HostedTeamApprovalAuthorityStoragePort.ts @@ -0,0 +1,51 @@ +import type { + HostedTeamApprovalAuthorityScope, + HostedTeamApprovalDeliveryAcknowledgeRequest, + HostedTeamApprovalDeliveryClaimRequest, + HostedTeamApprovalDeliveryOperatorRequiredRequest, + HostedTeamApprovalDeliveryReconciliationReadResult, + HostedTeamApprovalDeliveryReconciliationRequest, + HostedTeamApprovalDeliveryReconciliationSettleRequest, + HostedTeamApprovalDeliveryRecord, + HostedTeamApprovalPendingReadRecord, + HostedTeamApprovalPendingStorageRecord, +} from '@features/internal-storage/contracts'; +import type { QueryContext, TeamId } from '@shared/contracts/hosted'; + +/** + * Resolves the current trusted approval authority scope. Browser requests do + * not supply workspace, authority, or restore-generation identity. + */ +export interface HostedTeamApprovalAuthorityScopeResolverPort { + resolveScope( + teamId: TeamId, + context: QueryContext + ): Promise; +} + +/** Trusted lifecycle-owner ingress; it never starts or owns that lifecycle. */ +export interface HostedTeamApprovalPendingIngressPort { + observePending( + record: HostedTeamApprovalPendingStorageRecord + ): Promise; +} + +/** + * Recoverable delivery handoff for the external lifecycle owner. Delivery IDs + * remain stable across lease recovery so the receiver can be idempotent. + */ +export interface HostedTeamApprovalDeliveryOutboxPort { + claimDeliveries( + request: HostedTeamApprovalDeliveryClaimRequest + ): Promise; + acknowledgeDelivery(request: HostedTeamApprovalDeliveryAcknowledgeRequest): Promise; + markDeliveryOperatorRequired( + request: HostedTeamApprovalDeliveryOperatorRequiredRequest + ): Promise; + readDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationRequest + ): Promise; + settleDeliveryReconciliation( + request: HostedTeamApprovalDeliveryReconciliationSettleRequest + ): Promise; +} diff --git a/src/features/team-approvals/main/ports/HostedTeamApprovalRuntimeBridgePorts.ts b/src/features/team-approvals/main/ports/HostedTeamApprovalRuntimeBridgePorts.ts new file mode 100644 index 0000000000..5f8f1f2ddc --- /dev/null +++ b/src/features/team-approvals/main/ports/HostedTeamApprovalRuntimeBridgePorts.ts @@ -0,0 +1,80 @@ +import type { + HostedTeamApprovalAuthorityScope, + HostedTeamApprovalStorageDecision, +} from '@features/internal-storage/contracts'; +import type { RuntimeIngressPermissionOutboxPort } from '@features/team-runtime-control'; +import type { RuntimePermissionApprovalIngressAuthority } from '@features/team-runtime-control/contracts'; + +/** The bridge can only claim and acknowledge committed ingress effects. */ +export type HostedRuntimePermissionIngressEffectPort = Pick< + RuntimeIngressPermissionOutboxPort, + 'claimPermissionApprovalIngressEffects' | 'acknowledgePermissionApprovalIngressEffect' +>; + +/** A small clock seam keeps lease and deadline checks deterministic. */ +export interface HostedTeamApprovalRuntimeBridgeClockPort { + now(): number; +} + +/** + * Resolves an approval scope from the immutable authority copied into the + * committed ingress effect. Implementations must not use a provider body, + * runtime-selected principal, or a caller-selected team/run/lane/provider. + */ +export interface HostedRuntimePermissionIngressAuthorityPort { + resolvePersistedIngressAuthority(authority: RuntimePermissionApprovalIngressAuthority): Promise< + | { readonly status: 'resolved'; readonly scope: HostedTeamApprovalAuthorityScope } + | { + readonly status: 'stale_generation' | 'wrong_lane' | 'unavailable'; + } + >; +} + +/** + * Narrow external-lifecycle handoff for a durable approval decision. The + * lifecycle owner re-binds deliveryRef to its persisted ingress authority and + * treats providerDeliveryId as an idempotency key before answering a provider. + */ +export interface HostedApprovalDecisionExternalLifecycleDeliveryPort { + deliverRuntimePermissionDecision(request: { + readonly providerDeliveryId: string; + /** Stable durable reference persisted before the provider boundary is crossed. */ + readonly reconciliationRef: string; + readonly principal: + | Readonly<{ readonly kind: 'operator'; readonly actorId: string }> + | Readonly<{ readonly kind: 'system_timeout' }>; + readonly deliveryRef: string; + readonly approvalId: string; + readonly approvalGeneration: string; + readonly decision: HostedTeamApprovalStorageDecision; + readonly partition: Readonly<{ teamId: string; runId: string }>; + readonly requestId: string; + }): Promise< + | { readonly status: 'delivered' | 'idempotent_replay' } + | { + readonly status: + | 'stale_generation' + | 'expired' + | 'wrong_lane' + | 'self_approval' + | 'unavailable'; + } + | { + /** The owner may have crossed the provider boundary. Never retry or acknowledge. */ + readonly status: 'operator_required'; + readonly reconciliationRef: string; + } + >; +} + +/** Explicit operator-owned resolution for terminal ambiguous delivery effects. */ +export interface HostedApprovalDecisionReconciliationPort { + reconcileRuntimePermissionDecision(request: { + readonly reconciliationRef: string; + readonly providerDeliveryId: string; + readonly partition: Readonly<{ teamId: string; runId: string }>; + }): Promise< + | { readonly status: 'delivered' | 'not_delivered' } + | { readonly status: 'operator_required' | 'unavailable' } + >; +} diff --git a/src/features/team-approvals/renderer/components/HostedTeamApprovalPanel.tsx b/src/features/team-approvals/renderer/components/HostedTeamApprovalPanel.tsx new file mode 100644 index 0000000000..f6c58fa05e --- /dev/null +++ b/src/features/team-approvals/renderer/components/HostedTeamApprovalPanel.tsx @@ -0,0 +1,308 @@ +import { useEffect, useId, useLayoutEffect, useRef, useSyncExternalStore } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; +import { Check, Loader2, RefreshCw, ShieldAlert, X } from 'lucide-react'; + +import type { HostedTeamApprovalItem } from '../../contracts'; +import type { HostedTeamApprovalRendererSlice } from '../ports/HostedTeamApprovalRendererPorts'; + +export interface HostedTeamApprovalPanelProps { + readonly slice: HostedTeamApprovalRendererSlice; + readonly heading?: string; + readonly description?: string; + /** Read and preview stay mounted when mutation readiness is unavailable. */ + readonly decisionsEnabled?: boolean; +} + +function categoryLabel(category: HostedTeamApprovalItem['category']): string { + switch (category) { + case 'file_change': + return 'File change'; + case 'command': + return 'Command'; + case 'network': + return 'Network'; + case 'other': + return 'Other'; + } +} + +export const HostedTeamApprovalPanel = ({ + slice, + heading = 'Pending approvals', + description = 'Review provider requests before allowing or denying them.', + decisionsEnabled = true, +}: HostedTeamApprovalPanelProps): React.JSX.Element => { + const snapshot = useSyncExternalStore(slice.subscribe, slice.getSnapshot, slice.getSnapshot); + const headingId = useId(); + const descriptionId = useId(); + const refreshButtonRef = useRef(null); + const itemRefs = useRef(new Map()); + const appliedFocus = useRef({ slice, sequence: 0 }); + + useEffect(() => slice.mount(), [slice]); + + useLayoutEffect(() => { + if (appliedFocus.current.slice !== slice) { + appliedFocus.current = { slice, sequence: 0 }; + } + const request = snapshot.focusRequest; + if (request === null || request.sequence <= appliedFocus.current.sequence) return; + appliedFocus.current = { slice, sequence: request.sequence }; + const target = + request.approvalId === null + ? refreshButtonRef.current + : itemRefs.current.get(`${request.runId}:${request.approvalId}`); + target?.focus(); + }, [slice, snapshot.focusRequest]); + + const selectedItem = snapshot.items.find( + (item) => + item.runId === snapshot.selectedRunId && item.approvalId === snapshot.selectedApprovalId + ); + const selectedIsPending = + selectedItem !== undefined && + snapshot.pendingDecision?.approvalId === selectedItem.approvalId && + snapshot.pendingDecision.runId === selectedItem.runId && + snapshot.pendingDecision.generation === selectedItem.generation; + + return ( +
+
+
+
+
+

+ {description} +

+
+ + + + + + + Refresh approvals + + +
+ +
+
+ {snapshot.pageStatus === 'loading' && snapshot.items.length === 0 ? ( +

+

+ ) : null} + + {snapshot.pageError !== null ? ( +

+ {snapshot.pageError} +

+ ) : null} + + {snapshot.pageStatus === 'ready' && snapshot.items.length === 0 ? ( +

+ There are no pending approvals. +

+ ) : null} + + {snapshot.items.length > 0 ? ( +
    + {snapshot.items.map((item) => { + const selected = + item.runId === snapshot.selectedRunId && + item.approvalId === snapshot.selectedApprovalId; + const identity = `${item.runId}:${item.approvalId}`; + return ( +
  • + +
  • + ); + })} +
+ ) : null} + + {snapshot.nextCursor !== null ? ( + + ) : null} +
+ +
+ {selectedItem === undefined ? ( +

+ Select an approval to review its current request. +

+ ) : ( +
+
+

+ {categoryLabel(selectedItem.category)} +

+

+ {selectedItem.summary} +

+
+ + {selectedItem.previewRef === null ? ( +

+ This request does not include a preview. +

+ ) : null} + + {snapshot.previewStatus === 'loading' ? ( +

+

+ ) : null} + + {snapshot.previewError !== null ? ( +

+ {snapshot.previewError} +

+ ) : null} + + {snapshot.preview !== null ? ( + snapshot.preview.isBinary ? ( +

+ Binary preview omitted ({snapshot.preview.byteLength} bytes). +

+ ) : ( +
+                    {snapshot.preview.content}
+                  
+ ) + ) : null} + +
+ + +
+ {!decisionsEnabled ? ( +

+ Approval decisions are temporarily unavailable. You can still review requests. +

+ ) : null} +
+ )} +
+
+ +
+ {snapshot.decisionReceipt !== null ? ( +

+ {snapshot.decisionReceipt.decision === 'allow' ? 'Allowed' : 'Denied'} and confirmed by + the server. +

+ ) : null} + {snapshot.decisionError !== null ? ( +

+ {snapshot.decisionError} +

+ ) : null} +
+
+ ); +}; diff --git a/src/features/team-approvals/renderer/composition/createHostedTeamApprovalTransport.ts b/src/features/team-approvals/renderer/composition/createHostedTeamApprovalTransport.ts new file mode 100644 index 0000000000..8e76d0763f --- /dev/null +++ b/src/features/team-approvals/renderer/composition/createHostedTeamApprovalTransport.ts @@ -0,0 +1,261 @@ +import { + type DecideHostedTeamApprovalResult, + type GetHostedTeamApprovalPageResult, + type GetHostedTeamApprovalPreviewResult, + HOSTED_TEAM_APPROVAL_DECISION_ROUTE, + HOSTED_TEAM_APPROVAL_PAGE_ROUTE, + HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE, + type HostedTeamApprovalDecisionCommand, + type HostedTeamApprovalPageRequest, + type HostedTeamApprovalPreviewRequest, + parseHostedTeamApprovalDecisionCommand, + parseHostedTeamApprovalDecisionReceipt, + parseHostedTeamApprovalErrorEnvelope, + parseHostedTeamApprovalPage, + parseHostedTeamApprovalPageRequest, + parseHostedTeamApprovalPreview, + parseHostedTeamApprovalPreviewRequest, +} from '../../contracts'; + +import type { + HostedTeamApprovalHttpResponse, + HostedTeamApprovalTransport, + HostedTeamApprovalTransportDependencies, + HostedTeamApprovalTransportOptions, +} from '../ports/HostedTeamApprovalTransportPorts'; + +const JSON_HEADERS = Object.freeze({ + Accept: 'application/json', + 'Content-Type': 'application/json', +}); +const CSRF_HEADER = 'x-agent-teams-csrf'; +const CSRF_TOKEN = /^[A-Za-z0-9_-]{32,512}$/; + +function unavailable(retryAfterMs?: number): T { + return ( + retryAfterMs === undefined + ? Object.freeze({ kind: 'unavailable' }) + : Object.freeze({ kind: 'unavailable', retryAfterMs }) + ) as T; +} + +function readCsrfToken(dependencies: HostedTeamApprovalTransportDependencies): string | null { + try { + const value: unknown = dependencies.getCsrfToken(); + return typeof value === 'string' && CSRF_TOKEN.test(value) ? value : null; + } catch { + return null; + } +} + +async function readJson(response: HostedTeamApprovalHttpResponse): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function pageError(status: number, value: unknown): GetHostedTeamApprovalPageResult { + const envelope = parseHostedTeamApprovalErrorEnvelope(value); + if (!envelope.ok) return unavailable(); + const reason = envelope.value.error.reason; + if (status === 400 && reason === 'approval_page_request_invalid') { + return Object.freeze({ kind: 'invalid_request' }); + } + if (status === 404 && reason === 'team_not_found') { + return Object.freeze({ kind: 'not_found' }); + } + return status === 503 && reason === 'team_approval_unavailable' + ? unavailable(envelope.value.error.retryAfterMs) + : unavailable(); +} + +function previewError(status: number, value: unknown): GetHostedTeamApprovalPreviewResult { + const envelope = parseHostedTeamApprovalErrorEnvelope(value); + if (!envelope.ok) return unavailable(); + const reason = envelope.value.error.reason; + if (status === 400 && reason === 'approval_preview_request_invalid') { + return Object.freeze({ kind: 'invalid_request' }); + } + if (status === 404 && reason === 'approval_not_found') { + return Object.freeze({ kind: 'not_found' }); + } + if (status === 409 && reason === 'stale_generation') { + return envelope.value.currentGeneration === undefined + ? unavailable() + : Object.freeze({ + kind: 'stale_generation', + currentGeneration: envelope.value.currentGeneration, + }); + } + return status === 503 && reason === 'team_approval_unavailable' + ? unavailable(envelope.value.error.retryAfterMs) + : unavailable(); +} + +function decisionError(status: number, value: unknown): DecideHostedTeamApprovalResult { + const envelope = parseHostedTeamApprovalErrorEnvelope(value); + if (!envelope.ok) return unavailable(); + const reason = envelope.value.error.reason; + if (status === 400 && reason === 'approval_decision_invalid') { + return Object.freeze({ kind: 'invalid_request' }); + } + if (status === 404 && reason === 'approval_not_found') { + return Object.freeze({ kind: 'not_found' }); + } + if (status === 410 && reason === 'approval_expired') { + return Object.freeze({ kind: 'expired' }); + } + if (status === 503 && reason === 'team_approval_unavailable') { + return unavailable(envelope.value.error.retryAfterMs); + } + if (status !== 409) return unavailable(); + if (reason === 'stale_generation') { + return envelope.value.currentGeneration === undefined + ? unavailable() + : Object.freeze({ + kind: 'stale_generation', + currentGeneration: envelope.value.currentGeneration, + }); + } + if (reason === 'approval_already_resolved') { + return envelope.value.currentGeneration === undefined || + envelope.value.resolvedDecision === undefined + ? unavailable() + : Object.freeze({ + kind: 'already_resolved', + generation: envelope.value.currentGeneration, + decision: envelope.value.resolvedDecision, + }); + } + return reason === 'idempotency_mismatch' + ? Object.freeze({ kind: 'conflict', reason: 'idempotency_mismatch' }) + : unavailable(); +} + +export function createHostedTeamApprovalTransport( + dependencies: HostedTeamApprovalTransportDependencies +): HostedTeamApprovalTransport { + const post = async ( + route: string, + body: unknown, + options: HostedTeamApprovalTransportOptions | undefined, + csrfToken: string + ): Promise => { + try { + return await dependencies.fetch(route, { + method: 'POST', + credentials: 'include', + cache: 'no-store', + headers: Object.freeze({ + ...JSON_HEADERS, + [CSRF_HEADER]: csrfToken, + }), + body: JSON.stringify(body), + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); + } catch { + return null; + } + }; + + return Object.freeze({ + async getPage( + requestValue: HostedTeamApprovalPageRequest, + options?: HostedTeamApprovalTransportOptions + ) { + const request = parseHostedTeamApprovalPageRequest(requestValue); + if (!request.ok) return Object.freeze({ kind: 'invalid_request' }); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + const csrfToken = readCsrfToken(dependencies); + if (csrfToken === null) return unavailable(); + const response = await post( + HOSTED_TEAM_APPROVAL_PAGE_ROUTE, + request.value, + options, + csrfToken + ); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + if (response === null) { + return unavailable(); + } + const value = await readJson(response); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + if (response.status !== 200) return pageError(response.status, value); + const page = parseHostedTeamApprovalPage(value); + return page.ok && + page.value.teamId === request.value.teamId && + page.value.items.every((item) => item.runId === request.value.expectedRunId) + ? Object.freeze({ kind: 'success', page: page.value }) + : unavailable(); + }, + + async getPreview( + requestValue: HostedTeamApprovalPreviewRequest, + options?: HostedTeamApprovalTransportOptions + ) { + const request = parseHostedTeamApprovalPreviewRequest(requestValue); + if (!request.ok) return Object.freeze({ kind: 'invalid_request' }); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + const csrfToken = readCsrfToken(dependencies); + if (csrfToken === null) return unavailable(); + const response = await post( + HOSTED_TEAM_APPROVAL_PREVIEW_ROUTE, + request.value, + options, + csrfToken + ); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + if (response === null) { + return unavailable(); + } + const value = await readJson(response); + if (options?.signal?.aborted) return Object.freeze({ kind: 'cancelled' }); + if (response.status !== 200) return previewError(response.status, value); + const preview = parseHostedTeamApprovalPreview(value); + return preview.ok && + preview.value.teamId === request.value.teamId && + preview.value.runId === request.value.expectedRunId && + preview.value.approvalId === request.value.approvalId && + preview.value.generation === request.value.expectedGeneration + ? Object.freeze({ kind: 'success', preview: preview.value }) + : unavailable(); + }, + + async decide( + commandValue: HostedTeamApprovalDecisionCommand, + options?: HostedTeamApprovalTransportOptions + ) { + const command = parseHostedTeamApprovalDecisionCommand(commandValue); + if (!command.ok) return Object.freeze({ kind: 'invalid_request' }); + const csrfToken = readCsrfToken(dependencies); + if (csrfToken === null || options?.signal?.aborted) { + return unavailable(); + } + const response = await post( + HOSTED_TEAM_APPROVAL_DECISION_ROUTE, + command.value, + options, + csrfToken + ); + if (response === null) return unavailable(); + const value = await readJson(response); + if (response.status !== 200) return decisionError(response.status, value); + const receipt = parseHostedTeamApprovalDecisionReceipt(value); + if ( + !receipt.ok || + receipt.value.teamId !== command.value.teamId || + receipt.value.runId !== command.value.expectedRunId || + receipt.value.approvalId !== command.value.approvalId || + receipt.value.generation !== command.value.expectedGeneration || + receipt.value.decision !== command.value.decision + ) { + return unavailable(); + } + return receipt.value.outcome === 'committed' + ? Object.freeze({ kind: 'committed', receipt: receipt.value }) + : Object.freeze({ kind: 'idempotent_replay', receipt: receipt.value }); + }, + }); +} diff --git a/src/features/team-approvals/renderer/index.ts b/src/features/team-approvals/renderer/index.ts new file mode 100644 index 0000000000..00c683b06d --- /dev/null +++ b/src/features/team-approvals/renderer/index.ts @@ -0,0 +1,26 @@ +export { + HostedTeamApprovalPanel, + type HostedTeamApprovalPanelProps, +} from './components/HostedTeamApprovalPanel'; +export { createHostedTeamApprovalTransport } from './composition/createHostedTeamApprovalTransport'; +export type { + HostedTeamApprovalIdempotencyKeyPort, + HostedTeamApprovalRendererFocusRequest, + HostedTeamApprovalRendererLoadStatus, + HostedTeamApprovalRendererPendingDecision, + HostedTeamApprovalRendererReconnectPort, + HostedTeamApprovalRendererRefreshPort, + HostedTeamApprovalRendererSlice, + HostedTeamApprovalRendererSliceDependencies, + HostedTeamApprovalRendererState, +} from './ports/HostedTeamApprovalRendererPorts'; +export type { + HostedTeamApprovalFetchPort, + HostedTeamApprovalHttpRequestInit, + HostedTeamApprovalHttpResponse, + HostedTeamApprovalTransport, + HostedTeamApprovalTransportDependencies, + HostedTeamApprovalTransportOptions, +} from './ports/HostedTeamApprovalTransportPorts'; +export type { ToolApprovalDiffFileReadPort } from './ports/ToolApprovalDiffFileReadPort'; +export { createHostedTeamApprovalRendererSlice } from './slices/createHostedTeamApprovalRendererSlice'; diff --git a/src/features/team-approvals/renderer/ports/HostedTeamApprovalRendererPorts.ts b/src/features/team-approvals/renderer/ports/HostedTeamApprovalRendererPorts.ts new file mode 100644 index 0000000000..a88d11b27e --- /dev/null +++ b/src/features/team-approvals/renderer/ports/HostedTeamApprovalRendererPorts.ts @@ -0,0 +1,82 @@ +import type { + HostedTeamApprovalDecision, + HostedTeamApprovalDecisionReceipt, + HostedTeamApprovalId, + HostedTeamApprovalIdempotencyKey, + HostedTeamApprovalItem, + HostedTeamApprovalPreview, +} from '../../contracts'; +import type { HostedTeamApprovalTransport } from './HostedTeamApprovalTransportPorts'; +import type { Cursor, RunId } from '@shared/contracts/hosted'; + +export type HostedTeamApprovalRendererLoadStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export interface HostedTeamApprovalRendererPendingDecision { + readonly runId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly decision: HostedTeamApprovalDecision; + readonly generation: HostedTeamApprovalItem['generation']; +} + +export interface HostedTeamApprovalRendererFocusRequest { + readonly sequence: number; + readonly runId: RunId | null; + readonly approvalId: HostedTeamApprovalId | null; +} + +export interface HostedTeamApprovalRendererState { + readonly mounted: boolean; + readonly items: readonly HostedTeamApprovalItem[]; + readonly nextCursor: Cursor | null; + readonly pageStatus: HostedTeamApprovalRendererLoadStatus; + readonly pageError: string | null; + readonly selectedApprovalId: HostedTeamApprovalId | null; + readonly selectedRunId: RunId | null; + readonly preview: HostedTeamApprovalPreview | null; + readonly previewStatus: HostedTeamApprovalRendererLoadStatus; + readonly previewError: string | null; + readonly pendingDecision: HostedTeamApprovalRendererPendingDecision | null; + readonly decisionReceipt: HostedTeamApprovalDecisionReceipt | null; + readonly decisionError: string | null; + readonly focusRequest: HostedTeamApprovalRendererFocusRequest | null; +} + +export interface HostedTeamApprovalRendererSlice { + getSnapshot(): HostedTeamApprovalRendererState; + subscribe(listener: () => void): () => void; + mount(): () => void; + reload(): Promise; + loadMore(): Promise; + selectApproval(approvalId: HostedTeamApprovalId | null, runId: RunId | null): Promise; + allow(): Promise; + deny(): Promise; +} + +export interface HostedTeamApprovalRendererRefreshPort { + subscribe(listener: () => void): () => void; +} + +export interface HostedTeamApprovalRendererReconnectPort { + subscribe(listener: () => void): () => void; +} + +export interface HostedTeamApprovalIdempotencyKeyPort { + create(input: { + readonly runId: RunId; + readonly approvalId: HostedTeamApprovalId; + readonly decision: HostedTeamApprovalDecision; + readonly generation: HostedTeamApprovalItem['generation']; + }): HostedTeamApprovalIdempotencyKey; +} + +export interface HostedTeamApprovalRendererSliceDependencies { + readonly teamId: HostedTeamApprovalItem['teamId']; + readonly currentRunId: () => HostedTeamApprovalItem['runId'] | null; + readonly transport: HostedTeamApprovalTransport; + readonly refresh: HostedTeamApprovalRendererRefreshPort; + readonly reconnect: HostedTeamApprovalRendererReconnectPort; + readonly idempotencyKeys: HostedTeamApprovalIdempotencyKeyPort; + readonly pageLimit?: number; + /** Bounded authoritative refresh used when no push channel is available. */ + readonly pollIntervalMs?: number; +} diff --git a/src/features/team-approvals/renderer/ports/HostedTeamApprovalTransportPorts.ts b/src/features/team-approvals/renderer/ports/HostedTeamApprovalTransportPorts.ts new file mode 100644 index 0000000000..c55692d58a --- /dev/null +++ b/src/features/team-approvals/renderer/ports/HostedTeamApprovalTransportPorts.ts @@ -0,0 +1,52 @@ +import type { + DecideHostedTeamApprovalResult, + GetHostedTeamApprovalPageResult, + GetHostedTeamApprovalPreviewResult, + HostedTeamApprovalDecisionCommand, + HostedTeamApprovalPageRequest, + HostedTeamApprovalPreviewRequest, +} from '../../contracts'; + +export interface HostedTeamApprovalHttpRequestInit { + readonly method: 'POST'; + readonly credentials: 'include'; + readonly cache: 'no-store'; + readonly headers: Readonly>; + readonly body: string; + readonly signal?: AbortSignal; +} + +export interface HostedTeamApprovalHttpResponse { + readonly status: number; + json(): Promise; +} + +export type HostedTeamApprovalFetchPort = ( + input: string, + init: HostedTeamApprovalHttpRequestInit +) => Promise; + +export interface HostedTeamApprovalTransportDependencies { + readonly fetch: HostedTeamApprovalFetchPort; + /** Returns only the current in-memory token. The transport never persists or returns it. */ + readonly getCsrfToken: () => string | null; +} + +export interface HostedTeamApprovalTransportOptions { + readonly signal?: AbortSignal; +} + +export interface HostedTeamApprovalTransport { + getPage( + request: HostedTeamApprovalPageRequest, + options?: HostedTeamApprovalTransportOptions + ): Promise; + getPreview( + request: HostedTeamApprovalPreviewRequest, + options?: HostedTeamApprovalTransportOptions + ): Promise; + decide( + command: HostedTeamApprovalDecisionCommand, + options?: HostedTeamApprovalTransportOptions + ): Promise; +} diff --git a/src/features/team-approvals/renderer/ports/ToolApprovalDiffFileReadPort.ts b/src/features/team-approvals/renderer/ports/ToolApprovalDiffFileReadPort.ts new file mode 100644 index 0000000000..26ef34eec4 --- /dev/null +++ b/src/features/team-approvals/renderer/ports/ToolApprovalDiffFileReadPort.ts @@ -0,0 +1,6 @@ +import type { ToolApprovalFileReadRequest } from '../../contracts'; +import type { ToolApprovalFileContent } from '@shared/types'; + +export interface ToolApprovalDiffFileReadPort { + readFile(request: ToolApprovalFileReadRequest): Promise; +} diff --git a/src/features/team-approvals/renderer/slices/createHostedTeamApprovalRendererSlice.ts b/src/features/team-approvals/renderer/slices/createHostedTeamApprovalRendererSlice.ts new file mode 100644 index 0000000000..e654af22e6 --- /dev/null +++ b/src/features/team-approvals/renderer/slices/createHostedTeamApprovalRendererSlice.ts @@ -0,0 +1,661 @@ +import { + HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + type HostedTeamApprovalDecision, + type HostedTeamApprovalId, + type HostedTeamApprovalItem, +} from '../../contracts'; + +import type { + HostedTeamApprovalRendererSlice, + HostedTeamApprovalRendererSliceDependencies, + HostedTeamApprovalRendererState, +} from '../ports/HostedTeamApprovalRendererPorts'; +import type { Cursor, RunId } from '@shared/contracts/hosted'; + +const DEFAULT_PAGE_LIMIT = 25; + +type PageReason = 'decision' | 'manual' | 'poll' | 'reconnect' | 'refresh'; + +interface FocusContext { + readonly runId: HostedTeamApprovalItem['runId']; + readonly approvalId: HostedTeamApprovalId; + readonly index: number; +} + +interface PendingPage { + readonly cursor: Cursor | null; + readonly promise: Promise; +} + +interface PendingPreview { + readonly runId: HostedTeamApprovalItem['runId']; + readonly approvalId: HostedTeamApprovalId; + readonly generation: HostedTeamApprovalItem['generation']; + readonly promise: Promise; +} + +interface PendingDecision { + readonly runId: HostedTeamApprovalItem['runId']; + readonly approvalId: HostedTeamApprovalId; + readonly decision: HostedTeamApprovalDecision; + readonly generation: HostedTeamApprovalItem['generation']; + readonly promise: Promise; +} + +function initialState(mounted: boolean): HostedTeamApprovalRendererState { + return Object.freeze({ + mounted, + items: Object.freeze([]), + nextCursor: null, + pageStatus: 'idle', + pageError: null, + selectedApprovalId: null, + selectedRunId: null, + preview: null, + previewStatus: 'idle', + previewError: null, + pendingDecision: null, + decisionReceipt: null, + decisionError: null, + focusRequest: null, + }); +} + +function mergePageItems( + previous: readonly HostedTeamApprovalItem[], + incoming: readonly HostedTeamApprovalItem[] +): readonly HostedTeamApprovalItem[] { + const merged = [...previous]; + const identity = (item: HostedTeamApprovalItem): string => `${item.runId}:${item.approvalId}`; + const indexById = new Map(merged.map((item, index) => [identity(item), index])); + for (const item of incoming) { + const key = identity(item); + const index = indexById.get(key); + if (index === undefined) { + indexById.set(key, merged.length); + merged.push(item); + } else { + merged[index] = item; + } + } + return Object.freeze(merged); +} + +function focusTarget( + items: readonly HostedTeamApprovalItem[], + context: FocusContext +): HostedTeamApprovalItem | null { + const sameApproval = items.find( + (item) => item.runId === context.runId && item.approvalId === context.approvalId + ); + return sameApproval ?? items[context.index] ?? items[Math.max(0, context.index - 1)] ?? null; +} + +function pageFailure(kind: string): string { + switch (kind) { + case 'invalid_request': + return 'The approval request was rejected.'; + case 'not_found': + return 'This team is no longer available.'; + case 'cancelled': + return 'The approval refresh was cancelled.'; + default: + return 'Approvals are temporarily unavailable.'; + } +} + +function previewFailure(kind: string): string { + switch (kind) { + case 'invalid_request': + return 'The preview request was rejected.'; + case 'stale_generation': + return 'This approval changed. The pending list was refreshed.'; + case 'not_found': + return 'This approval is no longer pending.'; + case 'cancelled': + return 'The preview request was cancelled.'; + default: + return 'The preview is temporarily unavailable.'; + } +} + +function decisionFailure(kind: string): string { + switch (kind) { + case 'already_resolved': + return 'This approval was already answered.'; + case 'stale_generation': + return 'This approval changed before the decision was accepted.'; + case 'expired': + return 'This approval expired before the decision was accepted.'; + case 'not_found': + return 'This approval is no longer pending.'; + case 'invalid_request': + return 'The approval decision was rejected.'; + case 'conflict': + return 'The approval decision conflicts with an earlier command.'; + default: + return 'The approval decision could not be confirmed.'; + } +} + +export function createHostedTeamApprovalRendererSlice( + dependencies: HostedTeamApprovalRendererSliceDependencies +): HostedTeamApprovalRendererSlice { + const pageLimit = dependencies.pageLimit ?? DEFAULT_PAGE_LIMIT; + const pollIntervalMs = dependencies.pollIntervalMs ?? 2_000; + if (!Number.isSafeInteger(pageLimit) || pageLimit < 1 || pageLimit > 50) { + throw new TypeError('hosted-team-approval-renderer-page-limit-invalid'); + } + if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 250 || pollIntervalMs > 30_000) { + throw new TypeError('hosted-team-approval-renderer-poll-interval-invalid'); + } + + let state = initialState(false); + let mountCount = 0; + let pageGeneration = 0; + let previewGeneration = 0; + let decisionGeneration = 0; + let focusSequence = 0; + let pageController: AbortController | null = null; + let previewController: AbortController | null = null; + let decisionController: AbortController | null = null; + let pendingPage: PendingPage | null = null; + let pendingPreview: PendingPreview | null = null; + let pendingDecision: PendingDecision | null = null; + let unsubscribeRefresh: (() => void) | null = null; + let unsubscribeReconnect: (() => void) | null = null; + let pollTimer: ReturnType | null = null; + const listeners = new Set<() => void>(); + + const publish = (patch: Partial): void => { + state = Object.freeze({ ...state, ...patch }); + for (const listener of listeners) listener(); + }; + + const advancePage = (): number => { + pageGeneration += 1; + pageController?.abort(); + pageController = null; + pendingPage = null; + return pageGeneration; + }; + + const advancePreview = (): number => { + previewGeneration += 1; + previewController?.abort(); + previewController = null; + pendingPreview = null; + return previewGeneration; + }; + + const advanceDecision = (): number => { + decisionGeneration += 1; + decisionController?.abort(); + decisionController = null; + pendingDecision = null; + return decisionGeneration; + }; + + const advanceAll = (): void => { + advancePage(); + advancePreview(); + advanceDecision(); + }; + + const isCurrentPage = (generation: number): boolean => + state.mounted && pageGeneration === generation; + const isCurrentPreview = (generation: number, item: HostedTeamApprovalItem): boolean => + state.mounted && + previewGeneration === generation && + state.selectedApprovalId === item.approvalId && + state.selectedRunId === item.runId && + state.items.some( + (candidate) => + candidate.runId === item.runId && + candidate.approvalId === item.approvalId && + candidate.generation === item.generation + ); + const isCurrentDecision = (generation: number, item: HostedTeamApprovalItem): boolean => + state.mounted && + decisionGeneration === generation && + state.selectedApprovalId === item.approvalId && + state.selectedRunId === item.runId && + state.items.some( + (candidate) => + candidate.runId === item.runId && + candidate.approvalId === item.approvalId && + candidate.generation === item.generation + ); + + const requestPage = ( + cursor: Cursor | null, + reason: PageReason, + focusAfter?: FocusContext, + force = false + ): Promise => { + if (!state.mounted) return Promise.resolve(); + const expectedRunId = dependencies.currentRunId(); + if (expectedRunId === null) { + publish({ pageStatus: 'error', pageError: pageFailure('unavailable') }); + return Promise.resolve(); + } + if (!force && pendingPage?.cursor === cursor) return pendingPage.promise; + + const selectedBefore = state.selectedApprovalId; + const selectedRunBefore = state.selectedRunId; + const selectedIndex = state.items.findIndex( + (item) => item.runId === selectedRunBefore && item.approvalId === selectedBefore + ); + const selectedContext = + selectedBefore === null || selectedIndex < 0 + ? undefined + : { runId: selectedRunBefore!, approvalId: selectedBefore, index: selectedIndex }; + + if (cursor === null && reason !== 'poll') { + advanceAll(); + } else { + advancePage(); + } + const generation = pageGeneration; + const controller = new AbortController(); + pageController = controller; + + const clearSelection = reason === 'reconnect'; + publish({ + pageStatus: reason === 'poll' ? state.pageStatus : 'loading', + pageError: null, + pendingDecision: null, + focusRequest: null, + ...(reason === 'decision' ? {} : { decisionReceipt: null, decisionError: null }), + ...(clearSelection + ? { + selectedApprovalId: null, + selectedRunId: null, + preview: null, + previewStatus: 'idle', + previewError: null, + } + : {}), + }); + + const promise = (async (): Promise => { + let result: Awaited>; + try { + result = await dependencies.transport.getPage( + { + schemaVersion: HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + teamId: dependencies.teamId, + expectedRunId, + cursor, + limit: pageLimit, + }, + { signal: controller.signal } + ); + } catch { + if (!isCurrentPage(generation)) return; + publish({ pageStatus: 'error', pageError: pageFailure('unavailable') }); + return; + } + if (!isCurrentPage(generation)) return; + + if (result.kind !== 'success') { + publish({ pageStatus: 'error', pageError: pageFailure(result.kind) }); + return; + } + if (result.page.items.some((item) => item.runId !== expectedRunId)) { + publish({ pageStatus: 'error', pageError: pageFailure('unavailable') }); + return; + } + + const items = + cursor === null + ? Object.freeze([...result.page.items]) + : mergePageItems(state.items, result.page.items); + const previouslySelected = state.selectedApprovalId; + const previouslySelectedRun = state.selectedRunId; + const previousItem = state.items.find( + (item) => item.runId === previouslySelectedRun && item.approvalId === previouslySelected + ); + const currentItem = items.find( + (item) => item.runId === previouslySelectedRun && item.approvalId === previouslySelected + ); + const selectionIsCurrent = + previousItem !== undefined && + currentItem !== undefined && + previousItem.generation === currentItem.generation; + const staleSelectionContext = selectionIsCurrent ? undefined : selectedContext; + const requestedFocus = focusAfter ?? staleSelectionContext; + const requestedFocusItem = + requestedFocus === undefined ? null : focusTarget(items, requestedFocus); + + publish({ + items, + nextCursor: result.page.nextCursor, + pageStatus: 'ready', + pageError: null, + ...(selectionIsCurrent + ? {} + : { + selectedApprovalId: null, + selectedRunId: null, + preview: null, + previewStatus: 'idle', + previewError: null, + pendingDecision: null, + }), + ...(requestedFocus === undefined + ? {} + : { + focusRequest: Object.freeze({ + sequence: ++focusSequence, + runId: requestedFocusItem?.runId ?? null, + approvalId: requestedFocusItem?.approvalId ?? null, + }), + }), + }); + })().finally(() => { + if (pendingPage?.promise === promise) pendingPage = null; + if (pageController === controller) pageController = null; + }); + + pendingPage = { cursor, promise }; + return promise; + }; + + const requestPreview = (item: HostedTeamApprovalItem): Promise => { + if (item.previewRef === null || !state.mounted) return Promise.resolve(); + const previewRef = item.previewRef; + if ( + pendingPreview?.approvalId === item.approvalId && + pendingPreview.runId === item.runId && + pendingPreview.generation === item.generation + ) { + return pendingPreview.promise; + } + + const generation = advancePreview(); + const controller = new AbortController(); + previewController = controller; + publish({ preview: null, previewStatus: 'loading', previewError: null }); + + const promise = (async (): Promise => { + let result: Awaited>; + try { + result = await dependencies.transport.getPreview( + { + schemaVersion: HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + teamId: dependencies.teamId, + expectedRunId: item.runId, + approvalId: item.approvalId, + expectedGeneration: item.generation, + previewRef, + }, + { signal: controller.signal } + ); + } catch { + if (!isCurrentPreview(generation, item)) return; + publish({ previewStatus: 'error', previewError: previewFailure('unavailable') }); + return; + } + if (!isCurrentPreview(generation, item)) return; + + if (result.kind === 'success') { + publish({ preview: result.preview, previewStatus: 'ready', previewError: null }); + return; + } + + if (result.kind === 'stale_generation' || result.kind === 'not_found') { + const index = state.items.findIndex( + (candidate) => candidate.runId === item.runId && candidate.approvalId === item.approvalId + ); + publish({ + selectedApprovalId: null, + selectedRunId: null, + preview: null, + previewStatus: 'error', + previewError: previewFailure(result.kind), + pendingDecision: null, + }); + await requestPage( + null, + 'refresh', + { runId: item.runId, approvalId: item.approvalId, index: Math.max(0, index) }, + true + ); + return; + } + + publish({ previewStatus: 'error', previewError: previewFailure(result.kind) }); + })().finally(() => { + if (pendingPreview?.promise === promise) pendingPreview = null; + if (previewController === controller) previewController = null; + }); + + pendingPreview = { + runId: item.runId, + approvalId: item.approvalId, + generation: item.generation, + promise, + }; + return promise; + }; + + const decide = (decision: HostedTeamApprovalDecision): Promise => { + const item = state.items.find( + (candidate) => + candidate.runId === state.selectedRunId && candidate.approvalId === state.selectedApprovalId + ); + if (!state.mounted || item === undefined) return Promise.resolve(); + if ( + pendingDecision?.approvalId === item.approvalId && + pendingDecision.runId === item.runId && + pendingDecision.generation === item.generation && + pendingDecision.decision === decision + ) { + return pendingDecision.promise; + } + + advancePage(); + const generation = advanceDecision(); + const controller = new AbortController(); + decisionController = controller; + const index = state.items.findIndex( + (candidate) => candidate.runId === item.runId && candidate.approvalId === item.approvalId + ); + + let idempotencyKey: ReturnType; + try { + idempotencyKey = dependencies.idempotencyKeys.create({ + runId: item.runId, + approvalId: item.approvalId, + generation: item.generation, + decision, + }); + } catch { + publish({ + pageStatus: state.items.length > 0 ? 'ready' : state.pageStatus, + pendingDecision: null, + decisionReceipt: null, + decisionError: 'A secure approval command could not be created.', + }); + return Promise.resolve(); + } + + publish({ + pageStatus: state.items.length > 0 ? 'ready' : state.pageStatus, + pendingDecision: Object.freeze({ + runId: item.runId, + approvalId: item.approvalId, + generation: item.generation, + decision, + }), + decisionReceipt: null, + decisionError: null, + focusRequest: null, + }); + + const promise = (async (): Promise => { + let result: Awaited>; + try { + result = await dependencies.transport.decide( + { + schemaVersion: HOSTED_TEAM_APPROVAL_SCHEMA_VERSION, + teamId: dependencies.teamId, + expectedRunId: item.runId, + approvalId: item.approvalId, + expectedGeneration: item.generation, + idempotencyKey, + decision, + }, + { signal: controller.signal } + ); + } catch { + if (!isCurrentDecision(generation, item)) return; + publish({ + pendingDecision: null, + decisionError: decisionFailure('unavailable'), + }); + return; + } + if (!isCurrentDecision(generation, item)) return; + + if (result.kind === 'committed' || result.kind === 'idempotent_replay') { + publish({ + pendingDecision: null, + decisionReceipt: result.receipt, + decisionError: null, + }); + await requestPage( + null, + 'decision', + { runId: item.runId, approvalId: item.approvalId, index: Math.max(0, index) }, + true + ); + return; + } + + publish({ + pendingDecision: null, + decisionReceipt: null, + decisionError: decisionFailure(result.kind), + }); + if ( + result.kind === 'already_resolved' || + result.kind === 'stale_generation' || + result.kind === 'expired' || + result.kind === 'not_found' + ) { + await requestPage( + null, + 'decision', + { runId: item.runId, approvalId: item.approvalId, index: Math.max(0, index) }, + true + ); + } + })().finally(() => { + if (pendingDecision?.promise === promise) pendingDecision = null; + if (decisionController === controller) decisionController = null; + }); + + pendingDecision = { + runId: item.runId, + approvalId: item.approvalId, + generation: item.generation, + decision, + promise, + }; + return promise; + }; + + const slice: HostedTeamApprovalRendererSlice = Object.freeze({ + getSnapshot: () => state, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + mount: () => { + mountCount += 1; + if (mountCount === 1) { + advanceAll(); + state = initialState(true); + for (const listener of listeners) listener(); + const refresh = (): void => { + void requestPage(null, 'refresh', undefined, true); + }; + const reconnect = (): void => { + void requestPage(null, 'reconnect', undefined, true); + }; + unsubscribeRefresh = dependencies.refresh.subscribe(refresh); + unsubscribeReconnect = dependencies.reconnect.subscribe(reconnect); + pollTimer = globalThis.setInterval(() => { + // Never abort an operator action or overlap an existing page request. + if (pendingDecision === null && pendingPage === null) { + void requestPage(null, 'poll'); + } + }, pollIntervalMs); + void requestPage(null, 'manual', undefined, true); + } + + let active = true; + return () => { + if (!active) return; + active = false; + mountCount = Math.max(0, mountCount - 1); + if (mountCount > 0) return; + unsubscribeRefresh?.(); + unsubscribeReconnect?.(); + unsubscribeRefresh = null; + unsubscribeReconnect = null; + if (pollTimer !== null) globalThis.clearInterval(pollTimer); + pollTimer = null; + advanceAll(); + state = initialState(false); + for (const listener of listeners) listener(); + }; + }, + reload: () => requestPage(null, 'manual'), + loadMore: () => + state.nextCursor === null ? Promise.resolve() : requestPage(state.nextCursor, 'refresh'), + selectApproval: (approvalId: HostedTeamApprovalId | null, runId: RunId | null) => { + if (!state.mounted) return Promise.resolve(); + if (approvalId === state.selectedApprovalId && runId === state.selectedRunId) { + const selected = state.items.find( + (item) => item.runId === runId && item.approvalId === approvalId + ); + if ( + selected !== undefined && + pendingPreview?.approvalId === selected.approvalId && + pendingPreview.runId === selected.runId && + pendingPreview.generation === selected.generation + ) { + return pendingPreview.promise; + } + return Promise.resolve(); + } + + const item = state.items.find( + (candidate) => candidate.runId === runId && candidate.approvalId === approvalId + ); + advanceAll(); + publish({ + pageStatus: state.items.length > 0 ? 'ready' : state.pageStatus, + selectedApprovalId: item?.approvalId ?? null, + selectedRunId: item?.runId ?? null, + preview: null, + previewStatus: item?.previewRef === null ? 'ready' : 'idle', + previewError: null, + pendingDecision: null, + decisionReceipt: null, + decisionError: null, + focusRequest: null, + }); + return item === undefined || item.previewRef === null + ? Promise.resolve() + : requestPreview(item); + }, + allow: () => decide('allow'), + deny: () => decide('deny'), + }); + + return slice; +} diff --git a/src/features/team-configuration/README.md b/src/features/team-configuration/README.md new file mode 100644 index 0000000000..963608860a --- /dev/null +++ b/src/features/team-configuration/README.md @@ -0,0 +1,15 @@ +# Team Configuration + +Owns the Electron IPC workflows for creating and updating saved team configuration, +reading a saved provisioning request, and deleting an unconfigured draft team. + +Public entrypoints: + +- `@features/team-configuration` exposes pure runtime-selection validation reused by legacy provisioning and roster flows. +- `@features/team-configuration/contracts` exposes browser-safe IPC channel constants. +- `@features/team-configuration/main` exposes main-process composition and IPC registration. + +The input adapter deliberately preserves the existing desktop validation and normalization +semantics. Browser mode currently reports team configuration mutation as unsupported, and +the HTTP route parsers have different compatibility rules, so transport unification is out +of scope for this behavior-preserving extraction. diff --git a/src/features/team-configuration/contracts/channels.ts b/src/features/team-configuration/contracts/channels.ts new file mode 100644 index 0000000000..7b6157da30 --- /dev/null +++ b/src/features/team-configuration/contracts/channels.ts @@ -0,0 +1,4 @@ +export const TEAM_CREATE_CONFIG = 'team:createConfig'; +export const TEAM_UPDATE_CONFIG = 'team:updateConfig'; +export const TEAM_GET_SAVED_REQUEST = 'team:getSavedRequest'; +export const TEAM_DELETE_DRAFT = 'team:deleteDraft'; diff --git a/src/features/team-configuration/contracts/hosted.ts b/src/features/team-configuration/contracts/hosted.ts new file mode 100644 index 0000000000..fbc63cde13 --- /dev/null +++ b/src/features/team-configuration/contracts/hosted.ts @@ -0,0 +1,313 @@ +import { + HOSTED_SCHEMA_VERSION, + parseRevision, + parseTeamId, + parseWorkspaceId, + type Revision, + type SafeAppError, + type TeamId, + type WorkspaceId, +} from '@shared/contracts/hosted'; + +export const HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION = HOSTED_SCHEMA_VERSION; + +export const HOSTED_TEAM_CONFIGURATION_ROUTES = Object.freeze({ + getSavedRequest: '/api/hosted/v1/team-configuration/saved-request', + createDraft: '/api/hosted/v1/team-configuration/draft/create', + updateDraft: '/api/hosted/v1/team-configuration/draft/update', + deleteDraft: '/api/hosted/v1/team-configuration/draft/delete', +} as const); + +export interface HostedTeamConfigurationIdentity { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId; +} + +declare const hostedTeamConfigurationBrand: unique symbol; +export type HostedTeamConfigurationIdempotencyKey = string & { + readonly [hostedTeamConfigurationBrand]: 'HostedTeamConfigurationIdempotencyKey'; +}; + +export interface HostedTeamConfigurationMember { + readonly name: string; +} + +/** The bounded, provider-neutral draft fields which may cross the hosted boundary. */ +export interface HostedTeamConfigurationDraftMetadata { + readonly name: string; + readonly description?: string; + readonly color?: string; + readonly language?: string; +} + +export interface HostedSavedTeamRequest extends HostedTeamConfigurationIdentity { + readonly revision: Revision; + readonly metadata: HostedTeamConfigurationDraftMetadata; + readonly members: readonly HostedTeamConfigurationMember[]; +} + +export interface HostedGetSavedTeamRequest extends HostedTeamConfigurationIdentity { + readonly schemaVersion: typeof HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION; +} + +export interface HostedCreateDraftTeamRequest { + readonly schemaVersion: typeof HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION; + readonly workspaceId: WorkspaceId; + readonly idempotencyKey: HostedTeamConfigurationIdempotencyKey; + readonly name: string; + readonly members: readonly HostedTeamConfigurationMember[]; +} + +export interface HostedUpdateDraftTeamRequest extends HostedGetSavedTeamRequest { + readonly expectedRevision: Revision; + readonly updates: Readonly<{ + name?: string; + description?: string; + color?: string; + language?: string; + }>; +} + +export interface HostedDeleteDraftTeamRequest extends HostedGetSavedTeamRequest { + readonly expectedRevision: Revision; +} + +export interface HostedTeamConfigurationErrorResult { + readonly schemaVersion: typeof HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION; + readonly kind: 'error'; + readonly error: SafeAppError; + readonly retryable: boolean; +} + +export type HostedGetSavedTeamResult = + | Readonly<{ + schemaVersion: typeof HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION; + kind: 'found'; + draft: HostedSavedTeamRequest; + }> + | HostedTeamConfigurationErrorResult; + +export type HostedCreateDraftTeamResult = + | Readonly<{ + schemaVersion: typeof HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION; + kind: 'created'; + identity: HostedTeamConfigurationIdentity; + revision: Revision; + outcome: 'created' | 'idempotent_replay'; + }> + | HostedTeamConfigurationErrorResult; + +export type HostedUpdateDraftTeamResult = + | Readonly<{ + schemaVersion: typeof HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION; + kind: 'updated'; + draft: HostedSavedTeamRequest; + }> + | HostedTeamConfigurationErrorResult; + +export type HostedDeleteDraftTeamResult = + | Readonly<{ + schemaVersion: typeof HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION; + kind: 'deleted'; + identity: HostedTeamConfigurationIdentity; + outcome: 'deleted' | 'already_absent'; + }> + | HostedTeamConfigurationErrorResult; + +type ParseResult = Readonly<{ ok: true; value: T }> | Readonly<{ ok: false }>; + +const IDENTITY_KEYS = Object.freeze(['workspaceId', 'teamId'] as const); +const IDEMPOTENCY_KEY_PATTERN = /^idempotency_[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/; +const TEAM_NAME_LIMIT = 128; +const MEMBER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; +const MAX_MEMBERS = 32; +const UPDATE_LIMITS = Object.freeze({ + name: TEAM_NAME_LIMIT, + description: 4_000, + color: 64, + language: 64, +} as const); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function failure(): ParseResult { + return Object.freeze({ ok: false }); +} + +function parseName(value: unknown, limit = TEAM_NAME_LIMIT): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized.length > 0 && normalized.length <= limit ? normalized : null; +} + +export function parseHostedTeamConfigurationIdempotencyKey( + value: unknown +): HostedTeamConfigurationIdempotencyKey { + if (typeof value !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new TypeError('hosted-team-configuration-idempotency-key-invalid'); + } + return value as HostedTeamConfigurationIdempotencyKey; +} + +export function parseHostedTeamConfigurationIdentity( + value: unknown +): ParseResult { + try { + if (!isRecord(value) || !hasExactKeys(value, IDENTITY_KEYS)) return failure(); + return Object.freeze({ + ok: true, + value: Object.freeze({ + workspaceId: parseWorkspaceId(value.workspaceId), + teamId: parseTeamId(value.teamId), + }), + }); + } catch { + return failure(); + } +} + +function parseIdentityRequest( + value: unknown, + keys: readonly string[] = ['schemaVersion', ...IDENTITY_KEYS] +): ParseResult { + try { + if (!isRecord(value) || !hasExactKeys(value, keys)) return failure(); + const identity = parseHostedTeamConfigurationIdentity({ + workspaceId: value.workspaceId, + teamId: value.teamId, + }); + if (!identity.ok || value.schemaVersion !== HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION) { + return failure(); + } + return Object.freeze({ + ok: true, + value: Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + ...identity.value, + }), + }); + } catch { + return failure(); + } +} + +function parseMembers(value: unknown): readonly HostedTeamConfigurationMember[] | null { + if (!Array.isArray(value) || value.length < 1 || value.length > MAX_MEMBERS) return null; + const names = new Set(); + const members: HostedTeamConfigurationMember[] = []; + for (const candidate of value) { + if (!isRecord(candidate) || !hasExactKeys(candidate, ['name'])) return null; + const name = parseName(candidate.name, 64); + if (name === null || !MEMBER_NAME_PATTERN.test(name) || names.has(name)) return null; + names.add(name); + members.push(Object.freeze({ name })); + } + return Object.freeze(members); +} + +export function parseHostedGetSavedTeamRequest( + value: unknown +): ParseResult { + return parseIdentityRequest(value); +} + +export function parseHostedDeleteDraftTeamRequest( + value: unknown +): ParseResult { + try { + const base = parseIdentityRequest(value, [ + 'schemaVersion', + ...IDENTITY_KEYS, + 'expectedRevision', + ]); + return base.ok + ? Object.freeze({ + ok: true, + value: Object.freeze({ + ...base.value, + expectedRevision: parseRevision((value as Record).expectedRevision), + }), + }) + : failure(); + } catch { + return failure(); + } +} + +export function parseHostedCreateDraftTeamRequest( + value: unknown +): ParseResult { + try { + if ( + !isRecord(value) || + !hasExactKeys(value, ['schemaVersion', 'workspaceId', 'idempotencyKey', 'name', 'members']) || + value.schemaVersion !== HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION + ) { + return failure(); + } + const workspaceId = parseWorkspaceId(value.workspaceId); + const name = parseName(value.name); + const members = parseMembers(value.members); + if (name === null || members === null) return failure(); + return Object.freeze({ + ok: true, + value: Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + workspaceId, + idempotencyKey: parseHostedTeamConfigurationIdempotencyKey(value.idempotencyKey), + name, + members, + }), + }); + } catch { + return failure(); + } +} + +export function parseHostedUpdateDraftTeamRequest( + value: unknown +): ParseResult { + try { + const base = parseIdentityRequest(value, [ + 'schemaVersion', + ...IDENTITY_KEYS, + 'expectedRevision', + 'updates', + ]); + if (!base.ok || !isRecord(value) || !isRecord(value.updates)) return failure(); + const updateKeys = Reflect.ownKeys(value.updates); + if ( + updateKeys.length < 1 || + updateKeys.some((key) => typeof key !== 'string' || !Object.hasOwn(UPDATE_LIMITS, key)) + ) { + return failure(); + } + const updates: Record = {}; + for (const key of updateKeys as (keyof typeof UPDATE_LIMITS)[]) { + const normalized = parseName(value.updates[key], UPDATE_LIMITS[key]); + if (normalized === null) return failure(); + updates[key] = normalized; + } + return Object.freeze({ + ok: true, + value: Object.freeze({ + ...base.value, + expectedRevision: parseRevision(value.expectedRevision), + updates: Object.freeze(updates), + }), + }) as ParseResult; + } catch { + return failure(); + } +} diff --git a/src/features/team-configuration/contracts/index.ts b/src/features/team-configuration/contracts/index.ts new file mode 100644 index 0000000000..7cfe1cf878 --- /dev/null +++ b/src/features/team-configuration/contracts/index.ts @@ -0,0 +1,30 @@ +export { + TEAM_CREATE_CONFIG, + TEAM_DELETE_DRAFT, + TEAM_GET_SAVED_REQUEST, + TEAM_UPDATE_CONFIG, +} from './channels'; +export { + HOSTED_TEAM_CONFIGURATION_ROUTES, + HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + type HostedCreateDraftTeamRequest, + type HostedCreateDraftTeamResult, + type HostedDeleteDraftTeamRequest, + type HostedDeleteDraftTeamResult, + type HostedGetSavedTeamRequest, + type HostedGetSavedTeamResult, + type HostedSavedTeamRequest, + type HostedTeamConfigurationDraftMetadata, + type HostedTeamConfigurationErrorResult, + type HostedTeamConfigurationIdempotencyKey, + type HostedTeamConfigurationIdentity, + type HostedTeamConfigurationMember, + type HostedUpdateDraftTeamRequest, + type HostedUpdateDraftTeamResult, + parseHostedCreateDraftTeamRequest, + parseHostedDeleteDraftTeamRequest, + parseHostedGetSavedTeamRequest, + parseHostedTeamConfigurationIdempotencyKey, + parseHostedTeamConfigurationIdentity, + parseHostedUpdateDraftTeamRequest, +} from './hosted'; diff --git a/src/features/team-configuration/core/application/hosted-authority/HostedTeamConfigurationAuthority.ts b/src/features/team-configuration/core/application/hosted-authority/HostedTeamConfigurationAuthority.ts new file mode 100644 index 0000000000..562486258b --- /dev/null +++ b/src/features/team-configuration/core/application/hosted-authority/HostedTeamConfigurationAuthority.ts @@ -0,0 +1,129 @@ +import { + type AppErrorCode, + createSafeAppError, + type QueryContext, + type Revision, +} from '@shared/contracts/hosted'; + +import { canonicalHostedTeamConfigurationCreate } from './canonicalHostedTeamConfigurationCreate'; + +import type { + HostedTeamConfigurationIdentity, + HostedUpdateDraftTeamRequest, +} from '../../../contracts/hosted'; +import type { + HostedTeamConfigurationAuthorityCreateRequest, + HostedTeamConfigurationAuthorityDependencies, +} from './HostedTeamConfigurationAuthorityPorts'; + +function error(code: AppErrorCode, reason: string) { + return Object.freeze({ + kind: 'error' as const, + error: createSafeAppError({ code, reason }), + }); +} + +export class HostedTeamConfigurationAuthority { + constructor(private readonly dependencies: HostedTeamConfigurationAuthorityDependencies) {} + + async createDraft(request: HostedTeamConfigurationAuthorityCreateRequest) { + const rejected = this.admit(request.context); + if (rejected) return rejected; + try { + const result = await this.dependencies.storage.create( + { + workspaceId: request.workspaceId, + idempotencyKey: request.idempotencyKey, + payloadHash: await this.dependencies.sha256Hex( + canonicalHostedTeamConfigurationCreate(request) + ), + metadata: { name: request.name }, + members: request.members, + deadlineAtMs: request.context.deadlineAtMs, + }, + request.context.signal + ); + return result.kind === 'conflict' + ? error('conflict', 'team_configuration_idempotency_conflict') + : result; + } catch { + return this.unavailable(); + } + } + + async getSavedRequest(identity: HostedTeamConfigurationIdentity, context: QueryContext) { + const rejected = this.admit(context); + if (rejected) return rejected; + try { + const result = await this.dependencies.storage.read(identity); + if (this.admit(context)) return this.unavailable(); + return result.kind === 'found' + ? Object.freeze({ kind: 'found' as const, draft: result.draft }) + : error('not_found', 'team_configuration_not_found'); + } catch { + return this.unavailable(); + } + } + + async updateDraft( + identity: HostedTeamConfigurationIdentity, + expectedRevision: Revision, + updates: HostedUpdateDraftTeamRequest['updates'], + context: QueryContext + ) { + const rejected = this.admit(context); + if (rejected) return rejected; + try { + const result = await this.dependencies.storage.update( + { + ...identity, + expectedRevision, + updates, + deadlineAtMs: context.deadlineAtMs, + }, + context.signal + ); + if (result.kind === 'updated') return result; + return result.kind === 'not_found' + ? error('not_found', 'team_configuration_not_found') + : error('conflict', 'team_configuration_revision_conflict'); + } catch { + return this.unavailable(); + } + } + + async deleteDraft( + identity: HostedTeamConfigurationIdentity, + expectedRevision: Revision, + context: QueryContext + ) { + const rejected = this.admit(context); + if (rejected) return rejected; + try { + const result = await this.dependencies.storage.delete( + { ...identity, expectedRevision, deadlineAtMs: context.deadlineAtMs }, + context.signal + ); + return result.kind === 'conflict' + ? error('conflict', 'team_configuration_revision_conflict') + : result; + } catch { + return this.unavailable(); + } + } + + private admit(context: QueryContext): ReturnType | null { + if (context.signal.aborted) return error('cancelled', 'team_configuration_cancelled'); + if ( + !Number.isSafeInteger(context.deadlineAtMs) || + this.dependencies.now() >= context.deadlineAtMs + ) { + return this.unavailable(); + } + return null; + } + + private unavailable(): ReturnType { + return error('unavailable', 'team_configuration_unavailable'); + } +} diff --git a/src/features/team-configuration/core/application/hosted-authority/HostedTeamConfigurationAuthorityPorts.ts b/src/features/team-configuration/core/application/hosted-authority/HostedTeamConfigurationAuthorityPorts.ts new file mode 100644 index 0000000000..de34de1c93 --- /dev/null +++ b/src/features/team-configuration/core/application/hosted-authority/HostedTeamConfigurationAuthorityPorts.ts @@ -0,0 +1,77 @@ +import type { + HostedSavedTeamRequest, + HostedTeamConfigurationIdempotencyKey, + HostedTeamConfigurationIdentity, + HostedTeamConfigurationMember, + HostedUpdateDraftTeamRequest, +} from '../../../contracts/hosted'; +import type { QueryContext, Revision, TeamId, WorkspaceId } from '@shared/contracts/hosted'; + +export type HostedTeamConfigurationStorageCreateResult = + | Readonly<{ + kind: 'created'; + teamId: TeamId; + revision: Revision; + outcome: 'created' | 'idempotent_replay'; + }> + | Readonly<{ kind: 'conflict'; reason: 'idempotency_mismatch' }>; + +export type HostedTeamConfigurationStorageReadResult = + | Readonly<{ kind: 'found'; draft: HostedSavedTeamRequest }> + | Readonly<{ kind: 'not_found' }>; + +export type HostedTeamConfigurationStorageUpdateResult = + | Readonly<{ kind: 'updated'; draft: HostedSavedTeamRequest }> + | Readonly<{ kind: 'not_found' }> + | Readonly<{ kind: 'conflict'; reason: 'revision_mismatch' }>; + +export type HostedTeamConfigurationStorageDeleteResult = + | Readonly<{ kind: 'deleted'; outcome: 'deleted' | 'already_absent' }> + | Readonly<{ kind: 'conflict'; reason: 'revision_mismatch' }>; + +/** Application-owned persistence boundary; adapters may not own application policy. */ +export interface HostedTeamConfigurationAuthorityStoragePort { + create( + request: { + readonly workspaceId: WorkspaceId; + readonly idempotencyKey: HostedTeamConfigurationIdempotencyKey; + readonly payloadHash: string; + readonly metadata: Readonly<{ name: string }>; + readonly members: readonly HostedTeamConfigurationMember[]; + readonly deadlineAtMs: number; + }, + signal: AbortSignal + ): Promise; + read( + identity: HostedTeamConfigurationIdentity + ): Promise; + update( + request: HostedTeamConfigurationIdentity & { + readonly expectedRevision: Revision; + readonly updates: HostedUpdateDraftTeamRequest['updates']; + readonly deadlineAtMs: number; + }, + signal: AbortSignal + ): Promise; + delete( + request: HostedTeamConfigurationIdentity & { + readonly expectedRevision: Revision; + readonly deadlineAtMs: number; + }, + signal: AbortSignal + ): Promise; +} + +export interface HostedTeamConfigurationAuthorityDependencies { + readonly storage: HostedTeamConfigurationAuthorityStoragePort; + readonly sha256Hex: (canonicalPayload: string) => Promise | string; + readonly now: () => number; +} + +export interface HostedTeamConfigurationAuthorityCreateRequest { + readonly workspaceId: WorkspaceId; + readonly idempotencyKey: HostedTeamConfigurationIdempotencyKey; + readonly name: string; + readonly members: readonly HostedTeamConfigurationMember[]; + readonly context: QueryContext; +} diff --git a/src/features/team-configuration/core/application/hosted-authority/canonicalHostedTeamConfigurationCreate.ts b/src/features/team-configuration/core/application/hosted-authority/canonicalHostedTeamConfigurationCreate.ts new file mode 100644 index 0000000000..74b5b1eaec --- /dev/null +++ b/src/features/team-configuration/core/application/hosted-authority/canonicalHostedTeamConfigurationCreate.ts @@ -0,0 +1,16 @@ +import type { HostedTeamConfigurationMember } from '../../../contracts/hosted'; +import type { WorkspaceId } from '@shared/contracts/hosted'; + +/** Canonical create intent excludes transport context and the idempotency key itself. */ +export function canonicalHostedTeamConfigurationCreate(input: { + readonly workspaceId: WorkspaceId; + readonly name: string; + readonly members: readonly HostedTeamConfigurationMember[]; +}): string { + return JSON.stringify({ + schemaVersion: 1, + workspaceId: input.workspaceId, + metadata: { name: input.name }, + members: input.members.map((member) => ({ name: member.name })), + }); +} diff --git a/src/features/team-configuration/core/application/ports/TeamConfigurationPorts.ts b/src/features/team-configuration/core/application/ports/TeamConfigurationPorts.ts new file mode 100644 index 0000000000..614690de92 --- /dev/null +++ b/src/features/team-configuration/core/application/ports/TeamConfigurationPorts.ts @@ -0,0 +1,70 @@ +export interface TeamConfigurationMember { + name: string; +} + +export interface TeamConfigurationConfig { + name: string; + members?: TeamConfigurationMember[]; +} + +export interface TeamConfigurationCreateConfigRequest { + teamName: string; + members: TeamConfigurationMember[]; +} + +export interface TeamConfigurationSavedRequest extends TeamConfigurationCreateConfigRequest { + cwd: string; +} + +export interface TeamConfigurationUpdateRequest { + name?: string; + description?: string; + color?: string; + language?: string; +} + +export interface TeamConfigCreationRepositoryPort { + createTeamConfig(request: TeamConfigurationCreateConfigRequest): Promise; +} + +export interface TeamConfigUpdateRepositoryPort { + getTeamDisplayName(teamName: string): Promise; + updateConfig( + teamName: string, + updates: TeamConfigurationUpdateRequest + ): Promise; +} + +export interface SavedTeamRequestRepositoryPort { + getSavedRequest(teamName: string): Promise; +} + +export interface DraftTeamDeletionRepositoryPort { + permanentlyDeleteTeam(teamName: string): Promise; +} + +export type TeamConfigurationRepositoryPort = TeamConfigCreationRepositoryPort & + TeamConfigUpdateRepositoryPort & + SavedTeamRequestRepositoryPort & + DraftTeamDeletionRepositoryPort; + +export interface TeamConfigurationRuntimePort { + isTeamAlive(teamName: string): boolean; +} + +export interface TeamConfigurationMessagingPort { + sendMessageToTeam(teamName: string, message: string): Promise; +} + +export interface TeamConfigurationCachePort { + invalidateTeamConfig(teamName: string): void; +} + +export interface DraftTeamConfigGuardPort { + assertDraftCanBeDeleted(teamName: string): Promise; +} + +export interface TeamConfigurationLoggerPort { + error(message: string): void; + warn(message: string): void; +} diff --git a/src/features/team-configuration/core/application/use-cases/CreateTeamConfigUseCase.ts b/src/features/team-configuration/core/application/use-cases/CreateTeamConfigUseCase.ts new file mode 100644 index 0000000000..8df89669ee --- /dev/null +++ b/src/features/team-configuration/core/application/use-cases/CreateTeamConfigUseCase.ts @@ -0,0 +1,19 @@ +import type { + TeamConfigCreationRepositoryPort, + TeamConfigurationCachePort, + TeamConfigurationCreateConfigRequest, +} from '../ports/TeamConfigurationPorts'; + +export class CreateTeamConfigUseCase { + constructor( + private readonly dependencies: { + repository: TeamConfigCreationRepositoryPort; + cache: TeamConfigurationCachePort; + } + ) {} + + async execute(request: TeamConfigurationCreateConfigRequest): Promise { + await this.dependencies.repository.createTeamConfig(request); + this.dependencies.cache.invalidateTeamConfig(request.teamName); + } +} diff --git a/src/features/team-configuration/core/application/use-cases/DeleteDraftTeamUseCase.ts b/src/features/team-configuration/core/application/use-cases/DeleteDraftTeamUseCase.ts new file mode 100644 index 0000000000..498fe682e3 --- /dev/null +++ b/src/features/team-configuration/core/application/use-cases/DeleteDraftTeamUseCase.ts @@ -0,0 +1,18 @@ +import type { + DraftTeamConfigGuardPort, + DraftTeamDeletionRepositoryPort, +} from '../ports/TeamConfigurationPorts'; + +export class DeleteDraftTeamUseCase { + constructor( + private readonly dependencies: { + repository: DraftTeamDeletionRepositoryPort; + draftGuard: DraftTeamConfigGuardPort; + } + ) {} + + async execute(teamName: string): Promise { + await this.dependencies.draftGuard.assertDraftCanBeDeleted(teamName); + await this.dependencies.repository.permanentlyDeleteTeam(teamName); + } +} diff --git a/src/features/team-configuration/core/application/use-cases/GetSavedTeamRequestUseCase.ts b/src/features/team-configuration/core/application/use-cases/GetSavedTeamRequestUseCase.ts new file mode 100644 index 0000000000..dcb9297caf --- /dev/null +++ b/src/features/team-configuration/core/application/use-cases/GetSavedTeamRequestUseCase.ts @@ -0,0 +1,12 @@ +import type { + SavedTeamRequestRepositoryPort, + TeamConfigurationSavedRequest, +} from '../ports/TeamConfigurationPorts'; + +export class GetSavedTeamRequestUseCase { + constructor(private readonly repository: SavedTeamRequestRepositoryPort) {} + + execute(teamName: string): Promise { + return this.repository.getSavedRequest(teamName); + } +} diff --git a/src/features/team-configuration/core/application/use-cases/UpdateTeamConfigUseCase.ts b/src/features/team-configuration/core/application/use-cases/UpdateTeamConfigUseCase.ts new file mode 100644 index 0000000000..2bc5be3fea --- /dev/null +++ b/src/features/team-configuration/core/application/use-cases/UpdateTeamConfigUseCase.ts @@ -0,0 +1,49 @@ +import type { + TeamConfigUpdateRepositoryPort, + TeamConfigurationCachePort, + TeamConfigurationConfig, + TeamConfigurationLoggerPort, + TeamConfigurationMessagingPort, + TeamConfigurationRuntimePort, + TeamConfigurationUpdateRequest, +} from '../ports/TeamConfigurationPorts'; + +export class UpdateTeamConfigUseCase { + constructor( + private readonly dependencies: { + repository: TeamConfigUpdateRepositoryPort; + runtime: TeamConfigurationRuntimePort; + messaging: TeamConfigurationMessagingPort; + cache: TeamConfigurationCachePort; + logger: TeamConfigurationLoggerPort; + } + ) {} + + async execute( + teamName: string, + updates: TeamConfigurationUpdateRequest + ): Promise { + const previousDisplayName = await this.dependencies.repository + .getTeamDisplayName(teamName) + .catch(() => teamName); + const requestedName = typeof updates.name === 'string' ? updates.name.trim() : ''; + const result = await this.dependencies.repository.updateConfig(teamName, updates); + if (!result) { + throw new Error('Team config not found'); + } + + if (requestedName && requestedName !== (previousDisplayName?.trim() || teamName)) { + if (this.dependencies.runtime.isTeamAlive(teamName)) { + const message = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`; + try { + await this.dependencies.messaging.sendMessageToTeam(teamName, message); + } catch { + this.dependencies.logger.warn(`Failed to notify lead about team rename for ${teamName}`); + } + } + } + + this.dependencies.cache.invalidateTeamConfig(teamName); + return result; + } +} diff --git a/src/features/team-configuration/core/domain/teamRuntimeSelectionValidation.ts b/src/features/team-configuration/core/domain/teamRuntimeSelectionValidation.ts new file mode 100644 index 0000000000..1101714c9c --- /dev/null +++ b/src/features/team-configuration/core/domain/teamRuntimeSelectionValidation.ts @@ -0,0 +1,177 @@ +import { + formatEffortLevelListForProvider, + isTeamEffortLevelForProvider, +} from '@shared/utils/effortLevels'; +import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { isTeamProviderId } from '@shared/utils/teamProvider'; + +import type { + EffortLevel, + TeamFastMode, + TeamProviderBackendId, + TeamProviderId, +} from '@shared/types'; + +type ValidationResult = { valid: true; value: T } | { valid: false; error: string }; + +export function isProvisioningTeamName(teamName: string): boolean { + if (teamName.length > 64) return false; + const parts = teamName.split('-'); + return parts.every((part) => /^[a-z0-9]+$/.test(part)); +} + +function isValidEffort(value: unknown, providerId?: TeamProviderId | null): value is EffortLevel { + return isTeamEffortLevelForProvider(value, providerId); +} + +function parseOptionalProviderId( + value: unknown, + fieldName: string +): ValidationResult { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (isTeamProviderId(value)) { + return { valid: true, value }; + } + return { valid: false, error: `${fieldName} must be anthropic, codex, gemini, or opencode` }; +} + +export function parseOptionalMemberProviderId( + value: unknown +): ValidationResult { + return parseOptionalProviderId(value, 'member providerId'); +} + +export function parseOptionalTeamProviderId( + value: unknown +): ValidationResult { + return parseOptionalProviderId(value, 'providerId'); +} + +export function parseOptionalProviderBackendId( + value: unknown, + providerId?: TeamProviderId +): ValidationResult { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (typeof value !== 'string') { + return { valid: false, error: 'providerBackendId must be a string' }; + } + const trimmed = value.trim(); + if (!trimmed) { + return { valid: true, value: undefined }; + } + if (trimmed.length > 64) { + return { valid: false, error: 'providerBackendId too long (max 64)' }; + } + if (providerId) { + const migratedBackendId = migrateProviderBackendId(providerId, trimmed); + if (migratedBackendId) { + return { valid: true, value: migratedBackendId }; + } + } else if (isTeamProviderBackendId(trimmed)) { + return { valid: true, value: trimmed }; + } + + return { + valid: false, + error: + 'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)', + }; +} + +export function parseOptionalLaunchProviderBackendId( + value: unknown, + providerId?: TeamProviderId +): ValidationResult { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (typeof value !== 'string') { + return { valid: false, error: 'providerBackendId must be a string' }; + } + const trimmed = value.trim(); + if (!trimmed) { + return { valid: true, value: undefined }; + } + if (trimmed.length > 64) { + return { valid: false, error: 'providerBackendId too long (max 64)' }; + } + + const migratedBackendId = migrateProviderBackendId(providerId, trimmed); + if (migratedBackendId) { + return { valid: true, value: migratedBackendId }; + } + + if (isTeamProviderBackendId(trimmed)) { + return { valid: true, value: undefined }; + } + + return { + valid: false, + error: + 'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)', + }; +} + +export function parseOptionalMemberEffort( + value: unknown, + providerId?: TeamProviderId | null +): ValidationResult { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (isValidEffort(value, providerId)) { + return { valid: true, value }; + } + return { + valid: false, + error: `member effort must be one of ${formatEffortLevelListForProvider(providerId)}`, + }; +} + +export function parseOptionalTeamEffort( + value: unknown, + providerId?: TeamProviderId | null +): ValidationResult { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (isValidEffort(value, providerId)) { + return { valid: true, value }; + } + return { + valid: false, + error: `effort must be one of ${formatEffortLevelListForProvider(providerId)}`, + }; +} + +export function parseOptionalTeamFastMode( + value: unknown +): ValidationResult { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (value === 'inherit' || value === 'on' || value === 'off') { + return { valid: true, value }; + } + return { + valid: false, + error: 'fastMode must be one of inherit, on, or off', + }; +} + +export function parseOptionalBoolean( + value: unknown, + fieldName: string +): ValidationResult { + if (value === undefined) { + return { valid: true, value: undefined }; + } + if (typeof value === 'boolean') { + return { valid: true, value }; + } + return { valid: false, error: `${fieldName} must be a boolean` }; +} diff --git a/src/features/team-configuration/index.ts b/src/features/team-configuration/index.ts new file mode 100644 index 0000000000..6f4659f55c --- /dev/null +++ b/src/features/team-configuration/index.ts @@ -0,0 +1,11 @@ +export { + isProvisioningTeamName, + parseOptionalBoolean, + parseOptionalLaunchProviderBackendId, + parseOptionalMemberEffort, + parseOptionalMemberProviderId, + parseOptionalProviderBackendId, + parseOptionalTeamEffort, + parseOptionalTeamFastMode, + parseOptionalTeamProviderId, +} from './core/domain/teamRuntimeSelectionValidation'; diff --git a/src/features/team-configuration/main/adapters/input/http/HostedTeamConfigurationAdapter.ts b/src/features/team-configuration/main/adapters/input/http/HostedTeamConfigurationAdapter.ts new file mode 100644 index 0000000000..185d6acbcf --- /dev/null +++ b/src/features/team-configuration/main/adapters/input/http/HostedTeamConfigurationAdapter.ts @@ -0,0 +1,302 @@ +import { + type AppErrorCode, + createSafeAppError, + parseRevision, + parseTeamId, + type QueryContext, + type SafeAppError, +} from '@shared/contracts/hosted'; + +import { + HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + type HostedCreateDraftTeamResult, + type HostedDeleteDraftTeamResult, + type HostedGetSavedTeamResult, + type HostedSavedTeamRequest, + type HostedTeamConfigurationDraftMetadata, + type HostedTeamConfigurationIdentity, + type HostedUpdateDraftTeamResult, + parseHostedCreateDraftTeamRequest, + parseHostedDeleteDraftTeamRequest, + parseHostedGetSavedTeamRequest, + parseHostedUpdateDraftTeamRequest, +} from '../../../../contracts/hosted'; + +import type { + HostedTeamConfigurationApplicationPort, + HostedTeamConfigurationAuthorizationPort, + HostedTeamConfigurationAuthorizationScope, + HostedTeamConfigurationOperation, +} from '../../../ports/HostedTeamConfigurationAuthorizationPort'; + +const METADATA_LIMITS = Object.freeze({ + name: 128, + description: 4_000, + color: 64, + language: 64, +} as const); +const MEMBER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; + +function errorResult( + code: AppErrorCode, + reason: string, + retryable: boolean, + retryAfterMs?: number +) { + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'error' as const, + error: createSafeAppError({ + code, + reason, + ...(retryAfterMs === undefined ? {} : { retryAfterMs }), + }), + retryable, + }); +} + +function unavailable() { + return errorResult('unavailable', 'team_configuration_unavailable', true); +} + +const PUBLIC_APPLICATION_ERRORS = Object.freeze({ + not_found: Object.freeze({ reason: 'team_configuration_not_found', retryable: false }), + conflict: Object.freeze({ reason: 'team_configuration_revision_conflict', retryable: false }), + unavailable: Object.freeze({ reason: 'team_configuration_unavailable', retryable: true }), + cancelled: Object.freeze({ reason: 'team_configuration_cancelled', retryable: false }), +} as const); + +function applicationError(error: SafeAppError) { + try { + const safe = createSafeAppError(error); + if (!Object.hasOwn(PUBLIC_APPLICATION_ERRORS, safe.code)) return unavailable(); + const mapping = PUBLIC_APPLICATION_ERRORS[safe.code as keyof typeof PUBLIC_APPLICATION_ERRORS]; + return errorResult( + safe.code, + mapping.reason, + mapping.retryable, + safe.code === 'unavailable' ? safe.retryAfterMs : undefined + ); + } catch { + return unavailable(); + } +} + +function sameIdentity( + left: HostedTeamConfigurationIdentity, + right: HostedTeamConfigurationIdentity +): boolean { + return left.workspaceId === right.workspaceId && left.teamId === right.teamId; +} + +function sameScope( + left: HostedTeamConfigurationAuthorizationScope, + right: HostedTeamConfigurationAuthorizationScope +): boolean { + if (left.kind !== right.kind) return false; + return left.kind === 'workspace' && right.kind === 'workspace' + ? left.workspaceId === right.workspaceId + : left.kind === 'team' && right.kind === 'team' && sameIdentity(left.identity, right.identity); +} + +function normalizeMetadata(value: HostedTeamConfigurationDraftMetadata) { + if (!value || typeof value !== 'object') return null; + const result: Record = {}; + for (const key of Object.keys(METADATA_LIMITS) as (keyof typeof METADATA_LIMITS)[]) { + const raw = value[key]; + if (raw === undefined && key !== 'name') continue; + if (typeof raw !== 'string') return null; + const normalized = raw.trim(); + if (normalized.length < 1 || normalized.length > METADATA_LIMITS[key]) return null; + result[key] = normalized; + } + return Object.freeze(result) as unknown as HostedTeamConfigurationDraftMetadata; +} + +function projectDraft( + identity: HostedTeamConfigurationIdentity, + value: HostedSavedTeamRequest +): HostedSavedTeamRequest | null { + if ( + !sameIdentity(identity, value) || + !Array.isArray(value.members) || + value.members.length < 1 || + value.members.length > 32 + ) { + return null; + } + const metadata = normalizeMetadata(value.metadata); + if (metadata === null) return null; + let revision: ReturnType; + try { + revision = parseRevision(value.revision); + } catch { + return null; + } + const names = new Set(); + const members: HostedSavedTeamRequest['members'][number][] = []; + for (const member of value.members) { + if (!member || typeof member.name !== 'string') return null; + const name = member.name.trim(); + if (!MEMBER_NAME_PATTERN.test(name) || names.has(name)) return null; + names.add(name); + members.push(Object.freeze({ name })); + } + return Object.freeze({ ...identity, revision, metadata, members: Object.freeze(members) }); +} + +export interface HostedTeamConfigurationFacade { + getSavedRequest(body: unknown, principal: QueryContext): Promise; + createDraft(body: unknown, principal: QueryContext): Promise; + updateDraft(body: unknown, principal: QueryContext): Promise; + deleteDraft(body: unknown, principal: QueryContext): Promise; +} + +/** Hosted driving adapter; domain behavior and persistence stay behind the application port. */ +export class HostedTeamConfigurationAdapter implements HostedTeamConfigurationFacade { + constructor( + private readonly application: HostedTeamConfigurationApplicationPort, + private readonly authorization: HostedTeamConfigurationAuthorizationPort + ) {} + + async getSavedRequest(body: unknown, principal: QueryContext): Promise { + const parsed = parseHostedGetSavedTeamRequest(body); + if (!parsed.ok) + return errorResult('invalid_request', 'team_configuration_request_invalid', false); + const identity = Object.freeze({ + workspaceId: parsed.value.workspaceId, + teamId: parsed.value.teamId, + }); + if (!(await this.authorize('get_saved_request', { kind: 'team', identity }, principal))) { + return errorResult('forbidden', 'team_configuration_forbidden', false); + } + try { + const result = await this.application.getSavedRequest(identity, principal); + if (result.kind === 'error') return applicationError(result.error); + const draft = projectDraft(identity, result.draft); + return draft === null + ? unavailable() + : Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'found', + draft, + }); + } catch { + return unavailable(); + } + } + + async createDraft(body: unknown, principal: QueryContext): Promise { + const parsed = parseHostedCreateDraftTeamRequest(body); + if (!parsed.ok) + return errorResult('invalid_request', 'team_configuration_request_invalid', false); + const scope = Object.freeze({ + kind: 'workspace' as const, + workspaceId: parsed.value.workspaceId, + }); + if (!(await this.authorize('create_draft', scope, principal))) { + return errorResult('forbidden', 'team_configuration_forbidden', false); + } + try { + const result = await this.application.createDraft({ + workspaceId: parsed.value.workspaceId, + idempotencyKey: parsed.value.idempotencyKey, + name: parsed.value.name, + members: parsed.value.members, + context: principal, + }); + if (result.kind === 'error') return applicationError(result.error); + const identity = Object.freeze({ + workspaceId: parsed.value.workspaceId, + teamId: parseTeamId(result.teamId), + }); + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'created', + identity, + revision: parseRevision(result.revision), + outcome: result.outcome, + }); + } catch { + return unavailable(); + } + } + + async updateDraft(body: unknown, principal: QueryContext): Promise { + const parsed = parseHostedUpdateDraftTeamRequest(body); + if (!parsed.ok) + return errorResult('invalid_request', 'team_configuration_request_invalid', false); + const identity = Object.freeze({ + workspaceId: parsed.value.workspaceId, + teamId: parsed.value.teamId, + }); + if (!(await this.authorize('update_draft', { kind: 'team', identity }, principal))) { + return errorResult('forbidden', 'team_configuration_forbidden', false); + } + try { + const result = await this.application.updateDraft( + identity, + parsed.value.expectedRevision, + parsed.value.updates, + principal + ); + if (result.kind === 'error') return applicationError(result.error); + const draft = projectDraft(identity, result.draft); + return draft === null + ? unavailable() + : Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'updated', + draft, + }); + } catch { + return unavailable(); + } + } + + async deleteDraft(body: unknown, principal: QueryContext): Promise { + const parsed = parseHostedDeleteDraftTeamRequest(body); + if (!parsed.ok) + return errorResult('invalid_request', 'team_configuration_request_invalid', false); + const identity = Object.freeze({ + workspaceId: parsed.value.workspaceId, + teamId: parsed.value.teamId, + }); + if (!(await this.authorize('delete_draft', { kind: 'team', identity }, principal))) { + return errorResult('forbidden', 'team_configuration_forbidden', false); + } + try { + const result = await this.application.deleteDraft( + identity, + parsed.value.expectedRevision, + principal + ); + if (result.kind === 'error') return applicationError(result.error); + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'deleted', + identity, + outcome: result.outcome, + }); + } catch { + return unavailable(); + } + } + + private async authorize( + operation: HostedTeamConfigurationOperation, + scope: HostedTeamConfigurationAuthorizationScope, + principal: QueryContext + ): Promise { + try { + const result = await this.authorization.authorize({ operation, scope, principal }); + return ( + result.kind === 'authorized' && + result.principalId === principal.actorId && + sameScope(result.scope, scope) + ); + } catch { + return false; + } + } +} diff --git a/src/features/team-configuration/main/adapters/input/http/hostedTeamConfigurationRoutes.ts b/src/features/team-configuration/main/adapters/input/http/hostedTeamConfigurationRoutes.ts new file mode 100644 index 0000000000..2f384e8966 --- /dev/null +++ b/src/features/team-configuration/main/adapters/input/http/hostedTeamConfigurationRoutes.ts @@ -0,0 +1,47 @@ +import { HOSTED_TEAM_CONFIGURATION_ROUTES } from '../../../../contracts/hosted'; + +import type { RouteDescriptor } from '@main/composition/hosted/routing'; + +const READ_READINESS = Object.freeze(['serve', 'auth', 'read'] as const); +const MUTATION_READINESS = Object.freeze(['serve', 'auth', 'mutation'] as const); +const MUTATION_REFERENCES = Object.freeze({ + createDraft: 'create-draft', + updateDraft: 'update-draft', + deleteDraft: 'delete-draft', +} as const); + +export const HOSTED_TEAM_CONFIGURATION_ROUTE_DESCRIPTORS = Object.freeze([ + Object.freeze({ + id: 'team-configuration.saved-request.v1', + method: 'POST', + path: HOSTED_TEAM_CONFIGURATION_ROUTES.getSavedRequest, + owner: 'team-configuration', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session', + readiness: READ_READINESS, + requestSchemaId: 'team-configuration.saved-request.request.v1', + responseSchemaId: 'team-configuration.saved-request.response.v1', + handlerId: 'team-configuration.saved-request.handler.v1', + clientId: 'team-configuration.saved-request.client.v1', + semanticTestId: 'team-configuration.saved-request.semantic.v1', + testOnly: false, + }), + ...(['createDraft', 'updateDraft', 'deleteDraft'] as const).map((operation): RouteDescriptor => { + const reference = MUTATION_REFERENCES[operation]; + return Object.freeze({ + id: `team-configuration.${reference}.v1`, + method: 'POST', + path: HOSTED_TEAM_CONFIGURATION_ROUTES[operation], + owner: 'team-configuration', + trustKind: 'browser', + authPolicyId: 'hosted.browser.session.csrf', + readiness: MUTATION_READINESS, + requestSchemaId: `team-configuration.${reference}.request.v1`, + responseSchemaId: `team-configuration.${reference}.response.v1`, + handlerId: `team-configuration.${reference}.handler.v1`, + clientId: `team-configuration.${reference}.client.v1`, + semanticTestId: `team-configuration.${reference}.semantic.v1`, + testOnly: false, + }); + }), +] satisfies readonly RouteDescriptor[]); diff --git a/src/features/team-configuration/main/adapters/input/http/registerHostedTeamConfigurationHttp.ts b/src/features/team-configuration/main/adapters/input/http/registerHostedTeamConfigurationHttp.ts new file mode 100644 index 0000000000..575069e075 --- /dev/null +++ b/src/features/team-configuration/main/adapters/input/http/registerHostedTeamConfigurationHttp.ts @@ -0,0 +1,159 @@ +import { createSafeAppError, type QueryContext } from '@shared/contracts/hosted'; + +import { HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION } from '../../../../contracts/hosted'; + +import { HOSTED_TEAM_CONFIGURATION_ROUTE_DESCRIPTORS } from './hostedTeamConfigurationRoutes'; + +import type { HostedTeamConfigurationFacade } from './HostedTeamConfigurationAdapter'; +import type { + HostedRouteAdmission, + HostedRouteContribution, +} from '@main/composition/hosted/application'; +import type { RouteDescriptor } from '@main/composition/hosted/routing'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export type HostedTeamConfigurationContextFactory = ( + descriptor: RouteDescriptor, + request: FastifyRequest, + signal: AbortSignal +) => QueryContext | Promise; + +type Result = Awaited< + ReturnType +>; + +function sendResult(reply: FastifyReply, result: Result): FastifyReply { + switch (result.kind) { + case 'created': + return reply.status(201).send(result); + case 'found': + case 'updated': + case 'deleted': + return reply.status(200).send(result); + case 'error': { + const status = { + invalid_request: 400, + unauthenticated: 401, + forbidden: 403, + not_found: 404, + conflict: 409, + unsupported: 422, + unavailable: 503, + cancelled: 503, + internal: 500, + }[result.error.code]; + if (result.error.retryAfterMs !== undefined) { + void reply.header('Retry-After', String(Math.ceil(result.error.retryAfterMs / 1_000))); + } + return reply.status(status).send(result); + } + } +} + +function unavailableResult() { + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'error' as const, + error: createSafeAppError({ + code: 'unavailable', + reason: 'team_configuration_unavailable', + }), + retryable: true, + }); +} + +async function withRequestSignal( + request: FastifyRequest, + reply: FastifyReply, + operation: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + request.raw.once('aborted', abort); + request.raw.socket.once('close', abort); + reply.raw.once('close', abort); + if (request.raw.aborted || request.raw.socket.destroyed || reply.raw.destroyed) abort(); + try { + return await operation(controller.signal); + } finally { + request.raw.removeListener('aborted', abort); + request.raw.socket.removeListener('close', abort); + reply.raw.removeListener('close', abort); + } +} + +function registerOperation( + app: FastifyInstance, + descriptor: RouteDescriptor, + execute: (body: unknown, principal: QueryContext) => Promise, + routeAdmission: HostedRouteAdmission, + createContext: HostedTeamConfigurationContextFactory +): void { + app.post<{ Body: unknown }>(descriptor.path, async (request, reply) => { + void reply.header('Cache-Control', 'no-store'); + void reply.header('X-Content-Type-Options', 'nosniff'); + try { + return await withRequestSignal(request, reply, async (signal) => { + const invocation = await routeAdmission.invoke(descriptor.id, async () => { + const principal = await createContext(descriptor, request, signal); + if (signal.aborted || principal.signal !== signal) return unavailableResult(); + return execute(request.body, principal); + }); + return invocation.admitted + ? sendResult(reply, invocation.value) + : reply.status(invocation.statusCode).send(unavailableResult()); + }); + } catch { + return reply.status(503).send(unavailableResult()); + } + }); +} + +/** Session authentication is mounted for reads; mutation descriptors additionally require CSRF. */ +export function registerHostedTeamConfigurationHttp( + app: FastifyInstance, + contribution: HostedRouteContribution, + routeAdmission: HostedRouteAdmission, + createContext: HostedTeamConfigurationContextFactory +): void { + const routes = new Map(contribution.routes.map((descriptor) => [descriptor.id, descriptor])); + const descriptors = HOSTED_TEAM_CONFIGURATION_ROUTE_DESCRIPTORS.map((expected) => { + const descriptor = routes.get(expected.id); + if (descriptor !== expected) { + throw new TypeError('hosted-team-configuration-route-contribution-invalid'); + } + return descriptor; + }); + if (routes.size !== descriptors.length) { + throw new TypeError('hosted-team-configuration-route-contribution-invalid'); + } + const facade = contribution.facade; + registerOperation( + app, + descriptors[0], + facade.getSavedRequest.bind(facade), + routeAdmission, + createContext + ); + registerOperation( + app, + descriptors[1], + facade.createDraft.bind(facade), + routeAdmission, + createContext + ); + registerOperation( + app, + descriptors[2], + facade.updateDraft.bind(facade), + routeAdmission, + createContext + ); + registerOperation( + app, + descriptors[3], + facade.deleteDraft.bind(facade), + routeAdmission, + createContext + ); +} diff --git a/src/features/team-configuration/main/adapters/input/ipc/TeamConfigurationIpcDependencies.ts b/src/features/team-configuration/main/adapters/input/ipc/TeamConfigurationIpcDependencies.ts new file mode 100644 index 0000000000..6af823b292 --- /dev/null +++ b/src/features/team-configuration/main/adapters/input/ipc/TeamConfigurationIpcDependencies.ts @@ -0,0 +1 @@ +export type { TeamConfigurationFeature as TeamConfigurationIpcDependencies } from '../../../composition/TeamConfigurationIpcBoundary'; diff --git a/src/features/team-configuration/main/adapters/input/ipc/createTeamConfigurationIpcHandlers.ts b/src/features/team-configuration/main/adapters/input/ipc/createTeamConfigurationIpcHandlers.ts new file mode 100644 index 0000000000..8bbf517ce8 --- /dev/null +++ b/src/features/team-configuration/main/adapters/input/ipc/createTeamConfigurationIpcHandlers.ts @@ -0,0 +1,93 @@ +import { validateTeamName } from '@main/ipc/guards'; + +import { normalizeCreateTeamConfigRequest } from './normalizeCreateTeamConfigRequest'; + +import type { TeamConfigurationIpcHost } from '../../../composition/TeamConfigurationIpcBoundary'; +import type { TeamConfigurationIpcDependencies } from './TeamConfigurationIpcDependencies'; +import type { + IpcResult, + TeamConfig, + TeamCreateRequest, + TeamUpdateConfigRequest, +} from '@shared/types'; + +export function createTeamConfigurationIpcHandlers( + dependencies: TeamConfigurationIpcDependencies, + host?: TeamConfigurationIpcHost +): { + createConfig: (_event: unknown, request: unknown) => Promise>; + updateConfig: ( + _event: unknown, + teamName: unknown, + updates: unknown + ) => Promise>; + getSavedRequest: ( + _event: unknown, + teamName: unknown + ) => Promise>; + deleteDraft: (_event: unknown, teamName: unknown) => Promise>; +} { + const execute = async ( + operation: string, + handler: () => Promise + ): Promise> => { + try { + return { success: true, data: await handler() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + dependencies.logger.error(`[teams:${operation}] ${message}`); + return { success: false, error: message }; + } + }; + + return { + createConfig: async (_event, request) => { + const normalized = normalizeCreateTeamConfigRequest(request, host?.isAbsolutePath); + if (!normalized.valid) { + return { success: false, error: normalized.error }; + } + return execute('createConfig', () => dependencies.createConfig.execute(normalized.value)); + }, + + updateConfig: async (_event, teamName, updates) => { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (!updates || typeof updates !== 'object') { + return { success: false, error: 'Invalid updates object' }; + } + const { name, description, color } = updates as TeamUpdateConfigRequest; + if (name !== undefined && typeof name !== 'string') { + return { success: false, error: 'name must be a string' }; + } + if (description !== undefined && typeof description !== 'string') { + return { success: false, error: 'description must be a string' }; + } + if (color !== undefined && typeof color !== 'string') { + return { success: false, error: 'color must be a string' }; + } + return execute('updateConfig', () => + dependencies.updateConfig.execute(validated.value!, { name, description, color }) + ); + }, + + getSavedRequest: async (_event, teamName) => { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return execute('getSavedRequest', () => + dependencies.getSavedRequest.execute(validated.value!) + ); + }, + + deleteDraft: async (_event, teamName) => { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return execute('deleteDraft', () => dependencies.deleteDraft.execute(validated.value!)); + }, + }; +} diff --git a/src/features/team-configuration/main/adapters/input/ipc/normalizeCreateTeamConfigRequest.ts b/src/features/team-configuration/main/adapters/input/ipc/normalizeCreateTeamConfigRequest.ts new file mode 100644 index 0000000000..516393341d --- /dev/null +++ b/src/features/team-configuration/main/adapters/input/ipc/normalizeCreateTeamConfigRequest.ts @@ -0,0 +1,229 @@ +import { + isProvisioningTeamName, + parseOptionalLaunchProviderBackendId, + parseOptionalMemberEffort, + parseOptionalMemberProviderId, + parseOptionalProviderBackendId, + parseOptionalTeamEffort, + parseOptionalTeamFastMode, + parseOptionalTeamProviderId, +} from '@features/team-configuration'; +import { validateTeammateName } from '@main/ipc/guards'; +import { extractUserFlags, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; + +import type { TeamCreateConfigRequest } from '@shared/types'; + +type NormalizedCreateConfigResult = + | { valid: true; value: TeamCreateConfigRequest } + | { valid: false; error: string }; + +function isPortableAbsolutePath(value: string): boolean { + return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\'); +} + +export function normalizeCreateTeamConfigRequest( + request: unknown, + isAbsolutePath: (value: string) => boolean = isPortableAbsolutePath +): NormalizedCreateConfigResult { + if (!request || typeof request !== 'object') { + return { valid: false, error: 'Invalid create config request' }; + } + + const payload = request as Partial; + if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) { + return { valid: false, error: 'teamName is required' }; + } + const teamName = payload.teamName.trim(); + if (!isProvisioningTeamName(teamName)) { + return { valid: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' }; + } + + if (!Array.isArray(payload.members)) { + return { valid: false, error: 'members must be an array' }; + } + + if (payload.displayName !== undefined && typeof payload.displayName !== 'string') { + return { valid: false, error: 'displayName must be a string' }; + } + if (payload.description !== undefined && typeof payload.description !== 'string') { + return { valid: false, error: 'description must be a string' }; + } + if (payload.color !== undefined && typeof payload.color !== 'string') { + return { valid: false, error: 'color must be a string' }; + } + if (payload.cwd !== undefined) { + if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) { + return { valid: false, error: 'cwd must be a non-empty string if provided' }; + } + if (!isAbsolutePath(payload.cwd.trim())) { + return { valid: false, error: 'cwd must be an absolute path' }; + } + } + if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { + return { valid: false, error: 'prompt must be a string' }; + } + const teamProviderValidation = parseOptionalTeamProviderId(payload.providerId); + if (!teamProviderValidation.valid) { + return { valid: false, error: teamProviderValidation.error }; + } + const effectiveTeamProviderId = teamProviderValidation.value ?? 'anthropic'; + const providerBackendValidation = parseOptionalLaunchProviderBackendId( + payload.providerBackendId, + effectiveTeamProviderId + ); + if (!providerBackendValidation.valid) { + return { valid: false, error: providerBackendValidation.error }; + } + if (payload.model !== undefined && typeof payload.model !== 'string') { + return { valid: false, error: 'model must be a string' }; + } + const effortValidation = parseOptionalTeamEffort(payload.effort, effectiveTeamProviderId); + if (!effortValidation.valid) { + return { valid: false, error: effortValidation.error }; + } + const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode); + if (!fastModeValidation.valid) { + return { valid: false, error: fastModeValidation.error }; + } + if (payload.limitContext !== undefined && typeof payload.limitContext !== 'boolean') { + return { valid: false, error: 'limitContext must be a boolean' }; + } + if (payload.skipPermissions !== undefined && typeof payload.skipPermissions !== 'boolean') { + return { valid: false, error: 'skipPermissions must be a boolean' }; + } + if (payload.worktree !== undefined) { + if (typeof payload.worktree !== 'string') { + return { valid: false, error: 'worktree must be a string' }; + } + const worktree = payload.worktree.trim(); + if (worktree.length > 128) { + return { valid: false, error: 'worktree name too long (max 128)' }; + } + if (worktree && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(worktree)) { + return { + valid: false, + error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]', + }; + } + } + if (payload.extraCliArgs !== undefined) { + if (typeof payload.extraCliArgs !== 'string') { + return { valid: false, error: 'extraCliArgs must be a string' }; + } + if (payload.extraCliArgs.length > 1024) { + return { valid: false, error: 'extraCliArgs too long (max 1024)' }; + } + const protectedFlags = extractUserFlags(payload.extraCliArgs).filter((flag) => + PROTECTED_CLI_FLAGS.has(flag) + ); + if (protectedFlags.length > 0) { + return { + valid: false, + error: `extraCliArgs contains app-managed flags: ${[...new Set(protectedFlags)].join(', ')}`, + }; + } + } + + const seenNames = new Set(); + const members: TeamCreateConfigRequest['members'] = []; + for (const member of payload.members) { + if (!member || typeof member !== 'object') { + return { valid: false, error: 'member must be object' }; + } + const nameValidation = validateTeammateName((member as { name?: unknown }).name); + if (!nameValidation.valid) { + return { valid: false, error: nameValidation.error ?? 'Invalid member name' }; + } + const memberName = nameValidation.value!; + if (seenNames.has(memberName)) { + return { valid: false, error: 'member names must be unique' }; + } + seenNames.add(memberName); + + const role = (member as { role?: unknown }).role; + if (role !== undefined && typeof role !== 'string') { + return { valid: false, error: 'member role must be string' }; + } + const workflow = (member as { workflow?: unknown }).workflow; + if (workflow !== undefined && typeof workflow !== 'string') { + return { valid: false, error: 'member workflow must be string' }; + } + const isolation = (member as { isolation?: unknown }).isolation; + if (isolation !== undefined && isolation !== 'worktree') { + return { valid: false, error: 'member isolation must be "worktree" when provided' }; + } + const providerValidation = parseOptionalMemberProviderId( + (member as { providerId?: unknown }).providerId + ); + if (!providerValidation.valid) { + return { valid: false, error: providerValidation.error }; + } + const effectiveMemberProviderId = providerValidation.value ?? effectiveTeamProviderId; + const memberProviderBackendValidation = parseOptionalProviderBackendId( + (member as { providerBackendId?: unknown }).providerBackendId, + effectiveMemberProviderId + ); + if (!memberProviderBackendValidation.valid) { + return { valid: false, error: memberProviderBackendValidation.error }; + } + const model = (member as { model?: unknown }).model; + if (model !== undefined && typeof model !== 'string') { + return { valid: false, error: 'member model must be string' }; + } + const memberEffortValidation = parseOptionalMemberEffort( + (member as { effort?: unknown }).effort, + effectiveMemberProviderId + ); + if (!memberEffortValidation.valid) { + return { valid: false, error: memberEffortValidation.error }; + } + const memberFastModeValidation = parseOptionalTeamFastMode( + (member as { fastMode?: unknown }).fastMode + ); + if (!memberFastModeValidation.valid) { + return { valid: false, error: memberFastModeValidation.error }; + } + members.push({ + name: memberName, + role: typeof role === 'string' ? role.trim() : undefined, + workflow: typeof workflow === 'string' ? workflow.trim() : undefined, + isolation: isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: providerValidation.value, + providerBackendId: memberProviderBackendValidation.value, + model: typeof model === 'string' ? model.trim() || undefined : undefined, + effort: memberEffortValidation.value, + fastMode: memberFastModeValidation.value, + mcpPolicy: normalizeTeamMemberMcpPolicy((member as { mcpPolicy?: unknown }).mcpPolicy), + }); + } + + return { + valid: true, + value: { + teamName, + displayName: payload.displayName?.trim() || undefined, + description: payload.description?.trim() || undefined, + color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined, + members, + cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined, + prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: teamProviderValidation.value, + providerBackendId: providerBackendValidation.value, + model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + effort: effortValidation.value, + fastMode: fastModeValidation.value, + limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, + skipPermissions: + typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, + worktree: + typeof payload.worktree === 'string' && payload.worktree.trim() + ? payload.worktree.trim() + : undefined, + extraCliArgs: + typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim() + ? payload.extraCliArgs.trim() + : undefined, + }, + }; +} diff --git a/src/features/team-configuration/main/adapters/input/ipc/registerTeamConfigurationIpc.ts b/src/features/team-configuration/main/adapters/input/ipc/registerTeamConfigurationIpc.ts new file mode 100644 index 0000000000..584126cfeb --- /dev/null +++ b/src/features/team-configuration/main/adapters/input/ipc/registerTeamConfigurationIpc.ts @@ -0,0 +1,33 @@ +import { + TEAM_CREATE_CONFIG, + TEAM_DELETE_DRAFT, + TEAM_GET_SAVED_REQUEST, + TEAM_UPDATE_CONFIG, +} from '@features/team-configuration/contracts'; + +import { createTeamConfigurationIpcHandlers } from './createTeamConfigurationIpcHandlers'; + +import type { + TeamConfigurationFeature, + TeamConfigurationIpcHost, + TeamConfigurationIpcRegistrar, +} from '../../../composition/TeamConfigurationIpcBoundary'; + +export function registerTeamConfigurationIpc( + ipcMain: TeamConfigurationIpcRegistrar, + dependencies: TeamConfigurationFeature, + host?: TeamConfigurationIpcHost +): void { + const handlers = createTeamConfigurationIpcHandlers(dependencies, host); + ipcMain.handle(TEAM_CREATE_CONFIG, handlers.createConfig); + ipcMain.handle(TEAM_UPDATE_CONFIG, handlers.updateConfig); + ipcMain.handle(TEAM_GET_SAVED_REQUEST, handlers.getSavedRequest); + ipcMain.handle(TEAM_DELETE_DRAFT, handlers.deleteDraft); +} + +export function removeTeamConfigurationIpc(ipcMain: TeamConfigurationIpcRegistrar): void { + ipcMain.removeHandler(TEAM_CREATE_CONFIG); + ipcMain.removeHandler(TEAM_UPDATE_CONFIG); + ipcMain.removeHandler(TEAM_GET_SAVED_REQUEST); + ipcMain.removeHandler(TEAM_DELETE_DRAFT); +} diff --git a/src/features/team-configuration/main/adapters/output/TeamDataWorkerConfigCache.ts b/src/features/team-configuration/main/adapters/output/TeamDataWorkerConfigCache.ts new file mode 100644 index 0000000000..8d3b35fb82 --- /dev/null +++ b/src/features/team-configuration/main/adapters/output/TeamDataWorkerConfigCache.ts @@ -0,0 +1,9 @@ +import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient'; + +import type { TeamConfigurationCachePort } from '../../../core/application/ports/TeamConfigurationPorts'; + +export class TeamDataWorkerConfigCache implements TeamConfigurationCachePort { + invalidateTeamConfig(teamName: string): void { + getTeamDataWorkerClient().invalidateTeamConfig(teamName); + } +} diff --git a/src/features/team-configuration/main/adapters/output/TeamDraftConfigurationPersistenceRepository.ts b/src/features/team-configuration/main/adapters/output/TeamDraftConfigurationPersistenceRepository.ts new file mode 100644 index 0000000000..8041e31667 --- /dev/null +++ b/src/features/team-configuration/main/adapters/output/TeamDraftConfigurationPersistenceRepository.ts @@ -0,0 +1,269 @@ +import { getMemberColorByName } from '@shared/constants/memberColors'; +import { isTeamEffortLevel } from '@shared/utils/effortLevels'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; +import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + +import type { + TeamCreateConfigRequest, + TeamCreateRequest, + TeamFastMode, + TeamMember, + TeamProviderId, +} from '@shared/types'; + +interface DraftTeamMetadata { + displayName?: string; + description?: string; + color?: string; + cwd: string; + prompt?: string; + providerId?: TeamProviderId; + providerBackendId?: string; + model?: string; + effort?: string; + fastMode?: TeamFastMode; + skipPermissions?: boolean; + worktree?: string; + extraCliArgs?: string; + limitContext?: boolean; + createdAt: number; +} + +interface DraftTeamMembersMetadata { + providerBackendId?: string; + members: TeamMember[]; +} + +interface DraftTeamMetaStorePort { + getMeta(teamName: string): Promise; + writeMeta(teamName: string, data: DraftTeamMetadata): Promise; +} + +interface DraftTeamMembersMetaStorePort { + getMeta(teamName: string): Promise; + writeMembers( + teamName: string, + members: TeamMember[], + options?: { providerBackendId?: string } + ): Promise; +} + +interface DraftConfigurationFileSystemPort { + join(root: string, teamName: string): string; + lstat(targetPath: string): Promise; + mkdir(targetPath: string, options?: { recursive?: boolean }): Promise; + rm(targetPath: string, options: { recursive: true; force: true }): Promise; +} + +interface TeamDraftConfigurationRoots { + teamsRoot: string; + tasksRoot: string; +} + +interface TeamDraftConfigurationPersistenceRepositoryDependencies { + teamMetaStore: DraftTeamMetaStorePort; + teamMembersMetaStore: DraftTeamMembersMetaStorePort; + fileSystem: DraftConfigurationFileSystemPort; + invalidateListTeamsCache(): void; + now(): number; +} + +function applyDistinctRosterColors( + members: readonly T[] +): T[] { + const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false }); + return members.map((member) => ({ + ...member, + color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name), + })); +} + +function normalizeMember( + member: TeamCreateConfigRequest['members'][number], + joinedAt: number +): TeamMember { + const name = member.name.trim(); + if (!name) { + throw new Error('Member name cannot be empty'); + } + const formatError = validateTeamMemberNameFormat(name); + if (formatError) { + throw new Error(`Member name "${name}" is invalid: ${formatError}`); + } + if (name.toLowerCase() === 'user') { + throw new Error('Member name "user" is reserved'); + } + if (name.toLowerCase() === 'team-lead') { + throw new Error('Member name "team-lead" is reserved'); + } + const suffixInfo = parseNumericSuffixName(name); + if (suffixInfo && suffixInfo.suffix >= 2) { + throw new Error( + `Member name "${name}" is not allowed (reserved for runtime-managed numeric suffixes). Use "${suffixInfo.base}" instead.` + ); + } + + return { + name, + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + isolation: member.isolation === 'worktree' ? 'worktree' : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + providerBackendId: member.providerBackendId, + model: member.model?.trim() || undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + fastMode: member.fastMode, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), + agentType: 'general-purpose', + joinedAt, + }; +} + +async function pathExists( + fileSystem: DraftConfigurationFileSystemPort, + targetPath: string +): Promise { + try { + await fileSystem.lstat(targetPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +function teamAlreadyExistsError(teamName: string): Error { + return new Error(`Team already exists: ${teamName}`); +} + +export class TeamDraftConfigurationPersistenceRepository { + constructor( + private readonly dependencies: TeamDraftConfigurationPersistenceRepositoryDependencies + ) {} + + async getSavedRequest(teamName: string): Promise { + const meta = await this.dependencies.teamMetaStore.getMeta(teamName); + if (!meta) { + return null; + } + + const membersMeta = await this.dependencies.teamMembersMetaStore.getMeta(teamName); + const members = membersMeta?.members ?? []; + const resolvedProviderId = meta.providerId ?? 'anthropic'; + + return { + teamName, + displayName: meta.displayName, + description: meta.description, + color: meta.color, + cwd: meta.cwd, + prompt: meta.prompt, + providerId: resolvedProviderId, + providerBackendId: migrateProviderBackendId( + resolvedProviderId, + meta.providerBackendId ?? membersMeta?.providerBackendId + ), + model: meta.model, + effort: meta.effort as TeamCreateRequest['effort'], + fastMode: meta.fastMode, + skipPermissions: meta.skipPermissions, + worktree: meta.worktree, + extraCliArgs: meta.extraCliArgs, + limitContext: meta.limitContext, + members: members + .filter((member) => !member.removedAt) + .map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + isolation: member.isolation, + cwd: member.cwd, + providerId: member.providerId, + providerBackendId: member.providerBackendId, + model: member.model, + effort: member.effort, + fastMode: member.fastMode, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), + })), + }; + } + + async createTeamConfig( + request: TeamCreateConfigRequest, + roots: TeamDraftConfigurationRoots + ): Promise { + const teamDir = this.dependencies.fileSystem.join(roots.teamsRoot, request.teamName); + const tasksDir = this.dependencies.fileSystem.join(roots.tasksRoot, request.teamName); + await Promise.all([ + this.dependencies.fileSystem.mkdir(roots.teamsRoot, { recursive: true }), + this.dependencies.fileSystem.mkdir(roots.tasksRoot, { recursive: true }), + ]); + + if ( + (await pathExists(this.dependencies.fileSystem, teamDir)) || + (await pathExists(this.dependencies.fileSystem, tasksDir)) + ) { + throw teamAlreadyExistsError(request.teamName); + } + + try { + await this.dependencies.fileSystem.mkdir(teamDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw teamAlreadyExistsError(request.teamName); + } + throw error; + } + + let tasksDirectoryCreated = false; + try { + await this.dependencies.fileSystem.mkdir(tasksDir); + tasksDirectoryCreated = true; + + const joinedAt = this.dependencies.now(); + await this.dependencies.teamMetaStore.writeMeta(request.teamName, { + displayName: request.displayName, + description: request.description, + color: request.color, + cwd: request.cwd?.trim() || '', + prompt: request.prompt, + providerId: request.providerId, + providerBackendId: request.providerBackendId, + model: request.model, + effort: request.effort, + fastMode: request.fastMode, + skipPermissions: request.skipPermissions, + worktree: request.worktree, + extraCliArgs: request.extraCliArgs, + limitContext: request.limitContext, + createdAt: joinedAt, + }); + + const membersToWrite = applyDistinctRosterColors( + request.members.map((member) => normalizeMember(member, joinedAt)) + ); + await this.dependencies.teamMembersMetaStore.writeMembers(request.teamName, membersToWrite, { + providerBackendId: request.providerBackendId, + }); + this.dependencies.invalidateListTeamsCache(); + } catch (error) { + if (tasksDirectoryCreated) { + await this.dependencies.fileSystem + .rm(tasksDir, { recursive: true, force: true }) + .catch(() => undefined); + } + await this.dependencies.fileSystem + .rm(teamDir, { recursive: true, force: true }) + .catch(() => undefined); + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw teamAlreadyExistsError(request.teamName); + } + throw error; + } + } +} diff --git a/src/features/team-configuration/main/adapters/output/hosted-authority/InternalStorageHostedTeamConfigurationStorageAdapter.ts b/src/features/team-configuration/main/adapters/output/hosted-authority/InternalStorageHostedTeamConfigurationStorageAdapter.ts new file mode 100644 index 0000000000..ff4d9def02 --- /dev/null +++ b/src/features/team-configuration/main/adapters/output/hosted-authority/InternalStorageHostedTeamConfigurationStorageAdapter.ts @@ -0,0 +1,32 @@ +import type { HostedTeamConfigurationAuthorityStoragePort } from '../../../../core/application/hosted-authority/HostedTeamConfigurationAuthorityPorts'; +import type { HostedTeamConfigurationStorageGateway } from '@features/internal-storage/contracts'; + +/** Storage-only adapter from team-configuration's application port to internal-storage. */ +export class InternalStorageHostedTeamConfigurationStorageAdapter implements HostedTeamConfigurationAuthorityStoragePort { + constructor(private readonly storage: HostedTeamConfigurationStorageGateway) {} + + create( + request: Parameters[0], + signal: AbortSignal + ) { + return this.storage.createHostedTeamConfiguration(request, { signal }); + } + + read(identity: Parameters[0]) { + return this.storage.readHostedTeamConfiguration(identity); + } + + update( + request: Parameters[0], + signal: AbortSignal + ) { + return this.storage.updateHostedTeamConfiguration(request, { signal }); + } + + delete( + request: Parameters[0], + signal: AbortSignal + ) { + return this.storage.deleteHostedTeamConfiguration(request, { signal }); + } +} diff --git a/src/features/team-configuration/main/composition/TeamConfigurationIpcBoundary.ts b/src/features/team-configuration/main/composition/TeamConfigurationIpcBoundary.ts new file mode 100644 index 0000000000..cb2efd5260 --- /dev/null +++ b/src/features/team-configuration/main/composition/TeamConfigurationIpcBoundary.ts @@ -0,0 +1,41 @@ +import { + registerTeamConfigurationIpc as registerConfigurationIpc, + removeTeamConfigurationIpc as removeConfigurationIpc, +} from '../adapters/input/ipc/registerTeamConfigurationIpc'; + +import type { TeamConfigurationLoggerPort } from '../../core/application/ports/TeamConfigurationPorts'; +import type { TeamConfig, TeamCreateConfigRequest, TeamCreateRequest } from '@shared/types'; + +export interface TeamConfigurationFeature { + createConfig: { execute(request: TeamCreateConfigRequest): Promise }; + updateConfig: { + execute( + teamName: string, + updates: { name?: string; description?: string; color?: string } + ): Promise; + }; + getSavedRequest: { execute(teamName: string): Promise }; + deleteDraft: { execute(teamName: string): Promise }; + logger: TeamConfigurationLoggerPort; +} + +export interface TeamConfigurationIpcRegistrar { + readonly handle: CallableFunction; + readonly removeHandler: CallableFunction; +} + +export interface TeamConfigurationIpcHost { + readonly isAbsolutePath: (value: string) => boolean; +} + +export function registerTeamConfigurationIpc( + ipcMain: TeamConfigurationIpcRegistrar, + feature: TeamConfigurationFeature, + host?: TeamConfigurationIpcHost +): void { + registerConfigurationIpc(ipcMain, feature, host); +} + +export function removeTeamConfigurationIpc(ipcMain: TeamConfigurationIpcRegistrar): void { + removeConfigurationIpc(ipcMain); +} diff --git a/src/features/team-configuration/main/composition/createHostedTeamConfigurationAuthority.ts b/src/features/team-configuration/main/composition/createHostedTeamConfigurationAuthority.ts new file mode 100644 index 0000000000..a84afb7487 --- /dev/null +++ b/src/features/team-configuration/main/composition/createHostedTeamConfigurationAuthority.ts @@ -0,0 +1,23 @@ +import { HostedTeamConfigurationAuthority } from '../../core/application/hosted-authority/HostedTeamConfigurationAuthority'; +import { InternalStorageHostedTeamConfigurationStorageAdapter } from '../adapters/output/hosted-authority/InternalStorageHostedTeamConfigurationStorageAdapter'; + +import type { HostedTeamConfigurationApplicationPort } from '../ports/HostedTeamConfigurationAuthorizationPort'; +import type { HostedTeamConfigurationStorageGateway } from '@features/internal-storage/contracts'; + +export function createHostedTeamConfigurationAuthority( + storage: HostedTeamConfigurationStorageGateway +): HostedTeamConfigurationApplicationPort { + return new HostedTeamConfigurationAuthority({ + storage: new InternalStorageHostedTeamConfigurationStorageAdapter(storage), + sha256Hex: async (value) => { + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(value) + ); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( + '' + ); + }, + now: Date.now, + }); +} diff --git a/src/features/team-configuration/main/composition/createHostedTeamConfigurationFeature.ts b/src/features/team-configuration/main/composition/createHostedTeamConfigurationFeature.ts new file mode 100644 index 0000000000..63a0b11cc9 --- /dev/null +++ b/src/features/team-configuration/main/composition/createHostedTeamConfigurationFeature.ts @@ -0,0 +1,37 @@ +import { HostedTeamConfigurationAdapter } from '../adapters/input/http/HostedTeamConfigurationAdapter'; +import { HOSTED_TEAM_CONFIGURATION_ROUTE_DESCRIPTORS } from '../adapters/input/http/hostedTeamConfigurationRoutes'; + +import type { HostedTeamConfigurationFacade } from '../adapters/input/http/HostedTeamConfigurationAdapter'; +import type { + HostedTeamConfigurationApplicationPort, + HostedTeamConfigurationAuthorizationPort, +} from '../ports/HostedTeamConfigurationAuthorizationPort'; +import type { HostedRouteContribution } from '@main/composition/hosted/application'; + +export interface HostedTeamConfigurationFeature extends HostedTeamConfigurationFacade { + readonly routes: typeof HOSTED_TEAM_CONFIGURATION_ROUTE_DESCRIPTORS; +} + +export function createHostedTeamConfigurationFeature( + application: HostedTeamConfigurationApplicationPort, + authorization: HostedTeamConfigurationAuthorizationPort +): HostedTeamConfigurationFeature { + const adapter = new HostedTeamConfigurationAdapter(application, authorization); + return Object.freeze({ + routes: HOSTED_TEAM_CONFIGURATION_ROUTE_DESCRIPTORS, + getSavedRequest: adapter.getSavedRequest.bind(adapter), + createDraft: adapter.createDraft.bind(adapter), + updateDraft: adapter.updateDraft.bind(adapter), + deleteDraft: adapter.deleteDraft.bind(adapter), + }); +} + +export function createHostedTeamConfigurationRouteContribution( + feature: HostedTeamConfigurationFeature +): HostedRouteContribution { + return Object.freeze({ + id: 'team-configuration.hosted.v1', + facade: feature, + routes: feature.routes, + }); +} diff --git a/src/features/team-configuration/main/composition/createTeamConfigurationFeature.ts b/src/features/team-configuration/main/composition/createTeamConfigurationFeature.ts new file mode 100644 index 0000000000..fb357b3658 --- /dev/null +++ b/src/features/team-configuration/main/composition/createTeamConfigurationFeature.ts @@ -0,0 +1,51 @@ +import { CreateTeamConfigUseCase } from '../../core/application/use-cases/CreateTeamConfigUseCase'; +import { DeleteDraftTeamUseCase } from '../../core/application/use-cases/DeleteDraftTeamUseCase'; +import { GetSavedTeamRequestUseCase } from '../../core/application/use-cases/GetSavedTeamRequestUseCase'; +import { UpdateTeamConfigUseCase } from '../../core/application/use-cases/UpdateTeamConfigUseCase'; +import { TeamDataWorkerConfigCache } from '../adapters/output/TeamDataWorkerConfigCache'; + +import type { + DraftTeamConfigGuardPort, + TeamConfigurationCachePort, + TeamConfigurationLoggerPort, + TeamConfigurationMessagingPort, + TeamConfigurationRepositoryPort, + TeamConfigurationRuntimePort, +} from '../../core/application/ports/TeamConfigurationPorts'; +import type { TeamConfigurationFeature } from './TeamConfigurationIpcBoundary'; + +export type { TeamConfigurationFeature } from './TeamConfigurationIpcBoundary'; + +export function createTeamConfigurationFeature(dependencies: { + repository: TeamConfigurationRepositoryPort; + runtime: TeamConfigurationRuntimePort; + messaging: TeamConfigurationMessagingPort; + logger: TeamConfigurationLoggerPort; + cache?: TeamConfigurationCachePort; + draftGuard: DraftTeamConfigGuardPort; +}): TeamConfigurationFeature { + const cache = dependencies.cache ?? new TeamDataWorkerConfigCache(); + const createConfig = new CreateTeamConfigUseCase({ repository: dependencies.repository, cache }); + const updateConfig = new UpdateTeamConfigUseCase({ + repository: dependencies.repository, + runtime: dependencies.runtime, + messaging: dependencies.messaging, + cache, + logger: dependencies.logger, + }); + const getSavedRequest = new GetSavedTeamRequestUseCase(dependencies.repository); + const deleteDraft = new DeleteDraftTeamUseCase({ + repository: dependencies.repository, + draftGuard: dependencies.draftGuard, + }); + + return { + createConfig: { execute: (request) => createConfig.execute(request) }, + updateConfig: { + execute: (teamName, updates) => updateConfig.execute(teamName, updates), + }, + getSavedRequest: { execute: (teamName) => getSavedRequest.execute(teamName) }, + deleteDraft: { execute: (teamName) => deleteDraft.execute(teamName) }, + logger: dependencies.logger, + }; +} diff --git a/src/features/team-configuration/main/composition/createTeamDraftConfigurationPersistenceRepository.ts b/src/features/team-configuration/main/composition/createTeamDraftConfigurationPersistenceRepository.ts new file mode 100644 index 0000000000..6666b51c2f --- /dev/null +++ b/src/features/team-configuration/main/composition/createTeamDraftConfigurationPersistenceRepository.ts @@ -0,0 +1,72 @@ +import { TeamDraftConfigurationPersistenceRepository } from '../adapters/output/TeamDraftConfigurationPersistenceRepository'; + +import type { + TeamCreateConfigRequest, + TeamCreateRequest, + TeamFastMode, + TeamMember, + TeamProviderId, +} from '@shared/types'; + +interface DraftTeamMetadata { + displayName?: string; + description?: string; + color?: string; + cwd: string; + prompt?: string; + providerId?: TeamProviderId; + providerBackendId?: string; + model?: string; + effort?: string; + fastMode?: TeamFastMode; + skipPermissions?: boolean; + worktree?: string; + extraCliArgs?: string; + limitContext?: boolean; + createdAt: number; +} + +interface TeamDraftConfigurationPersistenceRepositoryFactoryDependencies { + teamMetaStore: { + getMeta(teamName: string): Promise; + writeMeta(teamName: string, data: DraftTeamMetadata): Promise; + }; + teamMembersMetaStore: { + getMeta(teamName: string): Promise<{ + providerBackendId?: string; + members: TeamMember[]; + } | null>; + writeMembers( + teamName: string, + members: TeamMember[], + options?: { providerBackendId?: string } + ): Promise; + }; + fileSystem: { + join(root: string, teamName: string): string; + lstat(targetPath: string): Promise; + mkdir(targetPath: string, options?: { recursive?: boolean }): Promise; + rm(targetPath: string, options: { recursive: true; force: true }): Promise; + }; + invalidateListTeamsCache(): void; + now(): number; +} + +export interface TeamDraftConfigurationRoots { + teamsRoot: string; + tasksRoot: string; +} + +export interface TeamDraftConfigurationPersistenceRepositoryPort { + getSavedRequest(teamName: string): Promise; + createTeamConfig( + request: TeamCreateConfigRequest, + roots: TeamDraftConfigurationRoots + ): Promise; +} + +export function createTeamDraftConfigurationPersistenceRepository( + dependencies: TeamDraftConfigurationPersistenceRepositoryFactoryDependencies +): TeamDraftConfigurationPersistenceRepositoryPort { + return new TeamDraftConfigurationPersistenceRepository(dependencies); +} diff --git a/src/features/team-configuration/main/hosted.ts b/src/features/team-configuration/main/hosted.ts new file mode 100644 index 0000000000..edc7a527ce --- /dev/null +++ b/src/features/team-configuration/main/hosted.ts @@ -0,0 +1,25 @@ +export { + HostedTeamConfigurationAdapter, + type HostedTeamConfigurationFacade, +} from './adapters/input/http/HostedTeamConfigurationAdapter'; +export { HOSTED_TEAM_CONFIGURATION_ROUTE_DESCRIPTORS } from './adapters/input/http/hostedTeamConfigurationRoutes'; +export { + type HostedTeamConfigurationContextFactory, + registerHostedTeamConfigurationHttp, +} from './adapters/input/http/registerHostedTeamConfigurationHttp'; +export { createHostedTeamConfigurationAuthority } from './composition/createHostedTeamConfigurationAuthority'; +export { + createHostedTeamConfigurationFeature, + createHostedTeamConfigurationRouteContribution, + type HostedTeamConfigurationFeature, +} from './composition/createHostedTeamConfigurationFeature'; +export { + HOSTED_TEAM_CONFIGURATION_OPERATIONS, + type HostedTeamConfigurationApplicationError, + type HostedTeamConfigurationApplicationPort, + type HostedTeamConfigurationAuthorizationPort, + type HostedTeamConfigurationAuthorizationRequest, + type HostedTeamConfigurationAuthorizationResult, + type HostedTeamConfigurationAuthorizationScope, + type HostedTeamConfigurationOperation, +} from './ports/HostedTeamConfigurationAuthorizationPort'; diff --git a/src/features/team-configuration/main/index.ts b/src/features/team-configuration/main/index.ts new file mode 100644 index 0000000000..d883616497 --- /dev/null +++ b/src/features/team-configuration/main/index.ts @@ -0,0 +1,14 @@ +export { createHostedTeamConfigurationAuthority } from './composition/createHostedTeamConfigurationAuthority'; +export { + createTeamConfigurationFeature, + type TeamConfigurationFeature, +} from './composition/createTeamConfigurationFeature'; +export { + createTeamDraftConfigurationPersistenceRepository, + type TeamDraftConfigurationPersistenceRepositoryPort, + type TeamDraftConfigurationRoots, +} from './composition/createTeamDraftConfigurationPersistenceRepository'; +export { + registerTeamConfigurationIpc, + removeTeamConfigurationIpc, +} from './composition/TeamConfigurationIpcBoundary'; diff --git a/src/features/team-configuration/main/ports/HostedTeamConfigurationAuthorizationPort.ts b/src/features/team-configuration/main/ports/HostedTeamConfigurationAuthorizationPort.ts new file mode 100644 index 0000000000..489fcef3fd --- /dev/null +++ b/src/features/team-configuration/main/ports/HostedTeamConfigurationAuthorizationPort.ts @@ -0,0 +1,105 @@ +import type { + HostedSavedTeamRequest, + HostedTeamConfigurationIdempotencyKey, + HostedTeamConfigurationIdentity, + HostedTeamConfigurationMember, + HostedUpdateDraftTeamRequest, +} from '../../contracts/hosted'; +import type { + ActorId, + QueryContext, + Revision, + SafeAppError, + TeamId, + WorkspaceId, +} from '@shared/contracts/hosted'; + +export const HOSTED_TEAM_CONFIGURATION_OPERATIONS = Object.freeze([ + 'get_saved_request', + 'create_draft', + 'update_draft', + 'delete_draft', +] as const); + +export type HostedTeamConfigurationOperation = + (typeof HOSTED_TEAM_CONFIGURATION_OPERATIONS)[number]; + +export type HostedTeamConfigurationAuthorizationScope = + | Readonly<{ kind: 'workspace'; workspaceId: WorkspaceId }> + | Readonly<{ kind: 'team'; identity: HostedTeamConfigurationIdentity }>; + +export interface HostedTeamConfigurationAuthorizationRequest { + readonly operation: HostedTeamConfigurationOperation; + readonly scope: HostedTeamConfigurationAuthorizationScope; + /** Server-created authenticated context; this value is never accepted from the payload. */ + readonly principal: QueryContext; +} + +export type HostedTeamConfigurationAuthorizationResult = + | Readonly<{ + kind: 'authorized'; + principalId: ActorId; + scope: HostedTeamConfigurationAuthorizationScope; + }> + | Readonly<{ kind: 'denied' }>; + +/** Revalidates the authenticated principal and the complete workspace/team grant atomically. */ +export interface HostedTeamConfigurationAuthorizationPort { + authorize( + request: HostedTeamConfigurationAuthorizationRequest + ): Promise; +} + +export interface HostedTeamConfigurationApplicationError { + readonly kind: 'error'; + readonly error: SafeAppError; +} + +export interface HostedTeamConfigurationApplicationPort { + createDraft(request: { + readonly workspaceId: WorkspaceId; + /** + * The application atomically binds this key to the workspace, canonical payload, TeamId, and + * initial revision. An identical replay returns that same TeamId/revision; key reuse with a + * different canonical payload returns conflict without mutation. + */ + readonly idempotencyKey: HostedTeamConfigurationIdempotencyKey; + readonly name: string; + readonly members: readonly HostedTeamConfigurationMember[]; + readonly context: QueryContext; + }): Promise< + | Readonly<{ + kind: 'created'; + teamId: TeamId; + revision: Revision; + outcome: 'created' | 'idempotent_replay'; + }> + | HostedTeamConfigurationApplicationError + >; + getSavedRequest( + identity: HostedTeamConfigurationIdentity, + context: QueryContext + ): Promise< + | Readonly<{ kind: 'found'; draft: HostedSavedTeamRequest }> + | HostedTeamConfigurationApplicationError + >; + updateDraft( + identity: HostedTeamConfigurationIdentity, + /** Must be checked atomically; a mismatch returns conflict before any mutation. */ + expectedRevision: Revision, + updates: HostedUpdateDraftTeamRequest['updates'], + context: QueryContext + ): Promise< + | Readonly<{ kind: 'updated'; draft: HostedSavedTeamRequest }> + | HostedTeamConfigurationApplicationError + >; + deleteDraft( + identity: HostedTeamConfigurationIdentity, + /** Must be checked atomically; a mismatch returns conflict before any mutation. */ + expectedRevision: Revision, + context: QueryContext + ): Promise< + | Readonly<{ kind: 'deleted'; outcome: 'deleted' | 'already_absent' }> + | HostedTeamConfigurationApplicationError + >; +} diff --git a/src/features/team-configuration/renderer/composition/createHostedTeamConfigurationTransport.ts b/src/features/team-configuration/renderer/composition/createHostedTeamConfigurationTransport.ts new file mode 100644 index 0000000000..932f44d437 --- /dev/null +++ b/src/features/team-configuration/renderer/composition/createHostedTeamConfigurationTransport.ts @@ -0,0 +1,381 @@ +import { createSafeAppError, parseRevision, type Revision } from '@shared/contracts/hosted'; + +import { + HOSTED_TEAM_CONFIGURATION_ROUTES, + HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + type HostedSavedTeamRequest, + type HostedTeamConfigurationIdentity, + parseHostedCreateDraftTeamRequest, + parseHostedDeleteDraftTeamRequest, + parseHostedGetSavedTeamRequest, + parseHostedTeamConfigurationIdentity, + parseHostedUpdateDraftTeamRequest, +} from '../../contracts/hosted'; + +import type { + HostedTeamConfigurationHttpResponse, + HostedTeamConfigurationTransport, + HostedTeamConfigurationTransportDependencies, + HostedTeamConfigurationTransportOptions, +} from '../ports/HostedTeamConfigurationRendererPorts'; + +const CSRF_TOKEN = /^[A-Za-z0-9_-]{32,512}$/; +const METADATA_LIMITS = Object.freeze({ name: 128, description: 4_000, color: 64, language: 64 }); +const MEMBER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; +const PUBLIC_ERROR_REASONS = Object.freeze({ + invalid_request: 'team_configuration_request_invalid', + unauthenticated: 'team_configuration_unauthenticated', + forbidden: 'team_configuration_forbidden', + not_found: 'team_configuration_not_found', + conflict: 'team_configuration_revision_conflict', + unsupported: 'team_configuration_unsupported', + unavailable: 'team_configuration_unavailable', + cancelled: 'team_configuration_cancelled', + internal: 'team_configuration_unavailable', +} as const); + +function errorResult(code: 'invalid_request' | 'unavailable' | 'cancelled', reason: string) { + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'error' as const, + error: createSafeAppError({ code, reason }), + retryable: code === 'unavailable', + }); +} + +function unavailable() { + return errorResult('unavailable', 'team_configuration_unavailable'); +} + +function invalidRequest() { + return errorResult('invalid_request', 'team_configuration_request_invalid'); +} + +function cancelled() { + return errorResult('cancelled', 'team_configuration_cancelled'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function tryParseRevision(value: unknown): Revision | null { + try { + return parseRevision(value); + } catch { + return null; + } +} + +function parseIdentity(value: unknown, expected: HostedTeamConfigurationIdentity) { + const parsed = parseHostedTeamConfigurationIdentity(value); + return parsed.ok && + parsed.value.workspaceId === expected.workspaceId && + parsed.value.teamId === expected.teamId + ? parsed.value + : null; +} + +function parseDraft(value: unknown, expected: HostedTeamConfigurationIdentity) { + if ( + !isRecord(value) || + !hasExactKeys(value, ['workspaceId', 'teamId', 'revision', 'metadata', 'members']) || + parseIdentity({ workspaceId: value.workspaceId, teamId: value.teamId }, expected) === null || + !isRecord(value.metadata) || + !Array.isArray(value.members) || + value.members.length < 1 || + value.members.length > 32 + ) { + return null; + } + const revision = tryParseRevision(value.revision); + if (revision === null) return null; + const metadataKeys = Reflect.ownKeys(value.metadata); + if ( + !Object.hasOwn(value.metadata, 'name') || + metadataKeys.some((key) => typeof key !== 'string' || !Object.hasOwn(METADATA_LIMITS, key)) + ) { + return null; + } + const metadata: Record = {}; + for (const key of metadataKeys as (keyof typeof METADATA_LIMITS)[]) { + const field = value.metadata[key]; + if ( + typeof field !== 'string' || + field.length < 1 || + field.length > METADATA_LIMITS[key] || + field !== field.trim() + ) { + return null; + } + metadata[key] = field; + } + const names = new Set(); + const members: { readonly name: string }[] = []; + for (const member of value.members) { + if ( + !isRecord(member) || + !hasExactKeys(member, ['name']) || + typeof member.name !== 'string' || + !MEMBER_NAME_PATTERN.test(member.name) || + member.name !== member.name.trim() || + names.has(member.name) + ) { + return null; + } + names.add(member.name); + members.push(Object.freeze({ name: member.name })); + } + return Object.freeze({ + ...expected, + revision, + metadata: Object.freeze(metadata), + members: Object.freeze(members), + }) as unknown as HostedSavedTeamRequest; +} + +function parseError(value: unknown, status: number | undefined) { + try { + if ( + !isRecord(value) || + !hasExactKeys(value, ['schemaVersion', 'kind', 'error', 'retryable']) || + value.schemaVersion !== HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION || + value.kind !== 'error' || + typeof value.retryable !== 'boolean' + ) { + return null; + } + const error = createSafeAppError(value.error); + if (error.reason !== PUBLIC_ERROR_REASONS[error.code] || error.diagnosticId !== undefined) { + return null; + } + const expectedStatus = { + invalid_request: 400, + unauthenticated: 401, + forbidden: 403, + not_found: 404, + conflict: 409, + unsupported: 422, + unavailable: 503, + cancelled: 503, + internal: 500, + }[error.code]; + if (status !== expectedStatus || value.retryable !== (error.code === 'unavailable')) + return null; + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'error' as const, + error, + retryable: value.retryable, + }); + } catch { + return null; + } +} + +async function post( + dependencies: HostedTeamConfigurationTransportDependencies, + path: string, + body: unknown, + mutation: boolean, + options: HostedTeamConfigurationTransportOptions | undefined +): Promise { + try { + if (options?.signal?.aborted) return null; + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (mutation) { + const csrfToken = dependencies.getCsrfToken(); + if (typeof csrfToken !== 'string' || !CSRF_TOKEN.test(csrfToken)) return null; + headers['x-agent-teams-csrf'] = csrfToken; + } + if (options?.signal?.aborted) return null; + return await dependencies.fetch(path, { + method: 'POST', + credentials: 'include', + cache: 'no-store', + headers: Object.freeze(headers), + body: JSON.stringify(body), + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); + } catch { + return null; + } +} + +async function json(response: HostedTeamConfigurationHttpResponse | null): Promise { + if (response === null) return null; + try { + return await response.json(); + } catch { + return null; + } +} + +export function createHostedTeamConfigurationTransport( + dependencies: HostedTeamConfigurationTransportDependencies +): HostedTeamConfigurationTransport { + const transport: HostedTeamConfigurationTransport = { + async getSavedRequest(request, options) { + const normalized = parseHostedGetSavedTeamRequest(request); + if (!normalized.ok) return invalidRequest(); + if (options?.signal?.aborted) return cancelled(); + const identity = Object.freeze({ + workspaceId: normalized.value.workspaceId, + teamId: normalized.value.teamId, + }); + const response = await post( + dependencies, + HOSTED_TEAM_CONFIGURATION_ROUTES.getSavedRequest, + normalized.value, + false, + options + ); + if (options?.signal?.aborted) return cancelled(); + const value = await json(response); + if (options?.signal?.aborted) return cancelled(); + if ( + response?.status === 200 && + isRecord(value) && + hasExactKeys(value, ['schemaVersion', 'kind', 'draft']) && + value.schemaVersion === HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION && + value.kind === 'found' + ) { + const draft = parseDraft(value.draft, identity); + if (draft !== null) { + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'found', + draft, + }); + } + } + return parseError(value, response?.status) ?? unavailable(); + }, + + async createDraft(request, options) { + const normalized = parseHostedCreateDraftTeamRequest(request); + if (!normalized.ok) return invalidRequest(); + if (options?.signal?.aborted) return cancelled(); + const response = await post( + dependencies, + HOSTED_TEAM_CONFIGURATION_ROUTES.createDraft, + normalized.value, + true, + options + ); + if (options?.signal?.aborted) return cancelled(); + const value = await json(response); + if (options?.signal?.aborted) return cancelled(); + if ( + response?.status === 201 && + isRecord(value) && + hasExactKeys(value, ['schemaVersion', 'kind', 'identity', 'revision', 'outcome']) && + value.schemaVersion === HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION && + value.kind === 'created' && + (value.outcome === 'created' || value.outcome === 'idempotent_replay') && + isRecord(value.identity) && + value.identity.workspaceId === normalized.value.workspaceId + ) { + const identity = parseHostedTeamConfigurationIdentity(value.identity); + const revision = tryParseRevision(value.revision); + if (identity.ok && revision !== null) { + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'created', + identity: identity.value, + revision, + outcome: value.outcome, + }); + } + } + return parseError(value, response?.status) ?? unavailable(); + }, + + async updateDraft(request, options) { + const normalized = parseHostedUpdateDraftTeamRequest(request); + if (!normalized.ok) return invalidRequest(); + if (options?.signal?.aborted) return cancelled(); + const identity = Object.freeze({ + workspaceId: normalized.value.workspaceId, + teamId: normalized.value.teamId, + }); + const response = await post( + dependencies, + HOSTED_TEAM_CONFIGURATION_ROUTES.updateDraft, + normalized.value, + true, + options + ); + if (options?.signal?.aborted) return cancelled(); + const value = await json(response); + if (options?.signal?.aborted) return cancelled(); + if ( + response?.status === 200 && + isRecord(value) && + hasExactKeys(value, ['schemaVersion', 'kind', 'draft']) && + value.schemaVersion === HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION && + value.kind === 'updated' + ) { + const draft = parseDraft(value.draft, identity); + if (draft !== null) { + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'updated', + draft, + }); + } + } + return parseError(value, response?.status) ?? unavailable(); + }, + + async deleteDraft(request, options) { + const normalized = parseHostedDeleteDraftTeamRequest(request); + if (!normalized.ok) return invalidRequest(); + if (options?.signal?.aborted) return cancelled(); + const identity = Object.freeze({ + workspaceId: normalized.value.workspaceId, + teamId: normalized.value.teamId, + }); + const response = await post( + dependencies, + HOSTED_TEAM_CONFIGURATION_ROUTES.deleteDraft, + normalized.value, + true, + options + ); + if (options?.signal?.aborted) return cancelled(); + const value = await json(response); + if (options?.signal?.aborted) return cancelled(); + if ( + response?.status === 200 && + isRecord(value) && + hasExactKeys(value, ['schemaVersion', 'kind', 'identity', 'outcome']) && + value.schemaVersion === HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION && + value.kind === 'deleted' && + (value.outcome === 'deleted' || value.outcome === 'already_absent') && + parseIdentity(value.identity, identity) !== null + ) { + return Object.freeze({ + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + kind: 'deleted', + identity, + outcome: value.outcome, + }); + } + return parseError(value, response?.status) ?? unavailable(); + }, + }; + return Object.freeze(transport); +} diff --git a/src/features/team-configuration/renderer/index.ts b/src/features/team-configuration/renderer/index.ts new file mode 100644 index 0000000000..b17e1e9ffa --- /dev/null +++ b/src/features/team-configuration/renderer/index.ts @@ -0,0 +1,14 @@ +export { createHostedTeamConfigurationTransport } from './composition/createHostedTeamConfigurationTransport'; +export type { + HostedTeamConfigurationFetchPort, + HostedTeamConfigurationHttpRequestInit, + HostedTeamConfigurationHttpResponse, + HostedTeamConfigurationTransport, + HostedTeamConfigurationTransportDependencies, + HostedTeamConfigurationTransportOptions, +} from './ports/HostedTeamConfigurationRendererPorts'; +export type { TeamConfigurationRendererPorts } from './ports/TeamConfigurationRendererPorts'; +export { + HostedTeamConfigurationPanel, + type HostedTeamConfigurationPanelProps, +} from './ui/HostedTeamConfigurationPanel'; diff --git a/src/features/team-configuration/renderer/ports/HostedTeamConfigurationRendererPorts.ts b/src/features/team-configuration/renderer/ports/HostedTeamConfigurationRendererPorts.ts new file mode 100644 index 0000000000..00ab7d11b9 --- /dev/null +++ b/src/features/team-configuration/renderer/ports/HostedTeamConfigurationRendererPorts.ts @@ -0,0 +1,58 @@ +import type { + HostedCreateDraftTeamRequest, + HostedCreateDraftTeamResult, + HostedDeleteDraftTeamRequest, + HostedDeleteDraftTeamResult, + HostedGetSavedTeamRequest, + HostedGetSavedTeamResult, + HostedUpdateDraftTeamRequest, + HostedUpdateDraftTeamResult, +} from '../../contracts/hosted'; + +export interface HostedTeamConfigurationHttpRequestInit { + readonly method: 'POST'; + readonly credentials: 'include'; + readonly cache: 'no-store'; + readonly headers: Readonly>; + readonly body: string; + readonly signal?: AbortSignal; +} + +export interface HostedTeamConfigurationHttpResponse { + readonly status: number; + json(): Promise; +} + +export type HostedTeamConfigurationFetchPort = ( + input: string, + init: HostedTeamConfigurationHttpRequestInit +) => Promise; + +export interface HostedTeamConfigurationTransportDependencies { + readonly fetch: HostedTeamConfigurationFetchPort; + /** Reads the current in-memory token. The transport never stores or returns it. */ + readonly getCsrfToken: () => string | null; +} + +export interface HostedTeamConfigurationTransportOptions { + readonly signal?: AbortSignal; +} + +export interface HostedTeamConfigurationTransport { + getSavedRequest( + request: HostedGetSavedTeamRequest, + options?: HostedTeamConfigurationTransportOptions + ): Promise; + createDraft( + request: HostedCreateDraftTeamRequest, + options?: HostedTeamConfigurationTransportOptions + ): Promise; + updateDraft( + request: HostedUpdateDraftTeamRequest, + options?: HostedTeamConfigurationTransportOptions + ): Promise; + deleteDraft( + request: HostedDeleteDraftTeamRequest, + options?: HostedTeamConfigurationTransportOptions + ): Promise; +} diff --git a/src/features/team-configuration/renderer/ports/TeamConfigurationRendererPorts.ts b/src/features/team-configuration/renderer/ports/TeamConfigurationRendererPorts.ts new file mode 100644 index 0000000000..3b5a6ee284 --- /dev/null +++ b/src/features/team-configuration/renderer/ports/TeamConfigurationRendererPorts.ts @@ -0,0 +1,12 @@ +import type { + TeamConfig, + TeamCreateConfigRequest, + TeamCreateRequest, + TeamUpdateConfigRequest, +} from '@shared/types'; + +export interface TeamConfigurationRendererPorts { + createConfig(request: TeamCreateConfigRequest): Promise; + getSavedRequest(teamName: string): Promise; + updateConfig(teamName: string, updates: TeamUpdateConfigRequest): Promise; +} diff --git a/src/features/team-configuration/renderer/ui/HostedTeamConfigurationPanel.tsx b/src/features/team-configuration/renderer/ui/HostedTeamConfigurationPanel.tsx new file mode 100644 index 0000000000..227318857a --- /dev/null +++ b/src/features/team-configuration/renderer/ui/HostedTeamConfigurationPanel.tsx @@ -0,0 +1,405 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@renderer/components/ui/alert-dialog'; +import { Button } from '@renderer/components/ui/button'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { Textarea } from '@renderer/components/ui/textarea'; + +import { + HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + type HostedSavedTeamRequest, + type HostedTeamConfigurationIdempotencyKey, + parseHostedTeamConfigurationIdempotencyKey, +} from '../../contracts/hosted'; + +import type { HostedTeamConfigurationTransport } from '../ports/HostedTeamConfigurationRendererPorts'; +import type { TeamId, WorkspaceId } from '@shared/contracts/hosted'; + +export interface HostedTeamConfigurationPanelProps { + readonly workspaceId: WorkspaceId; + readonly teamId: TeamId | null; + readonly transport: HostedTeamConfigurationTransport; + readonly onTeamCreated: (teamId: TeamId) => void; + readonly onTeamDeleted: (teamId: TeamId) => void; + readonly createIdempotencyKey?: () => HostedTeamConfigurationIdempotencyKey; +} + +type Feedback = Readonly<{ tone: 'error' | 'status'; text: string }> | null; + +let fallbackKeySequence = 0; + +function defaultIdempotencyKey(): HostedTeamConfigurationIdempotencyKey { + const suffix = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `fallback-${Date.now()}-${++fallbackKeySequence}`; + return parseHostedTeamConfigurationIdempotencyKey( + `idempotency_team-configuration-renderer-${suffix}` + ); +} + +function memberNames(value: string): readonly { readonly name: string }[] { + return value + .split(/[\s,]+/u) + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => Object.freeze({ name })); +} + +function errorText(code: string): string { + if (code === 'conflict') return 'This draft changed on the server. Reload it before retrying.'; + if (code === 'not_found') return 'This draft is no longer available.'; + if (code === 'cancelled') return 'The request was cancelled.'; + return 'The team configuration request could not be completed.'; +} + +export const HostedTeamConfigurationPanel = ({ + workspaceId, + teamId, + transport, + onTeamCreated, + onTeamDeleted, + createIdempotencyKey = defaultIdempotencyKey, +}: HostedTeamConfigurationPanelProps): React.JSX.Element => { + const identityKey = `${workspaceId}:${teamId ?? 'create'}`; + const latestIdentityKey = useRef(identityKey); + latestIdentityKey.current = identityKey; + const operation = useRef(null); + const createIntent = useRef<{ + readonly fingerprint: string; + readonly key: HostedTeamConfigurationIdempotencyKey; + } | null>(null); + const [draft, setDraft] = useState(null); + const [name, setName] = useState(''); + const [members, setMembers] = useState('lead'); + const [description, setDescription] = useState(''); + const [color, setColor] = useState(''); + const [language, setLanguage] = useState(''); + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState(null); + + const applyDraft = useCallback((value: HostedSavedTeamRequest): void => { + setDraft(value); + setName(value.metadata.name); + setMembers(value.members.map((member) => member.name).join(', ')); + setDescription(value.metadata.description ?? ''); + setColor(value.metadata.color ?? ''); + setLanguage(value.metadata.language ?? ''); + }, []); + + const load = useCallback((): void => { + if (teamId === null) return; + operation.current?.abort(); + const controller = new AbortController(); + const requestIdentity = identityKey; + operation.current = controller; + setBusy(true); + setFeedback({ tone: 'status', text: 'Loading team configuration…' }); + void transport + .getSavedRequest( + { schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, workspaceId, teamId }, + { signal: controller.signal } + ) + .then((result) => { + if (controller.signal.aborted || latestIdentityKey.current !== requestIdentity) return; + if (result.kind === 'found') { + applyDraft(result.draft); + setFeedback(null); + } else { + setDraft(null); + setFeedback({ tone: 'error', text: errorText(result.error.code) }); + } + }) + .finally(() => { + if (!controller.signal.aborted && latestIdentityKey.current === requestIdentity) { + setBusy(false); + } + }); + }, [applyDraft, identityKey, teamId, transport, workspaceId]); + + useEffect(() => { + operation.current?.abort(); + createIntent.current = null; + setBusy(false); + setFeedback(null); + setDraft(null); + setName(''); + setMembers('lead'); + setDescription(''); + setColor(''); + setLanguage(''); + if (teamId === null) { + return; + } + load(); + return () => operation.current?.abort(); + }, [identityKey, load]); + + const createDraft = (): void => { + const normalizedMembers = memberNames(members); + const normalizedName = name.trim(); + const fingerprint = JSON.stringify({ name: normalizedName, members: normalizedMembers }); + const intent = + createIntent.current?.fingerprint === fingerprint + ? createIntent.current + : { fingerprint, key: createIdempotencyKey() }; + createIntent.current = intent; + operation.current?.abort(); + const controller = new AbortController(); + const requestIdentity = identityKey; + operation.current = controller; + setBusy(true); + setFeedback({ tone: 'status', text: 'Creating draft…' }); + void transport + .createDraft( + { + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + workspaceId, + idempotencyKey: intent.key, + name: normalizedName, + members: normalizedMembers, + }, + { signal: controller.signal } + ) + .then((result) => { + if (controller.signal.aborted || latestIdentityKey.current !== requestIdentity) return; + if (result.kind === 'created') { + createIntent.current = null; + setFeedback({ + tone: 'status', + text: + result.outcome === 'idempotent_replay' + ? 'Draft recovered from the original create request.' + : 'Draft created.', + }); + onTeamCreated(result.identity.teamId); + } else { + setFeedback({ tone: 'error', text: errorText(result.error.code) }); + } + }) + .finally(() => { + if (!controller.signal.aborted && latestIdentityKey.current === requestIdentity) { + setBusy(false); + } + }); + }; + + const updateDraft = (): void => { + if (teamId === null || draft === null) return; + const updates: Record = {}; + const candidates = { name, description, color, language }; + for (const [field, value] of Object.entries(candidates)) { + const normalized = value.trim(); + if (normalized && normalized !== (draft.metadata[field as keyof typeof candidates] ?? '')) { + updates[field] = normalized; + } + } + if (Object.keys(updates).length === 0) { + setFeedback({ tone: 'status', text: 'No configuration changes to save.' }); + return; + } + operation.current?.abort(); + const controller = new AbortController(); + const requestIdentity = identityKey; + operation.current = controller; + setBusy(true); + setFeedback({ tone: 'status', text: 'Saving configuration…' }); + void transport + .updateDraft( + { + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + workspaceId, + teamId, + expectedRevision: draft.revision, + updates, + }, + { signal: controller.signal } + ) + .then((result) => { + if (controller.signal.aborted || latestIdentityKey.current !== requestIdentity) return; + if (result.kind === 'updated') { + applyDraft(result.draft); + setFeedback({ tone: 'status', text: 'Configuration saved.' }); + } else { + setFeedback({ tone: 'error', text: errorText(result.error.code) }); + } + }) + .finally(() => { + if (!controller.signal.aborted && latestIdentityKey.current === requestIdentity) { + setBusy(false); + } + }); + }; + + const deleteDraft = (): void => { + if (teamId === null || draft === null) return; + operation.current?.abort(); + const controller = new AbortController(); + const requestIdentity = identityKey; + operation.current = controller; + setBusy(true); + setFeedback({ tone: 'status', text: 'Discarding draft…' }); + void transport + .deleteDraft( + { + schemaVersion: HOSTED_TEAM_CONFIGURATION_SCHEMA_VERSION, + workspaceId, + teamId, + expectedRevision: draft.revision, + }, + { signal: controller.signal } + ) + .then((result) => { + if (controller.signal.aborted || latestIdentityKey.current !== requestIdentity) return; + if (result.kind === 'deleted') { + setDraft(null); + setFeedback({ tone: 'status', text: 'Draft discarded.' }); + onTeamDeleted(teamId); + } else { + setFeedback({ tone: 'error', text: errorText(result.error.code) }); + } + }) + .finally(() => { + if (!controller.signal.aborted && latestIdentityKey.current === requestIdentity) { + setBusy(false); + } + }); + }; + + const editing = teamId !== null; + return ( +
+
+

+ {editing ? 'Team configuration' : 'Create team draft'} +

+ {editing ? ( + + ) : null} +
+ +
+ + setName(event.target.value)} + /> +
+ + {editing ? null : ( +
+ + setMembers(event.target.value)} + placeholder="lead, researcher" + /> +
+ )} + + {editing ? ( + <> +
+ +